@uniflowed/router 0.0.0-alpha.7 → 0.0.0-alpha.8

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/middleware.js ADDED
@@ -0,0 +1,211 @@
1
+ // @flow
2
+ //
3
+ // Middleware: what runs before a path answers, whatever answers it.
4
+ //
5
+ // `app/dashboard/_uf.middleware.js` guards `/dashboard` and everything under
6
+ // it — the pages, the route handlers, and the paths under it that match
7
+ // nothing at all. There is no `matcher` to write because the directory the
8
+ // file sits in *is* the matcher, which is the same composition rule layouts
9
+ // already use and the reason uf does not inherit Next's regular expressions.
10
+ //
11
+ // // app/dashboard/_uf.middleware.js
12
+ // // @flow
13
+ // import { cookies } from "@uniflowed/server";
14
+ //
15
+ // export default function middleware(request: Request): Response | void {
16
+ // if (cookies().get("session") == null) {
17
+ // return Response.redirect(new URL("/sign-in", request.url), 302);
18
+ // }
19
+ // }
20
+ //
21
+ // # The signature, and what it deliberately does not say
22
+ //
23
+ // `(request, context) => Response | void`. Returning a `Response` *is* the
24
+ // answer: nothing after it runs, no page is resolved and no handler is called.
25
+ // Returning nothing continues to the next middleware and then to whatever the
26
+ // path would otherwise have done. Both halves are load-bearing — a middleware
27
+ // that could only observe would not be able to reject, and one that had to
28
+ // answer could not be a logger.
29
+ //
30
+ // There is no `next()` and no way to rewrite the request. Rewriting needs a
31
+ // spelling — a returned `Request`, or a `next(request)` argument — and picking
32
+ // one badly is harder to undo than not having it, so it is not spelled here
33
+ // yet. What exists answers or continues, and says so.
34
+ //
35
+ // # The request it runs inside
36
+ //
37
+ // The runner does not establish one. The host does — `beginRequest` in
38
+ // `@uniflowed/server/host`, once per request, around everything that answers
39
+ // it — and the guard, the handler or page underneath it, and the render all
40
+ // see that one context. So `cookies()` in a guard and `cookies()` in the page
41
+ // it guards are the same cookies, `draftMode().enable()` in a guard is visible
42
+ // to what it guards, and every `after()` on the request is one ordered list
43
+ // the host drains after the response has gone.
44
+ //
45
+ // It used to be the other way, and it is worth saying why that was wrong
46
+ // rather than merely different: this module built its own context and drained
47
+ // it before returning, which is early in both outcomes. When the chain
48
+ // answered, the caller was several lines from writing a byte; when it
49
+ // declined, there was no response at all yet and the dispatcher below was
50
+ // about to build a second context nothing here could see. `after()` says "once
51
+ // the response has been sent". See ubugeeei-prod/uf#389.
52
+ //
53
+ // # Server only
54
+ //
55
+ // This module is imported by `virtual:uf/server` and by nothing the browser
56
+ // loads. That is not decoration: a middleware is where an application puts the
57
+ // check it does not want a user to be able to read, and the client entry
58
+ // importing the table it lives in would ship every one of them to the page.
59
+ // `routesModuleSource` keeps the middleware table in an export the client
60
+ // never imports, for the same reason it does that with route handlers.
61
+
62
+ import { requireRequest } from "./internal/request.js";
63
+ import type { RouteParams } from "./internal/runtime.js";
64
+
65
+ /** What a middleware is given besides the request. */
66
+ export type MiddlewareContext = {|
67
+ /** The `[param]` segments of the *directory the middleware guards*. */
68
+ readonly params: RouteParams,
69
+ /** The parsed query string, for the common case of reading one value. */
70
+ readonly searchParams: URLSearchParams,
71
+ |};
72
+
73
+ /** One middleware function. */
74
+ export type Middleware = (
75
+ request: Request,
76
+ context: MiddlewareContext,
77
+ ) => Response | void | Promise<Response | void>;
78
+
79
+ /** A middleware module, as the generated table loads it. */
80
+ export type MiddlewareModule = { readonly [name: string]: mixed };
81
+
82
+ /** One entry of the generated middleware table. */
83
+ export type MiddlewareRecord = {|
84
+ /** The route path of the directory this middleware guards, `/` at the root. */
85
+ readonly path: string,
86
+ readonly file: string,
87
+ readonly load: () => Promise<MiddlewareModule>,
88
+ |};
89
+
90
+ /**
91
+ * Build the middleware runner for one application.
92
+ *
93
+ * Returns `null` when every middleware on the path declined, which is the
94
+ * caller's signal to carry on to the handler or the page.
95
+ *
96
+ * The runner is called once per request, above both the dispatcher and the
97
+ * renderer, rather than from inside each of them. Putting the call inside
98
+ * `createDispatcher` and again inside `createRenderer` was the first shape and
99
+ * it is wrong twice over: a request that matches neither — `/dashboard/typo`,
100
+ * which is a 404 — would have run no middleware at all, and a path that is
101
+ * both a page and a handler would have run it twice. Middleware is a property
102
+ * of the request, so it belongs where the request arrives.
103
+ */
104
+ export function createMiddlewareRunner(options: {|
105
+ readonly middleware: $ReadOnlyArray<MiddlewareRecord>,
106
+ |}): (request: Request) => Promise<Response | null> {
107
+ // Root first, so an application-wide check runs before the one that guards a
108
+ // section of it. A shorter path is always an ancestor of a longer one that
109
+ // also matched, so segment count is the whole of the ordering.
110
+ const table = [...options.middleware].sort(
111
+ (a, b) => segmentsOf(a.path).length - segmentsOf(b.path).length,
112
+ );
113
+
114
+ return async function runMiddleware(request: Request): Promise<Response | null> {
115
+ // Checked rather than assumed, and checked before the table so that a host
116
+ // is caught on its first request whether or not this project happens to
117
+ // have a middleware. `createApplicationHandler` makes the same argument
118
+ // about `entry.runMiddleware` itself — called rather than tested for, so a
119
+ // server bundle without it is a `TypeError` on the first request instead of
120
+ // an application whose auth check quietly stopped running. The same
121
+ // argument applies to the request this runs inside: without one, the first
122
+ // `cookies()` in somebody's guard would throw "called outside a request …
123
+ // a static prerender, a module's top level, or a client component", which
124
+ // is three wrong places to look, and an application with no `cookies()`
125
+ // anywhere would reach its render with no context at all and lose every
126
+ // `after()` to a different exception later.
127
+ requireRequest("runMiddleware");
128
+ if (table.length === 0) {
129
+ return null;
130
+ }
131
+
132
+ const url = new URL(request.url);
133
+
134
+ for (const record of table) {
135
+ const params = matchPrefix(record.path, url.pathname);
136
+ if (params == null) {
137
+ continue;
138
+ }
139
+
140
+ const middleware = pick(await record.load(), record.file);
141
+ // In the host's context, not one of this module's own. Two middleware on
142
+ // the same path see the same cookies, and so does the handler or the page
143
+ // underneath them: `draftMode().enable()` in a guard is visible to what
144
+ // it guards, and every `after()` on the request lands in one ordered list
145
+ // that the host drains once, after the response has gone.
146
+ const result = await middleware(request, { params, searchParams: url.searchParams });
147
+ if (result != null) {
148
+ return result;
149
+ }
150
+ }
151
+
152
+ return null;
153
+ };
154
+ }
155
+
156
+ /**
157
+ * The function a middleware module exports.
158
+ *
159
+ * `default` or `middleware`, the same two spellings a page offers for its
160
+ * component. Anything else is an authoring mistake and throws rather than
161
+ * being skipped: a file named `_uf.middleware.js` that the router quietly
162
+ * ignored is the bug this whole module exists to stop happening.
163
+ */
164
+ function pick(module: MiddlewareModule, file: string): Middleware {
165
+ const exported = typeof module.default === "function" ? module.default : module.middleware;
166
+ if (typeof exported !== "function") {
167
+ throw new Error(
168
+ `${file} is a middleware but exports no middleware function: export it as \`default\` or as \`middleware\`.`,
169
+ );
170
+ }
171
+ return exported as $FlowFixMe;
172
+ }
173
+
174
+ /**
175
+ * Match a middleware's directory path against a pathname, as a *prefix*.
176
+ *
177
+ * The difference from the dispatcher's `matchPath` is the whole point:
178
+ * `/dashboard` matches `/dashboard`, `/dashboard/settings` and
179
+ * `/dashboard/a/b`, because a middleware guards a subtree rather than a path.
180
+ * `null` when it does not match, so a route with no parameters is still
181
+ * distinguishable from a miss.
182
+ */
183
+ function matchPrefix(routePath: string, pathname: string): RouteParams | null {
184
+ const wanted = segmentsOf(routePath);
185
+ const given = segmentsOf(pathname);
186
+ const params: { [string]: string | Array<string> } = {};
187
+
188
+ for (let index = 0; index < wanted.length; index += 1) {
189
+ const segment = wanted[index];
190
+ if (segment.startsWith(":") && segment.endsWith("*")) {
191
+ params[segment.slice(1, -1)] = given.slice(index);
192
+ return params as $FlowFixMe;
193
+ }
194
+ if (index >= given.length) {
195
+ return null;
196
+ }
197
+ if (segment.startsWith(":")) {
198
+ params[segment.slice(1)] = given[index];
199
+ continue;
200
+ }
201
+ if (segment !== given[index]) {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ return params as $FlowFixMe;
207
+ }
208
+
209
+ function segmentsOf(value: string): Array<string> {
210
+ return value.split("/").filter((segment) => segment !== "");
211
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.7",
3
+ "version": "0.0.0-alpha.8",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,13 +15,15 @@
15
15
  "./client": "./client.js",
16
16
  "./server": "./server.js",
17
17
  "./package.json": "./package.json",
18
- "./handler": "./handler.js"
18
+ "./handler": "./handler.js",
19
+ "./middleware": "./middleware.js"
19
20
  },
20
21
  "files": [
21
22
  "client.js",
22
23
  "handler.js",
23
24
  "index.js",
24
25
  "internal",
26
+ "middleware.js",
25
27
  "server.js"
26
28
  ],
27
29
  "peerDependencies": {
@@ -29,6 +31,6 @@
29
31
  "react-dom": ">=19"
30
32
  },
31
33
  "dependencies": {
32
- "@uniflowed/server": "0.0.0-alpha.7"
34
+ "@uniflowed/server": "0.0.0-alpha.8"
33
35
  }
34
36
  }
package/server.js CHANGED
@@ -3,13 +3,37 @@
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` renders every document request through the result while
7
- // `uf build` renders every static route through it once. Both produce the
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 `_uf.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
26
  import { DATA_ID, ROOT_ID } from "./internal/document.js";
11
27
  import * as React from "react";
12
- import { renderToString } from "react-dom/server";
28
+
29
+ import {
30
+ type DocumentBody,
31
+ type DocumentShell,
32
+ type WritableLike,
33
+ bodyOfText,
34
+ prerenderDocument,
35
+ renderDocument,
36
+ } from "./internal/stream.js";
13
37
 
14
38
  import {
15
39
  type AppProps,
@@ -28,11 +52,24 @@ export type RenderAssets = {|
28
52
  readonly preloads: $ReadOnlyArray<string>,
29
53
  |};
30
54
 
31
- /** A rendered document. */
55
+ /**
56
+ * A document that has begun.
57
+ *
58
+ * `status` and `headers` are known once the shell is ready, which is the moment
59
+ * this resolves and is why a streaming renderer can still answer with a status
60
+ * line. The body arrives afterwards, through exactly one of `pipe`, `stream`
61
+ * and `text` — they are three views of one pass over the same chunks, not three
62
+ * copies of the document.
63
+ */
32
64
  export type RenderResult = {|
33
65
  readonly status: number,
34
- readonly html: string,
35
66
  readonly headers?: { readonly [string]: string },
67
+ /** Write the document into a Node response. */
68
+ readonly pipe: (destination: WritableLike) => Promise<void>,
69
+ /** The document as a web stream, for `new Response(…)`. */
70
+ readonly stream: () => ReadableStream,
71
+ /** The whole document, once it has finished streaming. */
72
+ readonly text: () => Promise<string>,
36
73
  /**
37
74
  * The exception this render fell back to its error boundary for.
38
75
  *
@@ -44,24 +81,114 @@ export type RenderResult = {|
44
81
  *
45
82
  * `forbidden()` and `unauthorized()` do not set it: those are answers an
46
83
  * application chose, and a build that prerendered one has not failed.
84
+ *
85
+ * Only the failures known before the first byte: a loader that threw, or a
86
+ * shell that did. An exception inside a `<Suspense>` boundary happens after
87
+ * this has been read, so it is reported through `render`'s `onError` instead
88
+ * — a streaming renderer cannot put a late failure in a value the caller
89
+ * already has.
47
90
  */
48
91
  readonly error?: mixed,
49
92
  |};
50
93
 
94
+ /** A document that is finished: every boundary resolved, nothing left to wait for. */
95
+ export type PrerenderResult = {|
96
+ readonly status: number,
97
+ readonly html: string,
98
+ readonly headers?: { readonly [string]: string },
99
+ /** The exception this render fell back to its error boundary for; see [`RenderResult`]. */
100
+ readonly error?: mixed,
101
+ |};
102
+
103
+ /** What a host may tell the renderer about one request. */
104
+ export type RenderOptions = {|
105
+ /**
106
+ * Every exception React recovered from, including the ones it answered by
107
+ * streaming a boundary's fallback after the response had begun.
108
+ *
109
+ * A callback rather than a field on the result, because that is the shape of
110
+ * the truth: by the time one of these happens the caller is already writing
111
+ * bytes. `uf dev` reports them in the terminal; a production host logs them.
112
+ */
113
+ readonly onError?: (error: mixed) => void,
114
+ |};
115
+
116
+ /** The two ids the server writes and the client reads. */
117
+ export { DATA_ID, ROOT_ID } from "./internal/document.js";
118
+
51
119
  /**
52
- * Build a `render(url, assets)` for one app.
120
+ * How a host begins the request everything below runs inside.
121
+ *
122
+ * Re-exported rather than left to the host to import, and the reason is the
123
+ * one thing about `@uniflowed/server` that is easy to get wrong: the request
124
+ * lives in an `AsyncLocalStorage` belonging to *that module instance*. A host
125
+ * that resolved `@uniflowed/server/host` for itself — from its own
126
+ * `node_modules`, or from outside the bundle a build produced — would begin a
127
+ * request in a second storage, and every `cookies()` in the application would
128
+ * still be outside one, silently. Handing it out from here makes the copy the
129
+ * host begins with the copy this module dispatches and renders with, because
130
+ * it is the same import.
131
+ *
132
+ * `run` wraps everything that decides the response; `settle` is called once
133
+ * the response has been *written*, which is a different line in every host.
134
+ * `createMiddlewareRunner` and `createDispatcher` refuse to run outside it.
135
+ * See ubugeeei-prod/uf#389.
53
136
  */
54
- export { DATA_ID, ROOT_ID } from "./internal/document.js";
137
+ export type { RequestLifecycle } from "@uniflowed/server/host";
138
+ export { beginRequest } from "@uniflowed/server/host";
55
139
 
56
140
  export type { Handler, HandlerContext, HandlerModule, HandlerRecord } from "./handler.js";
57
141
  export { createDispatcher } from "./handler.js";
58
142
 
143
+ export type {
144
+ Middleware,
145
+ MiddlewareContext,
146
+ MiddlewareModule,
147
+ MiddlewareRecord,
148
+ } from "./middleware.js";
149
+ export { createMiddlewareRunner } from "./middleware.js";
150
+
151
+ /**
152
+ * What a URL turned out to be: a route to render, or a redirect to answer with.
153
+ *
154
+ * Tagged, and returned rather than thrown, because both entry points need the
155
+ * same answer and a redirect is the one thing `resolveMatch` lets out. Without
156
+ * the tag this would be a union of two exact objects and reading either field
157
+ * would be a type error on the branch that does not have it.
158
+ */
159
+ type Resolution =
160
+ | {| readonly kind: "route", readonly route: ResolvedRoute |}
161
+ | {| readonly kind: "redirect", readonly error: RedirectError |};
162
+
163
+ /** A redirect, as the finished document `prerender` answers with. */
164
+ async function redirectResult(document: RenderResult): Promise<PrerenderResult> {
165
+ return {
166
+ status: document.status,
167
+ headers: document.headers,
168
+ html: await document.text(),
169
+ };
170
+ }
171
+
172
+ /** The two ways one app answers for a URL. */
173
+ export type Renderer = {|
174
+ readonly render: (
175
+ url: string,
176
+ assets: RenderAssets,
177
+ options?: RenderOptions,
178
+ ) => Promise<RenderResult>,
179
+ readonly prerender: (
180
+ url: string,
181
+ assets: RenderAssets,
182
+ options?: RenderOptions,
183
+ ) => Promise<PrerenderResult>,
184
+ |};
185
+
59
186
  export function createRenderer(options: {|
60
187
  readonly App: React.ComponentType<AppProps>,
61
188
  readonly routes: RouteTable["routes"],
62
189
  readonly notFound: RouteTable["notFound"],
63
190
  readonly errors: RouteTable["errors"],
64
- |}): (url: string, assets: RenderAssets) => Promise<RenderResult> {
191
+ |}): Renderer {
65
192
  const table: RouteTable = {
66
193
  routes: options.routes,
67
194
  notFound: options.notFound,
@@ -70,43 +197,133 @@ export function createRenderer(options: {|
70
197
  installRoutes(table);
71
198
  const { App } = options;
72
199
 
73
- return async function render(url: string, assets: RenderAssets): Promise<RenderResult> {
74
- let resolved: ResolvedRoute;
200
+ /**
201
+ * The route to render, or the redirect to answer with instead.
202
+ *
203
+ * Shared by both entry points, because *what* a URL resolves to has nothing
204
+ * to do with how the answer is delivered. Returning the redirect rather than
205
+ * throwing it keeps the two callers from each having to remember that a
206
+ * redirect is the one thing `resolveMatch` lets out.
207
+ */
208
+ async function resolve(url: string): Promise<Resolution> {
75
209
  try {
76
- resolved = await resolveMatch(table, url);
210
+ return { kind: "route", route: await resolveMatch(table, url) };
77
211
  } catch (error) {
78
- // A redirect is the only thing `resolveMatch` lets out, because a
79
- // redirect is a response rather than a page.
80
212
  if (error instanceof RedirectError) {
81
- return redirectDocument(error);
213
+ return { kind: "redirect", error };
82
214
  }
83
215
  throw error;
84
216
  }
217
+ }
218
+
219
+ async function render(
220
+ url: string,
221
+ assets: RenderAssets,
222
+ settings?: RenderOptions,
223
+ ): Promise<RenderResult> {
224
+ const resolution = await resolve(url);
225
+ if (resolution.kind === "redirect") {
226
+ return redirectDocument(resolution.error);
227
+ }
228
+ let resolved: ResolvedRoute = resolution.route;
229
+ const report = settings?.onError ?? (() => {});
230
+
231
+ // React reports an exception to `onError` *and*, if it was in the shell, to
232
+ // `onShellError` — so forwarding both would tell the host about one failure
233
+ // twice, once through `onError` and once as `result.error` after this
234
+ // re-renders. Errors are held until the shell is known to have survived;
235
+ // if it did not, they are the failure the caller is about to be handed, and
236
+ // the render they came from is being thrown away with them.
237
+ let streaming = false;
238
+ let held: Array<mixed> = [];
239
+ const onError = (error: mixed) => {
240
+ if (streaming) {
241
+ report(error);
242
+ return;
243
+ }
244
+ held.push(error);
245
+ };
85
246
 
86
- let markup: string;
247
+ let body: DocumentBody;
87
248
  try {
88
- markup = renderToString(<App url={url} initial={resolved} />);
249
+ body = await renderDocument(<App url={url} initial={resolved} />, {
250
+ shell: shellFor(resolved, assets),
251
+ onError,
252
+ });
253
+ streaming = true;
254
+ // Recovered before the shell was ready: a `<Suspense>` boundary whose
255
+ // content threw while the shell was still rendering. The response is
256
+ // fine and the host still has to hear about it.
257
+ for (const error of held) {
258
+ report(error);
259
+ }
260
+ held = [];
89
261
  } catch (error) {
90
- // The server's half of the error boundary. React does not run class
91
- // boundaries in `renderToString` — Fizz has no `getDerivedStateFromError`
92
- // step outside a Suspense boundary — so `RouteView`'s boundary is the
93
- // browser's containment and this is the server's. Without it one
94
- // component that throws is the whole response, and during `uf build` the
95
- // whole build. See ubugeeei-prod/uf#257.
262
+ // The server's half of the error boundary. React runs a class boundary
263
+ // inside a `<Suspense>` and not outside one, so a throw in the shell —
264
+ // the layouts, or a page with no boundary above it — still reaches here
265
+ // rather than `RouteView`'s. Nothing has been written yet, which is what
266
+ // makes answering with a different document possible at all: `onShellError`
267
+ // fires before the first byte, and once it has not, this is unreachable.
268
+ // See ubugeeei-prod/uf#257.
96
269
  if (error instanceof RedirectError) {
97
270
  return redirectDocument(error);
98
271
  }
272
+ held = [];
99
273
  resolved = await resolveFailure(table, url, error);
100
274
  // Deliberately not caught again: this render is the boundary's own
101
275
  // component, and a boundary that throws has nothing left to answer with.
102
276
  // It reaches `uf dev`'s overlay and fails `uf build`'s route, which is
103
277
  // where somebody can fix it.
104
- markup = renderToString(<App url={url} initial={resolved} />);
278
+ streaming = true;
279
+ body = await renderDocument(<App url={url} initial={resolved} />, {
280
+ shell: shellFor(resolved, assets),
281
+ onError,
282
+ });
283
+ }
284
+
285
+ return {
286
+ status: resolved.status,
287
+ pipe: body.pipe,
288
+ stream: body.stream,
289
+ text: body.text,
290
+ error: renderFailure(resolved),
291
+ };
292
+ }
293
+
294
+ async function prerender(
295
+ url: string,
296
+ assets: RenderAssets,
297
+ settings?: RenderOptions,
298
+ ): Promise<PrerenderResult> {
299
+ const resolution = await resolve(url);
300
+ if (resolution.kind === "redirect") {
301
+ return redirectResult(redirectDocument(resolution.error));
302
+ }
303
+ let resolved: ResolvedRoute = resolution.route;
304
+ const report = settings?.onError ?? (() => {});
305
+
306
+ let html: string;
307
+ try {
308
+ html = await prerenderDocument(<App url={url} initial={resolved} />, {
309
+ shell: shellFor(resolved, assets),
310
+ onError: report,
311
+ });
312
+ } catch (error) {
313
+ if (error instanceof RedirectError) {
314
+ return redirectResult(redirectDocument(error));
315
+ }
316
+ resolved = await resolveFailure(table, url, error);
317
+ html = await prerenderDocument(<App url={url} initial={resolved} />, {
318
+ shell: shellFor(resolved, assets),
319
+ onError: report,
320
+ });
105
321
  }
106
322
 
107
- const html = assemble(markup, resolved, assets);
108
323
  return { status: resolved.status, html, error: renderFailure(resolved) };
109
- };
324
+ }
325
+
326
+ return { render, prerender };
110
327
  }
111
328
 
112
329
  /** The exception a resolved route fell back to its error boundary for. */
@@ -123,32 +340,45 @@ function renderFailure(resolved: ResolvedRoute): mixed {
123
340
 
124
341
  function redirectDocument(error: RedirectError): RenderResult {
125
342
  const target = escapeAttribute(error.to);
343
+ // A document rather than an empty body, because a redirect is still an answer
344
+ // a browser may be shown; it goes through the same three methods as a
345
+ // rendered one so that a host has one shape to write, not two.
346
+ const body = bodyOfText(
347
+ `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><title>Redirecting</title></head><body><a href="${target}">Redirecting…</a></body></html>\n`,
348
+ );
126
349
  return {
127
350
  status: error.permanent ? 308 : 307,
128
351
  headers: { Location: error.to },
129
- html: `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><title>Redirecting</title></head><body><a href="${target}">Redirecting…</a></body></html>\n`,
352
+ pipe: body.pipe,
353
+ stream: body.stream,
354
+ text: body.text,
130
355
  };
131
356
  }
132
357
 
133
358
  /**
134
- * Turn the app's markup into a complete document.
359
+ * The document uf writes around the app's markup.
135
360
  *
136
- * An app whose root layout renders `<html>` owns the whole document, and the
137
- * client hydrates `document`; the scripts and stylesheets are inserted before
361
+ * The same two shapes `assemble` chose between, decided from the same evidence
362
+ * — whether the markup opens with `<html>` — but stated up front instead of
363
+ * afterwards, because a stream has no "afterwards" in which to splice a head.
364
+ * An app whose root layout renders `<html>` owns the whole document and the
365
+ * client hydrates `document`, so uf contributes only the tags that go before
138
366
  * `</head>`. An app that renders only content is wrapped in a minimal shell
139
367
  * around `<div id="uf-root">`, which is what the client hydrates instead.
368
+ *
369
+ * `internal/stream.js` picks between them on the opening bytes React writes;
370
+ * everything either shape is made of is here, so what a uf document contains is
371
+ * still readable in one place.
140
372
  */
141
- function assemble(markup: string, resolved: ResolvedRoute, assets: RenderAssets): string {
373
+ function shellFor(resolved: ResolvedRoute, assets: RenderAssets): DocumentShell {
142
374
  const head = headTags(assets) + dataScript(resolved.data);
143
- if (/^\s*<html[\s>]/i.test(markup)) {
144
- const document = markup.includes("</head>")
145
- ? markup.replace("</head>", `${head}</head>`)
146
- : markup.replace(/<html([^>]*)>/i, `<html$1><head>${head}</head>`);
147
- return `<!doctype html>\n${document}\n`;
148
- }
149
375
  const title =
150
376
  resolved.metadata.title != null ? `<title>${escapeText(resolved.metadata.title)}</title>` : "";
151
- return `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${title}${head}</head><body><div id="${ROOT_ID}">${markup}</div></body></html>\n`;
377
+ return {
378
+ head,
379
+ open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${title}${head}</head><body><div id="${ROOT_ID}">`,
380
+ close: `</div></body></html>\n`,
381
+ };
152
382
  }
153
383
 
154
384
  function headTags(assets: RenderAssets): string {