@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.39

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/rsc-ssr.js ADDED
@@ -0,0 +1,440 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/router/rsc/ssr`: a document, rendered from the payload React
4
+ // Server Components wrote.
5
+ //
6
+ // `virtual:uf/server` imports this entry when routes render as Server
7
+ // Components, which is the default, and `createRenderer` from
8
+ // `@uniflowed/router/server` when they render from their modules
9
+ // (`app.rsc: false`). It is an entry of its own rather than one more export of
10
+ // `./server.js` because it reads the payload with React's Flight client,
11
+ // `react-server-dom-parcel`. That package is an optional peer and needs React
12
+ // 19.3, while the rest of the router runs on the React 19.2.3 that Expo SDK 57
13
+ // and React Native 0.87 ship (ubugeeei-prod/uf#992). A bundler resolves every
14
+ // import in the graph it is given, whether or not anything calls it, so a
15
+ // server bundle that renders no Server Component leaves the package out only if
16
+ // nothing it imports names it. `crates/uf_lib/tests/package_surface.rs` holds
17
+ // the router to that.
18
+ //
19
+ // On an older React the renderer refuses to start, naming the version it found
20
+ // and the one it needs; see `./internal/react-version.js`.
21
+
22
+ import * as React from "react";
23
+
24
+ import { addressOf } from "./internal/base-path.js";
25
+ import {
26
+ type ClientModuleLoader,
27
+ installServerModules,
28
+ readPayload,
29
+ } from "./internal/flight-ssr.js";
30
+ import { FLIGHT_CONTENT_TYPE, flightUrl } from "./internal/flight.js";
31
+ import { type StreamRecord, streamReporter } from "./internal/inspector.js";
32
+ import { requireServerComponentsReact } from "./internal/react-version.js";
33
+ import { RedirectError } from "./internal/routing.js";
34
+ import type { AppProps } from "./internal/runtime.js";
35
+ import { currentNonce } from "@uniflowed/server/host";
36
+
37
+ import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
38
+ import { type DocumentBody, prerenderDocument, renderDocument } from "./internal/stream.js";
39
+ import type { FlightRenderer } from "./rsc.js";
40
+ import type {
41
+ FlightResponse,
42
+ PrerenderResult,
43
+ RenderAssets,
44
+ RenderOptions,
45
+ RenderResult,
46
+ Renderer,
47
+ } from "./server.js";
48
+
49
+ /** What `virtual:uf/server` hands the renderer for React Server Components. */
50
+ export type DocumentRendererOptions = {|
51
+ readonly App: React.ComponentType<AppProps>,
52
+ /** The Flight renderer, from the module graph resolved under `react-server`. */
53
+ readonly renderFlight: FlightRenderer,
54
+ /** The server copy of the client module at a browser chunk URL. */
55
+ readonly loadClientModule: ClientModuleLoader,
56
+ |};
57
+
58
+ /**
59
+ * The renderer for an application rendered by React Server Components.
60
+ *
61
+ * [`createRenderer`] renders a route's modules into HTML. This one renders no
62
+ * route module at all: the Flight renderer (`./rsc.js`), in the module graph
63
+ * resolved under `react-server`, renders the route into a payload, and this
64
+ * reads that payload with React's own Flight client and renders what comes
65
+ * out. The same bytes are written into the document as they arrive, so the
66
+ * browser hydrates the tree the server rendered rather than rendering the
67
+ * route's modules a second time — which is what keeps a Server Component's
68
+ * module out of the browser. See ubugeeei-prod/uf#519.
69
+ *
70
+ * The contract with a host is [`Renderer`]'s, unchanged: `render` resolves
71
+ * when the shell is ready with a status and a body, `prerender` resolves with a
72
+ * finished document, and `error` is the exception a route fell back to its
73
+ * error boundary for. It adds `flight`, which answers a browser that is
74
+ * navigating, and `prerender` adds the payload a static host serves for one.
75
+ *
76
+ * # When the shell throws
77
+ *
78
+ * The HTML renderer only ever sees React's serialisation of what a Server
79
+ * Component threw — in a build, a sentence and a digest — and resolving the
80
+ * error boundary needs the exception itself: `forbidden()` is a 403 and a
81
+ * `RedirectError` is a redirect. So the first exception the Flight renderer
82
+ * reports is kept, as thrown, and it is what the error route is resolved for.
83
+ * An exception only the HTML renderer saw — a client component that threw
84
+ * while it rendered on the server — is resolved for as it is.
85
+ */
86
+ export function createDocumentRenderer(options: DocumentRendererOptions): Renderer {
87
+ requireServerComponentsReact("@uniflowed/router/rsc/ssr");
88
+ const { App, renderFlight } = options;
89
+ installServerModules(options.loadClientModule);
90
+
91
+ /**
92
+ * The document for one payload: React's Flight client reads one copy of the
93
+ * stream while the other copy is written into the document.
94
+ */
95
+ async function documentOf(
96
+ stream: ReadableStream<Uint8Array>,
97
+ url: string,
98
+ assets: RenderAssets,
99
+ settings: {|
100
+ readonly onError: (error: mixed) => void,
101
+ readonly transformHead?: (html: string) => Promise<string>,
102
+ readonly onStream?: (record: StreamRecord) => void,
103
+ |},
104
+ ): Promise<DocumentBody> {
105
+ const [forHtml, forBrowser] = stream.tee();
106
+ // Read rather than minted, for the reason `../server.js` gives: a project
107
+ // that has not asked for a nonce gets `null` and the document it has
108
+ // always had. Read here rather than passed in because both of this
109
+ // renderer's callers reach `documentOf`, and a render is one response.
110
+ const nonce = currentNonce();
111
+ try {
112
+ return await renderDocument(<App url={url} flight={readPayload(forHtml)} />, {
113
+ shell: shellFor(assets, nonce),
114
+ onError: settings.onError,
115
+ transformHead: settings.transformHead,
116
+ onStream: settings.onStream,
117
+ payload: forBrowser,
118
+ nonce,
119
+ });
120
+ } catch (error) {
121
+ void forBrowser.cancel();
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ async function render(
127
+ url: string,
128
+ assets: RenderAssets,
129
+ settings?: RenderOptions,
130
+ ): Promise<RenderResult> {
131
+ const report = settings?.onError ?? (() => {});
132
+ const send = settings?.onStream;
133
+ const onStream = send == null ? undefined : streamReporter(url, send);
134
+ const transformHead = settings?.transformHead;
135
+ const ledger = createErrorLedger();
136
+
137
+ // Held until the shell is known to have survived, for the reason
138
+ // [`createRenderer`] holds them — and dropped rather than reported once the
139
+ // render they came from has been given up for the error boundary. That
140
+ // render goes on reporting while it stops, and nothing it says then is
141
+ // about the response.
142
+ let streaming = false;
143
+ let abandoned = false;
144
+ let held: Array<mixed> = [];
145
+ const onError = (error: mixed) => {
146
+ if (abandoned) {
147
+ return;
148
+ }
149
+ if (streaming) {
150
+ report(error);
151
+ return;
152
+ }
153
+ held.push(error);
154
+ };
155
+
156
+ const stop = new AbortController();
157
+ const rendered = await renderFlight(url, {
158
+ onError: (error: mixed) => {
159
+ onError(error);
160
+ return ledger.record(error);
161
+ },
162
+ signal: stop.signal,
163
+ });
164
+ if (rendered.kind === "redirect") {
165
+ return redirectDocument(new RedirectError(rendered.location, rendered.status === 308));
166
+ }
167
+ let status = rendered.status;
168
+ let failure = rendered.failure;
169
+ let body: DocumentBody;
170
+ try {
171
+ body = await documentOf(rendered.stream, url, assets, {
172
+ onError: (error: mixed) => {
173
+ if (!ledger.isCopy(error)) {
174
+ onError(error);
175
+ }
176
+ },
177
+ transformHead,
178
+ onStream,
179
+ });
180
+ streaming = true;
181
+ for (const error of held) {
182
+ report(error);
183
+ }
184
+ held = [];
185
+ } catch (error) {
186
+ abandoned = true;
187
+ held = [];
188
+ stop.abort();
189
+ const cause = ledger.originalOf(error);
190
+ if (cause instanceof RedirectError) {
191
+ return redirectDocument(cause);
192
+ }
193
+ // Deliberately not caught again: this render is the boundary's own, and a
194
+ // boundary that throws has nothing left to answer with. It reaches
195
+ // `uf dev`'s overlay and fails `uf build`'s route, which is where somebody
196
+ // can fix it.
197
+ const recovered = await renderFlight(url, {
198
+ failure: { error: cause },
199
+ onError: (late: mixed) => {
200
+ report(late);
201
+ return ledger.record(late);
202
+ },
203
+ });
204
+ if (recovered.kind === "redirect") {
205
+ return redirectDocument(new RedirectError(recovered.location, recovered.status === 308));
206
+ }
207
+ status = recovered.status;
208
+ failure = recovered.failure;
209
+ try {
210
+ body = await documentOf(recovered.stream, url, assets, {
211
+ onError: (late: mixed) => {
212
+ if (!ledger.isCopy(late)) {
213
+ report(late);
214
+ }
215
+ },
216
+ transformHead,
217
+ onStream,
218
+ });
219
+ } catch (thrown) {
220
+ // The boundary's own render threw; see `prerender` for why what leaves
221
+ // is the exception as thrown rather than React's copy of it.
222
+ throw ledger.originalOf(thrown);
223
+ }
224
+ }
225
+
226
+ return { status, pipe: body.pipe, stream: body.stream, text: body.text, error: failure };
227
+ }
228
+
229
+ async function prerender(
230
+ url: string,
231
+ assets: RenderAssets,
232
+ settings?: RenderOptions,
233
+ ): Promise<PrerenderResult> {
234
+ const report = settings?.onError ?? (() => {});
235
+ const ledger = createErrorLedger();
236
+ // The Flight renderer's copy of an exception is the one reported, because it
237
+ // is the exception as thrown; the HTML renderer's copy of the same one is
238
+ // recognised by its digest and dropped. See [`createErrorLedger`].
239
+ const onServerError = (error: mixed): string => {
240
+ report(error);
241
+ return ledger.record(error);
242
+ };
243
+ const onHtmlError = (error: mixed) => {
244
+ if (!ledger.isCopy(error)) {
245
+ report(error);
246
+ }
247
+ };
248
+
249
+ // `defer: false`, for the reason [`createRenderer`]'s prerender passes it:
250
+ // a file has no fallback to show first.
251
+ const rendered = await renderFlight(url, { defer: false, onError: onServerError });
252
+ if (rendered.kind === "redirect") {
253
+ return redirectResult(
254
+ redirectDocument(new RedirectError(rendered.location, rendered.status === 308)),
255
+ );
256
+ }
257
+ let status = rendered.status;
258
+ let failure = rendered.failure;
259
+ let payload: Uint8Array;
260
+ let html: string;
261
+ try {
262
+ // Inside the `try`, with the document: a payload the Flight renderer
263
+ // could not finish — a function handed to a client component, say —
264
+ // fails the stream itself, and that is this route's failure like any
265
+ // other.
266
+ payload = await bytesOf(rendered.stream);
267
+ html = await staticDocumentOf(payload, url, assets, onHtmlError);
268
+ } catch (error) {
269
+ const cause = ledger.originalOf(error);
270
+ if (cause instanceof RedirectError) {
271
+ return redirectResult(redirectDocument(cause));
272
+ }
273
+ const recovered = await renderFlight(url, {
274
+ defer: false,
275
+ failure: { error: cause },
276
+ onError: onServerError,
277
+ });
278
+ if (recovered.kind === "redirect") {
279
+ return redirectResult(
280
+ redirectDocument(new RedirectError(recovered.location, recovered.status === 308)),
281
+ );
282
+ }
283
+ status = recovered.status;
284
+ failure = recovered.failure;
285
+ try {
286
+ payload = await bytesOf(recovered.stream);
287
+ html = await staticDocumentOf(payload, url, assets, onHtmlError);
288
+ } catch (thrown) {
289
+ // The boundary's own render threw, and nothing is left to answer with.
290
+ // What leaves is the exception as thrown rather than the copy React's
291
+ // client rebuilt from its row, whose message in a build says only that
292
+ // the real one was omitted — which is all `uf build` would then print.
293
+ throw ledger.originalOf(thrown);
294
+ }
295
+ }
296
+ return { status, html, error: failure, payload };
297
+ }
298
+
299
+ /** A finished document for a finished payload, with the payload written into it. */
300
+ function staticDocumentOf(
301
+ payload: Uint8Array,
302
+ url: string,
303
+ assets: RenderAssets,
304
+ onError: (error: mixed) => void,
305
+ ): Promise<string> {
306
+ return prerenderDocument(<App url={url} flight={readPayload(streamOf(payload))} />, {
307
+ shell: shellFor(assets),
308
+ onError,
309
+ payload: streamOf(payload),
310
+ });
311
+ }
312
+
313
+ async function flight(
314
+ url: string,
315
+ settings?: {| readonly onError?: (error: mixed) => void |},
316
+ ): Promise<FlightResponse> {
317
+ const rendered = await renderFlight(url, { onError: settings?.onError });
318
+ if (rendered.kind === "redirect") {
319
+ const { location } = rendered;
320
+ // A redirect on this origin points at its target's payload, so the
321
+ // browser's `fetch` follows it and lands on one. A redirect elsewhere is
322
+ // left as written: what the browser finds there is not a payload, and it
323
+ // loads the URL as a document instead.
324
+ const onThisOrigin = location.startsWith("/") && !location.startsWith("//");
325
+ return {
326
+ status: rendered.status,
327
+ headers: { location: onThisOrigin ? flightUrl(addressOf(location)) : location },
328
+ stream: null,
329
+ };
330
+ }
331
+ return {
332
+ status: rendered.status,
333
+ headers: { "content-type": FLIGHT_CONTENT_TYPE },
334
+ stream: rendered.stream,
335
+ error: rendered.failure,
336
+ };
337
+ }
338
+
339
+ return { render, prerender, flight };
340
+ }
341
+
342
+ /**
343
+ * Pairs each exception the Flight renderer reports with the copy of it the HTML
344
+ * renderer sees.
345
+ *
346
+ * A Server Component that throws is seen twice. The Flight renderer catches the
347
+ * exception as it was thrown and writes an error row where that part of the
348
+ * tree would have been; React's Flight client reads the row and throws an error
349
+ * rebuilt from it, which is what the HTML renderer sees at the same spot — and
350
+ * in a build, that error's message is a sentence saying the real one was
351
+ * omitted. Reporting both tells a host about one failure twice, once uselessly.
352
+ * Resolving the error boundary for the rebuilt one is worse: `forbidden()`
353
+ * would become a 500 and `redirect()` an error page, because neither survives
354
+ * being rebuilt as a plain `Error`.
355
+ *
356
+ * The row carries a digest, and that is React's field for exactly this: what
357
+ * the Flight renderer's `onError` returns is written into the row and set on the
358
+ * rebuilt error as `digest`. So every exception is recorded here under a digest
359
+ * of its own, and an error the HTML renderer reports with a recorded digest is a
360
+ * copy — dropped from the report, and exchanged for the original when the
361
+ * boundary is resolved.
362
+ *
363
+ * The digest reaches the browser inside the payload, and says nothing: it counts
364
+ * the exceptions one render has recorded.
365
+ */
366
+ function createErrorLedger(): ErrorLedger {
367
+ const originals: Map<string, mixed> = new Map();
368
+ const recorded = (error: mixed): string | null => {
369
+ const digest = digestOf(error);
370
+ return digest != null && originals.has(digest) ? digest : null;
371
+ };
372
+ return {
373
+ record(error: mixed): string {
374
+ const digest = `uf:${originals.size + 1}`;
375
+ originals.set(digest, error);
376
+ return digest;
377
+ },
378
+ isCopy(error: mixed): boolean {
379
+ return recorded(error) != null;
380
+ },
381
+ originalOf(error: mixed): mixed {
382
+ const digest = recorded(error);
383
+ return digest == null ? error : originals.get(digest);
384
+ },
385
+ };
386
+ }
387
+
388
+ /** See [`createErrorLedger`]. */
389
+ type ErrorLedger = {|
390
+ /** Keep an exception the Flight renderer reported, and return its digest. */
391
+ readonly record: (error: mixed) => string,
392
+ /** Whether an error the HTML renderer reported is the copy of a kept one. */
393
+ readonly isCopy: (error: mixed) => boolean,
394
+ /** The exception as thrown, for a copy; any other error, as it is. */
395
+ readonly originalOf: (error: mixed) => mixed,
396
+ |};
397
+
398
+ /** The digest React set on an error, when it set one. */
399
+ function digestOf(error: mixed): string | null {
400
+ if (error == null || typeof error !== "object") {
401
+ return null;
402
+ }
403
+ const tagged: { +digest?: mixed, ... } = (error: $FlowFixMe);
404
+ return typeof tagged.digest === "string" ? tagged.digest : null;
405
+ }
406
+
407
+ /** Every byte of a stream, as one array. */
408
+ async function bytesOf(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
409
+ const reader = stream.getReader();
410
+ const chunks: Array<Uint8Array> = [];
411
+ let total = 0;
412
+ while (true) {
413
+ const step = await reader.read();
414
+ if (step.done === true) {
415
+ break;
416
+ }
417
+ const chunk = step.value;
418
+ if (chunk != null) {
419
+ chunks.push(chunk);
420
+ total += chunk.byteLength;
421
+ }
422
+ }
423
+ const bytes = new Uint8Array(total);
424
+ let at = 0;
425
+ for (const chunk of chunks) {
426
+ bytes.set(chunk, at);
427
+ at += chunk.byteLength;
428
+ }
429
+ return bytes;
430
+ }
431
+
432
+ /** A stream of one array, read as many times as a caller constructs one. */
433
+ function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
434
+ return new ReadableStream({
435
+ start(controller) {
436
+ controller.enqueue(bytes);
437
+ controller.close();
438
+ },
439
+ });
440
+ }
package/rsc.js CHANGED
@@ -57,6 +57,7 @@ import { routeBoundaries } from "./internal/boundary-data.js";
57
57
  import { composeRoute, pageComponent } from "./internal/compose.js";
58
58
  import { ErrorRoutePage } from "./internal/error-view.js";
59
59
  import { type FlightRoot, routeState } from "./internal/flight.js";
60
+ import { requireServerComponentsReact } from "./internal/react-version.js";
60
61
  import type { ErrorModule, PageModule, ResolvedRoute, ResolvedSlot } from "./internal/resolve.js";
61
62
  import { resolveFailure, resolveMatch } from "./internal/resolve.js";
62
63
  import type { RouteParams, RouteTable, SearchParams } from "./internal/routing.js";
@@ -64,6 +65,8 @@ import { RedirectError, nearestBoundary } from "./internal/routing.js";
64
65
  import { withServerRoute } from "./internal/server-route.js";
65
66
 
66
67
  export type { FlightRoot, RouteState } from "./internal/flight.js";
68
+ // For `virtual:uf/rsc`, so a Server Component's `basePath()` is the project's.
69
+ export { installRouting } from "./internal/base-path.js";
67
70
 
68
71
  /** What a host may tell the renderer about one render. */
69
72
  export type FlightOptions = {|
@@ -139,6 +142,9 @@ export function createFlightRenderer(options: {|
139
142
  readonly notFound: RouteTable["notFound"],
140
143
  readonly errors: RouteTable["errors"],
141
144
  |}): FlightRenderer {
145
+ // Before anything else: on a React older than 19.3 nothing below can render,
146
+ // and React's Flight renderer would only say so from inside a render.
147
+ requireServerComponentsReact("@uniflowed/router/rsc");
142
148
  const table: RouteTable = {
143
149
  routes: options.routes,
144
150
  notFound: options.notFound,
@@ -232,6 +238,11 @@ component RoutePage(
232
238
  data: mixed,
233
239
  ) {
234
240
  const Page = pageComponent(page);
241
+ // The route module's own export, looked up by route: `pageComponent` hands
242
+ // back its `default` or `Page` as it is, so this is the same component on
243
+ // every render of the same route. The React Compiler cannot see through the
244
+ // lookup and reports a component created during render.
245
+ // uf-lint-disable-next-line react-compiler/static-components
235
246
  return <Page params={params} searchParams={searchParams} data={data} />;
236
247
  }
237
248
 
@@ -72,6 +72,10 @@ export type {
72
72
  } from "./internal/runtime.js";
73
73
 
74
74
  export { Link, RouteView, RouterProvider, routerView } from "./internal/runtime.js";
75
+ // `app.router.basePath`, for a Server Component that builds an address itself.
76
+ // The rsc entry installs it; `./internal/base-path.js` has no directive, so this
77
+ // graph gets the function rather than a reference to it.
78
+ export { basePath } from "./internal/base-path.js";
75
79
 
76
80
  export {
77
81
  ForbiddenError,