@zerotal/flow 1.3.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/CHANGELOG.md +317 -4
- package/README.md +1 -1
- package/package.json +8 -9
- package/src/Component.ts +214 -41
- package/src/browser/FlowBrowser.ts +270 -0
- package/src/browser/cdp.ts +273 -0
- package/src/browser/index.ts +19 -0
- package/src/client/FlowComponent.ts +1 -1
- package/src/client/bridge.ts +345 -38
- package/src/client/headless.ts +2 -1
- package/src/compiler/directives.ts +1 -0
- package/src/compiler/transform.ts +4 -0
- package/src/components.ts +286 -4
- package/src/config.ts +2 -2
- package/src/decorators.ts +122 -35
- package/src/dehydrate.ts +2 -2
- package/src/events.ts +1 -1
- package/src/index.ts +8 -2
- package/src/jsx-runtime.ts +90 -7
- package/src/pagination.ts +5 -7
- package/src/provider/FlowProvider.ts +46 -13
- package/src/router.ts +186 -116
- package/src/sections.ts +121 -0
- package/src/store.ts +0 -1
- package/src/streaming.ts +92 -0
- package/src/synths/index.ts +2 -2
- package/src/testing.ts +1 -1
- package/src/types.ts +1 -1
- package/src/uploads/FileUploads.ts +3 -4
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
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
|
-
* (
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
package/src/sections.ts
ADDED
|
@@ -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
package/src/streaming.ts
ADDED
|
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
92
|
+
}
|
package/src/synths/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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).
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|