@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/package.json +84 -0
  5. package/src/Component.ts +1498 -0
  6. package/src/Form.ts +189 -0
  7. package/src/Layout.ts +47 -0
  8. package/src/augment.ts +94 -0
  9. package/src/client/FlowClientError.ts +12 -0
  10. package/src/client/FlowComponent.ts +96 -0
  11. package/src/client/alpinejs-morph.d.ts +5 -0
  12. package/src/client/bridge.ts +3463 -0
  13. package/src/client/confirm.ts +338 -0
  14. package/src/client/cspAdapter.ts +79 -0
  15. package/src/client/cspEvaluator.ts +494 -0
  16. package/src/client/errorOverlay.ts +124 -0
  17. package/src/client/headless.ts +948 -0
  18. package/src/client/index.csp.ts +47 -0
  19. package/src/client/index.ts +56 -0
  20. package/src/client/optimistic.ts +52 -0
  21. package/src/client/timeline.ts +160 -0
  22. package/src/client/url.ts +51 -0
  23. package/src/commands/MakeFlowCommand.ts +290 -0
  24. package/src/compiler/cache.ts +101 -0
  25. package/src/compiler/directives.ts +107 -0
  26. package/src/compiler/helpers.ts +26 -0
  27. package/src/compiler/index.ts +172 -0
  28. package/src/compiler/transform.ts +2070 -0
  29. package/src/components.ts +1465 -0
  30. package/src/config.ts +52 -0
  31. package/src/decorators.ts +978 -0
  32. package/src/dehydrate.ts +333 -0
  33. package/src/durable.ts +243 -0
  34. package/src/events.ts +118 -0
  35. package/src/frameworkEvents.ts +40 -0
  36. package/src/headless.ts +1147 -0
  37. package/src/index.ts +267 -0
  38. package/src/jsx-dev-runtime.ts +14 -0
  39. package/src/jsx-runtime.ts +1272 -0
  40. package/src/mixins.ts +199 -0
  41. package/src/observability.ts +167 -0
  42. package/src/pagination.ts +300 -0
  43. package/src/presence.ts +81 -0
  44. package/src/provider/FlowProvider.ts +1401 -0
  45. package/src/registry.ts +118 -0
  46. package/src/renderContext.ts +55 -0
  47. package/src/router.ts +401 -0
  48. package/src/shared.ts +226 -0
  49. package/src/store.ts +116 -0
  50. package/src/synths/CarbonIntervalSynth.ts +22 -0
  51. package/src/synths/CarbonSynth.ts +20 -0
  52. package/src/synths/CollectionSynth.ts +62 -0
  53. package/src/synths/DateSynth.ts +24 -0
  54. package/src/synths/ModelSynth.ts +78 -0
  55. package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
  56. package/src/synths/index.ts +148 -0
  57. package/src/testing.ts +361 -0
  58. package/src/types.ts +315 -0
  59. package/src/uploads/FileUploads.ts +94 -0
  60. package/src/uploads/TemporaryUploadedFile.ts +274 -0
  61. package/src/uploads/sign.ts +18 -0
  62. package/src/urlSafety.ts +0 -0
  63. package/src/utils.ts +216 -0
  64. package/src/validation.ts +55 -0
@@ -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
+ }