@zerotal/flow 1.4.0 → 1.5.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/src/router.ts CHANGED
@@ -12,6 +12,8 @@ import { _renderFlowPage } from "./jsx-runtime.ts";
12
12
  import { populatePresence } from "./presence.ts";
13
13
  import { populateShared } from "./shared.ts";
14
14
  import { restoreDurable, persistDurable } from "./durable.ts";
15
+ import { createSectionStore, runWithSections, resolveSections } from "./sections.ts";
16
+ import { createStreamStore, runWithStreaming, streamChunk } from "./streaming.ts";
15
17
  import type { HtmlNode } from "./jsx-runtime.ts";
16
18
  import { _runtimeJs } from "./provider/FlowProvider.ts";
17
19
  import { randomComponentId, toScriptJson } from "./utils.ts";
@@ -31,7 +33,7 @@ import {
31
33
  *
32
34
  * Middleware attached here (or via an enclosing Router.group()) runs on the
33
35
  * initial page load AND is re-applied on every WebSocket update for this page
34
- * (Livewire-style persistent middleware).
36
+ * (persistent middleware).
35
37
  *
36
38
  * @example
37
39
  * import { Router } from '@zerotal/core';
@@ -90,127 +92,142 @@ export function _layoutId(page: Component): string {
90
92
  return "l" + (hash >>> 0).toString(36);
91
93
  }
92
94
 
93
- function _makeFlowHandler(path: string, PageClass: PageClassWithMeta) {
95
+ /**
96
+ * Build the GET handler for a Flow page.
97
+ *
98
+ * @internal Exported so the document-assembly path — section resolution and the
99
+ * streamed response — can be driven directly in tests, without standing up a
100
+ * router and a server to reach it.
101
+ */
102
+ export function _makeFlowHandler(path: string, PageClass: PageClassWithMeta) {
94
103
  return class FlowPageHandler {
95
104
  async handle(ctx: HttpContext): Promise<void> {
96
105
  const http = ctx;
97
106
  // Wrap the initial render: an onMount()/render() throw surfaces as the dev error overlay
98
107
  // (like an action throw) rather than a bare 500. Production rethrows → a normal 500 with no
99
108
  // stack leak.
109
+ // Both stores are per-request and must stay active for the whole render:
110
+ // sections are published during the page render and read after the layout
111
+ // one, and a streamed child is queued mid-render and drained afterwards.
112
+ const sections = createSectionStore();
113
+ const streams = createStreamStore();
114
+
100
115
  try {
101
- const page = new PageClass() as Component;
102
- const compId = randomComponentId(PageClass.name);
103
- const compName = PageClass.name;
104
-
105
- // Identity used by nested children (deterministic child ids) and by
106
- // persistent middleware (children inherit the parent's route path).
107
- page._flowId = compId;
108
- page._flowPath = path;
109
-
110
- // Seed @url properties from the request's query string before onMount().
111
- // This makes the page state reflect the URL on initial render.
112
- _seedUrlProps(page, http.request);
113
-
114
- // Seed @session properties from the session (if SessionMiddleware is active).
115
- _seedSessionProps(page, compName, http);
116
-
117
- // Seed @param properties from the matched route segments — including resolved
118
- // model bindings. Initial GET only; round-trips restore these from the snapshot.
119
- _seedRouteParams(page, ctx);
120
-
121
- // boot() runs on every request (here: the initial render), before mount().
122
- // Forward the request HttpContext so dynamic-segment pages can read route
123
- // params and implicitly-bound models off `ctx.params` (e.g. `:account` ->
124
- // Account at `ctx.params.account`) inside onBoot()/onMount().
125
- await page.onBoot(ctx);
126
-
127
- // Durable/resumable snapshots: if this component opted into `static durable` and a valid
128
- // stored snapshot exists for this user/session + route, resume from it — restore state and
129
- // run onHydrate (like a WS round-trip), skipping onMount. Otherwise mount fresh. A missing,
130
- // tampered, or stale-key entry falls through to a normal mount. No-op unless opted in.
131
- if (await restoreDurable(page, ctx)) {
132
- await page.onHydrate();
133
- } else {
134
- await page.onMount(ctx);
135
- }
136
-
137
- // Persist @session props back to session after onMount (captures any changes).
138
- _persistSessionProps(page, compName, http);
139
-
140
- // A redirect() issued from onBoot()/onMount() short-circuits the initial render —
141
- // the page never paints, the browser is sent straight on (e.g. a landing page that
142
- // forwards "/" to "/dashboard", or a server-side auth bounce). Without this, the
143
- // redirect was only honoured on WebSocket actions and silently ignored on first GET.
144
- if (page._redirectUrl) {
145
- http.redirect(page._redirectUrl, _redirectStatus(page._redirectStatus));
146
- return;
147
- }
148
-
149
- // Fill @presence member lists before the first paint (the client refreshes them on
150
- // join/leave thereafter). No-op unless the component has @presence props.
151
- await populatePresence(page);
152
-
153
- // Refill @shared props from the room store before the first paint (seeding the store
154
- // with the default when the room is new). No-op unless the component has @shared props.
155
- populateShared(page);
156
-
157
- const innerHtml = await _renderFlowPage(page, () => page.render());
158
-
159
- // dehydrate() hook runs at the end of the request, before serialisation.
160
- await page.onDehydrate();
161
- const snapshot = dehydrate(page, { id: compId, name: compName, path });
162
- warnIfLarge(snapshot, compName);
163
-
164
- // Persist the durable snapshot (or clear it if clearDurable() was called). No-op unless
165
- // the component opted into `static durable`.
166
- await persistDurable(page, ctx, snapshot);
167
-
168
- // toScriptJson, not JSON.stringify: the snapshot carries @expose/@locked values and
169
- // @url props seeded from the query string, so it routinely holds attacker-controlled
170
- // strings that would otherwise close the <script> island. See toScriptJson's docblock.
171
- const snapshotJson = toScriptJson(snapshot);
172
-
173
- // The flow component root — always the same wrapper regardless of layout.
174
- const flowRoot: HtmlNode = {
175
- html: `<div data-flow-root x-data="{}" data-flow-id="${compId}" data-flow-name="${compName}">${innerHtml}</div>`,
176
- };
177
-
178
- // Layout resolution. The JSX-native `layout(page)` instance hook wins; otherwise
179
- // fall back to the legacy `static layout = SomeLayout` class. Both wrap the root in
180
- // a [data-flow-layout] marker so the client navigate logic swaps only the
181
- // [data-flow-root] on same-layout navigations (a full navigation otherwise).
182
- let bodyContent: string;
183
- let layoutHead = "";
184
-
185
- const wrapped = await page.layout(flowRoot);
186
- const LayoutClass = (PageClass as PageClassWithLayout).layout;
187
-
188
- if (wrapped !== flowRoot) {
189
- // JSX-native layout. If the layout component declared its own identity
190
- // (data-flow-layout on its root), honour it — that's the robust way to keep the
191
- // shell persistent across navigations, especially when the wrapper passes
192
- // page-specific props. Otherwise fall back to a source-derived id, which matches
193
- // for identical wrappers like `(page) => <AppLayout>{page}</AppLayout>`.
194
- bodyContent = /\bdata-flow-layout\b/.test(wrapped.html)
195
- ? wrapped.html
196
- : `<div data-flow-layout="${_layoutId(page)}">${wrapped.html}</div>`;
197
- } else if (LayoutClass) {
198
- const layout = new LayoutClass();
199
- const layoutNode = await layout.render(flowRoot);
200
- bodyContent = `<div data-flow-layout="${LayoutClass.name}">${layoutNode.html}</div>`;
201
- layoutHead = (LayoutClass as { head?: string }).head ?? "";
202
- } else {
203
- bodyContent = flowRoot.html;
204
- }
205
-
206
- const titleTag = PageClass.title ? `<title>${PageClass.title}</title>` : "";
207
- // Component head takes precedence; layout head provides global resources.
208
- const headExtra = [PageClass.head ?? "", layoutHead].filter(Boolean).join("\n ");
209
-
210
- const runtimeSrc = _runtimeJs() ? "/__flow/runtime.js" : "";
211
- const runtimeTag = runtimeSrc ? `<script src="${runtimeSrc}" defer></script>` : "";
212
-
213
- const html = `<!DOCTYPE html>
116
+ await runWithSections(sections, () =>
117
+ runWithStreaming(streams, async () => {
118
+ const page = new PageClass() as Component;
119
+ const compId = randomComponentId(PageClass.name);
120
+ const compName = PageClass.name;
121
+
122
+ // Identity used by nested children (deterministic child ids) and by
123
+ // persistent middleware (children inherit the parent's route path).
124
+ page._flowId = compId;
125
+ page._flowPath = path;
126
+
127
+ // Seed @url properties from the request's query string before onMount().
128
+ // This makes the page state reflect the URL on initial render.
129
+ _seedUrlProps(page, http.request);
130
+
131
+ // Seed @session properties from the session (if SessionMiddleware is active).
132
+ _seedSessionProps(page, compName, http);
133
+
134
+ // Seed @param properties from the matched route segments — including resolved
135
+ // model bindings. Initial GET only; round-trips restore these from the snapshot.
136
+ _seedRouteParams(page, ctx);
137
+
138
+ // boot() runs on every request (here: the initial render), before mount().
139
+ // Forward the request HttpContext so dynamic-segment pages can read route
140
+ // params and implicitly-bound models off `ctx.params` (e.g. `:account` ->
141
+ // Account at `ctx.params.account`) inside onBoot()/onMount().
142
+ await page.onBoot(ctx);
143
+
144
+ // Durable/resumable snapshots: if this component opted into `static durable` and a valid
145
+ // stored snapshot exists for this user/session + route, resume from it — restore state and
146
+ // run onHydrate (like a WS round-trip), skipping onMount. Otherwise mount fresh. A missing,
147
+ // tampered, or stale-key entry falls through to a normal mount. No-op unless opted in.
148
+ if (await restoreDurable(page, ctx)) {
149
+ await page.onHydrate();
150
+ } else {
151
+ await page.onMount(ctx);
152
+ }
153
+
154
+ // Persist @session props back to session after onMount (captures any changes).
155
+ _persistSessionProps(page, compName, http);
156
+
157
+ // A redirect() issued from onBoot()/onMount() short-circuits the initial render —
158
+ // the page never paints, the browser is sent straight on (e.g. a landing page that
159
+ // forwards "/" to "/dashboard", or a server-side auth bounce). Without this, the
160
+ // redirect was only honoured on WebSocket actions and silently ignored on first GET.
161
+ if (page._redirectUrl) {
162
+ http.redirect(page._redirectUrl, _redirectStatus(page._redirectStatus));
163
+ return;
164
+ }
165
+
166
+ // Fill @presence member lists before the first paint (the client refreshes them on
167
+ // join/leave thereafter). No-op unless the component has @presence props.
168
+ await populatePresence(page);
169
+
170
+ // Refill @shared props from the room store before the first paint (seeding the store
171
+ // with the default when the room is new). No-op unless the component has @shared props.
172
+ populateShared(page);
173
+
174
+ const innerHtml = await _renderFlowPage(page, () => page.render());
175
+
176
+ // dehydrate() hook runs at the end of the request, before serialisation.
177
+ await page.onDehydrate();
178
+ const snapshot = dehydrate(page, { id: compId, name: compName, path });
179
+ warnIfLarge(snapshot, compName);
180
+
181
+ // Persist the durable snapshot (or clear it if clearDurable() was called). No-op unless
182
+ // the component opted into `static durable`.
183
+ await persistDurable(page, ctx, snapshot);
184
+
185
+ // toScriptJson, not JSON.stringify: the snapshot carries @expose/@locked values and
186
+ // @url props seeded from the query string, so it routinely holds attacker-controlled
187
+ // strings that would otherwise close the <script> island. See toScriptJson's docblock.
188
+ const snapshotJson = toScriptJson(snapshot);
189
+
190
+ // The flow component root — always the same wrapper regardless of layout.
191
+ const flowRoot: HtmlNode = {
192
+ html: `<div data-flow-root x-data="{}" data-flow-id="${compId}" data-flow-name="${compName}">${innerHtml}</div>`,
193
+ };
194
+
195
+ // Layout resolution. The JSX-native `layout(page)` instance hook wins; otherwise
196
+ // fall back to the legacy `static layout = SomeLayout` class. Both wrap the root in
197
+ // a [data-flow-layout] marker so the client navigate logic swaps only the
198
+ // [data-flow-root] on same-layout navigations (a full navigation otherwise).
199
+ let bodyContent: string;
200
+ let layoutHead = "";
201
+
202
+ const wrapped = await page.layout(flowRoot);
203
+ const LayoutClass = (PageClass as PageClassWithLayout).layout;
204
+
205
+ if (wrapped !== flowRoot) {
206
+ // JSX-native layout. If the layout component declared its own identity
207
+ // (data-flow-layout on its root), honour it — that's the robust way to keep the
208
+ // shell persistent across navigations, especially when the wrapper passes
209
+ // page-specific props. Otherwise fall back to a source-derived id, which matches
210
+ // for identical wrappers like `(page) => <AppLayout>{page}</AppLayout>`.
211
+ bodyContent = /\bdata-flow-layout\b/.test(wrapped.html)
212
+ ? wrapped.html
213
+ : `<div data-flow-layout="${_layoutId(page)}">${wrapped.html}</div>`;
214
+ } else if (LayoutClass) {
215
+ const layout = new LayoutClass();
216
+ const layoutNode = await layout.render(flowRoot);
217
+ bodyContent = `<div data-flow-layout="${LayoutClass.name}">${layoutNode.html}</div>`;
218
+ layoutHead = (LayoutClass as { head?: string }).head ?? "";
219
+ } else {
220
+ bodyContent = flowRoot.html;
221
+ }
222
+
223
+ const titleTag = PageClass.title ? `<title>${PageClass.title}</title>` : "";
224
+ // Component head takes precedence; layout head provides global resources.
225
+ const headExtra = [PageClass.head ?? "", layoutHead].filter(Boolean).join("\n ");
226
+
227
+ const runtimeSrc = _runtimeJs() ? "/__flow/runtime.js" : "";
228
+ const runtimeTag = runtimeSrc ? `<script src="${runtimeSrc}" defer></script>` : "";
229
+
230
+ const html = `<!DOCTYPE html>
214
231
  <html lang="en">
215
232
  <head>
216
233
  <meta charset="utf-8" />
@@ -226,7 +243,60 @@ function _makeFlowHandler(path: string, PageClass: PageClassWithMeta) {
226
243
  </body>
227
244
  </html>`;
228
245
 
229
- http.html(html);
246
+ // Outlets are filled only now: this is the first moment at which both the
247
+ // page and the layout have rendered, so every publication has happened.
248
+ const document = resolveSections(html, sections);
249
+
250
+ if (streams.pending.length === 0) {
251
+ http.html(document);
252
+ return;
253
+ }
254
+
255
+ // Streamed children: flush the document up to </body>, then append each
256
+ // child's markup as it finishes, then close. Constructed here, inside the
257
+ // request's async context, so the renders below still see the same
258
+ // HttpContext and section store.
259
+ const closing = document.lastIndexOf("</body>");
260
+ const shell = closing === -1 ? document : document.slice(0, closing);
261
+ const tail = closing === -1 ? "" : document.slice(closing);
262
+ const queued = [...streams.pending];
263
+
264
+ const encoder = new TextEncoder();
265
+ const body = new ReadableStream<Uint8Array>({
266
+ async start(controller) {
267
+ controller.enqueue(encoder.encode(shell));
268
+ for (const entry of queued) {
269
+ let markup: string;
270
+ try {
271
+ markup = await entry.render();
272
+ } catch (error) {
273
+ // The shell is already on the wire, so there is no failing the
274
+ // response — the placeholder is replaced with a notice instead.
275
+ console.error(
276
+ `[Flow] Streamed child ${entry.childId} failed to render:`,
277
+ error,
278
+ );
279
+ markup =
280
+ '<div data-flow-stream-error role="alert" class="rounded border ' +
281
+ 'border-red-300 bg-red-50 p-3 text-sm text-red-700">' +
282
+ "Something went wrong loading this section.</div>";
283
+ }
284
+ controller.enqueue(encoder.encode(streamChunk(entry.childId, markup)));
285
+ }
286
+ controller.enqueue(encoder.encode(tail));
287
+ controller.close();
288
+ },
289
+ });
290
+
291
+ http.response = new Response(body, {
292
+ headers: {
293
+ "Content-Type": "text/html; charset=utf-8",
294
+ // Proxies that buffer defeat streaming; this asks nginx not to.
295
+ "X-Accel-Buffering": "no",
296
+ },
297
+ });
298
+ }),
299
+ );
230
300
  } catch (error) {
231
301
  // Only UNEXPECTED throws get the dev overlay. Intended HTTP errors — auth (401/403), 404,
232
302
  // validation, and redirects thrown as errors — carry a numeric `status` and must keep
@@ -0,0 +1,121 @@
1
+ // ── Named sections ────────────────────────────────────────────────────────────
2
+ //
3
+ // `<SectionContent name="toolbar">` publishes markup from anywhere in the page
4
+ // tree; `<SectionOutlet name="toolbar" />` renders it somewhere else — usually
5
+ // in the layout, which is exactly where a page cannot otherwise reach.
6
+ //
7
+ // Without this, a page that wants to put buttons in the layout's toolbar has to
8
+ // thread them through every intermediate component as props, or the layout has
9
+ // to know about every page that might contribute. Sections invert that: the
10
+ // component that owns the content declares it, and the layout declares a hole.
11
+ //
12
+ // ## Why outlets resolve last
13
+ //
14
+ // An outlet emits a token and is filled in a final pass over the finished
15
+ // document, rather than reading the store when it renders. Rendering is a single
16
+ // forward pass, so a store read would make the result depend on whether the
17
+ // outlet happened to render before or after the content — which for the common
18
+ // case (outlet in the layout, content in the page) is *backwards*, since the
19
+ // layout wraps a page that has already rendered. Deferring makes order
20
+ // irrelevant in both directions.
21
+
22
+ import { AsyncLocalStorage } from "node:async_hooks";
23
+
24
+ /** One `<SectionOutlet>` awaiting content. */
25
+ interface Outlet {
26
+ name: string;
27
+ /** Markup to use when nothing was published to `name`. */
28
+ fallback: string;
29
+ }
30
+
31
+ /** Per-request section state. */
32
+ export interface SectionStore {
33
+ /** Published markup, in publication order, per section name. */
34
+ readonly content: Map<string, string[]>;
35
+ /** Outlets awaiting resolution, keyed by their token index. */
36
+ readonly outlets: Outlet[];
37
+ /**
38
+ * Per-request random suffix on the outlet token.
39
+ *
40
+ * The resolving splice is a blind `split`/`join` over finished HTML, so any
41
+ * occurrence of the token is replaced — including one a user put in a string.
42
+ * Escaping is the actual control; this makes the token unguessable so a path
43
+ * that ever misses an escape still cannot be aimed at. Same reasoning as the
44
+ * child-placeholder nonce in `jsx-runtime.ts`.
45
+ */
46
+ readonly nonce: string;
47
+ }
48
+
49
+ const _storage = new AsyncLocalStorage<SectionStore>();
50
+
51
+ /** A fresh store for one request. */
52
+ export function createSectionStore(): SectionStore {
53
+ return {
54
+ content: new Map(),
55
+ outlets: [],
56
+ nonce: crypto.randomUUID().slice(0, 8),
57
+ };
58
+ }
59
+
60
+ /** Run `fn` with `store` as the active section store for the current async chain. */
61
+ export function runWithSections<T>(store: SectionStore, fn: () => T): T {
62
+ return _storage.run(store, fn);
63
+ }
64
+
65
+ /** The active store, or `undefined` outside a page render (e.g. a WS patch). */
66
+ export function getSectionStore(): SectionStore | undefined {
67
+ return _storage.getStore();
68
+ }
69
+
70
+ /**
71
+ * Publish `html` to the section called `name`.
72
+ *
73
+ * Repeated publications to one name accumulate in order rather than replacing,
74
+ * so two components can both contribute toolbar buttons.
75
+ */
76
+ export function publishSection(name: string, html: string): void {
77
+ const store = _storage.getStore();
78
+ if (!store) return;
79
+ const existing = store.content.get(name);
80
+ if (existing) existing.push(html);
81
+ else store.content.set(name, [html]);
82
+ }
83
+
84
+ /**
85
+ * Reserve a place for section `name`, returning the token to emit.
86
+ *
87
+ * Returns `fallback` directly when there is no active store — a WS patch
88
+ * re-renders a component, not the document, so there is nothing to resolve
89
+ * against and the outlet renders its default.
90
+ */
91
+ export function reserveSection(name: string, fallback: string): string {
92
+ const store = _storage.getStore();
93
+ if (!store) return fallback;
94
+ const index = store.outlets.length;
95
+ store.outlets.push({ name, fallback });
96
+ return `<!--flow-section:${store.nonce}:${index}-->`;
97
+ }
98
+
99
+ /**
100
+ * Replace every outlet token in `html` with what was published to it.
101
+ *
102
+ * Call once, after the page *and* its layout have rendered — that is the only
103
+ * point at which every publication has happened.
104
+ */
105
+ export function resolveSections(html: string, store: SectionStore): string {
106
+ if (store.outlets.length === 0) return html;
107
+
108
+ // One pass, not one per outlet. Replacing outlets in a loop would rescan text
109
+ // that was itself just substituted, so markup published to an early section
110
+ // could go on to fill a later one. A function replacer is also what keeps `$&`
111
+ // and friends in the content from being interpreted as replacement patterns.
112
+ // The nonce is hex, so it needs no escaping to sit in this pattern.
113
+ const pattern = new RegExp(`<!--flow-section:${store.nonce}:(\\d+)-->`, "g");
114
+
115
+ return html.replace(pattern, (match, digits: string) => {
116
+ const outlet = store.outlets[Number(digits)];
117
+ if (!outlet) return match;
118
+ const published = store.content.get(outlet.name);
119
+ return published && published.length > 0 ? published.join("") : outlet.fallback;
120
+ });
121
+ }
package/src/store.ts CHANGED
@@ -33,7 +33,6 @@
33
33
  * }
34
34
  * ```
35
35
  */
36
- // eslint-disable-next-line @typescript-eslint/no-empty-object-type
37
36
  export interface FlowStore {}
38
37
 
39
38
  /** The one Alpine store name that backs `$flow.store`. */
@@ -0,0 +1,92 @@
1
+ // ── Streaming rendering ───────────────────────────────────────────────────────
2
+ //
3
+ // `<SlowWidget stream />` renders its placeholder immediately and its real
4
+ // markup later, on the *same HTTP response*. The shell reaches the browser
5
+ // without waiting for the slow child; the child's HTML arrives as a trailing
6
+ // chunk and is swapped in by a one-line inline script.
7
+ //
8
+ // ## How this differs from `lazy` / `defer`
9
+ //
10
+ // `lazy` and `defer` also paint a placeholder first, but the real render happens
11
+ // on a **second round trip**: the client mounts the child over the socket once
12
+ // it is visible (`lazy`) or once the document is parsed (`defer`). That is the
13
+ // right tool when the content may never be needed — a widget below the fold, a
14
+ // tab nobody opens.
15
+ //
16
+ // `stream` is for content that is definitely needed and merely slow. There is no
17
+ // second request, no socket involvement, and no client runtime requirement: the
18
+ // swap happens during parse, so it works before (and without) Alpine.
19
+ //
20
+ // ## Degradation
21
+ //
22
+ // Streaming needs an unbuffered response. Behind a proxy that buffers, the
23
+ // browser simply receives the whole document at once and the swap scripts run in
24
+ // order — the page is correct, just not progressive. Nothing detects or reports
25
+ // this, because there is nothing the app could do about it.
26
+
27
+ import { AsyncLocalStorage } from "node:async_hooks";
28
+
29
+ /** One child whose real markup is still being rendered. */
30
+ export interface PendingStream {
31
+ /** `data-flow-id` of the placeholder root this will replace the contents of. */
32
+ childId: string;
33
+ /** Resolves to the child's finished inner HTML plus its state script. */
34
+ render: () => Promise<string>;
35
+ }
36
+
37
+ /** Per-request streaming state. */
38
+ export interface StreamStore {
39
+ readonly pending: PendingStream[];
40
+ }
41
+
42
+ const _storage = new AsyncLocalStorage<StreamStore>();
43
+
44
+ /** A fresh store for one request. */
45
+ export function createStreamStore(): StreamStore {
46
+ return { pending: [] };
47
+ }
48
+
49
+ /** Run `fn` with `store` as the active stream store for the current async chain. */
50
+ export function runWithStreaming<T>(store: StreamStore, fn: () => T): T {
51
+ return _storage.run(store, fn);
52
+ }
53
+
54
+ /**
55
+ * The active store, or `undefined` when streaming is not available.
56
+ *
57
+ * Absent during WebSocket patches, which have no response to stream on. Callers
58
+ * treat that as "render this child inline instead", so `stream` degrades to an
59
+ * ordinary child rather than failing.
60
+ */
61
+ export function getStreamStore(): StreamStore | undefined {
62
+ return _storage.getStore();
63
+ }
64
+
65
+ /** Queue a child to be rendered after the shell has been flushed. */
66
+ export function queueStream(entry: PendingStream): void {
67
+ _storage.getStore()?.pending.push(entry);
68
+ }
69
+
70
+ /**
71
+ * The chunk that swaps one streamed child into place.
72
+ *
73
+ * The markup rides in a `<template>` so the parser will not execute or render it
74
+ * where it lands, and the script that follows moves it into the placeholder and
75
+ * removes itself. `document.currentScript` is used rather than an id lookup so
76
+ * the pair stays self-contained and repeatable.
77
+ */
78
+ export function streamChunk(childId: string, html: string): string {
79
+ const id = JSON.stringify(childId);
80
+ return (
81
+ `<template data-flow-stream-for="${_escapeAttr(childId)}">${html}</template>` +
82
+ `<script>(function(){var s=document.currentScript;var t=s.previousElementSibling;` +
83
+ `var host=document.querySelector('[data-flow-id='+CSS.escape(${id})+']');` +
84
+ `if(host&&t){host.innerHTML='';host.appendChild(t.content.cloneNode(true));` +
85
+ `host.removeAttribute('data-flow-streaming');}` +
86
+ `if(t)t.remove();s.remove();})()</script>`
87
+ );
88
+ }
89
+
90
+ function _escapeAttr(value: string): string {
91
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
92
+ }
@@ -5,7 +5,7 @@
5
5
  // - convert it to a JSON-safe primitive + metadata object
6
6
  // - reconstruct the original value from that primitive + metadata
7
7
  //
8
- // Pattern lifted directly from Livewire v3's synthesizer system.
8
+ // The synthesizer pattern: one registered handler per non-JSON type.
9
9
 
10
10
  /**
11
11
  * A synthesizer ("synth") teaches flow how to send a non-JSON type across the
@@ -19,7 +19,7 @@
19
19
  * match's {@link dehydrate} produces the stored data and its {@link key} is
20
20
  * written to `meta.s`. On the way back, {@link key} selects the synth whose
21
21
  * {@link hydrate} reconstructs the value. `hydrate` may be async (e.g. a model
22
- * synth re-fetches by id). The pattern is lifted from Livewire v3's synthesizers.
22
+ * synth re-fetches by id).
23
23
  *
24
24
  * @typeParam T - The live value type this synth handles.
25
25
  */
package/src/testing.ts CHANGED
@@ -34,7 +34,7 @@
34
34
  import type { Component } from "./Component.ts";
35
35
  import type { FlowEffects } from "./Component.ts";
36
36
  import { _renderFlowPage } from "./jsx-runtime.ts";
37
- import { dehydrate, hydrate } from "./dehydrate.ts";
37
+ import { dehydrate } from "./dehydrate.ts";
38
38
  import { ValidationError } from "./validation.ts";
39
39
  import type { Snapshot } from "./types.ts";
40
40
 
package/src/types.ts CHANGED
@@ -18,7 +18,7 @@ export type SnapshotData = Record<string, SnapshotTuple>;
18
18
 
19
19
  /**
20
20
  * Metadata for a child Component component embedded inside a parent's render output.
21
- * Stored in memo.children (excluded from HMAC signing — same as Livewire).
21
+ * Stored in memo.children (excluded from HMAC signing).
22
22
  * @category Snapshot
23
23
  */
24
24
  export interface ChildMemo {
@@ -1,6 +1,6 @@
1
1
  // ── FileUploads mixin ─────────────────────────────────────────────────────────
2
2
  //
3
- // The TypeScript equivalent of Livewire's `WithFileUploads` trait. File uploads work
3
+ // A mixin that adds server-side upload management. File uploads work
4
4
  // like any other input: a `<FileUpload>` / file `<input flow:model>` POSTs the bytes to
5
5
  // `/__flow/upload` (over HTTP), shows live progress, then `$set`s a signed reference
6
6
  // that the base Component resolves into a `TemporaryUploadedFile` (signature verified).
@@ -29,8 +29,7 @@ import { expose } from "../decorators.ts";
29
29
  type AbstractComponentCtor = abstract new (...args: any[]) => Component;
30
30
 
31
31
  /**
32
- * Component mixin that adds server-side file-upload management — flow's
33
- * equivalent of Livewire's `WithFileUploads` trait.
32
+ * Component mixin that adds server-side file-upload management.
34
33
  *
35
34
  * @remarks
36
35
  * Uploading itself needs no mixin: a `<FileUpload>` / file `<input flow:model>`
@@ -72,7 +71,7 @@ type AbstractComponentCtor = abstract new (...args: any[]) => Component;
72
71
  export function FileUploads<TBase extends AbstractComponentCtor>(Base: TBase) {
73
72
  abstract class WithUploads extends Base {
74
73
  /**
75
- * Remove a pending upload (Livewire's `removeUpload`). For a single-file property it
74
+ * Remove a pending upload. For a single-file property it
76
75
  * clears it (null); for a multiple-file array it removes the item at `index`, or all
77
76
  * when `index` is omitted.
78
77
  *
@@ -82,7 +82,7 @@ async function disk(name?: string): Promise<Disk> {
82
82
  }
83
83
 
84
84
  /**
85
- * A Livewire-style temporary uploaded file — the server-side handle a component
85
+ * A temporary uploaded file — the server-side handle a component
86
86
  * receives for a pending upload before it's promoted to permanent storage.
87
87
  *
88
88
  * @remarks