@uniflowed/router 0.0.0-alpha.4 → 0.0.0-alpha.41
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 +301 -0
- package/client.js +295 -8
- package/handler.js +127 -106
- package/http-client.js +104 -0
- package/index.js +72 -4
- package/internal/action-endpoint.js +435 -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/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +238 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-ssr.js +78 -0
- package/internal/flight.js +183 -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 +49 -0
- package/internal/react-version.js +77 -0
- package/internal/request.js +43 -0
- package/internal/resolve.js +1613 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1613 -548
- package/internal/server-route.js +58 -0
- package/internal/shell.js +115 -0
- package/internal/stream.js +1099 -0
- package/middleware.js +350 -0
- 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 +446 -0
- package/rsc.js +381 -0
- package/server-components.js +159 -0
- package/server.js +450 -75
package/server.js
CHANGED
|
@@ -3,13 +3,35 @@
|
|
|
3
3
|
// Rendering one URL to an HTML document.
|
|
4
4
|
//
|
|
5
5
|
// `virtual:uf/server` calls `createRenderer` with the app root and the route
|
|
6
|
-
// table, and `uf dev`
|
|
7
|
-
// `uf build`
|
|
6
|
+
// table, and `uf dev` streams every document request through `render` while
|
|
7
|
+
// `uf build` writes every static route through `prerender`. Both produce the
|
|
8
8
|
// same markup from the same code, which is the point.
|
|
9
|
+
//
|
|
10
|
+
// # Two entry points, because there are two questions
|
|
11
|
+
//
|
|
12
|
+
// `render` streams and `prerender` waits, and which one a host wants is not a
|
|
13
|
+
// detail of how it was called — it is what the host is *for*. A server has a
|
|
14
|
+
// browser on the other end and a reason to send the layouts and the fallbacks
|
|
15
|
+
// now; a build writes a file that something may later serve to a crawler, and a
|
|
16
|
+
// file whose content is a `<template>` waiting for a script to move it is a
|
|
17
|
+
// file that is blank to everything but a browser.
|
|
18
|
+
//
|
|
19
|
+
// They were one function with `renderToString` behind it, which answered the
|
|
20
|
+
// first question by giving up on it: nothing streamed, so nothing could
|
|
21
|
+
// usefully suspend, so `$loading.js` had nothing to be. Making the split
|
|
22
|
+
// explicit is the point of ubugeeei-prod/uf#254 rather than a side effect —
|
|
23
|
+
// `internal/stream.js` holds the mechanics and says which React renderer serves
|
|
24
|
+
// which.
|
|
9
25
|
|
|
10
|
-
import {
|
|
26
|
+
import { currentNonce, noteRoute } from "@uniflowed/server/host";
|
|
11
27
|
import * as React from "react";
|
|
12
|
-
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
type DocumentBody,
|
|
31
|
+
type WritableLike,
|
|
32
|
+
prerenderDocument,
|
|
33
|
+
renderDocument,
|
|
34
|
+
} from "./internal/stream.js";
|
|
13
35
|
|
|
14
36
|
import {
|
|
15
37
|
type AppProps,
|
|
@@ -17,9 +39,14 @@ import {
|
|
|
17
39
|
type RouteTable,
|
|
18
40
|
RedirectError,
|
|
19
41
|
installRoutes,
|
|
42
|
+
resolveFailure,
|
|
20
43
|
resolveMatch,
|
|
21
44
|
} from "./internal/runtime.js";
|
|
22
45
|
|
|
46
|
+
import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
|
|
47
|
+
|
|
48
|
+
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
49
|
+
|
|
23
50
|
/** Asset URLs to reference from the document. */
|
|
24
51
|
export type RenderAssets = {|
|
|
25
52
|
readonly scripts: $ReadOnlyArray<string>,
|
|
@@ -27,113 +54,461 @@ export type RenderAssets = {|
|
|
|
27
54
|
readonly preloads: $ReadOnlyArray<string>,
|
|
28
55
|
|};
|
|
29
56
|
|
|
30
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* A document that has begun.
|
|
59
|
+
*
|
|
60
|
+
* `status` and `headers` are known once the shell is ready, which is the moment
|
|
61
|
+
* this resolves and is why a streaming renderer can still answer with a status
|
|
62
|
+
* line. The body arrives afterwards, through exactly one of `pipe`, `stream`
|
|
63
|
+
* and `text` — they are three views of one pass over the same chunks, not three
|
|
64
|
+
* copies of the document.
|
|
65
|
+
*/
|
|
31
66
|
export type RenderResult = {|
|
|
67
|
+
readonly status: number,
|
|
68
|
+
readonly headers?: { readonly [string]: string },
|
|
69
|
+
/** Write the document into a Node response. */
|
|
70
|
+
readonly pipe: (destination: WritableLike) => Promise<void>,
|
|
71
|
+
/** The document as a web stream, for `new Response(…)`. */
|
|
72
|
+
readonly stream: () => ReadableStream,
|
|
73
|
+
/** The whole document, once it has finished streaming. */
|
|
74
|
+
readonly text: () => Promise<string>,
|
|
75
|
+
/**
|
|
76
|
+
* The exception this render fell back to its error boundary for.
|
|
77
|
+
*
|
|
78
|
+
* The document is still a document — the boundary rendered — and this is how
|
|
79
|
+
* the caller learns that it is an error page rather than the page it asked
|
|
80
|
+
* for. `uf build` fails the route it names; `uf dev` reports it in the
|
|
81
|
+
* terminal. Without it, containment would mean a build that quietly wrote a
|
|
82
|
+
* directory of error pages and exited 0.
|
|
83
|
+
*
|
|
84
|
+
* `forbidden()` and `unauthorized()` do not set it: those are answers an
|
|
85
|
+
* application chose, and a build that prerendered one has not failed.
|
|
86
|
+
*
|
|
87
|
+
* Only the failures known before the first byte: a loader that threw, or a
|
|
88
|
+
* shell that did. An exception inside a `<Suspense>` boundary happens after
|
|
89
|
+
* this has been read, so it is reported through `render`'s `onError` instead
|
|
90
|
+
* — a streaming renderer cannot put a late failure in a value the caller
|
|
91
|
+
* already has.
|
|
92
|
+
*/
|
|
93
|
+
readonly error?: mixed,
|
|
94
|
+
|};
|
|
95
|
+
|
|
96
|
+
/** A document that is finished: every boundary resolved, nothing left to wait for. */
|
|
97
|
+
export type PrerenderResult = {|
|
|
32
98
|
readonly status: number,
|
|
33
99
|
readonly html: string,
|
|
34
100
|
readonly headers?: { readonly [string]: string },
|
|
101
|
+
/** The exception this render fell back to its error boundary for; see [`RenderResult`]. */
|
|
102
|
+
readonly error?: mixed,
|
|
103
|
+
/**
|
|
104
|
+
* The Flight payload the document was rendered from, for a document React
|
|
105
|
+
* Server Components rendered.
|
|
106
|
+
*
|
|
107
|
+
* `uf build` writes it beside the document as the route's payload file, so a
|
|
108
|
+
* browser that navigates to a prerendered route fetches a file rather than
|
|
109
|
+
* asking a server — which is what makes a static host able to serve client
|
|
110
|
+
* navigation at all. Absent for a document rendered from its modules.
|
|
111
|
+
*/
|
|
112
|
+
readonly payload?: Uint8Array,
|
|
35
113
|
|};
|
|
36
114
|
|
|
37
115
|
/**
|
|
38
|
-
*
|
|
116
|
+
* A route's payload, as a browser navigating to it is answered.
|
|
117
|
+
*
|
|
118
|
+
* `stream` is `null` for a redirect, whose `location` is already the target's
|
|
119
|
+
* payload URL when the target is on this origin: `fetch` follows it and lands
|
|
120
|
+
* on a payload.
|
|
39
121
|
*/
|
|
122
|
+
export type FlightResponse = {|
|
|
123
|
+
readonly status: number,
|
|
124
|
+
readonly headers: { readonly [string]: string },
|
|
125
|
+
readonly stream: ReadableStream<Uint8Array> | null,
|
|
126
|
+
/** The exception the route resolved to its error boundary for; see [`RenderResult`]. */
|
|
127
|
+
readonly error?: mixed,
|
|
128
|
+
|};
|
|
129
|
+
|
|
130
|
+
/** What a host may tell the renderer about one request. */
|
|
131
|
+
export type RenderOptions = {|
|
|
132
|
+
/**
|
|
133
|
+
* Every exception React recovered from, including the ones it answered by
|
|
134
|
+
* streaming a boundary's fallback after the response had begun.
|
|
135
|
+
*
|
|
136
|
+
* A callback rather than a field on the result, because that is the shape of
|
|
137
|
+
* the truth: by the time one of these happens the caller is already writing
|
|
138
|
+
* bytes. `uf dev` reports them in the terminal; a production host logs them.
|
|
139
|
+
*/
|
|
140
|
+
readonly onError?: (error: mixed) => void,
|
|
141
|
+
/**
|
|
142
|
+
* Rewrite the document opening — the head and, when present, the body start
|
|
143
|
+
* tag — before it goes out.
|
|
144
|
+
*
|
|
145
|
+
* For `uf dev` and nothing else. Vite's `transformIndexHtml` injects
|
|
146
|
+
* `/@vite/client` and the refresh preamble and rewrites asset URLs, and it
|
|
147
|
+
* is a *whole document* hook, so the development server used to collect the
|
|
148
|
+
* page and transform it at the end. That made the one place a developer
|
|
149
|
+
* would notice streaming the one place it did not happen: a slow page showed
|
|
150
|
+
* nothing until it was finished, and `$loading.js` looked broken.
|
|
151
|
+
* See ubugeeei-prod/uf#374.
|
|
152
|
+
*
|
|
153
|
+
* A production host passes nothing here and streams as it always did.
|
|
154
|
+
*
|
|
155
|
+
* # What a plugin that injects into the body gets
|
|
156
|
+
*
|
|
157
|
+
* `transformIndexHtml` is a whole-document hook and this hands it only the
|
|
158
|
+
* parseable opening of the document. Measured against Vite 8.2.2, injecting
|
|
159
|
+
* all four positions into a whole document and into the streamed opening:
|
|
160
|
+
*
|
|
161
|
+
* | `injectTo` | whole document | streamed |
|
|
162
|
+
* | -------------- | ------------------- | -------- |
|
|
163
|
+
* | `head-prepend` | after `<head>` | same |
|
|
164
|
+
* | `head` | before `</head>` | same |
|
|
165
|
+
* | `body-prepend` | after `<body>` | same |
|
|
166
|
+
* | `body` | before `</body>` | after `<body>` |
|
|
167
|
+
*
|
|
168
|
+
* Nothing is dropped — every tag still reaches the document — but a `body`
|
|
169
|
+
* tag lands at the top of the body rather than after the content, because
|
|
170
|
+
* the content is deliberately not passed to the hook. That keeps Vite's
|
|
171
|
+
* parser away from chunk boundaries that may sit inside an attribute.
|
|
172
|
+
*
|
|
173
|
+
* uf's own injections are `head` and `head-prepend`, and Vite's client is
|
|
174
|
+
* head-injected, so this is about a third-party plugin.
|
|
175
|
+
* `packages/vite/dev-head-transform.test.js` pins the table above, so the day
|
|
176
|
+
* it changes is a failing test rather than a surprise.
|
|
177
|
+
*/
|
|
178
|
+
readonly transformHead?: (html: string) => Promise<string>,
|
|
179
|
+
/**
|
|
180
|
+
* Told, in words, when a document streamed differently than it did last time.
|
|
181
|
+
*
|
|
182
|
+
* For `uf dev` and nothing else, like `transformHead` above. It answers the
|
|
183
|
+
* half of ubugeeei-prod/uf#520 that is about the wire — what arrived, in what
|
|
184
|
+
* order, and which part of the tree each chunk built — for the stream uf has
|
|
185
|
+
* today, which is a document whose Suspense boundaries resolve independently.
|
|
186
|
+
* `internal/inspector.js` is what it is and what it deliberately is not.
|
|
187
|
+
*
|
|
188
|
+
* A host that passes nothing here records nothing: no recorder is
|
|
189
|
+
* constructed, and the chunks a production stream yields are untouched.
|
|
190
|
+
*
|
|
191
|
+
* It is handed a message and its detail lines rather than the record they
|
|
192
|
+
* came from, because the caller is `@uniflowed/vite` — plain JavaScript, run
|
|
193
|
+
* by Vite before any Flow transform exists, which is why `DEVTOOLS_HOOK` and
|
|
194
|
+
* `DIAGNOSTIC_ENDPOINT` are spelled twice rather than imported. The
|
|
195
|
+
* vocabulary of the report belongs on this side of that line.
|
|
196
|
+
*/
|
|
197
|
+
readonly onStream?: (diagnostic: StreamDiagnostic) => void,
|
|
198
|
+
|};
|
|
199
|
+
|
|
200
|
+
/** The two ids the server writes and the client reads. */
|
|
40
201
|
export { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
41
202
|
|
|
203
|
+
/**
|
|
204
|
+
* How a host begins the request everything below runs inside.
|
|
205
|
+
*
|
|
206
|
+
* Re-exported rather than left to the host to import, and the reason is the
|
|
207
|
+
* one thing about `@uniflowed/server` that is easy to get wrong: the request
|
|
208
|
+
* store is shared by every copy of one *release* of that package, and no more.
|
|
209
|
+
* A host that resolved `@uniflowed/server/host` for itself — from its own
|
|
210
|
+
* `node_modules`, or from outside the bundle a build produced — may hold a
|
|
211
|
+
* different release, and would begin a request in a store the application
|
|
212
|
+
* never reads, so every `cookies()` in it would still be outside one, silently.
|
|
213
|
+
* Handing it out from here makes the copy the host begins with the copy this
|
|
214
|
+
* module dispatches and renders with, because it is the same import.
|
|
215
|
+
*
|
|
216
|
+
* `run` wraps everything that decides the response; `settle` is called once
|
|
217
|
+
* the response has been *written*, which is a different line in every host.
|
|
218
|
+
* `createMiddlewareRunner` and `createDispatcher` refuse to run outside it.
|
|
219
|
+
* See ubugeeei-prod/uf#389.
|
|
220
|
+
*/
|
|
221
|
+
export type { RequestLifecycle } from "@uniflowed/server/host";
|
|
222
|
+
export { beginRequest } from "@uniflowed/server/host";
|
|
223
|
+
|
|
42
224
|
export type { Handler, HandlerContext, HandlerModule, HandlerRecord } from "./handler.js";
|
|
43
225
|
export { createDispatcher } from "./handler.js";
|
|
44
226
|
|
|
227
|
+
export type {
|
|
228
|
+
Middleware,
|
|
229
|
+
MiddlewareContext,
|
|
230
|
+
MiddlewareModule,
|
|
231
|
+
MiddlewareRecord,
|
|
232
|
+
} from "./middleware.js";
|
|
233
|
+
export { createMiddlewareRunner } from "./middleware.js";
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `app.router.basePath` and `trailingSlash`, for `virtual:uf/server` to install
|
|
237
|
+
* before the first render, and `basePath()` for a middleware or a route handler
|
|
238
|
+
* that builds an address itself. See `./internal/base-path.js`.
|
|
239
|
+
*/
|
|
240
|
+
export type { RoutingSettings, TrailingSlash } from "./internal/base-path.js";
|
|
241
|
+
export { basePath, installRouting } from "./internal/base-path.js";
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The endpoint a `"use server"` export is dialled at.
|
|
245
|
+
*
|
|
246
|
+
* Here rather than beside `@uniflowed/router/action`, which is the browser's
|
|
247
|
+
* half of the same feature and must stay reachable from a client component:
|
|
248
|
+
* this one refuses outside a request, so it imports `internal/request.js` and
|
|
249
|
+
* through it `node:async_hooks`. The two halves share `internal/action-wire.js`
|
|
250
|
+
* and nothing else, which is what keeps one grammar rather than two.
|
|
251
|
+
*
|
|
252
|
+
* `virtual:uf/server` calls it with the table `virtual:uf/actions` built from
|
|
253
|
+
* the RSC manifest, and every host runs it between the middleware and the
|
|
254
|
+
* route handlers. See `internal/action-endpoint.js` for what the endpoint
|
|
255
|
+
* refuses and why.
|
|
256
|
+
*/
|
|
257
|
+
export type { ActionModule, ActionRecord } from "./internal/action-endpoint.js";
|
|
258
|
+
export { createActionDispatcher } from "./internal/action-endpoint.js";
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* What a URL turned out to be: a route to render, or a redirect to answer with.
|
|
262
|
+
*
|
|
263
|
+
* Tagged, and returned rather than thrown, because both entry points need the
|
|
264
|
+
* same answer and a redirect is the one thing `resolveMatch` lets out. Without
|
|
265
|
+
* the tag this would be a union of two exact objects and reading either field
|
|
266
|
+
* would be a type error on the branch that does not have it.
|
|
267
|
+
*/
|
|
268
|
+
type Resolution =
|
|
269
|
+
| {| readonly kind: "route", readonly route: ResolvedRoute |}
|
|
270
|
+
| {| readonly kind: "redirect", readonly error: RedirectError |};
|
|
271
|
+
|
|
272
|
+
/** The two ways one app answers for a URL. */
|
|
273
|
+
export type Renderer = {|
|
|
274
|
+
readonly render: (
|
|
275
|
+
url: string,
|
|
276
|
+
assets: RenderAssets,
|
|
277
|
+
options?: RenderOptions,
|
|
278
|
+
) => Promise<RenderResult>,
|
|
279
|
+
readonly prerender: (
|
|
280
|
+
url: string,
|
|
281
|
+
assets: RenderAssets,
|
|
282
|
+
options?: RenderOptions,
|
|
283
|
+
) => Promise<PrerenderResult>,
|
|
284
|
+
/**
|
|
285
|
+
* A route's payload, for a browser that is navigating rather than loading a
|
|
286
|
+
* document. Only a renderer for React Server Components has one.
|
|
287
|
+
*/
|
|
288
|
+
readonly flight?: (
|
|
289
|
+
url: string,
|
|
290
|
+
options?: {|
|
|
291
|
+
readonly onError?: (error: mixed) => void,
|
|
292
|
+
readonly interceptedFrom?: string,
|
|
293
|
+
|},
|
|
294
|
+
) => Promise<FlightResponse>,
|
|
295
|
+
|};
|
|
296
|
+
|
|
45
297
|
export function createRenderer(options: {|
|
|
46
298
|
readonly App: React.ComponentType<AppProps>,
|
|
47
299
|
readonly routes: RouteTable["routes"],
|
|
48
300
|
readonly notFound: RouteTable["notFound"],
|
|
49
|
-
|
|
50
|
-
|
|
301
|
+
readonly errors: RouteTable["errors"],
|
|
302
|
+
|}): Renderer {
|
|
303
|
+
const table: RouteTable = {
|
|
304
|
+
routes: options.routes,
|
|
305
|
+
notFound: options.notFound,
|
|
306
|
+
errors: options.errors,
|
|
307
|
+
};
|
|
51
308
|
installRoutes(table);
|
|
52
309
|
const { App } = options;
|
|
53
310
|
|
|
54
|
-
|
|
55
|
-
|
|
311
|
+
/**
|
|
312
|
+
* The route to render, or the redirect to answer with instead.
|
|
313
|
+
*
|
|
314
|
+
* Shared by both entry points, because *what* a URL resolves to has nothing
|
|
315
|
+
* to do with how the answer is delivered — with one exception, which is
|
|
316
|
+
* `defer` and is the exception that proves it. Whether the router may hand
|
|
317
|
+
* the page a loader that has not answered yet *is* a question about delivery:
|
|
318
|
+
* only a renderer with a `<Suspense>` fallback to send first has anywhere to
|
|
319
|
+
* put the wait. `render` says yes and `prerender` says no; see
|
|
320
|
+
* `ResolveOptions.defer` and ubugeeei-prod/uf#373.
|
|
321
|
+
*
|
|
322
|
+
* Returning the redirect rather than throwing it keeps the two callers from
|
|
323
|
+
* each having to remember that a redirect is the one thing `resolveMatch`
|
|
324
|
+
* lets out.
|
|
325
|
+
*
|
|
326
|
+
* `onMatch` is what makes the request's log line say `/orders/:id` rather
|
|
327
|
+
* than `/orders/8813`. It is handed to `resolveMatch` rather than read off
|
|
328
|
+
* the route this returns, because a loader runs *inside* that call and a
|
|
329
|
+
* loader has things to log: recording the route afterwards would leave every
|
|
330
|
+
* line the loader wrote claiming to belong to no route at all. `noteRoute`
|
|
331
|
+
* does nothing outside a request, which is what lets `prerender` — a build,
|
|
332
|
+
* with no request anywhere — call the same function.
|
|
333
|
+
*/
|
|
334
|
+
async function resolve(url: string, defer: boolean): Promise<Resolution> {
|
|
56
335
|
try {
|
|
57
|
-
|
|
336
|
+
return {
|
|
337
|
+
kind: "route",
|
|
338
|
+
route: await resolveMatch(table, url, { defer, onMatch: noteRoute }),
|
|
339
|
+
};
|
|
58
340
|
} catch (error) {
|
|
59
341
|
if (error instanceof RedirectError) {
|
|
60
|
-
return
|
|
342
|
+
return { kind: "redirect", error };
|
|
61
343
|
}
|
|
62
344
|
throw error;
|
|
63
345
|
}
|
|
346
|
+
}
|
|
64
347
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
348
|
+
async function render(
|
|
349
|
+
url: string,
|
|
350
|
+
assets: RenderAssets,
|
|
351
|
+
settings?: RenderOptions,
|
|
352
|
+
): Promise<RenderResult> {
|
|
353
|
+
const resolution = await resolve(url, true);
|
|
354
|
+
if (resolution.kind === "redirect") {
|
|
355
|
+
return redirectDocument(resolution.error);
|
|
356
|
+
}
|
|
357
|
+
let resolved: ResolvedRoute = resolution.route;
|
|
358
|
+
const report = settings?.onError ?? (() => {});
|
|
359
|
+
// Built once and shared by both renders below, so a page that threw its
|
|
360
|
+
// shell away and rendered its error boundary instead reports the stream the
|
|
361
|
+
// browser was actually sent rather than the one that was abandoned.
|
|
362
|
+
const send = settings?.onStream;
|
|
363
|
+
const onStream = send == null ? undefined : streamReporter(url, send);
|
|
364
|
+
// Read rather than minted: a project that has not asked for a nonce gets
|
|
365
|
+
// `null` and the document it has always had. Read once for both renders
|
|
366
|
+
// below, so the error document a failed shell falls back to carries the
|
|
367
|
+
// same nonce as the policy already on the response.
|
|
368
|
+
const nonce = currentNonce();
|
|
70
369
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
370
|
+
// React reports an exception to `onError` *and*, if it was in the shell, to
|
|
371
|
+
// `onShellError` — so forwarding both would tell the host about one failure
|
|
372
|
+
// twice, once through `onError` and once as `result.error` after this
|
|
373
|
+
// re-renders. Errors are held until the shell is known to have survived;
|
|
374
|
+
// if it did not, they are the failure the caller is about to be handed, and
|
|
375
|
+
// the render they came from is being thrown away with them.
|
|
376
|
+
let streaming = false;
|
|
377
|
+
let held: Array<mixed> = [];
|
|
378
|
+
const onError = (error: mixed) => {
|
|
379
|
+
if (streaming) {
|
|
380
|
+
report(error);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
held.push(error);
|
|
384
|
+
};
|
|
79
385
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
386
|
+
let body: DocumentBody;
|
|
387
|
+
try {
|
|
388
|
+
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
389
|
+
shell: shellFor(assets, nonce),
|
|
390
|
+
onError,
|
|
391
|
+
transformHead: settings?.transformHead,
|
|
392
|
+
onStream,
|
|
393
|
+
nonce,
|
|
394
|
+
});
|
|
395
|
+
streaming = true;
|
|
396
|
+
// Recovered before the shell was ready: a `<Suspense>` boundary whose
|
|
397
|
+
// content threw while the shell was still rendering. The response is
|
|
398
|
+
// fine and the host still has to hear about it.
|
|
399
|
+
for (const error of held) {
|
|
400
|
+
report(error);
|
|
401
|
+
}
|
|
402
|
+
held = [];
|
|
403
|
+
} catch (error) {
|
|
404
|
+
// The server's half of the error boundary. React runs a class boundary
|
|
405
|
+
// inside a `<Suspense>` and not outside one, so a throw in the shell —
|
|
406
|
+
// the layouts, or a page with no boundary above it — still reaches here
|
|
407
|
+
// rather than `RouteView`'s. Nothing has been written yet, which is what
|
|
408
|
+
// makes answering with a different document possible at all: `onShellError`
|
|
409
|
+
// fires before the first byte, and once it has not, this is unreachable.
|
|
410
|
+
// See ubugeeei-prod/uf#257.
|
|
411
|
+
if (error instanceof RedirectError) {
|
|
412
|
+
return redirectDocument(error);
|
|
413
|
+
}
|
|
414
|
+
held = [];
|
|
415
|
+
resolved = await resolveFailure(table, url, error);
|
|
416
|
+
// Deliberately not caught again: this render is the boundary's own
|
|
417
|
+
// component, and a boundary that throws has nothing left to answer with.
|
|
418
|
+
// It reaches `uf dev`'s overlay and fails `uf build`'s route, which is
|
|
419
|
+
// where somebody can fix it.
|
|
420
|
+
streaming = true;
|
|
421
|
+
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
422
|
+
shell: shellFor(assets, nonce),
|
|
423
|
+
onError,
|
|
424
|
+
transformHead: settings?.transformHead,
|
|
425
|
+
onStream,
|
|
426
|
+
nonce,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
100
429
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
430
|
+
return {
|
|
431
|
+
status: resolved.status,
|
|
432
|
+
pipe: body.pipe,
|
|
433
|
+
stream: body.stream,
|
|
434
|
+
text: body.text,
|
|
435
|
+
error: renderFailure(resolved),
|
|
436
|
+
};
|
|
105
437
|
}
|
|
106
|
-
|
|
107
|
-
|
|
438
|
+
|
|
439
|
+
async function prerender(
|
|
440
|
+
url: string,
|
|
441
|
+
assets: RenderAssets,
|
|
442
|
+
settings?: RenderOptions,
|
|
443
|
+
): Promise<PrerenderResult> {
|
|
444
|
+
const resolution = await resolve(url, false);
|
|
445
|
+
if (resolution.kind === "redirect") {
|
|
446
|
+
return redirectResult(redirectDocument(resolution.error));
|
|
447
|
+
}
|
|
448
|
+
let resolved: ResolvedRoute = resolution.route;
|
|
449
|
+
const report = settings?.onError ?? (() => {});
|
|
450
|
+
|
|
451
|
+
let html: string;
|
|
452
|
+
try {
|
|
453
|
+
html = await prerenderDocument(<App url={url} initial={resolved} />, {
|
|
454
|
+
shell: shellFor(assets),
|
|
455
|
+
onError: report,
|
|
456
|
+
});
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error instanceof RedirectError) {
|
|
459
|
+
return redirectResult(redirectDocument(error));
|
|
460
|
+
}
|
|
461
|
+
resolved = await resolveFailure(table, url, error);
|
|
462
|
+
html = await prerenderDocument(<App url={url} initial={resolved} />, {
|
|
463
|
+
shell: shellFor(assets),
|
|
464
|
+
onError: report,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { status: resolved.status, html, error: renderFailure(resolved) };
|
|
108
469
|
}
|
|
109
|
-
|
|
110
|
-
|
|
470
|
+
|
|
471
|
+
return { render, prerender };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The exception a resolved route fell back to its error boundary for. */
|
|
475
|
+
function renderFailure(resolved: ResolvedRoute): mixed {
|
|
476
|
+
if (resolved.error == null) {
|
|
477
|
+
return undefined;
|
|
111
478
|
}
|
|
112
|
-
return
|
|
479
|
+
return match (resolved.error) {
|
|
480
|
+
{kind: "thrown", error: const error} => error,
|
|
481
|
+
{kind: "unauthorized"} => undefined,
|
|
482
|
+
{kind: "forbidden"} => undefined,
|
|
483
|
+
};
|
|
113
484
|
}
|
|
114
485
|
|
|
115
486
|
/**
|
|
116
|
-
* The
|
|
487
|
+
* The document a single-page build writes, and the only one it writes.
|
|
488
|
+
*
|
|
489
|
+
* `app.rendering.modes: ["csr"]` renders no route at build time: the client
|
|
490
|
+
* router resolves and renders every one of them in the browser, so what the
|
|
491
|
+
* build has to leave behind is the *chrome* — the stylesheets, the module
|
|
492
|
+
* script, and the empty root the client renders into. That is exactly
|
|
493
|
+
* [`shellFor`]'s three strings with nothing between them, which is why this is
|
|
494
|
+
* three concatenations rather than a fourth shape of document to keep in step
|
|
495
|
+
* with the other three.
|
|
496
|
+
*
|
|
497
|
+
* No React runs. There is nothing to render: no URL has been asked for, and
|
|
498
|
+
* whatever this document is served for is decided by the host rather than by
|
|
499
|
+
* this build.
|
|
500
|
+
*
|
|
501
|
+
* # What it costs, said here because it is not visible from the file
|
|
117
502
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
503
|
+
* The document has no `<title>`, no `<meta name="description">` and no content.
|
|
504
|
+
* A crawler that runs no JavaScript sees an empty page for **every** URL, and a
|
|
505
|
+
* reader sees nothing until the bundle has loaded and the route has resolved.
|
|
506
|
+
* That is what a single-page application is, and it is why `modes: ["csr"]` is
|
|
507
|
+
* a declaration a project makes rather than something a build falls back to.
|
|
508
|
+
* A project that wants a document per route has `ssg`, and one that wants a
|
|
509
|
+
* document per request has `ssr`.
|
|
121
510
|
*/
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
const json = JSON.stringify(data)
|
|
127
|
-
.replace(/</g, "\\u003c")
|
|
128
|
-
.replace(/\u2028/g, "\\u2028")
|
|
129
|
-
.replace(/\u2029/g, "\\u2029");
|
|
130
|
-
return `<script id="${DATA_ID}" type="application/json">${json}</script>`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function escapeAttribute(value: string): string {
|
|
134
|
-
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function escapeText(value: string): string {
|
|
138
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
511
|
+
export function shellDocument(assets: RenderAssets): string {
|
|
512
|
+
const shell = shellFor(assets);
|
|
513
|
+
return `${shell.open}${shell.body}${shell.close}`;
|
|
139
514
|
}
|