@lesto/ui 0.1.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/package.json +58 -0
- package/src/bfcache.ts +121 -0
- package/src/client.ts +54 -0
- package/src/data-client.ts +361 -0
- package/src/data-resolve.tsx +120 -0
- package/src/data.ts +183 -0
- package/src/define-island.tsx +222 -0
- package/src/errors.ts +36 -0
- package/src/hydrate.tsx +563 -0
- package/src/index.ts +165 -0
- package/src/island.ts +258 -0
- package/src/link.tsx +103 -0
- package/src/metadata.ts +195 -0
- package/src/mount.ts +99 -0
- package/src/node.ts +8 -0
- package/src/props.ts +95 -0
- package/src/registry.ts +141 -0
- package/src/render.tsx +349 -0
- package/src/resources.ts +233 -0
- package/src/route.ts +62 -0
- package/src/routes.ts +101 -0
- package/src/schema.ts +244 -0
- package/src/serialize.ts +148 -0
- package/src/server-preact.ts +59 -0
- package/src/server.ts +44 -0
- package/src/softnav-contract.ts +144 -0
- package/src/softnav.ts +934 -0
- package/src/stream.tsx +387 -0
- package/src/types.ts +46 -0
- package/src/validate.ts +107 -0
package/src/stream.tsx
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming page render — flush the shell, reveal `<Suspense>` content as it
|
|
3
|
+
* resolves.
|
|
4
|
+
*
|
|
5
|
+
* `renderPageMarkup` (see `render.tsx`) buffers the whole document into one
|
|
6
|
+
* string before a byte goes out. That is exactly right for a crawler or an SSG
|
|
7
|
+
* build, which want the finished HTML — but it makes a human wait for the
|
|
8
|
+
* slowest part of the page before seeing any of it. React 19's
|
|
9
|
+
* `renderToReadableStream` (now a unified Node path as of React 19.2) flushes the
|
|
10
|
+
* shell immediately and streams each `<Suspense>` boundary's content as its data
|
|
11
|
+
* settles, so first paint no longer waits on the tail.
|
|
12
|
+
*
|
|
13
|
+
* This module is the streaming twin of `renderPageMarkup`, and it preserves the
|
|
14
|
+
* SAME island/hydration contract:
|
|
15
|
+
*
|
|
16
|
+
* - `renderToReadableStream` is a real React *server* renderer, so it emits the
|
|
17
|
+
* `<!-- -->` text-segment markers that `hydrateRoot` walks — the very markers
|
|
18
|
+
* `renderToStaticMarkup` strips and that `renderPageMarkup` reaches for
|
|
19
|
+
* `renderToString` to keep. A streamed page therefore lets an `ssr: true`
|
|
20
|
+
* island hydrate with no special casing: the markers are always present.
|
|
21
|
+
* - The island manifest is unchanged. The caller serializes `page.islands`
|
|
22
|
+
* into the document exactly as the buffered path does (the estate example's
|
|
23
|
+
* `<script id="lesto-islands">`), and `bootstrapScriptContent`/`bootstrapModules`
|
|
24
|
+
* options let the shell carry the manifest + client bundle so hydration runs.
|
|
25
|
+
*
|
|
26
|
+
* Two exits, one render:
|
|
27
|
+
* - {@link renderPageStream} returns the live `ReadableStream` for humans.
|
|
28
|
+
* - {@link renderPageStreamToString} awaits `allReady` and drains the same
|
|
29
|
+
* stream to a complete string — the buffered path crawlers/SEO and SSG keep
|
|
30
|
+
* using. Its content equals the buffered renderer's; it just arrives via the
|
|
31
|
+
* streaming machinery so a single render configuration serves both audiences.
|
|
32
|
+
*
|
|
33
|
+
* The headers-already-sent constraint is structural, not a footnote: once
|
|
34
|
+
* {@link renderPageStream}'s shell flushes, status and headers are on the wire and
|
|
35
|
+
* cannot change. An error after that point can only be logged or the stream
|
|
36
|
+
* aborted — which is precisely what the {@link StreamErrorSink} is for. A caller
|
|
37
|
+
* that needs to *branch* on an error (a 500 page) must use the buffered exit,
|
|
38
|
+
* where the whole document resolves before anything is sent.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { ReactElement } from "react";
|
|
42
|
+
import { renderToReadableStream } from "react-dom/server";
|
|
43
|
+
|
|
44
|
+
import { UiError } from "./errors";
|
|
45
|
+
import type { Page } from "./render";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where a render error that surfaces *during streaming* goes.
|
|
49
|
+
*
|
|
50
|
+
* React calls `onError` for any error thrown while rendering — including inside a
|
|
51
|
+
* `<Suspense>` boundary that resolves after the shell has flushed. By then the
|
|
52
|
+
* status and headers are already on the wire, so the only honest responses are to
|
|
53
|
+
* log it and/or let React abort the affected subtree (it substitutes the
|
|
54
|
+
* boundary's fallback and surfaces the error to the client for a `hydrateRoot`
|
|
55
|
+
* recovery). The sink is injectable so an app wires it to its logger and a test
|
|
56
|
+
* can assert it fired without a real failure escaping.
|
|
57
|
+
*
|
|
58
|
+
* It mirrors React's own `onError(error, errorInfo)` signature so nothing is lost
|
|
59
|
+
* in translation; `errorInfo` carries a `componentStack` when React has one.
|
|
60
|
+
*/
|
|
61
|
+
export type StreamErrorSink = (error: unknown, errorInfo: ErrorInfo) => void;
|
|
62
|
+
|
|
63
|
+
/** The diagnostic context React passes alongside a streamed render error. */
|
|
64
|
+
export interface ErrorInfo {
|
|
65
|
+
componentStack?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Options for a streamed render.
|
|
70
|
+
*
|
|
71
|
+
* `onError` is the streamed-error sink (defaults to a `console.error` that names
|
|
72
|
+
* the package, so an unhandled streaming error is never silent). `bootstrapModules`
|
|
73
|
+
* and `bootstrapScriptContent` are passed straight through to React: the former
|
|
74
|
+
* injects `<script type="module" src=…>` tags (the island client bundle) into the
|
|
75
|
+
* shell, the latter an inline script (the serialized island manifest), so a
|
|
76
|
+
* streamed page bootstraps hydration the same way a buffered document's shell does.
|
|
77
|
+
*/
|
|
78
|
+
export interface StreamOptions {
|
|
79
|
+
onError?: StreamErrorSink;
|
|
80
|
+
|
|
81
|
+
bootstrapModules?: readonly string[];
|
|
82
|
+
|
|
83
|
+
bootstrapScriptContent?: string;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A caller/transport abort signal — typically a request's, fired when the
|
|
87
|
+
* client disconnects. React keeps a suspended render (and the socket) alive
|
|
88
|
+
* until its data settles; a disconnected client should cancel it, not pay for
|
|
89
|
+
* a render no one will read. Chained with {@link renderTimeoutMs}: whichever
|
|
90
|
+
* fires first aborts the render.
|
|
91
|
+
*/
|
|
92
|
+
signal?: AbortSignal;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A hard render deadline in milliseconds. React ships NO default timeout, so a
|
|
96
|
+
* slow or never-resolving `<Suspense>` boundary would hold the render and the
|
|
97
|
+
* socket open indefinitely — a streaming DoS. When set, the render is aborted
|
|
98
|
+
* past the deadline with a coded {@link UiError} `UI_STREAM_TIMEOUT` as the
|
|
99
|
+
* abort reason, so `onError` can tell a timeout from a genuine render error.
|
|
100
|
+
*/
|
|
101
|
+
renderTimeoutMs?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The slice of `renderToReadableStream` this module needs — its result.
|
|
106
|
+
*
|
|
107
|
+
* React's stream is a `ReadableStream` carrying one extra promise, `allReady`,
|
|
108
|
+
* that settles when *every* `<Suspense>` boundary has resolved (the whole
|
|
109
|
+
* document is rendered). The buffered exit awaits it; the streaming exit ignores
|
|
110
|
+
* it (the shell is already flowing).
|
|
111
|
+
*/
|
|
112
|
+
export interface ReactRenderStream extends ReadableStream<Uint8Array> {
|
|
113
|
+
allReady: Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `renderToReadableStream` seam.
|
|
118
|
+
*
|
|
119
|
+
* Named and injectable for one reason: `renderToReadableStream` is async and its
|
|
120
|
+
* error/abort behavior is awkward to drive through the real renderer in a unit
|
|
121
|
+
* test (a post-shell error needs a genuinely suspended, then-rejecting child).
|
|
122
|
+
* A test substitutes a stand-in to exercise the `onError` plumbing and the
|
|
123
|
+
* drain/`allReady` logic deterministically, while the default is the real React
|
|
124
|
+
* renderer used in production.
|
|
125
|
+
*/
|
|
126
|
+
export type RenderToReadableStream = (
|
|
127
|
+
element: ReactElement,
|
|
128
|
+
options: {
|
|
129
|
+
onError?: (error: unknown, errorInfo: ErrorInfo) => void;
|
|
130
|
+
bootstrapModules?: string[];
|
|
131
|
+
bootstrapScriptContent?: string;
|
|
132
|
+
signal?: AbortSignal;
|
|
133
|
+
},
|
|
134
|
+
) => Promise<ReactRenderStream>;
|
|
135
|
+
|
|
136
|
+
const reactRenderToReadableStream = renderToReadableStream as unknown as RenderToReadableStream;
|
|
137
|
+
|
|
138
|
+
/** Default sink: surface a streamed render error on the console, never swallow it. */
|
|
139
|
+
const consoleStreamError: StreamErrorSink = (error) => {
|
|
140
|
+
console.error("[lesto/ui] streamed render error", error);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** A render's effective abort signal, plus `clear` to disarm its deadline timer. */
|
|
144
|
+
interface RenderAbort {
|
|
145
|
+
signal: AbortSignal | undefined;
|
|
146
|
+
|
|
147
|
+
clear: () => void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Fold the caller's abort signal and a render deadline into the single signal
|
|
152
|
+
* handed to React.
|
|
153
|
+
*
|
|
154
|
+
* With no `timeoutMs` we pass the caller's signal straight through (or none) and
|
|
155
|
+
* `clear` is a no-op. With a deadline we arm a timer that aborts the render with
|
|
156
|
+
* a coded {@link UiError} `UI_STREAM_TIMEOUT` — a *typed* reason, so an `onError`
|
|
157
|
+
* sink can tell "timed out" from a genuine render error — and chain the caller's
|
|
158
|
+
* signal in: an already-aborted caller signal aborts at once, otherwise whichever
|
|
159
|
+
* of timer/caller fires first wins. `clear` cancels the timer so a render that
|
|
160
|
+
* finished in time leaves no pending deadline behind.
|
|
161
|
+
*/
|
|
162
|
+
function renderAbort(signal: AbortSignal | undefined, timeoutMs: number | undefined): RenderAbort {
|
|
163
|
+
if (timeoutMs === undefined) {
|
|
164
|
+
return { signal, clear: () => {} };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
controller.abort(
|
|
171
|
+
new UiError("UI_STREAM_TIMEOUT", `streamed render exceeded its ${timeoutMs}ms deadline`, {
|
|
172
|
+
ms: timeoutMs,
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}, timeoutMs);
|
|
176
|
+
|
|
177
|
+
if (signal !== undefined) {
|
|
178
|
+
if (signal.aborted) {
|
|
179
|
+
controller.abort(signal.reason);
|
|
180
|
+
} else {
|
|
181
|
+
signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { signal: controller.signal, clear: () => clearTimeout(timer) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Render a built {@link Page} to a live HTML stream that flushes the shell first.
|
|
190
|
+
*
|
|
191
|
+
* Returns a `ReadableStream<Uint8Array>` the transport pipes straight to the
|
|
192
|
+
* socket: React writes the shell (everything outside an unresolved `<Suspense>`)
|
|
193
|
+
* immediately, then streams each boundary's real content as its data settles.
|
|
194
|
+
* The island manifest is unchanged — pass the client bundle via
|
|
195
|
+
* `bootstrapModules` and the serialized manifest via `bootstrapScriptContent`
|
|
196
|
+
* (or emit them yourself around the body) so an `ssr: true` island hydrates from
|
|
197
|
+
* the streamed markup, whose text markers React's server renderer always emits.
|
|
198
|
+
*
|
|
199
|
+
* A page whose element degraded to `null` has nothing to render: we return an
|
|
200
|
+
* already-closed empty stream rather than invoke React on nothing, mirroring
|
|
201
|
+
* `renderPageMarkup`'s empty-string for the same case.
|
|
202
|
+
*
|
|
203
|
+
* Errors that surface *after* the shell flushes can only be logged/aborted (the
|
|
204
|
+
* headers are gone) — they go to {@link StreamOptions.onError}. The returned
|
|
205
|
+
* promise still rejects if the *shell itself* fails to render, because nothing
|
|
206
|
+
* has been sent yet and the caller can choose a buffered error response.
|
|
207
|
+
*/
|
|
208
|
+
export async function renderPageStream(
|
|
209
|
+
page: Page,
|
|
210
|
+
options: StreamOptions = {},
|
|
211
|
+
render: RenderToReadableStream = reactRenderToReadableStream,
|
|
212
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
213
|
+
if (page.element === null) return emptyStream();
|
|
214
|
+
|
|
215
|
+
const onError = options.onError ?? consoleStreamError;
|
|
216
|
+
|
|
217
|
+
const aborter = renderAbort(options.signal, options.renderTimeoutMs);
|
|
218
|
+
|
|
219
|
+
// Only forward optional fields React understands; `exactOptionalPropertyTypes`
|
|
220
|
+
// forbids handing it `undefined`, so each is included only when present.
|
|
221
|
+
const stream = await render(page.element, {
|
|
222
|
+
onError,
|
|
223
|
+
...(aborter.signal !== undefined ? { signal: aborter.signal } : {}),
|
|
224
|
+
...(options.bootstrapModules !== undefined
|
|
225
|
+
? { bootstrapModules: [...options.bootstrapModules] }
|
|
226
|
+
: {}),
|
|
227
|
+
...(options.bootstrapScriptContent !== undefined
|
|
228
|
+
? { bootstrapScriptContent: options.bootstrapScriptContent }
|
|
229
|
+
: {}),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Disarm the deadline once the whole document has settled (resolved, errored,
|
|
233
|
+
// or aborted): the live stream may still be draining to a slow client, but the
|
|
234
|
+
// render itself is done, so the timer has no more work. Both settle paths clear
|
|
235
|
+
// it; we swallow any rejection here so an aborted/errored render never surfaces
|
|
236
|
+
// as an unhandled rejection — the live stream's errors travel via `onError`.
|
|
237
|
+
void Promise.resolve(stream.allReady).then(aborter.clear, aborter.clear);
|
|
238
|
+
|
|
239
|
+
return stream;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Render a built {@link Page} to a COMPLETE HTML string — the buffered exit.
|
|
244
|
+
*
|
|
245
|
+
* This is the crawler/SEO and SSG/prerender path: it awaits the stream's
|
|
246
|
+
* `allReady` (every `<Suspense>` boundary resolved) and then drains the whole
|
|
247
|
+
* stream to one string, so the bytes are identical in content to what a buffered
|
|
248
|
+
* renderer would produce — the slow children are present, not their fallbacks.
|
|
249
|
+
* Use it wherever a finished document is required and progressive reveal is not
|
|
250
|
+
* (a bot that does not run JS, a static file written to disk).
|
|
251
|
+
*
|
|
252
|
+
* It deliberately shares the streaming render path rather than reaching for
|
|
253
|
+
* `renderToString`, so a page renders byte-identically whether a human streams it
|
|
254
|
+
* or a crawler buffers it — one render configuration, two audiences, no drift.
|
|
255
|
+
* (`renderPageMarkup` remains the untouched, dependency-light buffered API for
|
|
256
|
+
* callers that never opt into streaming at all; this is the buffered *exit of the
|
|
257
|
+
* streaming path*.)
|
|
258
|
+
*
|
|
259
|
+
* COMPLETENESS IS NOT SILENT-BEST-EFFORT. `renderToReadableStream` resolves
|
|
260
|
+
* `allReady` even when a `<Suspense>` boundary ERRORED — it does not reject. A
|
|
261
|
+
* boundary that threw (or whose data rejected) is "switched to client rendering":
|
|
262
|
+
* the drained string then holds React's error marker (`<!--$!-->`), a recovery
|
|
263
|
+
* `<template data-msg="Switched to client rendering because the server rendering
|
|
264
|
+
* errored: …">`, and the boundary's FALLBACK — never its real content. For the
|
|
265
|
+
* cited audience (a no-JS crawler, a static file written to disk) that is degraded,
|
|
266
|
+
* un-indexable markup with no second chance to recover on the client. So this exit
|
|
267
|
+
* does NOT quietly return that string: it watches whether React reported an error
|
|
268
|
+
* during the render and, once `allReady` settles, throws a coded
|
|
269
|
+
* {@link UiError} `UI_STREAM_INCOMPLETE`. The SSG/crawler caller catches it and
|
|
270
|
+
* falls back (a buffered error page, a retry, surfacing a real failure) instead of
|
|
271
|
+
* persisting a half-rendered document. A caller that legitimately wants the live,
|
|
272
|
+
* progressively-recovering stream uses {@link renderPageStream}, where an errored
|
|
273
|
+
* boundary's client-recovery is the intended behavior. `options.onError` still
|
|
274
|
+
* fires for every error (it is the caller's log sink); the throw is the additional,
|
|
275
|
+
* load-bearing signal the string return cannot carry.
|
|
276
|
+
*
|
|
277
|
+
* A null-element page yields `""`, exactly like `renderPageMarkup`.
|
|
278
|
+
*
|
|
279
|
+
* @throws {UiError} `UI_STREAM_INCOMPLETE` if any boundary errored during the
|
|
280
|
+
* render, so the drained string would be incomplete (recovery template + fallback,
|
|
281
|
+
* not real content).
|
|
282
|
+
*/
|
|
283
|
+
export async function renderPageStreamToString(
|
|
284
|
+
page: Page,
|
|
285
|
+
options: StreamOptions = {},
|
|
286
|
+
render: RenderToReadableStream = reactRenderToReadableStream,
|
|
287
|
+
): Promise<string> {
|
|
288
|
+
if (page.element === null) return "";
|
|
289
|
+
|
|
290
|
+
const sink = options.onError ?? consoleStreamError;
|
|
291
|
+
|
|
292
|
+
// Wrap the caller's sink so we learn — without disturbing it — whether React
|
|
293
|
+
// reported ANY error during this render. `renderToReadableStream` resolves
|
|
294
|
+
// `allReady` even when a boundary errored (it switches that subtree to client
|
|
295
|
+
// rendering rather than rejecting), so the flag, not the promise, is how the
|
|
296
|
+
// buffered exit detects an incomplete document. The first error is captured for
|
|
297
|
+
// the thrown error's `cause`; the caller's sink still sees every one.
|
|
298
|
+
let renderError: unknown;
|
|
299
|
+
|
|
300
|
+
let errored = false;
|
|
301
|
+
|
|
302
|
+
const onError: StreamErrorSink = (error, errorInfo) => {
|
|
303
|
+
if (!errored) {
|
|
304
|
+
errored = true;
|
|
305
|
+
renderError = error;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
sink(error, errorInfo);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const aborter = renderAbort(options.signal, options.renderTimeoutMs);
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const stream = await render(page.element, {
|
|
315
|
+
onError,
|
|
316
|
+
...(aborter.signal !== undefined ? { signal: aborter.signal } : {}),
|
|
317
|
+
...(options.bootstrapModules !== undefined
|
|
318
|
+
? { bootstrapModules: [...options.bootstrapModules] }
|
|
319
|
+
: {}),
|
|
320
|
+
...(options.bootstrapScriptContent !== undefined
|
|
321
|
+
? { bootstrapScriptContent: options.bootstrapScriptContent }
|
|
322
|
+
: {}),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Wait for the entire document — including every suspended child — before
|
|
326
|
+
// draining, so the buffered string holds the resolved content, not fallbacks.
|
|
327
|
+
await stream.allReady;
|
|
328
|
+
|
|
329
|
+
// A boundary errored: `allReady` still resolved, but the drained string would
|
|
330
|
+
// be degraded (error marker + client-recovery template + fallback, not real
|
|
331
|
+
// content). The buffered audience (crawler/SSG) cannot recover on the client,
|
|
332
|
+
// so we refuse to hand back half-rendered HTML — throw a coded error the
|
|
333
|
+
// caller can catch and fall back on, instead of silently writing it to disk.
|
|
334
|
+
if (errored) {
|
|
335
|
+
throw new UiError(
|
|
336
|
+
"UI_STREAM_INCOMPLETE",
|
|
337
|
+
"buffered streamed render is incomplete: a <Suspense> boundary errored, so the HTML " +
|
|
338
|
+
"holds the fallback and a client-recovery marker, not the real content",
|
|
339
|
+
{ cause: renderError },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return drainToString(stream);
|
|
344
|
+
} finally {
|
|
345
|
+
// Always disarm the deadline — on success, on UI_STREAM_INCOMPLETE, or on a
|
|
346
|
+
// timeout/caller abort that rejected `allReady` — so no pending timer is left.
|
|
347
|
+
aborter.clear();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Drain a UTF-8 byte stream to a single string, releasing the reader at the end. */
|
|
352
|
+
async function drainToString(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
353
|
+
const reader = stream.getReader();
|
|
354
|
+
|
|
355
|
+
const decoder = new TextDecoder();
|
|
356
|
+
|
|
357
|
+
let out = "";
|
|
358
|
+
|
|
359
|
+
// A read loop, not `for await`: the Web stream reader is the lowest common
|
|
360
|
+
// denominator across the runtimes this code targets, and `releaseLock` in a
|
|
361
|
+
// `finally` guarantees the stream is not left locked even if decoding throws.
|
|
362
|
+
try {
|
|
363
|
+
for (;;) {
|
|
364
|
+
const { done, value } = await reader.read();
|
|
365
|
+
|
|
366
|
+
if (done) break;
|
|
367
|
+
|
|
368
|
+
out += decoder.decode(value, { stream: true });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Flush any multibyte character left straddling the final chunk boundary.
|
|
372
|
+
out += decoder.decode();
|
|
373
|
+
|
|
374
|
+
return out;
|
|
375
|
+
} finally {
|
|
376
|
+
reader.releaseLock();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** An already-closed, empty byte stream — the null-element body. */
|
|
381
|
+
function emptyStream(): ReadableStream<Uint8Array> {
|
|
382
|
+
return new ReadableStream<Uint8Array>({
|
|
383
|
+
start(controller) {
|
|
384
|
+
controller.close();
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary an AI uses to describe UI, and the shapes the engine renders.
|
|
3
|
+
*
|
|
4
|
+
* A component is declared once in a `ComponentDef`: what props it accepts, what
|
|
5
|
+
* children it may hold, and how to turn validated props into a React element.
|
|
6
|
+
* The AI never sees React — it emits a plain JSON `UiNode` tree, which the
|
|
7
|
+
* engine validates against the registry and renders.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ReactElement, ReactNode } from "react";
|
|
11
|
+
|
|
12
|
+
/** The primitive kinds a prop may take. `enum` is a string constrained to `values`. */
|
|
13
|
+
export type PropType = "string" | "number" | "boolean" | "enum" | "object" | "array";
|
|
14
|
+
|
|
15
|
+
/** The contract for a single prop: its type plus optional constraints and defaults. */
|
|
16
|
+
export interface PropSpec {
|
|
17
|
+
type: PropType;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
values?: readonly string[];
|
|
20
|
+
default?: unknown;
|
|
21
|
+
description?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What children a component accepts.
|
|
26
|
+
* false — a leaf; no children allowed
|
|
27
|
+
* true — any registered component
|
|
28
|
+
* [names] — only these component types
|
|
29
|
+
*/
|
|
30
|
+
export type ChildrenPolicy = boolean | string[];
|
|
31
|
+
|
|
32
|
+
/** A vetted component: its prop schema, child policy, and render function. */
|
|
33
|
+
export interface ComponentDef {
|
|
34
|
+
name: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
props: Record<string, PropSpec>;
|
|
37
|
+
children: ChildrenPolicy;
|
|
38
|
+
render: (props: Record<string, unknown>, children: ReactNode) => ReactElement;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A node in the AI-emitted UI tree — plain JSON, no React. */
|
|
42
|
+
export interface UiNode {
|
|
43
|
+
type: string;
|
|
44
|
+
props?: Record<string, unknown>;
|
|
45
|
+
children?: unknown[];
|
|
46
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, React-free validation of a UI tree against the registry.
|
|
3
|
+
*
|
|
4
|
+
* It walks the tree and reports, with a JSON-pointer-ish `path` for each issue:
|
|
5
|
+
* - an unknown component type
|
|
6
|
+
* - a missing required prop
|
|
7
|
+
* - a child that the parent's `ChildrenPolicy` forbids
|
|
8
|
+
*
|
|
9
|
+
* It never renders and never throws — it returns a verdict the caller acts on.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isNodeObject } from "./node";
|
|
13
|
+
import { validateProps } from "./props";
|
|
14
|
+
import type { Registry } from "./registry";
|
|
15
|
+
import type { ChildrenPolicy } from "./types";
|
|
16
|
+
|
|
17
|
+
/** One thing wrong with the tree, located by `path`. */
|
|
18
|
+
export interface TreeError {
|
|
19
|
+
path: string;
|
|
20
|
+
type: string;
|
|
21
|
+
detail?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Does `policy` permit a child of component type `childType`? */
|
|
25
|
+
function allowsChild(policy: ChildrenPolicy, childType: string): boolean {
|
|
26
|
+
// `true` = any registered component; `false` = none; a list = only its members.
|
|
27
|
+
if (policy === true) return true;
|
|
28
|
+
|
|
29
|
+
if (policy === false) return false;
|
|
30
|
+
|
|
31
|
+
return policy.includes(childType);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Validate a tree. Pure: no throws, no React. */
|
|
35
|
+
export function validateTree(
|
|
36
|
+
registry: Registry,
|
|
37
|
+
tree: unknown,
|
|
38
|
+
): { valid: boolean; errors: TreeError[] } {
|
|
39
|
+
const errors: TreeError[] = [];
|
|
40
|
+
|
|
41
|
+
walk(registry, tree, "$", errors);
|
|
42
|
+
|
|
43
|
+
return { valid: errors.length === 0, errors };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Recursively check one node and its children, appending to `errors`. */
|
|
47
|
+
function walk(registry: Registry, node: unknown, path: string, errors: TreeError[]): void {
|
|
48
|
+
// A bare string is always a valid text leaf — nothing to check.
|
|
49
|
+
if (typeof node === "string") return;
|
|
50
|
+
|
|
51
|
+
// Anything else that isn't a node object is malformed.
|
|
52
|
+
if (!isNodeObject(node)) {
|
|
53
|
+
errors.push({ path, type: "invalid_node", detail: "node must be a string or an object" });
|
|
54
|
+
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// An island validates against its client schema and is, to the server tree, a
|
|
59
|
+
// leaf: the client component owns whatever lives inside it, so the JSON tree
|
|
60
|
+
// never describes an island's children.
|
|
61
|
+
const client = registry.getClient(node.type);
|
|
62
|
+
|
|
63
|
+
if (client !== undefined) {
|
|
64
|
+
const { errors: islandErrors } = validateProps(client.props ?? {}, node.props ?? {});
|
|
65
|
+
|
|
66
|
+
for (const detail of islandErrors) {
|
|
67
|
+
errors.push({ path, type: "invalid_props", detail });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const [index, child] of (node.children ?? []).entries()) {
|
|
71
|
+
const detail = isNodeObject(child) ? child.type : typeof child;
|
|
72
|
+
|
|
73
|
+
errors.push({ path: `${path}.children[${index}]`, type: "disallowed_child", detail });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const def = registry.get(node.type);
|
|
80
|
+
|
|
81
|
+
// Unknown component: we can't validate props or children against nothing.
|
|
82
|
+
if (def === undefined) {
|
|
83
|
+
errors.push({ path, type: "unknown_component", detail: node.type });
|
|
84
|
+
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Required props, via the shared validator (enum/required rules live there).
|
|
89
|
+
const { errors: propErrors } = validateProps(def.props, node.props ?? {});
|
|
90
|
+
|
|
91
|
+
for (const detail of propErrors) {
|
|
92
|
+
errors.push({ path, type: "invalid_props", detail });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const children = node.children ?? [];
|
|
96
|
+
|
|
97
|
+
for (const [index, child] of children.entries()) {
|
|
98
|
+
const childPath = `${path}.children[${index}]`;
|
|
99
|
+
|
|
100
|
+
// A non-string child must be an allowed component type for this parent.
|
|
101
|
+
if (isNodeObject(child) && !allowsChild(def.children, child.type)) {
|
|
102
|
+
errors.push({ path: childPath, type: "disallowed_child", detail: child.type });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
walk(registry, child, childPath, errors);
|
|
106
|
+
}
|
|
107
|
+
}
|