@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/client.js +29 -1
- package/handler.js +20 -13
- package/index.js +4 -1
- package/internal/request.js +43 -0
- package/internal/runtime.js +249 -22
- package/internal/stream.js +627 -0
- package/middleware.js +211 -0
- package/package.json +5 -3
- package/server.js +267 -37
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.
|
|
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.
|
|
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`
|
|
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 `_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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|}):
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
247
|
+
let body: DocumentBody;
|
|
87
248
|
try {
|
|
88
|
-
|
|
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
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
352
|
+
pipe: body.pipe,
|
|
353
|
+
stream: body.stream,
|
|
354
|
+
text: body.text,
|
|
130
355
|
};
|
|
131
356
|
}
|
|
132
357
|
|
|
133
358
|
/**
|
|
134
|
-
*
|
|
359
|
+
* The document uf writes around the app's markup.
|
|
135
360
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
|
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
|
|
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 {
|