@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
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a document as bytes rather than a string.
|
|
4
|
+
//
|
|
5
|
+
// `server.js` decides *what* the document says; this decides how it leaves.
|
|
6
|
+
// The split is here because the two answers are unrelated — a redirect, an
|
|
7
|
+
// error page and a page that suspends for half a second all produce the same
|
|
8
|
+
// three shapes for a host to choose from — and because everything below is
|
|
9
|
+
// about React's two server renderers and the difference between them, which is
|
|
10
|
+
// not something the renderer above should have to spell out twice.
|
|
11
|
+
//
|
|
12
|
+
// # The head is written before the body
|
|
13
|
+
//
|
|
14
|
+
// `packages/web/head.js` says this from the other side, as the reason `useHead`
|
|
15
|
+
// does nothing on a server: once the body is streaming, the head has gone and
|
|
16
|
+
// no component can still change it. This module is what makes that true rather
|
|
17
|
+
// than merely claimed.
|
|
18
|
+
//
|
|
19
|
+
// It is also the one thing that stops a document from being a straight
|
|
20
|
+
// pass-through of React's chunks. uf has head content React does not know
|
|
21
|
+
// about — the built asset URLs and the loader data the client hydrates from —
|
|
22
|
+
// and both have to be in the head. So the first chunks are held until the head
|
|
23
|
+
// is complete, the tags go in, and everything after that is forwarded
|
|
24
|
+
// untouched. What is held is bounded by the head: React writes `<head>` before
|
|
25
|
+
// any body content, so waiting for `</head>` never waits for a page.
|
|
26
|
+
//
|
|
27
|
+
// That is the whole of the buffering, and it is worth being precise about what
|
|
28
|
+
// it does *not* delay. A `<Suspense>` fallback is part of the shell, so it goes
|
|
29
|
+
// out with the head; the content that replaces it arrives in later chunks that
|
|
30
|
+
// pass straight through. The fallback is not what is being waited on — it is
|
|
31
|
+
// what is being sent.
|
|
32
|
+
//
|
|
33
|
+
// # Two renderers, and how the host picks
|
|
34
|
+
//
|
|
35
|
+
// `renderToPipeableStream` exists in React's Node build and not in its
|
|
36
|
+
// Web-standard ones; `renderToReadableStream` is in both. A namespace import is
|
|
37
|
+
// what makes that a runtime question rather than an import-time crash: a named
|
|
38
|
+
// import of `renderToPipeableStream` from `react-dom/server` in a worker is a
|
|
39
|
+
// module that will not link, and the failure is a blank deploy rather than a
|
|
40
|
+
// message. So the check is `typeof …` on the namespace, once, below.
|
|
41
|
+
|
|
42
|
+
import * as React from "react";
|
|
43
|
+
import * as ReactDOMServer from "react-dom/server";
|
|
44
|
+
import * as ReactDOMStatic from "react-dom/static";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where a document is written, when the host has a Node stream.
|
|
48
|
+
*
|
|
49
|
+
* The three methods React's own `pipe` uses, and nothing else. Typed here
|
|
50
|
+
* rather than imported so this module holds no Node types: a worker bundles it
|
|
51
|
+
* too, and `stream$Writable` in the signature would be a Node type in a file
|
|
52
|
+
* that must not need one.
|
|
53
|
+
*/
|
|
54
|
+
export type WritableLike = {
|
|
55
|
+
readonly write: (chunk: string) => mixed,
|
|
56
|
+
readonly end: () => mixed,
|
|
57
|
+
...
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A rendered document, in whichever shape the host can take.
|
|
62
|
+
*
|
|
63
|
+
* Three methods rather than one, because the three hosts uf actually has want
|
|
64
|
+
* three different things and converting between them costs a copy of the
|
|
65
|
+
* document: `uf dev` has a `ServerResponse`, `uf start` builds a `Response`,
|
|
66
|
+
* and `uf build` and the tests want the text. Each is a single pass over the
|
|
67
|
+
* same chunks, so exactly one of them may be called.
|
|
68
|
+
*/
|
|
69
|
+
export type DocumentBody = {|
|
|
70
|
+
/** Write the document into a Node response. Resolves when the last byte is in. */
|
|
71
|
+
readonly pipe: (destination: WritableLike) => Promise<void>,
|
|
72
|
+
/** The document as a web stream, for `new Response(…)`. */
|
|
73
|
+
readonly stream: () => ReadableStream,
|
|
74
|
+
/** The whole document, once it is finished. */
|
|
75
|
+
readonly text: () => Promise<string>,
|
|
76
|
+
|};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The parts of React's Node destination that Fizz actually uses.
|
|
80
|
+
*
|
|
81
|
+
* Written out rather than `any`, and rather than `stream$Writable`: this module
|
|
82
|
+
* is bundled for workers too, so a Node type in a signature here would be a Node
|
|
83
|
+
* type in a file that must not need one. What is below is the whole contract —
|
|
84
|
+
* if React starts calling something else, this stops compiling, which is the
|
|
85
|
+
* point of writing it down.
|
|
86
|
+
*/
|
|
87
|
+
type NodeDestination = {
|
|
88
|
+
readonly write: (chunk: string | Uint8Array) => boolean,
|
|
89
|
+
readonly end: () => mixed,
|
|
90
|
+
readonly destroy: (error?: mixed) => mixed,
|
|
91
|
+
readonly on: (event: string, listener: (...args: Array<mixed>) => mixed) => mixed,
|
|
92
|
+
readonly once: (event: string, listener: (...args: Array<mixed>) => mixed) => mixed,
|
|
93
|
+
readonly off: () => mixed,
|
|
94
|
+
readonly removeListener: () => mixed,
|
|
95
|
+
readonly emit: () => boolean,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The controller a `ReadableStream` source is handed. */
|
|
99
|
+
type StreamController = {
|
|
100
|
+
readonly enqueue: (chunk: Uint8Array) => mixed,
|
|
101
|
+
readonly close: () => mixed,
|
|
102
|
+
...
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A stream of bytes, as much of one as this module reads.
|
|
107
|
+
*
|
|
108
|
+
* Both of React's web-shaped outputs are one — `renderToReadableStream`'s
|
|
109
|
+
* result and `react-dom/static`'s `prelude` — and neither is typed by anything
|
|
110
|
+
* uf can import, so the shape it is used through is stated here.
|
|
111
|
+
*/
|
|
112
|
+
type ByteSource = {
|
|
113
|
+
readonly getReader: () => {
|
|
114
|
+
readonly read: () => Promise<{ readonly done?: boolean, readonly value?: Uint8Array, ... }>,
|
|
115
|
+
readonly releaseLock: () => mixed,
|
|
116
|
+
...
|
|
117
|
+
},
|
|
118
|
+
...
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** How the document is assembled around the app's markup. */
|
|
122
|
+
export type DocumentShell = {|
|
|
123
|
+
/**
|
|
124
|
+
* Head tags for an app that renders its own `<html>`, inserted before the
|
|
125
|
+
* `</head>` React writes.
|
|
126
|
+
*/
|
|
127
|
+
readonly head: string,
|
|
128
|
+
/** Everything before the markup of an app that renders no document. */
|
|
129
|
+
readonly open: string,
|
|
130
|
+
/** Everything after it. */
|
|
131
|
+
readonly close: string,
|
|
132
|
+
|};
|
|
133
|
+
|
|
134
|
+
/** How much unread output the producer is allowed to run ahead by. */
|
|
135
|
+
const HIGH_WATER_MARK = 16;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The chunks of one render, as an async iterable, with backpressure in both
|
|
139
|
+
* directions.
|
|
140
|
+
*
|
|
141
|
+
* React's Node renderer pushes and a `ReadableStream` pulls, and a host may be
|
|
142
|
+
* slower than either. One queue in the middle answers all three: the producer
|
|
143
|
+
* is told to stop once `HIGH_WATER_MARK` chunks are waiting — which is the
|
|
144
|
+
* `false` from `write` that React's `pipe` honours — and is let go again when
|
|
145
|
+
* the consumer has caught up.
|
|
146
|
+
*
|
|
147
|
+
* Without it a slow client would be answered by a server holding an entire
|
|
148
|
+
* rendered document per request in memory, which is the failure mode streaming
|
|
149
|
+
* exists to avoid; a queue that only ever grows would have been streaming in
|
|
150
|
+
* shape and buffering in fact.
|
|
151
|
+
*/
|
|
152
|
+
class ChunkQueue {
|
|
153
|
+
#chunks: Array<string> = [];
|
|
154
|
+
#ended: boolean = false;
|
|
155
|
+
#failure: mixed = null;
|
|
156
|
+
#failed: boolean = false;
|
|
157
|
+
#wake: ?() => void = null;
|
|
158
|
+
#drain: Array<() => void> = [];
|
|
159
|
+
|
|
160
|
+
/** Add a chunk. Returns whether the producer may keep going. */
|
|
161
|
+
push(chunk: string): boolean {
|
|
162
|
+
this.#chunks.push(chunk);
|
|
163
|
+
this.#ring();
|
|
164
|
+
return this.#chunks.length < HIGH_WATER_MARK;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** No more chunks are coming. */
|
|
168
|
+
end(): void {
|
|
169
|
+
this.#ended = true;
|
|
170
|
+
this.#ring();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The render failed after the shell went out; the document is truncated. */
|
|
174
|
+
fail(error: mixed): void {
|
|
175
|
+
this.#failed = true;
|
|
176
|
+
this.#failure = error;
|
|
177
|
+
this.#ended = true;
|
|
178
|
+
this.#ring();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Run `resume` when there is room again. */
|
|
182
|
+
onDrain(resume: () => void): void {
|
|
183
|
+
this.#drain.push(resume);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#ring(): void {
|
|
187
|
+
const wake = this.#wake;
|
|
188
|
+
this.#wake = null;
|
|
189
|
+
if (wake != null) {
|
|
190
|
+
wake();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#room(): void {
|
|
195
|
+
if (this.#chunks.length >= HIGH_WATER_MARK || this.#drain.length === 0) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const waiting = this.#drain;
|
|
199
|
+
this.#drain = [];
|
|
200
|
+
for (const resume of waiting) {
|
|
201
|
+
resume();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async *chunks(): AsyncGenerator<string, void, void> {
|
|
206
|
+
while (true) {
|
|
207
|
+
while (this.#chunks.length > 0) {
|
|
208
|
+
const chunk = this.#chunks.shift();
|
|
209
|
+
this.#room();
|
|
210
|
+
if (chunk != null) {
|
|
211
|
+
yield chunk;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (this.#failed) {
|
|
215
|
+
throw this.#failure;
|
|
216
|
+
}
|
|
217
|
+
if (this.#ended) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await new Promise<void>((resolve) => {
|
|
221
|
+
this.#wake = resolve;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A destination React's `pipe` will write into, backed by a queue.
|
|
229
|
+
*
|
|
230
|
+
* React's Node renderer wants a `Writable`, and the only parts of one it uses
|
|
231
|
+
* are `write`, `end`, `destroy` and the `drain` and `error` events. Handing it
|
|
232
|
+
* the real thing would mean importing `node:stream` into a module a worker also
|
|
233
|
+
* loads, for four methods.
|
|
234
|
+
*/
|
|
235
|
+
function queueDestination(queue: ChunkQueue): NodeDestination {
|
|
236
|
+
const decoder = new TextDecoder();
|
|
237
|
+
const listeners: Map<string, Array<(...args: Array<mixed>) => mixed>> = new Map();
|
|
238
|
+
const destination = {
|
|
239
|
+
write(chunk: string | Uint8Array): boolean {
|
|
240
|
+
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
241
|
+
const room = queue.push(text);
|
|
242
|
+
if (!room) {
|
|
243
|
+
queue.onDrain(() => {
|
|
244
|
+
for (const listener of listeners.get("drain") ?? []) {
|
|
245
|
+
listener();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return room;
|
|
250
|
+
},
|
|
251
|
+
end(): mixed {
|
|
252
|
+
queue.end();
|
|
253
|
+
return destination;
|
|
254
|
+
},
|
|
255
|
+
destroy(error?: mixed): mixed {
|
|
256
|
+
if (error != null) {
|
|
257
|
+
queue.fail(error);
|
|
258
|
+
} else {
|
|
259
|
+
queue.end();
|
|
260
|
+
}
|
|
261
|
+
return destination;
|
|
262
|
+
},
|
|
263
|
+
on(event: string, listener: (...args: Array<mixed>) => mixed): mixed {
|
|
264
|
+
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
|
265
|
+
return destination;
|
|
266
|
+
},
|
|
267
|
+
once(event: string, listener: (...args: Array<mixed>) => mixed): mixed {
|
|
268
|
+
return destination.on(event, listener);
|
|
269
|
+
},
|
|
270
|
+
off(): mixed {
|
|
271
|
+
return destination;
|
|
272
|
+
},
|
|
273
|
+
removeListener(): mixed {
|
|
274
|
+
return destination;
|
|
275
|
+
},
|
|
276
|
+
emit(): boolean {
|
|
277
|
+
return false;
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
return destination;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Insert uf's head tags, or wrap markup that is not a document.
|
|
285
|
+
*
|
|
286
|
+
* The two shapes `assemble` used to decide between, decided the same way and at
|
|
287
|
+
* the same moment — on the opening bytes rather than on a finished string. An
|
|
288
|
+
* app whose root layout renders `<html>` owns the document and React writes its
|
|
289
|
+
* head; an app that renders only content gets the minimal shell around
|
|
290
|
+
* `<div id="uf-root">` that the client hydrates instead.
|
|
291
|
+
*
|
|
292
|
+
* The document case waits for `</head>`, because that is where the tags go. If
|
|
293
|
+
* React writes a document with no head at all — an app whose root layout is
|
|
294
|
+
* `<html><body>` — the tags go in a head of uf's own, inserted after the
|
|
295
|
+
* opening tag, which is what the browser would have synthesized anyway.
|
|
296
|
+
*/
|
|
297
|
+
async function* assembled(
|
|
298
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
299
|
+
shell: DocumentShell,
|
|
300
|
+
): AsyncGenerator<string, void, void> {
|
|
301
|
+
let held = "";
|
|
302
|
+
let shape = "unknown";
|
|
303
|
+
|
|
304
|
+
for await (const chunk of chunks) {
|
|
305
|
+
if (shape === "document-open" || shape === "shell") {
|
|
306
|
+
yield chunk;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
held += chunk;
|
|
310
|
+
if (shape === "unknown") {
|
|
311
|
+
shape = documentShape(held);
|
|
312
|
+
if (shape === "unknown") {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (shape === "shell") {
|
|
316
|
+
yield shell.open + held;
|
|
317
|
+
held = "";
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// A document, and the tags go where its head closes.
|
|
322
|
+
const close = held.indexOf("</head>");
|
|
323
|
+
if (close !== -1) {
|
|
324
|
+
shape = "document-open";
|
|
325
|
+
yield ufDoctype(held.slice(0, close) + shell.head + held.slice(close));
|
|
326
|
+
held = "";
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// `<body` before `</head>` means React wrote no head; give the tags one.
|
|
330
|
+
const body = held.search(/<body[\s>]/i);
|
|
331
|
+
if (body !== -1) {
|
|
332
|
+
shape = "document-open";
|
|
333
|
+
yield ufDoctype(`${held.slice(0, body)}<head>${shell.head}</head>${held.slice(body)}`);
|
|
334
|
+
held = "";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The render ended before the decision could be made, or before the head it
|
|
339
|
+
// opened was closed: an empty document, or one with neither `</head>` nor
|
|
340
|
+
// `<body>` in it. There is nothing left to wait for either way.
|
|
341
|
+
if (shape === "document") {
|
|
342
|
+
yield ufDoctype(held + shell.head);
|
|
343
|
+
shape = "document-open";
|
|
344
|
+
} else if (shape === "unknown") {
|
|
345
|
+
shape = "shell";
|
|
346
|
+
yield shell.open + held;
|
|
347
|
+
}
|
|
348
|
+
if (shape === "shell") {
|
|
349
|
+
yield shell.close;
|
|
350
|
+
} else {
|
|
351
|
+
// The newline `assemble` ended a document with, kept: `uf build` writes
|
|
352
|
+
// these to files, and a file without a trailing newline is a diff with a
|
|
353
|
+
// "" in it forever.
|
|
354
|
+
yield "\n";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* uf's spelling of the doctype, in place of whichever one React wrote.
|
|
360
|
+
*
|
|
361
|
+
* A doctype is case-insensitive, so this is a formatting choice and not a
|
|
362
|
+
* correctness one — and uf already made it: `redirectDocument` and the shell
|
|
363
|
+
* around an app that renders no document both write `<!doctype html>`. Leaving
|
|
364
|
+
* React's `<!DOCTYPE html>` here would mean a project's documents were spelled
|
|
365
|
+
* one way or the other depending on whether its root layout renders `<html>`,
|
|
366
|
+
* which is not a distinction anybody asked for.
|
|
367
|
+
*/
|
|
368
|
+
function ufDoctype(document: string): string {
|
|
369
|
+
const existing = document.match(/^\s*<!doctype[^>]*>\s*/i);
|
|
370
|
+
const rest = existing == null ? document.replace(/^\s+/, "") : document.slice(existing[0].length);
|
|
371
|
+
return `<!doctype html>\n${rest}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Whether the bytes so far are the start of a document, and `"unknown"` when
|
|
376
|
+
* they are still too few to say.
|
|
377
|
+
*
|
|
378
|
+
* Read off the buffer rather than off the first chunk, because React decides
|
|
379
|
+
* where to split its output and a classification that depended on the split
|
|
380
|
+
* would be a bug that only appeared under load. `<!DOCTYPE html>` alone is the
|
|
381
|
+
* case that makes the third answer necessary: it is a complete token, it is
|
|
382
|
+
* not yet `<html`, and calling it either answer would be wrong.
|
|
383
|
+
*/
|
|
384
|
+
function documentShape(held: string): string {
|
|
385
|
+
const text = held.replace(/^\s+/, "").toLowerCase();
|
|
386
|
+
if (text === "") {
|
|
387
|
+
return "unknown";
|
|
388
|
+
}
|
|
389
|
+
let rest = text;
|
|
390
|
+
if (text.startsWith("<!doctype")) {
|
|
391
|
+
const close = text.indexOf(">");
|
|
392
|
+
if (close === -1) {
|
|
393
|
+
return "unknown";
|
|
394
|
+
}
|
|
395
|
+
rest = text.slice(close + 1).replace(/^\s+/, "");
|
|
396
|
+
} else if ("<!doctype".startsWith(text)) {
|
|
397
|
+
return "unknown";
|
|
398
|
+
}
|
|
399
|
+
if (rest === "" || "<html".startsWith(rest)) {
|
|
400
|
+
return "unknown";
|
|
401
|
+
}
|
|
402
|
+
// The delimiter matters: `<htmlish>` is somebody's component, not a document.
|
|
403
|
+
return /^<html[\s>]/.test(rest) ? "document" : "shell";
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The three shapes, over one pass of the chunks.
|
|
408
|
+
*
|
|
409
|
+
* `stop` is what a reader that gives up has to be able to do. A `HEAD` asks for
|
|
410
|
+
* a document and wants none of it, and a browser that navigates away closes the
|
|
411
|
+
* socket — and in both cases React is still rendering into a queue whose
|
|
412
|
+
* consumer is gone. Without this it renders until the queue is full and then
|
|
413
|
+
* waits for a drain that is never coming, which is a request that never ends.
|
|
414
|
+
*/
|
|
415
|
+
function bodyOf(chunks: AsyncGenerator<string, void, void>, stop?: () => void): DocumentBody {
|
|
416
|
+
return {
|
|
417
|
+
async pipe(destination: WritableLike): Promise<void> {
|
|
418
|
+
// `finally`, so a render that fails partway still closes the response.
|
|
419
|
+
// The alternative is a client holding an open connection to a document
|
|
420
|
+
// that stopped, waiting for bytes nobody is going to send.
|
|
421
|
+
try {
|
|
422
|
+
for await (const chunk of chunks) {
|
|
423
|
+
destination.write(chunk);
|
|
424
|
+
}
|
|
425
|
+
} finally {
|
|
426
|
+
destination.end();
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
stream(): ReadableStream {
|
|
430
|
+
const encoder = new TextEncoder();
|
|
431
|
+
// `pull`, not a loop in `start`: the queue's backpressure only means
|
|
432
|
+
// anything if this end waits to be asked.
|
|
433
|
+
return new ReadableStream({
|
|
434
|
+
async pull(controller: StreamController) {
|
|
435
|
+
const next = await chunks.next();
|
|
436
|
+
if (next.done === true) {
|
|
437
|
+
controller.close();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
controller.enqueue(encoder.encode(next.value));
|
|
441
|
+
},
|
|
442
|
+
cancel(): void {
|
|
443
|
+
stop?.();
|
|
444
|
+
void chunks.return(undefined);
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
async text(): Promise<string> {
|
|
449
|
+
let out = "";
|
|
450
|
+
for await (const chunk of chunks) {
|
|
451
|
+
out += chunk;
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** A document that is already text — a redirect, or a caller's own markup. */
|
|
459
|
+
export function bodyOfText(html: string): DocumentBody {
|
|
460
|
+
async function* one(): AsyncGenerator<string, void, void> {
|
|
461
|
+
yield html;
|
|
462
|
+
}
|
|
463
|
+
return bodyOf(one());
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** What React is told about a render, over both renderers. */
|
|
467
|
+
export type RenderOptions = {|
|
|
468
|
+
readonly shell: DocumentShell,
|
|
469
|
+
/**
|
|
470
|
+
* Every exception React recovers from, including the ones inside a
|
|
471
|
+
* `<Suspense>` that it answered by streaming the boundary's fallback. The
|
|
472
|
+
* shell's own failure is not reported here — it rejects instead.
|
|
473
|
+
*/
|
|
474
|
+
readonly onError: (error: mixed) => void,
|
|
475
|
+
|};
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Stream `node` as a document, resolving once the shell is ready.
|
|
479
|
+
*
|
|
480
|
+
* Resolving on the shell rather than on the whole document is the entire point:
|
|
481
|
+
* the caller has a status and a body to answer with while the page is still
|
|
482
|
+
* rendering. It rejects when the *shell* throws, and that is a different event
|
|
483
|
+
* from a page throwing — nothing has been written yet, so the caller can still
|
|
484
|
+
* resolve the error route and render it instead, which is what `createRenderer`
|
|
485
|
+
* does and what ubugeeei-prod/uf#257 is about.
|
|
486
|
+
*/
|
|
487
|
+
export function renderDocument(node: React.Node, options: RenderOptions): Promise<DocumentBody> {
|
|
488
|
+
const queue = new ChunkQueue();
|
|
489
|
+
return new Promise((resolve, reject) => {
|
|
490
|
+
if (typeof ReactDOMServer.renderToPipeableStream === "function") {
|
|
491
|
+
const { pipe, abort } = ReactDOMServer.renderToPipeableStream(node, {
|
|
492
|
+
onShellReady() {
|
|
493
|
+
pipe(queueDestination(queue));
|
|
494
|
+
resolve(bodyOf(assembled(queue.chunks(), options.shell), () => abort()));
|
|
495
|
+
},
|
|
496
|
+
onShellError(error: mixed) {
|
|
497
|
+
reject(error);
|
|
498
|
+
},
|
|
499
|
+
onError: options.onError,
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// A Web-standard host: no `pipe`, and the stream itself is what is
|
|
504
|
+
// awaited. `renderToReadableStream`'s promise settles on the shell, which
|
|
505
|
+
// is the same moment `onShellReady` is.
|
|
506
|
+
renderWithReadableStream(ReactDOMServer.renderToReadableStream, node, options).then(
|
|
507
|
+
resolve,
|
|
508
|
+
reject,
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* A `renderToReadableStream`, as this module calls one.
|
|
515
|
+
*
|
|
516
|
+
* Written down so [`renderWithReadableStream`] can be driven with something
|
|
517
|
+
* that is not React's. The branch below only runs on a host that has no
|
|
518
|
+
* `renderToPipeableStream` — a worker, never a test process — and a branch no
|
|
519
|
+
* test can reach is exactly how it came to be the one missing the cancellation
|
|
520
|
+
* its Node twin has had since it was written.
|
|
521
|
+
*/
|
|
522
|
+
type ReadableStreamRenderer = (
|
|
523
|
+
node: React.Node,
|
|
524
|
+
settings: {|
|
|
525
|
+
readonly onError: (error: mixed) => void,
|
|
526
|
+
readonly signal: AbortSignal,
|
|
527
|
+
|},
|
|
528
|
+
) => Promise<ByteSource>;
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The Web-standard half of [`renderDocument`], with a way to stop the render.
|
|
532
|
+
*
|
|
533
|
+
* The Node path holds `renderToPipeableStream`'s own `abort` and calls it when
|
|
534
|
+
* the consumer gives up. This one has no such handle, so it renders under an
|
|
535
|
+
* `AbortSignal` and aborts it in the same place — and without that,
|
|
536
|
+
* `releaseLock` in [`decoded`] merely detaches the reader while React goes on
|
|
537
|
+
* rendering into a stream nobody will ever read again, for however long the
|
|
538
|
+
* page's slowest boundary takes.
|
|
539
|
+
*
|
|
540
|
+
* That is not the exotic case. A `HEAD` cancels, and so does every browser that
|
|
541
|
+
* navigates away mid-document; on a worker each one would leave a render
|
|
542
|
+
* running against whatever CPU budget the host meters. The two paths answer
|
|
543
|
+
* every other question the same way, and this was the last one where they
|
|
544
|
+
* disagreed.
|
|
545
|
+
*/
|
|
546
|
+
export function renderWithReadableStream(
|
|
547
|
+
render: ReadableStreamRenderer,
|
|
548
|
+
node: React.Node,
|
|
549
|
+
options: RenderOptions,
|
|
550
|
+
): Promise<DocumentBody> {
|
|
551
|
+
const controller = new AbortController();
|
|
552
|
+
return render(node, { onError: options.onError, signal: controller.signal }).then(
|
|
553
|
+
(stream: ByteSource) =>
|
|
554
|
+
bodyOf(assembled(decoded(stream), options.shell), () => {
|
|
555
|
+
controller.abort();
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A web stream of bytes, as the string chunks the rest of this module speaks. */
|
|
561
|
+
async function* decoded(stream: ByteSource): AsyncGenerator<string, void, void> {
|
|
562
|
+
const decoder = new TextDecoder();
|
|
563
|
+
const reader = stream.getReader();
|
|
564
|
+
try {
|
|
565
|
+
while (true) {
|
|
566
|
+
const { done, value } = await reader.read();
|
|
567
|
+
if (done === true) {
|
|
568
|
+
const rest = decoder.decode();
|
|
569
|
+
if (rest !== "") {
|
|
570
|
+
yield rest;
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
yield decoder.decode(value, { stream: true });
|
|
575
|
+
}
|
|
576
|
+
} finally {
|
|
577
|
+
// Reached when a consumer stops early — `return()` on this generator lands
|
|
578
|
+
// here — and the lock has to go back or the render behind it never ends.
|
|
579
|
+
reader.releaseLock();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Render `node` to a finished document, with everything resolved.
|
|
585
|
+
*
|
|
586
|
+
* `uf build`'s renderer, and deliberately not `renderDocument` with the chunks
|
|
587
|
+
* joined up. React has two server renderers and they answer two different
|
|
588
|
+
* questions: the streaming one sends a fallback and then patches it from a
|
|
589
|
+
* script, because a browser is on the other end and the point is what it can
|
|
590
|
+
* paint first; the static one waits, and writes the resolved content where the
|
|
591
|
+
* fallback would have been. A file in `dist/` has no first paint to optimize
|
|
592
|
+
* and no guarantee that whatever serves it runs scripts at all, so it wants the
|
|
593
|
+
* second — an `index.html` full of `<template>` placeholders waiting for
|
|
594
|
+
* `$RC()` would be a page that is blank to a crawler and to `curl`.
|
|
595
|
+
*
|
|
596
|
+
* That is the static/streaming split, and it is this function versus the one
|
|
597
|
+
* above rather than a flag threaded through one of them.
|
|
598
|
+
*/
|
|
599
|
+
export async function prerenderDocument(node: React.Node, options: RenderOptions): Promise<string> {
|
|
600
|
+
const settings = { onError: options.onError };
|
|
601
|
+
const result =
|
|
602
|
+
typeof ReactDOMStatic.prerenderToNodeStream === "function"
|
|
603
|
+
? await ReactDOMStatic.prerenderToNodeStream(node, settings)
|
|
604
|
+
: await ReactDOMStatic.prerender(node, settings);
|
|
605
|
+
return bodyOf(assembled(preludeChunks(result.prelude), options.shell)).text();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The prelude of a static prerender, whichever stream this build produced.
|
|
610
|
+
*
|
|
611
|
+
* `prerenderToNodeStream` hands back a Node `Readable` and `prerender` a web
|
|
612
|
+
* `ReadableStream`, and which one a build has depends on which React entry
|
|
613
|
+
* point exists — so the union is real rather than defensive, and `getReader`
|
|
614
|
+
* is what tells them apart.
|
|
615
|
+
*/
|
|
616
|
+
async function* preludeChunks(
|
|
617
|
+
prelude: ByteSource | AsyncIterable<string | Uint8Array>,
|
|
618
|
+
): AsyncGenerator<string, void, void> {
|
|
619
|
+
if (typeof prelude.getReader === "function") {
|
|
620
|
+
yield* decoded(prelude);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const decoder = new TextDecoder();
|
|
624
|
+
for await (const chunk of prelude) {
|
|
625
|
+
yield typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
626
|
+
}
|
|
627
|
+
}
|