@uniflowed/router 0.0.0-alpha.9 → 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/action.js +324 -0
- package/client.js +261 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +438 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1593 -1341
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +125 -0
- package/internal/stream.js +754 -21
- package/middleware.js +161 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +637 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +254 -106
package/rsc-ssr.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
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 { withoutErrorRows } from "./internal/flight-rows.js";
|
|
26
|
+
import {
|
|
27
|
+
type ClientModuleLoader,
|
|
28
|
+
installServerModules,
|
|
29
|
+
readPayload,
|
|
30
|
+
} from "./internal/flight-ssr.js";
|
|
31
|
+
import { FLIGHT_CONTENT_TYPE, INTERCEPTED_FROM_HEADER, flightUrl } from "./internal/flight.js";
|
|
32
|
+
import { type StreamRecord, streamReporter } from "./internal/inspector.js";
|
|
33
|
+
import { requireServerComponentsReact } from "./internal/react-version.js";
|
|
34
|
+
import { RedirectError } from "./internal/routing.js";
|
|
35
|
+
import type { AppProps } from "./internal/runtime.js";
|
|
36
|
+
import {
|
|
37
|
+
currentNonce,
|
|
38
|
+
isPostponedRead,
|
|
39
|
+
newPartialPrerender,
|
|
40
|
+
runPartialPrerender,
|
|
41
|
+
} from "@uniflowed/server/host";
|
|
42
|
+
|
|
43
|
+
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
44
|
+
import {
|
|
45
|
+
type DocumentBody,
|
|
46
|
+
type PrerenderedShell,
|
|
47
|
+
prerenderDocument,
|
|
48
|
+
prerenderShell,
|
|
49
|
+
renderDocument,
|
|
50
|
+
resumeDocument,
|
|
51
|
+
} from "./internal/stream.js";
|
|
52
|
+
import type { FlightRenderer } from "./rsc.js";
|
|
53
|
+
import type {
|
|
54
|
+
FlightResponse,
|
|
55
|
+
PrerenderResult,
|
|
56
|
+
RenderAssets,
|
|
57
|
+
RenderOptions,
|
|
58
|
+
RenderResult,
|
|
59
|
+
Renderer,
|
|
60
|
+
} from "./server.js";
|
|
61
|
+
|
|
62
|
+
/** What `virtual:uf/server` hands the renderer for React Server Components. */
|
|
63
|
+
export type DocumentRendererOptions = {|
|
|
64
|
+
readonly App: React.ComponentType<AppProps>,
|
|
65
|
+
/** The Flight renderer, from the module graph resolved under `react-server`. */
|
|
66
|
+
readonly renderFlight: FlightRenderer,
|
|
67
|
+
/** The server copy of the client module at a browser chunk URL. */
|
|
68
|
+
readonly loadClientModule: ClientModuleLoader,
|
|
69
|
+
|};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The renderer for an application rendered by React Server Components.
|
|
73
|
+
*
|
|
74
|
+
* [`createRenderer`] renders a route's modules into HTML. This one renders no
|
|
75
|
+
* route module at all: the Flight renderer (`./rsc.js`), in the module graph
|
|
76
|
+
* resolved under `react-server`, renders the route into a payload, and this
|
|
77
|
+
* reads that payload with React's own Flight client and renders what comes
|
|
78
|
+
* out. The same bytes are written into the document as they arrive, so the
|
|
79
|
+
* browser hydrates the tree the server rendered rather than rendering the
|
|
80
|
+
* route's modules a second time — which is what keeps a Server Component's
|
|
81
|
+
* module out of the browser. See ubugeeei-prod/uf#519.
|
|
82
|
+
*
|
|
83
|
+
* The contract with a host is [`Renderer`]'s, unchanged: `render` resolves
|
|
84
|
+
* when the shell is ready with a status and a body, `prerender` resolves with a
|
|
85
|
+
* finished document, and `error` is the exception a route fell back to its
|
|
86
|
+
* error boundary for. It adds `flight`, which answers a browser that is
|
|
87
|
+
* navigating, and `prerender` adds the payload a static host serves for one.
|
|
88
|
+
*
|
|
89
|
+
* # When the shell throws
|
|
90
|
+
*
|
|
91
|
+
* The HTML renderer only ever sees React's serialisation of what a Server
|
|
92
|
+
* Component threw — in a build, a sentence and a digest — and resolving the
|
|
93
|
+
* error boundary needs the exception itself: `forbidden()` is a 403 and a
|
|
94
|
+
* `RedirectError` is a redirect. So the first exception the Flight renderer
|
|
95
|
+
* reports is kept, as thrown, and it is what the error route is resolved for.
|
|
96
|
+
* An exception only the HTML renderer saw — a client component that threw
|
|
97
|
+
* while it rendered on the server — is resolved for as it is.
|
|
98
|
+
*/
|
|
99
|
+
export function createDocumentRenderer(options: DocumentRendererOptions): Renderer {
|
|
100
|
+
requireServerComponentsReact("@uniflowed/router/rsc/ssr");
|
|
101
|
+
const { App, renderFlight } = options;
|
|
102
|
+
installServerModules(options.loadClientModule);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The document for one payload: React's Flight client reads one copy of the
|
|
106
|
+
* stream while the other copy is written into the document.
|
|
107
|
+
*/
|
|
108
|
+
async function documentOf(
|
|
109
|
+
stream: ReadableStream<Uint8Array>,
|
|
110
|
+
url: string,
|
|
111
|
+
assets: RenderAssets,
|
|
112
|
+
settings: {|
|
|
113
|
+
readonly onError: (error: mixed) => void,
|
|
114
|
+
readonly transformHead?: (html: string) => Promise<string>,
|
|
115
|
+
readonly onStream?: (record: StreamRecord) => void,
|
|
116
|
+
|},
|
|
117
|
+
): Promise<DocumentBody> {
|
|
118
|
+
const [forHtml, forBrowser] = stream.tee();
|
|
119
|
+
// Read rather than minted, for the reason `../server.js` gives: a project
|
|
120
|
+
// that has not asked for a nonce gets `null` and the document it has
|
|
121
|
+
// always had. Read here rather than passed in because both of this
|
|
122
|
+
// renderer's callers reach `documentOf`, and a render is one response.
|
|
123
|
+
const nonce = currentNonce();
|
|
124
|
+
try {
|
|
125
|
+
return await renderDocument(<App url={url} flight={readPayload(forHtml)} />, {
|
|
126
|
+
shell: shellFor(assets, nonce),
|
|
127
|
+
onError: settings.onError,
|
|
128
|
+
transformHead: settings.transformHead,
|
|
129
|
+
onStream: settings.onStream,
|
|
130
|
+
payload: forBrowser,
|
|
131
|
+
nonce,
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
void forBrowser.cancel();
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function render(
|
|
140
|
+
url: string,
|
|
141
|
+
assets: RenderAssets,
|
|
142
|
+
settings?: RenderOptions,
|
|
143
|
+
): Promise<RenderResult> {
|
|
144
|
+
const report = settings?.onError ?? (() => {});
|
|
145
|
+
const send = settings?.onStream;
|
|
146
|
+
const onStream = send == null ? undefined : streamReporter(url, send);
|
|
147
|
+
const transformHead = settings?.transformHead;
|
|
148
|
+
const ledger = createErrorLedger();
|
|
149
|
+
|
|
150
|
+
// Held until the shell is known to have survived, for the reason
|
|
151
|
+
// [`createRenderer`] holds them — and dropped rather than reported once the
|
|
152
|
+
// render they came from has been given up for the error boundary. That
|
|
153
|
+
// render goes on reporting while it stops, and nothing it says then is
|
|
154
|
+
// about the response.
|
|
155
|
+
let streaming = false;
|
|
156
|
+
let abandoned = false;
|
|
157
|
+
let held: Array<mixed> = [];
|
|
158
|
+
const onError = (error: mixed) => {
|
|
159
|
+
if (abandoned) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (streaming) {
|
|
163
|
+
report(error);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
held.push(error);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const stop = new AbortController();
|
|
170
|
+
const rendered = await renderFlight(url, {
|
|
171
|
+
onError: (error: mixed) => {
|
|
172
|
+
onError(error);
|
|
173
|
+
return ledger.record(error);
|
|
174
|
+
},
|
|
175
|
+
signal: stop.signal,
|
|
176
|
+
});
|
|
177
|
+
if (rendered.kind === "redirect") {
|
|
178
|
+
return redirectDocument(new RedirectError(rendered.location, rendered.status === 308));
|
|
179
|
+
}
|
|
180
|
+
let status = rendered.status;
|
|
181
|
+
let failure = rendered.failure;
|
|
182
|
+
let body: DocumentBody;
|
|
183
|
+
try {
|
|
184
|
+
body = await documentOf(rendered.stream, url, assets, {
|
|
185
|
+
onError: (error: mixed) => {
|
|
186
|
+
if (!ledger.isCopy(error)) {
|
|
187
|
+
onError(error);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
transformHead,
|
|
191
|
+
onStream,
|
|
192
|
+
});
|
|
193
|
+
streaming = true;
|
|
194
|
+
for (const error of held) {
|
|
195
|
+
report(error);
|
|
196
|
+
}
|
|
197
|
+
held = [];
|
|
198
|
+
} catch (error) {
|
|
199
|
+
abandoned = true;
|
|
200
|
+
held = [];
|
|
201
|
+
stop.abort();
|
|
202
|
+
const cause = ledger.originalOf(error);
|
|
203
|
+
if (cause instanceof RedirectError) {
|
|
204
|
+
return redirectDocument(cause);
|
|
205
|
+
}
|
|
206
|
+
// Deliberately not caught again: this render is the boundary's own, and a
|
|
207
|
+
// boundary that throws has nothing left to answer with. It reaches
|
|
208
|
+
// `uf dev`'s overlay and fails `uf build`'s route, which is where somebody
|
|
209
|
+
// can fix it.
|
|
210
|
+
const recovered = await renderFlight(url, {
|
|
211
|
+
failure: { error: cause },
|
|
212
|
+
onError: (late: mixed) => {
|
|
213
|
+
report(late);
|
|
214
|
+
return ledger.record(late);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
if (recovered.kind === "redirect") {
|
|
218
|
+
return redirectDocument(new RedirectError(recovered.location, recovered.status === 308));
|
|
219
|
+
}
|
|
220
|
+
status = recovered.status;
|
|
221
|
+
failure = recovered.failure;
|
|
222
|
+
try {
|
|
223
|
+
body = await documentOf(recovered.stream, url, assets, {
|
|
224
|
+
onError: (late: mixed) => {
|
|
225
|
+
if (!ledger.isCopy(late)) {
|
|
226
|
+
report(late);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
transformHead,
|
|
230
|
+
onStream,
|
|
231
|
+
});
|
|
232
|
+
} catch (thrown) {
|
|
233
|
+
// The boundary's own render threw; see `prerender` for why what leaves
|
|
234
|
+
// is the exception as thrown rather than React's copy of it.
|
|
235
|
+
throw ledger.originalOf(thrown);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { status, pipe: body.pipe, stream: body.stream, text: body.text, error: failure };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function prerender(
|
|
243
|
+
url: string,
|
|
244
|
+
assets: RenderAssets,
|
|
245
|
+
settings?: RenderOptions,
|
|
246
|
+
): Promise<PrerenderResult> {
|
|
247
|
+
const report = settings?.onError ?? (() => {});
|
|
248
|
+
const ledger = createErrorLedger();
|
|
249
|
+
// A read of the request, while the build may leave it for the request, is
|
|
250
|
+
// not an exception to report: it is how the build finds the holes. Its row
|
|
251
|
+
// carries a digest of its own, so the HTML renderer's copy of it is known
|
|
252
|
+
// too, and `./internal/flight-rows.js` can take the row out.
|
|
253
|
+
const partial = settings?.partial === true ? newPartialPrerender() : null;
|
|
254
|
+
let postponedRows = 0;
|
|
255
|
+
// The Flight renderer's copy of an exception is the one reported, because it
|
|
256
|
+
// is the exception as thrown; the HTML renderer's copy of the same one is
|
|
257
|
+
// recognised by its digest and dropped. See [`createErrorLedger`].
|
|
258
|
+
const onServerError = (error: mixed): string => {
|
|
259
|
+
if (partial != null && isPostponedRead(error)) {
|
|
260
|
+
postponedRows += 1;
|
|
261
|
+
return `${POSTPONED_DIGEST}${postponedRows}`;
|
|
262
|
+
}
|
|
263
|
+
report(error);
|
|
264
|
+
return ledger.record(error);
|
|
265
|
+
};
|
|
266
|
+
const onHtmlError = (error: mixed) => {
|
|
267
|
+
if (!ledger.isCopy(error) && !isPostponedCopy(error)) {
|
|
268
|
+
report(error);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// `defer: false`, for the reason [`createRenderer`]'s prerender passes it:
|
|
273
|
+
// a file has no fallback to show first.
|
|
274
|
+
const flightOf = () => renderFlight(url, { defer: false, onError: onServerError });
|
|
275
|
+
const rendered =
|
|
276
|
+
partial == null ? await flightOf() : await runPartialPrerender(partial, flightOf);
|
|
277
|
+
if (rendered.kind === "redirect") {
|
|
278
|
+
return redirectResult(
|
|
279
|
+
redirectDocument(new RedirectError(rendered.location, rendered.status === 308)),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
let status = rendered.status;
|
|
283
|
+
let failure = rendered.failure;
|
|
284
|
+
let payload: Uint8Array;
|
|
285
|
+
let html: string;
|
|
286
|
+
try {
|
|
287
|
+
// Inside the `try`, with the document: a payload the Flight renderer
|
|
288
|
+
// could not finish — a function handed to a client component, say —
|
|
289
|
+
// fails the stream itself, and that is this route's failure like any
|
|
290
|
+
// other.
|
|
291
|
+
payload = await bytesOf(rendered.stream);
|
|
292
|
+
// A read made before anything rendered — in a loader — has no boundary
|
|
293
|
+
// around it at all.
|
|
294
|
+
if (partial != null && isPostponedRead(failure)) {
|
|
295
|
+
throw readOutsideSuspense(url, partial.reads);
|
|
296
|
+
}
|
|
297
|
+
if (partial != null && partial.reads.length > 0 && failure == null) {
|
|
298
|
+
const shell = await partialDocumentOf(payload, url, assets, onHtmlError, partial.reads);
|
|
299
|
+
if (shell != null) {
|
|
300
|
+
return { status, html: shell.html, error: undefined, shell };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
html = await staticDocumentOf(payload, url, assets, onHtmlError);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error instanceof ReadOutsideSuspenseError) {
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
const cause = ledger.originalOf(error);
|
|
309
|
+
if (cause instanceof RedirectError) {
|
|
310
|
+
return redirectResult(redirectDocument(cause));
|
|
311
|
+
}
|
|
312
|
+
const recovered = await renderFlight(url, {
|
|
313
|
+
defer: false,
|
|
314
|
+
failure: { error: cause },
|
|
315
|
+
onError: onServerError,
|
|
316
|
+
});
|
|
317
|
+
if (recovered.kind === "redirect") {
|
|
318
|
+
return redirectResult(
|
|
319
|
+
redirectDocument(new RedirectError(recovered.location, recovered.status === 308)),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
status = recovered.status;
|
|
323
|
+
failure = recovered.failure;
|
|
324
|
+
try {
|
|
325
|
+
payload = await bytesOf(recovered.stream);
|
|
326
|
+
html = await staticDocumentOf(payload, url, assets, onHtmlError);
|
|
327
|
+
} catch (thrown) {
|
|
328
|
+
// The boundary's own render threw, and nothing is left to answer with.
|
|
329
|
+
// What leaves is the exception as thrown rather than the copy React's
|
|
330
|
+
// client rebuilt from its row, whose message in a build says only that
|
|
331
|
+
// the real one was omitted — which is all `uf build` would then print.
|
|
332
|
+
throw ledger.originalOf(thrown);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { status, html, error: failure, payload };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A page's static shell, for a payload whose render read the request.
|
|
340
|
+
*
|
|
341
|
+
* Two renders of the same payload. The first is the whole document, with an
|
|
342
|
+
* error where each read was, and it is there to prove one thing: that every
|
|
343
|
+
* read sits inside a `<Suspense>` boundary. A read outside one fails the
|
|
344
|
+
* shell itself, and the page is refused naming what it read, because a shell
|
|
345
|
+
* that has to wait for the request is not a shell. It also loads every client
|
|
346
|
+
* module the payload names, so the second render waits on nothing but the
|
|
347
|
+
* request.
|
|
348
|
+
*
|
|
349
|
+
* The second is the shell: the same payload with the rows the reads produced
|
|
350
|
+
* taken out, rendered until it has done everything it can and then stopped,
|
|
351
|
+
* so each boundary that is still waiting on one of those rows is a hole. It is
|
|
352
|
+
* `null` when that left no hole — a read whose result the page never rendered
|
|
353
|
+
* — and the page is then the plain document the first render already was.
|
|
354
|
+
*/
|
|
355
|
+
async function partialDocumentOf(
|
|
356
|
+
payload: Uint8Array,
|
|
357
|
+
url: string,
|
|
358
|
+
assets: RenderAssets,
|
|
359
|
+
onError: (error: mixed) => void,
|
|
360
|
+
reads: $ReadOnlyArray<string>,
|
|
361
|
+
): Promise<PrerenderedShell | null> {
|
|
362
|
+
try {
|
|
363
|
+
await staticDocumentOf(payload, url, assets, onError);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (isPostponedCopy(error)) {
|
|
366
|
+
throw readOutsideSuspense(url, reads);
|
|
367
|
+
}
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
const shellPayload = withoutErrorRows(payload, (digest) =>
|
|
371
|
+
digest.startsWith(POSTPONED_DIGEST),
|
|
372
|
+
).payload;
|
|
373
|
+
const shell = await prerenderShell(
|
|
374
|
+
<App url={url} flight={readPayload(streamOf(shellPayload), { partial: true })} />,
|
|
375
|
+
{ shell: shellFor(assets), onError, settle: settled },
|
|
376
|
+
);
|
|
377
|
+
return shell.postponed == null ? null : shell;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Answer `url` from its static shell: the shell now, and its holes as this
|
|
382
|
+
* request renders them.
|
|
383
|
+
*
|
|
384
|
+
* The route is resolved first, and that is the only thing the shell waits
|
|
385
|
+
* for: a redirect has to be the whole response, and a request that resolves
|
|
386
|
+
* to something other than the page the build prerendered — a `404`, an error
|
|
387
|
+
* boundary — is not answered with that page's shell. Either way it is
|
|
388
|
+
* rendered the way any other request is. Otherwise the whole route is
|
|
389
|
+
* rendered as Server Components for this request, the shell goes out, and
|
|
390
|
+
* React's `resume` renders the holes from the payload while the payload
|
|
391
|
+
* itself is written into the document for the browser to hydrate from.
|
|
392
|
+
*
|
|
393
|
+
* The payload is the request's whole one, not the shell's with the holes
|
|
394
|
+
* added: the browser hydrates the document from it, and the shell was
|
|
395
|
+
* rendered from the same modules without a request, so what it has in common
|
|
396
|
+
* with the shell is the same markup.
|
|
397
|
+
*/
|
|
398
|
+
async function resume(
|
|
399
|
+
url: string,
|
|
400
|
+
assets: RenderAssets,
|
|
401
|
+
shell: PrerenderedShell,
|
|
402
|
+
settings?: RenderOptions,
|
|
403
|
+
): Promise<RenderResult> {
|
|
404
|
+
const report = settings?.onError ?? (() => {});
|
|
405
|
+
const ledger = createErrorLedger();
|
|
406
|
+
const stop = new AbortController();
|
|
407
|
+
const rendered = await renderFlight(url, {
|
|
408
|
+
onError: (error: mixed) => {
|
|
409
|
+
report(error);
|
|
410
|
+
return ledger.record(error);
|
|
411
|
+
},
|
|
412
|
+
signal: stop.signal,
|
|
413
|
+
});
|
|
414
|
+
if (rendered.kind === "redirect") {
|
|
415
|
+
return redirectDocument(new RedirectError(rendered.location, rendered.status === 308));
|
|
416
|
+
}
|
|
417
|
+
if (rendered.status !== 200 || rendered.failure != null) {
|
|
418
|
+
stop.abort();
|
|
419
|
+
return render(url, assets, settings);
|
|
420
|
+
}
|
|
421
|
+
const send = settings?.onStream;
|
|
422
|
+
const [forHtml, forBrowser] = rendered.stream.tee();
|
|
423
|
+
const body = resumeDocument(<App url={url} flight={readPayload(forHtml)} />, shell, {
|
|
424
|
+
onError: (error: mixed) => {
|
|
425
|
+
if (!ledger.isCopy(error)) {
|
|
426
|
+
report(error);
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
payload: forBrowser,
|
|
430
|
+
nonce: currentNonce(),
|
|
431
|
+
onStream: send == null ? undefined : streamReporter(url, send),
|
|
432
|
+
});
|
|
433
|
+
return { status: 200, pipe: body.pipe, stream: body.stream, text: body.text };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A finished document for a finished payload, with the payload written into it. */
|
|
437
|
+
function staticDocumentOf(
|
|
438
|
+
payload: Uint8Array,
|
|
439
|
+
url: string,
|
|
440
|
+
assets: RenderAssets,
|
|
441
|
+
onError: (error: mixed) => void,
|
|
442
|
+
): Promise<string> {
|
|
443
|
+
return prerenderDocument(<App url={url} flight={readPayload(streamOf(payload))} />, {
|
|
444
|
+
shell: shellFor(assets),
|
|
445
|
+
onError,
|
|
446
|
+
payload: streamOf(payload),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function flight(
|
|
451
|
+
url: string,
|
|
452
|
+
settings?: {|
|
|
453
|
+
readonly onError?: (error: mixed) => void,
|
|
454
|
+
readonly interceptedFrom?: string,
|
|
455
|
+
|},
|
|
456
|
+
): Promise<FlightResponse> {
|
|
457
|
+
const rendered = await renderFlight(url, {
|
|
458
|
+
onError: settings?.onError,
|
|
459
|
+
interceptedFrom: settings?.interceptedFrom,
|
|
460
|
+
});
|
|
461
|
+
if (rendered.kind === "redirect") {
|
|
462
|
+
const { location } = rendered;
|
|
463
|
+
// A redirect on this origin points at its target's payload, so the
|
|
464
|
+
// browser's `fetch` follows it and lands on one. A redirect elsewhere is
|
|
465
|
+
// left as written: what the browser finds there is not a payload, and it
|
|
466
|
+
// loads the URL as a document instead.
|
|
467
|
+
const onThisOrigin = location.startsWith("/") && !location.startsWith("//");
|
|
468
|
+
return {
|
|
469
|
+
status: rendered.status,
|
|
470
|
+
headers: { location: onThisOrigin ? flightUrl(addressOf(location)) : location },
|
|
471
|
+
stream: null,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
status: rendered.status,
|
|
476
|
+
headers: { "content-type": FLIGHT_CONTENT_TYPE, vary: INTERCEPTED_FROM_HEADER },
|
|
477
|
+
stream: rendered.stream,
|
|
478
|
+
error: rendered.failure,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { render, prerender, flight, resume };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The digests a partial prerender gives the rows its reads of the request
|
|
487
|
+
* produced. React writes the digest into the row and onto the error its client
|
|
488
|
+
* rebuilds from it, which is how both copies are recognised.
|
|
489
|
+
*/
|
|
490
|
+
const POSTPONED_DIGEST = "uf:postponed:";
|
|
491
|
+
|
|
492
|
+
/** Whether `error` is the HTML renderer's copy of a read left for the request. */
|
|
493
|
+
function isPostponedCopy(error: mixed): boolean {
|
|
494
|
+
return digestOf(error)?.startsWith(POSTPONED_DIGEST) === true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A page whose render read the request where no `<Suspense>` boundary could
|
|
499
|
+
* leave the read for later.
|
|
500
|
+
*
|
|
501
|
+
* Thrown rather than resolved to the error boundary, because it is not the
|
|
502
|
+
* page's failure to render — the page would render fine with a request — but
|
|
503
|
+
* the build's refusal to write it, and `uf build` names the route with it.
|
|
504
|
+
*/
|
|
505
|
+
class ReadOutsideSuspenseError extends Error {
|
|
506
|
+
constructor(message: string) {
|
|
507
|
+
super(message);
|
|
508
|
+
this.name = "ReadOutsideSuspenseError";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function readOutsideSuspense(url: string, reads: $ReadOnlyArray<string>): ReadOutsideSuspenseError {
|
|
513
|
+
const named = [...new Set(reads)].map((read) => `${read}()`).join(", ");
|
|
514
|
+
return new ReadOutsideSuspenseError(
|
|
515
|
+
`${url} reads ${named} outside any <Suspense> boundary, so it has no static shell to ` +
|
|
516
|
+
"prerender. Move the part of the page that reads it inside a <Suspense> boundary, which " +
|
|
517
|
+
'leaves that part for the request, or export `dynamic = "force-dynamic"` from the page to ' +
|
|
518
|
+
"render all of it per request.",
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Until the render reading a finished payload has done everything it can.
|
|
524
|
+
*
|
|
525
|
+
* Every row it will ever get is in memory and every module the payload names
|
|
526
|
+
* was loaded by the render before it, so what is left is React's own work,
|
|
527
|
+
* which it schedules as microtasks and a task to start. A few turns of the
|
|
528
|
+
* event loop is more than that takes; stopping early would only make a hole
|
|
529
|
+
* larger, never the document wrong. See `prerenderShell`.
|
|
530
|
+
*/
|
|
531
|
+
async function settled(): Promise<void> {
|
|
532
|
+
for (let turn = 0; turn < 4; turn += 1) {
|
|
533
|
+
await new Promise((resolve) => {
|
|
534
|
+
setTimeout(resolve, 0);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Pairs each exception the Flight renderer reports with the copy of it the HTML
|
|
541
|
+
* renderer sees.
|
|
542
|
+
*
|
|
543
|
+
* A Server Component that throws is seen twice. The Flight renderer catches the
|
|
544
|
+
* exception as it was thrown and writes an error row where that part of the
|
|
545
|
+
* tree would have been; React's Flight client reads the row and throws an error
|
|
546
|
+
* rebuilt from it, which is what the HTML renderer sees at the same spot — and
|
|
547
|
+
* in a build, that error's message is a sentence saying the real one was
|
|
548
|
+
* omitted. Reporting both tells a host about one failure twice, once uselessly.
|
|
549
|
+
* Resolving the error boundary for the rebuilt one is worse: `forbidden()`
|
|
550
|
+
* would become a 500 and `redirect()` an error page, because neither survives
|
|
551
|
+
* being rebuilt as a plain `Error`.
|
|
552
|
+
*
|
|
553
|
+
* The row carries a digest, and that is React's field for exactly this: what
|
|
554
|
+
* the Flight renderer's `onError` returns is written into the row and set on the
|
|
555
|
+
* rebuilt error as `digest`. So every exception is recorded here under a digest
|
|
556
|
+
* of its own, and an error the HTML renderer reports with a recorded digest is a
|
|
557
|
+
* copy — dropped from the report, and exchanged for the original when the
|
|
558
|
+
* boundary is resolved.
|
|
559
|
+
*
|
|
560
|
+
* The digest reaches the browser inside the payload, and says nothing: it counts
|
|
561
|
+
* the exceptions one render has recorded.
|
|
562
|
+
*/
|
|
563
|
+
function createErrorLedger(): ErrorLedger {
|
|
564
|
+
const originals: Map<string, mixed> = new Map();
|
|
565
|
+
const recorded = (error: mixed): string | null => {
|
|
566
|
+
const digest = digestOf(error);
|
|
567
|
+
return digest != null && originals.has(digest) ? digest : null;
|
|
568
|
+
};
|
|
569
|
+
return {
|
|
570
|
+
record(error: mixed): string {
|
|
571
|
+
const digest = `uf:${originals.size + 1}`;
|
|
572
|
+
originals.set(digest, error);
|
|
573
|
+
return digest;
|
|
574
|
+
},
|
|
575
|
+
isCopy(error: mixed): boolean {
|
|
576
|
+
return recorded(error) != null;
|
|
577
|
+
},
|
|
578
|
+
originalOf(error: mixed): mixed {
|
|
579
|
+
const digest = recorded(error);
|
|
580
|
+
return digest == null ? error : originals.get(digest);
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** See [`createErrorLedger`]. */
|
|
586
|
+
type ErrorLedger = {|
|
|
587
|
+
/** Keep an exception the Flight renderer reported, and return its digest. */
|
|
588
|
+
readonly record: (error: mixed) => string,
|
|
589
|
+
/** Whether an error the HTML renderer reported is the copy of a kept one. */
|
|
590
|
+
readonly isCopy: (error: mixed) => boolean,
|
|
591
|
+
/** The exception as thrown, for a copy; any other error, as it is. */
|
|
592
|
+
readonly originalOf: (error: mixed) => mixed,
|
|
593
|
+
|};
|
|
594
|
+
|
|
595
|
+
/** The digest React set on an error, when it set one. */
|
|
596
|
+
function digestOf(error: mixed): string | null {
|
|
597
|
+
if (error == null || typeof error !== "object") {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
const tagged: { +digest?: mixed, ... } = (error: $FlowFixMe);
|
|
601
|
+
return typeof tagged.digest === "string" ? tagged.digest : null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Every byte of a stream, as one array. */
|
|
605
|
+
async function bytesOf(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
|
606
|
+
const reader = stream.getReader();
|
|
607
|
+
const chunks: Array<Uint8Array> = [];
|
|
608
|
+
let total = 0;
|
|
609
|
+
while (true) {
|
|
610
|
+
const step = await reader.read();
|
|
611
|
+
if (step.done === true) {
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
const chunk = step.value;
|
|
615
|
+
if (chunk != null) {
|
|
616
|
+
chunks.push(chunk);
|
|
617
|
+
total += chunk.byteLength;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const bytes = new Uint8Array(total);
|
|
621
|
+
let at = 0;
|
|
622
|
+
for (const chunk of chunks) {
|
|
623
|
+
bytes.set(chunk, at);
|
|
624
|
+
at += chunk.byteLength;
|
|
625
|
+
}
|
|
626
|
+
return bytes;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** A stream of one array, read as many times as a caller constructs one. */
|
|
630
|
+
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
631
|
+
return new ReadableStream({
|
|
632
|
+
start(controller) {
|
|
633
|
+
controller.enqueue(bytes);
|
|
634
|
+
controller.close();
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
}
|