@zerotal/flow 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/src/dehydrate.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { Component } from "./Component.ts";
|
|
2
|
+
import type { Snapshot, SnapshotData } from "./types.ts";
|
|
3
|
+
import { serializeValue, deserializeValue } from "./synths/index.ts";
|
|
4
|
+
import {
|
|
5
|
+
getComputedKeys,
|
|
6
|
+
getTransientKeys,
|
|
7
|
+
getExposedProps,
|
|
8
|
+
getLockedProps,
|
|
9
|
+
getUrlProps,
|
|
10
|
+
getListeners,
|
|
11
|
+
getPresenceProps,
|
|
12
|
+
resolvePresenceChannel,
|
|
13
|
+
getSharedProps,
|
|
14
|
+
resolveSharedChannel,
|
|
15
|
+
} from "./decorators.ts";
|
|
16
|
+
import { ZerotalError, safeEqual, hmacHex, RequestContext } from "@zerotal/core";
|
|
17
|
+
|
|
18
|
+
// ── Stable JSON stringify ─────────────────────────────────────────────────────
|
|
19
|
+
// Keys are sorted so the HMAC is deterministic regardless of insertion order.
|
|
20
|
+
|
|
21
|
+
function stableStringify(value: unknown): string {
|
|
22
|
+
if (value === null || value === undefined) return JSON.stringify(value);
|
|
23
|
+
if (typeof value !== "object" || Array.isArray(value)) return JSON.stringify(value);
|
|
24
|
+
const sorted: Record<string, unknown> = {};
|
|
25
|
+
for (const k of Object.keys(value as object).sort()) {
|
|
26
|
+
sorted[k] = (value as Record<string, unknown>)[k];
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(sorted, (_key, v: unknown) =>
|
|
29
|
+
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
30
|
+
? Object.fromEntries(
|
|
31
|
+
Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
32
|
+
)
|
|
33
|
+
: v,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── HMAC helpers ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function _appKey(): string {
|
|
40
|
+
const key = Bun.env["APP_KEY"];
|
|
41
|
+
if (!key) throw new Error("[Flow] APP_KEY is not set. Snapshot signing requires APP_KEY.");
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _sign(payload: string): string {
|
|
46
|
+
return hmacHex(payload, _appKey());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _verify(payload: string, checksum: string): boolean {
|
|
50
|
+
// Constant-time comparison via the shared helper (its length pre-check is
|
|
51
|
+
// safe here — the digest length is a fixed public constant).
|
|
52
|
+
return safeEqual(_sign(payload), checksum);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Identity binding ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How long a snapshot stays valid after being issued. 12 hours comfortably outlives any
|
|
59
|
+
* real page session while putting an end to a captured one.
|
|
60
|
+
*/
|
|
61
|
+
export const SNAPSHOT_MAX_AGE_SECONDS = 12 * 60 * 60;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The authenticated user a snapshot is being issued to, or `undefined` for an anonymous
|
|
65
|
+
* request. Read structurally: `@zerotal/flow` does not depend on `@zerotal/auth`.
|
|
66
|
+
* @internal
|
|
67
|
+
*/
|
|
68
|
+
function currentSubject(): string | undefined {
|
|
69
|
+
const ctx = RequestContext.tryGet() as { user?: { id?: unknown } | null } | undefined;
|
|
70
|
+
const id = ctx?.user?.id;
|
|
71
|
+
return id === undefined || id === null ? undefined : String(id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Signing payload ───────────────────────────────────────────────────────────
|
|
75
|
+
// children and listeners are excluded from signing:
|
|
76
|
+
// - children: Livewire pattern — ids are derived server-side, never trusted from client
|
|
77
|
+
// - listeners: server-derived from class decorators, cannot meaningfully be tampered
|
|
78
|
+
// with (@on methods are still validated against @expose allowlist on every call)
|
|
79
|
+
|
|
80
|
+
function _signingPayload(snapshot: Omit<Snapshot, "checksum">): string {
|
|
81
|
+
const memo = { ...snapshot.memo, children: undefined, listeners: undefined };
|
|
82
|
+
return stableStringify({ data: snapshot.data, memo });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Dehydration ───────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export function dehydrate(
|
|
88
|
+
page: Component,
|
|
89
|
+
memo: Pick<Snapshot["memo"], "id" | "name" | "path"> & {
|
|
90
|
+
children?: Snapshot["memo"]["children"];
|
|
91
|
+
},
|
|
92
|
+
): Snapshot {
|
|
93
|
+
const computedKeys = getComputedKeys(page);
|
|
94
|
+
const transientKeys = getTransientKeys(page);
|
|
95
|
+
const exposedProps = getExposedProps(page);
|
|
96
|
+
const lockedProps = getLockedProps(page);
|
|
97
|
+
const urlProps = getUrlProps(page);
|
|
98
|
+
const data: SnapshotData = {};
|
|
99
|
+
|
|
100
|
+
// Only @expose-d and @locked properties cross the network boundary.
|
|
101
|
+
const serializable = new Set([...exposedProps, ...lockedProps]);
|
|
102
|
+
|
|
103
|
+
for (const key of serializable) {
|
|
104
|
+
if (computedKeys.has(key)) continue;
|
|
105
|
+
if (transientKeys.has(key)) continue;
|
|
106
|
+
const val = (page as unknown as Record<string, unknown>)[key];
|
|
107
|
+
if (typeof val === "function") continue;
|
|
108
|
+
|
|
109
|
+
const [serialized, meta] = serializeValue(val);
|
|
110
|
+
|
|
111
|
+
// Mark @locked properties so the client proxy refuses writes to them.
|
|
112
|
+
if (lockedProps.has(key)) {
|
|
113
|
+
meta["locked"] = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Merge @url metadata into the synth tuple meta so the client knows
|
|
117
|
+
// which props to sync to the URL query string and how.
|
|
118
|
+
const urlOpts = urlProps.get(key);
|
|
119
|
+
if (urlOpts) {
|
|
120
|
+
meta["url"] = { as: urlOpts.as ?? key, history: urlOpts.history ?? "replace" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
data[key] = [serialized, meta];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Collect @on listeners for the client event router.
|
|
127
|
+
// Excluded from HMAC signing (server-derived, can't usefully be tampered with).
|
|
128
|
+
const listenersMap = getListeners(page.constructor as { prototype: object });
|
|
129
|
+
const listeners = listenersMap.size > 0 ? Object.fromEntries(listenersMap) : undefined;
|
|
130
|
+
|
|
131
|
+
// Named slot HTML passed down by the parent. Signed (server-generated), and carried
|
|
132
|
+
// so the child can re-render its own actions with the slot content intact.
|
|
133
|
+
const slots = Object.keys(page._flowSlots).length > 0 ? page._flowSlots : undefined;
|
|
134
|
+
|
|
135
|
+
// @presence props → resolved channel, so the client subscribes to the right presence
|
|
136
|
+
// channel(s). Signed (server-resolved from the component). Empty when none.
|
|
137
|
+
const presenceMap = getPresenceProps(page);
|
|
138
|
+
const presence =
|
|
139
|
+
presenceMap.size > 0
|
|
140
|
+
? [...presenceMap].map(([prop, channel]) => ({
|
|
141
|
+
prop,
|
|
142
|
+
channel: resolvePresenceChannel(channel, page),
|
|
143
|
+
}))
|
|
144
|
+
: undefined;
|
|
145
|
+
|
|
146
|
+
// @shared props → resolved channel, so the client subscribes and converges on change.
|
|
147
|
+
// Signed (server-resolved from the component). Empty when none.
|
|
148
|
+
const sharedMap = getSharedProps(page);
|
|
149
|
+
const shared =
|
|
150
|
+
sharedMap.size > 0
|
|
151
|
+
? [...sharedMap].map(([prop, channel]) => ({
|
|
152
|
+
prop,
|
|
153
|
+
channel: resolveSharedChannel(channel, page),
|
|
154
|
+
}))
|
|
155
|
+
: undefined;
|
|
156
|
+
|
|
157
|
+
// Children collected during the render that preceded this dehydrate call.
|
|
158
|
+
// Excluded from HMAC (Livewire pattern).
|
|
159
|
+
const snapshotMemo: Snapshot["memo"] = {
|
|
160
|
+
...memo,
|
|
161
|
+
children: [...page._childIds],
|
|
162
|
+
listeners,
|
|
163
|
+
slots,
|
|
164
|
+
presence,
|
|
165
|
+
shared,
|
|
166
|
+
sub: currentSubject(),
|
|
167
|
+
iat: Math.floor(Date.now() / 1000),
|
|
168
|
+
};
|
|
169
|
+
const partial = { data, memo: snapshotMemo };
|
|
170
|
+
const checksum = _sign(_signingPayload(partial));
|
|
171
|
+
|
|
172
|
+
return { data, memo: snapshotMemo, checksum };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Hydration ─────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export class FlowIntegrityError extends ZerotalError {
|
|
178
|
+
constructor(msg = "Invalid snapshot checksum") {
|
|
179
|
+
super(msg, "E_PULSE_INTEGRITY", 400);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A snapshot whose signature is genuine but which does not belong to this requester, or
|
|
185
|
+
* which has aged out. Distinct from {@link FlowIntegrityError} because the cause and the
|
|
186
|
+
* fix are different: nothing was tampered with, the snapshot is simply not yours.
|
|
187
|
+
*/
|
|
188
|
+
export class FlowSnapshotOwnershipError extends ZerotalError {
|
|
189
|
+
constructor(msg = "Snapshot was not issued to this session") {
|
|
190
|
+
super(msg, "E_PULSE_SNAPSHOT_OWNER", 403);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function hydrate<T extends Component>(
|
|
195
|
+
snapshot: Snapshot,
|
|
196
|
+
PageClass: new () => T,
|
|
197
|
+
): Promise<T> {
|
|
198
|
+
// 1. Verify HMAC (children and listeners excluded from signing).
|
|
199
|
+
if (!verifySnapshot(snapshot)) {
|
|
200
|
+
throw new FlowIntegrityError();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. Verify the snapshot belongs to whoever is asking, and has not aged out. The HMAC
|
|
204
|
+
// proves integrity, not ownership — a genuine signature over someone else's state is
|
|
205
|
+
// still someone else's state.
|
|
206
|
+
assertSnapshotSubject(snapshot);
|
|
207
|
+
|
|
208
|
+
// 3. Fresh blank instance — constructor runs but onMount() is skipped.
|
|
209
|
+
const page = new PageClass();
|
|
210
|
+
(page as Record<string, unknown>)["_skipMount"] = true;
|
|
211
|
+
|
|
212
|
+
// 4. Restore slots + each property (see applySnapshotData).
|
|
213
|
+
await applySnapshotData(page, snapshot);
|
|
214
|
+
|
|
215
|
+
return page;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Verify a snapshot's HMAC against the current APP_KEY. Public so durable-snapshot
|
|
219
|
+
* restore can validate a stored snapshot before trusting it (a rotated key → false). */
|
|
220
|
+
export function verifySnapshot(snapshot: Snapshot): boolean {
|
|
221
|
+
return _verify(_signingPayload(snapshot), snapshot.checksum);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Assert that a snapshot was issued to whoever is making the current request, and has not
|
|
226
|
+
* aged out.
|
|
227
|
+
*
|
|
228
|
+
* A signature says the bytes are unmodified. It does not say whose bytes they are — so
|
|
229
|
+
* `@locked accountId = 42`, which the docs describe as tamper-proof server-authoritative
|
|
230
|
+
* state, was replayable by any other authenticated user who came by a snapshot signed for
|
|
231
|
+
* someone else. Middleware re-runs on every frame, so authentication is genuinely
|
|
232
|
+
* re-checked; what it could not catch is that the *state* belonged to a different person.
|
|
233
|
+
*
|
|
234
|
+
* An anonymous-issued snapshot stays usable by anonymous requests only, and vice versa: a
|
|
235
|
+
* snapshot's subject changing means the identity changed, and identity changes are exactly
|
|
236
|
+
* when carried-over state should stop being trusted.
|
|
237
|
+
*
|
|
238
|
+
* @param snapshot - The snapshot about to be hydrated.
|
|
239
|
+
* @throws {@link FlowSnapshotOwnershipError} When the subject does not match or the
|
|
240
|
+
* snapshot is older than {@link SNAPSHOT_MAX_AGE_SECONDS}.
|
|
241
|
+
*/
|
|
242
|
+
export function assertSnapshotSubject(snapshot: Snapshot): void {
|
|
243
|
+
if (snapshot.memo.sub !== currentSubject()) {
|
|
244
|
+
throw new FlowSnapshotOwnershipError();
|
|
245
|
+
}
|
|
246
|
+
const iat = snapshot.memo.iat;
|
|
247
|
+
if (typeof iat !== "number" || Math.floor(Date.now() / 1000) - iat > SNAPSHOT_MAX_AGE_SECONDS) {
|
|
248
|
+
throw new FlowSnapshotOwnershipError("Snapshot has expired — reload the page.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Restore a snapshot's slots + property values into an EXISTING component instance (the
|
|
254
|
+
* deserialize half of {@link hydrate}, without the fresh-instance/HMAC steps). Used by the
|
|
255
|
+
* durable-snapshot resume path, which restores into a page that already ran onBoot().
|
|
256
|
+
*/
|
|
257
|
+
export async function applySnapshotData(page: Component, snapshot: Snapshot): Promise<void> {
|
|
258
|
+
// Restore parent-supplied slot HTML so this.slot(name) works. Signed in the memo.
|
|
259
|
+
if (snapshot.memo.slots) page._flowSlots = { ...snapshot.memo.slots };
|
|
260
|
+
// Restore each property through its synthesizer.
|
|
261
|
+
await Promise.all(
|
|
262
|
+
Object.entries(snapshot.data).map(async ([key, [data, meta]]) => {
|
|
263
|
+
(page as unknown as Record<string, unknown>)[key] = await deserializeValue(data, meta);
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Snapshot delta encoding ───────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/** The wire form of a snapshot delta — only what changed, plus the new identity/checksum. */
|
|
271
|
+
export interface SnapshotDelta {
|
|
272
|
+
memo: Snapshot["memo"];
|
|
273
|
+
checksum: string;
|
|
274
|
+
/** Changed (or newly added) `[value, meta]` tuples, keyed by property name. */
|
|
275
|
+
dataDelta: SnapshotData;
|
|
276
|
+
/** Property names present in `prev` but gone from `next`. */
|
|
277
|
+
dataRemoved: string[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Diff two snapshots into the minimal set of changes to transmit. `next` is the freshly
|
|
282
|
+
* dehydrated state; `prev` is the client's own snapshot (its `data` is exactly what the
|
|
283
|
+
* client holds), so applying this delta to `prev.data` reproduces `next.data` value-for-
|
|
284
|
+
* value — and the carried `next.checksum` (signed over the full `next.data`) therefore
|
|
285
|
+
* verifies when the client sends the reconstructed snapshot back on its next action.
|
|
286
|
+
*/
|
|
287
|
+
export function encodeSnapshotDelta(prev: Snapshot, next: Snapshot): SnapshotDelta {
|
|
288
|
+
const dataDelta: SnapshotData = {};
|
|
289
|
+
for (const [key, tuple] of Object.entries(next.data)) {
|
|
290
|
+
const before = prev.data[key];
|
|
291
|
+
if (before === undefined || JSON.stringify(before) !== JSON.stringify(tuple)) {
|
|
292
|
+
dataDelta[key] = tuple;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const dataRemoved: string[] = [];
|
|
296
|
+
for (const key of Object.keys(prev.data)) {
|
|
297
|
+
if (!(key in next.data)) dataRemoved.push(key);
|
|
298
|
+
}
|
|
299
|
+
return { memo: next.memo, checksum: next.checksum, dataDelta, dataRemoved };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Rebuild a full snapshot from a prior snapshot and a delta (mirror of
|
|
304
|
+
* {@link encodeSnapshotDelta}). Used on the client, and by tests to prove the
|
|
305
|
+
* reconstruction verifies. Pure — does not mutate `prev`.
|
|
306
|
+
*/
|
|
307
|
+
export function applySnapshotDelta(prev: Snapshot, delta: SnapshotDelta): Snapshot {
|
|
308
|
+
const data: SnapshotData = { ...prev.data };
|
|
309
|
+
for (const key of delta.dataRemoved) delete data[key];
|
|
310
|
+
Object.assign(data, delta.dataDelta);
|
|
311
|
+
return { data, memo: delta.memo, checksum: delta.checksum };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Snapshot size warning (dev mode) ─────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
const SNAPSHOT_WARN_BYTES = 100 * 1024; // 100 KB
|
|
317
|
+
|
|
318
|
+
export function warnIfLarge(snapshot: Snapshot, name: string): void {
|
|
319
|
+
const env = Bun.env["APP_ENV"] ?? "development";
|
|
320
|
+
if (env === "production" || env === "prod") return;
|
|
321
|
+
|
|
322
|
+
// Buffer.byteLength is a native call that avoids allocating a typed array.
|
|
323
|
+
const size = Buffer.byteLength(JSON.stringify(snapshot), "utf8");
|
|
324
|
+
if (size > SNAPSHOT_WARN_BYTES) {
|
|
325
|
+
console.warn(
|
|
326
|
+
`\n[Flow] Warning: ${name} snapshot is ${Math.round(size / 1024)} KB.\n` +
|
|
327
|
+
`Large snapshots slow every round trip. Consider:\n` +
|
|
328
|
+
` → @transient on display-only collections (re-load them inside the action that needs them)\n` +
|
|
329
|
+
` → Storing only IDs in state; eager-loading records inside individual actions\n` +
|
|
330
|
+
` → @computed for values derivable from other state\n`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/durable.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// ── Durable / resumable snapshots ─────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A component opts in with `static durable = true` (or `{ ttl, scope }`). Its signed
|
|
4
|
+
// snapshot is then persisted server-side after every request, keyed by user (or session)
|
|
5
|
+
// + route + component. On a fresh GET the stored snapshot is restored — skipping onMount —
|
|
6
|
+
// so the user resumes exactly across a tab close/reopen, a device switch, or a reconnect
|
|
7
|
+
// after the held client snapshot is gone. With a persistent store (see setDurableStore),
|
|
8
|
+
// it also survives a server redeploy.
|
|
9
|
+
//
|
|
10
|
+
// The snapshot is HMAC-signed, so a tampered or stale-key entry fails verification and
|
|
11
|
+
// falls back to a fresh mount; keying by user/session id isolates one user from another.
|
|
12
|
+
|
|
13
|
+
import type { Component } from "./Component.ts";
|
|
14
|
+
import type { Snapshot } from "./types.ts";
|
|
15
|
+
import type { HttpContext } from "@zerotal/core";
|
|
16
|
+
import { verifySnapshot, applySnapshotData } from "./dehydrate.ts";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A component's durable-snapshot opt-in, set as `static durable` on the component.
|
|
22
|
+
*
|
|
23
|
+
* - `true` — enable with defaults (24h TTL, `"user"` scope).
|
|
24
|
+
* - `{ ttl, scope }` — enable with a custom lifetime and keying:
|
|
25
|
+
* - `ttl` — a duration string like `"30s"`, `"15m"`, `"2h"`, `"7d"` (defaults to 24h if omitted/unparseable).
|
|
26
|
+
* - `scope` — `"user"` keys by authenticated user (falling back to session when
|
|
27
|
+
* anonymous); `"session"` always keys by session, for per-device durability
|
|
28
|
+
* even while logged in.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* When enabled, the component's signed snapshot is persisted after every request
|
|
32
|
+
* and restored on a fresh GET (skipping `onMount`), so a user resumes across a tab
|
|
33
|
+
* close/reopen, a device switch, or a reconnect. With a persistent store (see
|
|
34
|
+
* {@link setDurableStore}) it also survives a server redeploy.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* class Wizard extends Component {
|
|
39
|
+
* static durable = { ttl: "2h", scope: "session" } satisfies DurableOption;
|
|
40
|
+
* // ...on the final step:
|
|
41
|
+
* @expose finish() { this.clearDurable(); } // drop stored state on completion
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export type DurableOption = boolean | { ttl?: string; scope?: "user" | "session" };
|
|
46
|
+
|
|
47
|
+
interface DurableConfig {
|
|
48
|
+
ttlMs: number;
|
|
49
|
+
/** `"user"` keys by the authenticated user (falling back to session when anonymous);
|
|
50
|
+
* `"session"` always keys by session (per-device durability even when logged in). */
|
|
51
|
+
scope: "user" | "session";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Store ───────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Default is an in-process TTL Map — survives reconnect/tab-close/device-switch within
|
|
56
|
+
// the process lifetime. Swap a persistent backend (e.g. @zerotal/cache/Redis) via
|
|
57
|
+
// setDurableStore() to also survive a server redeploy.
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pluggable backing store for durable component snapshots.
|
|
61
|
+
*
|
|
62
|
+
* @remarks
|
|
63
|
+
* The default {@link MemoryDurableStore} keeps entries in-process with TTL
|
|
64
|
+
* eviction — durable across reconnect/tab-close/device-switch within the process
|
|
65
|
+
* lifetime, but lost on redeploy. Swap a persistent backend (e.g.
|
|
66
|
+
* `@zerotal/cache`/Redis) via {@link setDurableStore} to also survive redeploys.
|
|
67
|
+
* Methods may be sync or async; a swapped store is responsible for its own TTL
|
|
68
|
+
* eviction (the `ttlMs` hint is passed to {@link DurableStore.set | set}).
|
|
69
|
+
*/
|
|
70
|
+
export interface DurableStore {
|
|
71
|
+
/** Fetch a stored snapshot by key, or `undefined` if absent/expired. */
|
|
72
|
+
get(key: string): Promise<Snapshot | undefined> | Snapshot | undefined;
|
|
73
|
+
/** Store a snapshot under `key`, expiring after `ttlMs` if supported. */
|
|
74
|
+
set(key: string, value: Snapshot, ttlMs?: number): Promise<void> | void;
|
|
75
|
+
/** Remove a stored snapshot (used on flow completion via `clearDurable()`). */
|
|
76
|
+
delete(key: string): Promise<void> | void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class MemoryDurableStore implements DurableStore {
|
|
80
|
+
private _map = new Map<string, { snap: Snapshot; expiresAt: number }>();
|
|
81
|
+
get(key: string): Snapshot | undefined {
|
|
82
|
+
const e = this._map.get(key);
|
|
83
|
+
if (!e) return undefined;
|
|
84
|
+
if (e.expiresAt < Date.now()) {
|
|
85
|
+
this._map.delete(key);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
return e.snap;
|
|
89
|
+
}
|
|
90
|
+
set(key: string, value: Snapshot, ttlMs = DEFAULT_TTL_MS): void {
|
|
91
|
+
this._map.set(key, { snap: value, expiresAt: Date.now() + ttlMs });
|
|
92
|
+
}
|
|
93
|
+
delete(key: string): void {
|
|
94
|
+
this._map.delete(key);
|
|
95
|
+
}
|
|
96
|
+
/** @internal Evict expired entries so the map doesn't grow unbounded. */
|
|
97
|
+
_sweep(): void {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
for (const [k, e] of this._map) if (e.expiresAt < now) this._map.delete(k);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let _store: DurableStore = new MemoryDurableStore();
|
|
104
|
+
|
|
105
|
+
// Bound the default store's memory; a swapped store manages its own eviction (TTL).
|
|
106
|
+
setInterval(() => {
|
|
107
|
+
if (_store instanceof MemoryDurableStore) _store._sweep();
|
|
108
|
+
}, 60 * 60_000).unref?.();
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Replace the process-wide durable-snapshot store. Call once at boot to move
|
|
112
|
+
* durability off the default in-process map onto a persistent backend so resumable
|
|
113
|
+
* state survives a server redeploy (and is shared across instances).
|
|
114
|
+
*
|
|
115
|
+
* @param store - The store implementation to install.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* // boot: persist durable snapshots in Redis so they outlive redeploys.
|
|
120
|
+
* setDurableStore(new CacheDurableStore(cache));
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function setDurableStore(store: DurableStore): void {
|
|
124
|
+
_store = store;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get the active durable-snapshot store (the in-process default, or whatever was
|
|
129
|
+
* installed via {@link setDurableStore}). Mainly useful for tests or tooling that
|
|
130
|
+
* inspects persisted snapshots directly.
|
|
131
|
+
*
|
|
132
|
+
* @returns The current {@link DurableStore}.
|
|
133
|
+
*/
|
|
134
|
+
export function getDurableStore(): DurableStore {
|
|
135
|
+
return _store;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @internal Test hook — reset to a fresh in-process store between tests. */
|
|
139
|
+
export function _resetDurableStore(): void {
|
|
140
|
+
_store = new MemoryDurableStore();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** Parse a duration like `"30s"`, `"15m"`, `"2h"`, `"7d"` into milliseconds. */
|
|
146
|
+
function _parseDuration(ttl: string): number {
|
|
147
|
+
const m = /^(\d+)\s*(s|m|h|d)$/.exec(ttl.trim());
|
|
148
|
+
if (!m) return DEFAULT_TTL_MS;
|
|
149
|
+
const n = Number(m[1]);
|
|
150
|
+
const unit = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
|
|
151
|
+
return n * unit;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Resolve a component's `static durable` opt-in into a normalized config, or null when off. */
|
|
155
|
+
export function getDurableConfig(page: Component): DurableConfig | null {
|
|
156
|
+
const raw = (page.constructor as { durable?: DurableOption }).durable;
|
|
157
|
+
if (!raw) return null;
|
|
158
|
+
if (raw === true) return { ttlMs: DEFAULT_TTL_MS, scope: "user" };
|
|
159
|
+
return {
|
|
160
|
+
ttlMs: raw.ttl ? _parseDuration(raw.ttl) : DEFAULT_TTL_MS,
|
|
161
|
+
scope: raw.scope ?? "user",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Keying ──────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
type SessionLike = { get(k: string): unknown; set(k: string, v: unknown): void };
|
|
168
|
+
const _SID_KEY = "flow:durable:sid";
|
|
169
|
+
|
|
170
|
+
function _session(ctx: HttpContext): SessionLike | undefined {
|
|
171
|
+
return (ctx as unknown as { session?: SessionLike }).session;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _randomSid(): string {
|
|
175
|
+
return (
|
|
176
|
+
globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
177
|
+
).replace(/-/g, "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the store key for this component + visitor, or null when no stable identity is
|
|
182
|
+
* available (user-scoped but anonymous with no session). Session-scoped keys mint and persist
|
|
183
|
+
* a stable id in the session on first use.
|
|
184
|
+
*/
|
|
185
|
+
function _resolveKey(page: Component, ctx: HttpContext, scope: "user" | "session"): string | null {
|
|
186
|
+
const comp = page.constructor.name;
|
|
187
|
+
const routePath = page._flowPath || "/";
|
|
188
|
+
const userId = (ctx as unknown as { user?: { id?: unknown } }).user?.id;
|
|
189
|
+
|
|
190
|
+
if (scope === "user" && userId != null) {
|
|
191
|
+
return `flow:durable:u:${String(userId)}:${routePath}:${comp}`;
|
|
192
|
+
}
|
|
193
|
+
// Session id (user scope with no user → fall back here; or explicit session scope).
|
|
194
|
+
const session = _session(ctx);
|
|
195
|
+
if (!session) return null;
|
|
196
|
+
let sid = session.get(_SID_KEY) as string | undefined;
|
|
197
|
+
if (!sid) {
|
|
198
|
+
sid = _randomSid();
|
|
199
|
+
session.set(_SID_KEY, sid);
|
|
200
|
+
}
|
|
201
|
+
return `flow:durable:s:${sid}:${routePath}:${comp}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Persist / restore ─────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Persist a durable component's snapshot after a request (both the initial GET and every WS
|
|
208
|
+
* action). A no-op unless the component opts in and a key resolves. When the action called
|
|
209
|
+
* `this.clearDurable()`, the stored entry is deleted instead (flow completion).
|
|
210
|
+
*/
|
|
211
|
+
export async function persistDurable(
|
|
212
|
+
page: Component,
|
|
213
|
+
ctx: HttpContext,
|
|
214
|
+
snapshot: Snapshot,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const cfg = getDurableConfig(page);
|
|
217
|
+
if (!cfg) return;
|
|
218
|
+
const key = _resolveKey(page, ctx, cfg.scope);
|
|
219
|
+
if (!key) return;
|
|
220
|
+
if (page._clearDurable) {
|
|
221
|
+
await _store.delete(key);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
await _store.set(key, snapshot, cfg.ttlMs);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* On a fresh GET, restore a durable component from its stored snapshot into `page` (which has
|
|
229
|
+
* already run onBoot). Returns true when it resumed — the caller then runs onHydrate and skips
|
|
230
|
+
* onMount. A missing entry, a tampered snapshot, or a rotated APP_KEY (verify fails) returns
|
|
231
|
+
* false, so the caller mounts fresh.
|
|
232
|
+
*/
|
|
233
|
+
export async function restoreDurable(page: Component, ctx: HttpContext): Promise<boolean> {
|
|
234
|
+
const cfg = getDurableConfig(page);
|
|
235
|
+
if (!cfg) return false;
|
|
236
|
+
const key = _resolveKey(page, ctx, cfg.scope);
|
|
237
|
+
if (!key) return false;
|
|
238
|
+
const stored = await _store.get(key);
|
|
239
|
+
if (!stored || !verifySnapshot(stored)) return false;
|
|
240
|
+
(page as unknown as Record<string, unknown>)["_skipMount"] = true;
|
|
241
|
+
await applySnapshotData(page, stored);
|
|
242
|
+
return true;
|
|
243
|
+
}
|