@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/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
+ }
@@ -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
+ }