@uniflowed/router 0.0.0-alpha.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +324 -0
- package/client.js +261 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +438 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1593 -1341
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +125 -0
- package/internal/stream.js +754 -21
- package/middleware.js +161 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +637 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +254 -106
package/internal/stream.js
CHANGED
|
@@ -43,6 +43,9 @@ import * as React from "react";
|
|
|
43
43
|
import * as ReactDOMServer from "react-dom/server";
|
|
44
44
|
import * as ReactDOMStatic from "react-dom/static";
|
|
45
45
|
|
|
46
|
+
import { createChunkEncoder } from "./flight-chunks.js";
|
|
47
|
+
import { type StreamRecord, inspected } from "./inspector.js";
|
|
48
|
+
|
|
46
49
|
/**
|
|
47
50
|
* Where a document is written, when the host has a Node stream.
|
|
48
51
|
*
|
|
@@ -125,8 +128,13 @@ export type DocumentShell = {|
|
|
|
125
128
|
* `</head>` React writes.
|
|
126
129
|
*/
|
|
127
130
|
readonly head: string,
|
|
128
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* For an app that renders no document: everything up to the point uf's own
|
|
133
|
+
* head can still take tags — so it ends *inside* an open `<head>`.
|
|
134
|
+
*/
|
|
129
135
|
readonly open: string,
|
|
136
|
+
/** The rest of that head, and everything up to the app's markup. */
|
|
137
|
+
readonly body: string,
|
|
130
138
|
/** Everything after it. */
|
|
131
139
|
readonly close: string,
|
|
132
140
|
|};
|
|
@@ -293,16 +301,39 @@ function queueDestination(queue: ChunkQueue): NodeDestination {
|
|
|
293
301
|
* React writes a document with no head at all — an app whose root layout is
|
|
294
302
|
* `<html><body>` — the tags go in a head of uf's own, inserted after the
|
|
295
303
|
* opening tag, which is what the browser would have synthesized anyway.
|
|
304
|
+
*
|
|
305
|
+
* The shell case waits for the end of the run of hoistable elements React
|
|
306
|
+
* opened with, because that is where *its* tags go — see [`hoisted`]. Both
|
|
307
|
+
* waits are bounded by the head, and both are the same idea: the head goes out
|
|
308
|
+
* once, and everything that belongs in it has to be in hand by then.
|
|
296
309
|
*/
|
|
297
310
|
async function* assembled(
|
|
298
311
|
chunks: AsyncGenerator<string, void, void>,
|
|
299
312
|
shell: DocumentShell,
|
|
313
|
+
transformHead?: (html: string) => Promise<string>,
|
|
314
|
+
layout?: Layout,
|
|
300
315
|
): AsyncGenerator<string, void, void> {
|
|
301
316
|
let held = "";
|
|
302
317
|
let shape = "unknown";
|
|
318
|
+
// A document uf wraps is the only one whose root is not `<body>` itself.
|
|
319
|
+
const wrapped = () => {
|
|
320
|
+
if (layout != null) {
|
|
321
|
+
layout.rootDepth = advanced(NOTHING_WRITTEN, shell.open + shell.body, 0).depth ?? 0;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
// The opening chunk is the only one the hook sees, and every path below
|
|
325
|
+
// reaches exactly one of them. Awaiting here rather than at each `yield`
|
|
326
|
+
// keeps them from drifting apart. The hook sees only the document opening:
|
|
327
|
+
// Vite's HTML parser is happy with an open body, but not with a React chunk
|
|
328
|
+
// that happens to end inside an attribute.
|
|
329
|
+
const opening = async (html: string, rest: string = ""): Promise<string> => {
|
|
330
|
+
const split = transformableOpening(html);
|
|
331
|
+
const transformed = transformHead == null ? split.opening : await transformHead(split.opening);
|
|
332
|
+
return transformed + split.rest + rest;
|
|
333
|
+
};
|
|
303
334
|
|
|
304
335
|
for await (const chunk of chunks) {
|
|
305
|
-
if (shape === "document-open" || shape === "shell") {
|
|
336
|
+
if (shape === "document-open" || shape === "shell-open") {
|
|
306
337
|
yield chunk;
|
|
307
338
|
continue;
|
|
308
339
|
}
|
|
@@ -312,40 +343,57 @@ async function* assembled(
|
|
|
312
343
|
if (shape === "unknown") {
|
|
313
344
|
continue;
|
|
314
345
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
346
|
+
}
|
|
347
|
+
if (shape === "shell") {
|
|
348
|
+
const split = hoisted(held);
|
|
349
|
+
if (!split.complete) {
|
|
318
350
|
continue;
|
|
319
351
|
}
|
|
352
|
+
shape = "shell-open";
|
|
353
|
+
wrapped();
|
|
354
|
+
yield await opening(shell.open + split.head + shell.body, split.rest);
|
|
355
|
+
held = "";
|
|
356
|
+
continue;
|
|
320
357
|
}
|
|
321
358
|
// A document, and the tags go where its head closes.
|
|
322
359
|
const close = held.indexOf("</head>");
|
|
323
360
|
if (close !== -1) {
|
|
324
361
|
shape = "document-open";
|
|
325
|
-
yield ufDoctype(held.slice(0, close) + shell.head + held.slice(close));
|
|
362
|
+
yield await opening(ufDoctype(held.slice(0, close) + shell.head + held.slice(close)));
|
|
326
363
|
held = "";
|
|
327
364
|
continue;
|
|
328
365
|
}
|
|
329
366
|
// `<body` before `</head>` means React wrote no head; give the tags one.
|
|
330
367
|
const body = held.search(/<body[\s>]/i);
|
|
331
368
|
if (body !== -1) {
|
|
369
|
+
const bodyEnd = held.indexOf(">", body);
|
|
370
|
+
if (bodyEnd === -1) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
332
373
|
shape = "document-open";
|
|
333
|
-
yield
|
|
374
|
+
yield await opening(
|
|
375
|
+
ufDoctype(`${held.slice(0, body)}<head>${shell.head}</head>${held.slice(body)}`),
|
|
376
|
+
);
|
|
334
377
|
held = "";
|
|
335
378
|
}
|
|
336
379
|
}
|
|
337
380
|
|
|
338
|
-
// The render ended before the decision could be made, or before
|
|
339
|
-
//
|
|
340
|
-
// `<body>` in it
|
|
381
|
+
// The render ended before the decision could be made, or before what was
|
|
382
|
+
// being waited for arrived: an empty document, one with neither `</head>` nor
|
|
383
|
+
// `<body>` in it, or a shell that is hoistable elements all the way down.
|
|
384
|
+
// There is nothing left to wait for in any of them.
|
|
341
385
|
if (shape === "document") {
|
|
342
|
-
yield ufDoctype(held + shell.head);
|
|
386
|
+
yield await opening(ufDoctype(held + shell.head));
|
|
343
387
|
shape = "document-open";
|
|
344
|
-
} else if (shape === "unknown") {
|
|
345
|
-
|
|
346
|
-
|
|
388
|
+
} else if (shape === "shell" || shape === "unknown") {
|
|
389
|
+
// `complete` is not consulted: nothing more is coming, so a run that was
|
|
390
|
+
// still open is over and whatever was left of it is markup like any other.
|
|
391
|
+
const split = hoisted(held);
|
|
392
|
+
shape = "shell-open";
|
|
393
|
+
wrapped();
|
|
394
|
+
yield await opening(shell.open + split.head + shell.body, split.rest);
|
|
347
395
|
}
|
|
348
|
-
if (shape === "shell") {
|
|
396
|
+
if (shape === "shell-open") {
|
|
349
397
|
yield shell.close;
|
|
350
398
|
} else {
|
|
351
399
|
// The newline `assemble` ended a document with, kept: `uf build` writes
|
|
@@ -355,6 +403,116 @@ async function* assembled(
|
|
|
355
403
|
}
|
|
356
404
|
}
|
|
357
405
|
|
|
406
|
+
function transformableOpening(html: string): {| readonly opening: string, readonly rest: string |} {
|
|
407
|
+
const lower = html.toLowerCase();
|
|
408
|
+
const headEnd = lower.indexOf("</head>");
|
|
409
|
+
if (headEnd === -1) {
|
|
410
|
+
return { opening: html, rest: "" };
|
|
411
|
+
}
|
|
412
|
+
const afterHead = headEnd + "</head>".length;
|
|
413
|
+
const body = lower.indexOf("<body", afterHead);
|
|
414
|
+
if (body === -1) {
|
|
415
|
+
return { opening: html.slice(0, afterHead), rest: html.slice(afterHead) };
|
|
416
|
+
}
|
|
417
|
+
const bodyEnd = html.indexOf(">", body);
|
|
418
|
+
if (bodyEnd === -1) {
|
|
419
|
+
return { opening: html.slice(0, afterHead), rest: html.slice(afterHead) };
|
|
420
|
+
}
|
|
421
|
+
const afterBody = bodyEnd + 1;
|
|
422
|
+
return { opening: html.slice(0, afterBody), rest: html.slice(afterBody) };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The head elements React opened the app's markup with, split from the rest.
|
|
427
|
+
*
|
|
428
|
+
* `complete` is false while the buffer might still be in the middle of one — a
|
|
429
|
+
* chunk that ends inside `<meta cont`, or after a `<link>` and before whatever
|
|
430
|
+
* follows it. Deciding on an incomplete buffer would be a classification that
|
|
431
|
+
* depends on where React split its output, which is the bug `documentShape`
|
|
432
|
+
* above is written the way it is to avoid. The split is filled in either way,
|
|
433
|
+
* because the caller that has run out of chunks has nothing left to wait for
|
|
434
|
+
* and wants it.
|
|
435
|
+
*
|
|
436
|
+
* # Why a leading run rather than the whole document
|
|
437
|
+
*
|
|
438
|
+
* React hoists a `<title>`, a `<meta>` and a `<link>` into the `<head>` of a
|
|
439
|
+
* document *it* rendered. uf's shell is not one — React is handed the app, not
|
|
440
|
+
* the document — so with the shell every one of those landed in the body, and
|
|
441
|
+
* `<link rel="canonical">` in a body is a canonical link Google does not read.
|
|
442
|
+
* The fix is for uf to do the hoisting into the head it wrote itself.
|
|
443
|
+
*
|
|
444
|
+
* A leading run is what can be hoisted without holding the document. `RouteView`
|
|
445
|
+
* renders the route's metadata first, before the layouts and the page, so the
|
|
446
|
+
* run is exactly that metadata and the wait ends at the first byte of the
|
|
447
|
+
* application's own markup. Scanning further would mean buffering an arbitrary
|
|
448
|
+
* amount of a document to find a `<meta>` that might be at the end of it, which
|
|
449
|
+
* is streaming in shape and buffering in fact — the same trade this module
|
|
450
|
+
* refuses in `ChunkQueue`. So a tag a component renders further in stays where
|
|
451
|
+
* it is, and on a client React will hoist it into `document.head` itself.
|
|
452
|
+
*
|
|
453
|
+
* # Reading React's markup with a regular expression
|
|
454
|
+
*
|
|
455
|
+
* Which is only safe because it is React's. React escapes `>` in an attribute
|
|
456
|
+
* value and `<` in text, so the first `>` after an opening tag ends it and the
|
|
457
|
+
* first `</title>` ends a title — neither can appear inside one. This function
|
|
458
|
+
* is not an HTML parser and must never be handed markup from anywhere else.
|
|
459
|
+
*/
|
|
460
|
+
function hoisted(held: string): HoistedHead {
|
|
461
|
+
let index = 0;
|
|
462
|
+
while (index < held.length) {
|
|
463
|
+
const rest = held.slice(index);
|
|
464
|
+
const split = { head: held.slice(0, index), rest, complete: false };
|
|
465
|
+
const open = rest.match(/^<(title|meta|link)(?=[\s/>])/i);
|
|
466
|
+
if (open == null) {
|
|
467
|
+
// Not a hoistable element, or not yet enough bytes to say it is not one.
|
|
468
|
+
return { ...split, complete: !couldOpenHoistable(rest) };
|
|
469
|
+
}
|
|
470
|
+
const close = held.indexOf(">", index);
|
|
471
|
+
if (close === -1) {
|
|
472
|
+
return split;
|
|
473
|
+
}
|
|
474
|
+
if (open[1].toLowerCase() !== "title") {
|
|
475
|
+
index = close + 1;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const end = held.indexOf("</title>", close);
|
|
479
|
+
if (end === -1) {
|
|
480
|
+
return split;
|
|
481
|
+
}
|
|
482
|
+
index = end + "</title>".length;
|
|
483
|
+
}
|
|
484
|
+
// Every byte so far is a complete hoistable element, and the next one may
|
|
485
|
+
// still be on its way — the case a document that is metadata and nothing else
|
|
486
|
+
// ends in, and the reason the loop is bounded by the buffer rather than by
|
|
487
|
+
// `true`: a `while (true)` here is a function the checker reads as returning
|
|
488
|
+
// `void` on a path it cannot see is unreachable.
|
|
489
|
+
return { head: held, rest: "", complete: false };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** What [`hoisted`] found, and whether more bytes could still change it. */
|
|
493
|
+
type HoistedHead = {|
|
|
494
|
+
/** The hoistable elements the markup opened with. */
|
|
495
|
+
readonly head: string,
|
|
496
|
+
/** Everything after them. */
|
|
497
|
+
readonly rest: string,
|
|
498
|
+
/** Whether the run is known to have ended. */
|
|
499
|
+
readonly complete: boolean,
|
|
500
|
+
|};
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Whether `rest` could still turn into a hoistable element once more bytes
|
|
504
|
+
* arrive.
|
|
505
|
+
*
|
|
506
|
+
* True for `"<"` and for every proper prefix of `<title`, `<meta` and `<link` —
|
|
507
|
+
* the states a chunk boundary can leave the buffer in. False for `<main`, and
|
|
508
|
+
* false for `<titlebar>`, which is somebody's component and not a title however
|
|
509
|
+
* much of it has arrived.
|
|
510
|
+
*/
|
|
511
|
+
function couldOpenHoistable(rest: string): boolean {
|
|
512
|
+
const text = rest.toLowerCase();
|
|
513
|
+
return ["<title", "<meta", "<link"].some((tag) => tag.startsWith(text));
|
|
514
|
+
}
|
|
515
|
+
|
|
358
516
|
/**
|
|
359
517
|
* uf's spelling of the doctype, in place of whichever one React wrote.
|
|
360
518
|
*
|
|
@@ -472,8 +630,85 @@ export type RenderOptions = {|
|
|
|
472
630
|
* shell's own failure is not reported here — it rejects instead.
|
|
473
631
|
*/
|
|
474
632
|
readonly onError: (error: mixed) => void,
|
|
633
|
+
/**
|
|
634
|
+
* Rewrite the document opening — the head and, when present, the body start
|
|
635
|
+
* tag — before it goes out.
|
|
636
|
+
*
|
|
637
|
+
* For `uf dev`, and only for it. Vite's `transformIndexHtml` rewrites asset
|
|
638
|
+
* URLs and injects `/@vite/client` and the refresh preamble, and it is a
|
|
639
|
+
* *whole document* hook, so the development server used to collect the page
|
|
640
|
+
* and transform it at the end. That made the one place a developer would
|
|
641
|
+
* notice streaming the one place it did not happen: a slow page showed
|
|
642
|
+
* nothing until it was finished, and `$loading.js` looked broken.
|
|
643
|
+
* See ubugeeei-prod/uf#374.
|
|
644
|
+
*
|
|
645
|
+
* The hook never sees application body markup, which is what makes this
|
|
646
|
+
* safe. Vite's injections are string-based against `<head>` and `<body>`,
|
|
647
|
+
* and its parser accepts a document that stops after the body start tag.
|
|
648
|
+
* It does not accept a React chunk that ends in the middle of an attribute.
|
|
649
|
+
*
|
|
650
|
+
* Absent everywhere else. `uf start`, `uf preview` and every deploy adapter
|
|
651
|
+
* have no such hook and stream already.
|
|
652
|
+
*/
|
|
653
|
+
readonly transformHead?: (html: string) => Promise<string>,
|
|
654
|
+
/**
|
|
655
|
+
* Told what left, in what order, and what each chunk built.
|
|
656
|
+
*
|
|
657
|
+
* For `uf dev`, like `transformHead` above, and absent everywhere else —
|
|
658
|
+
* which is what makes it free rather than cheap: with nothing supplied no
|
|
659
|
+
* recorder is constructed, `./inspector.js` is never entered, and the
|
|
660
|
+
* generator a host consumes is the same object it was. See that module for
|
|
661
|
+
* what is done with the record and why it is a development affordance rather
|
|
662
|
+
* than a metric.
|
|
663
|
+
*
|
|
664
|
+
* Called once per document, after the last chunk and also after a render that
|
|
665
|
+
* was abandoned partway. `prerenderDocument` never calls it: a build resolves
|
|
666
|
+
* everything before it writes a byte, so there is no order to report.
|
|
667
|
+
*/
|
|
668
|
+
readonly onStream?: (record: StreamRecord) => void,
|
|
669
|
+
/**
|
|
670
|
+
* The Flight payload this document's tree was read from, to write into the
|
|
671
|
+
* document for the browser to hydrate from.
|
|
672
|
+
*
|
|
673
|
+
* Absent for a document rendered from the route's modules, which is what
|
|
674
|
+
* every document was before ubugeeei-prod/uf#519 and what `app.rsc: false`
|
|
675
|
+
* still asks for. Present, it is copied into the document as it arrives, by
|
|
676
|
+
* [`interleaved`], which says where it may and may not go.
|
|
677
|
+
*/
|
|
678
|
+
readonly payload?: ReadableStream<Uint8Array>,
|
|
679
|
+
/**
|
|
680
|
+
* This response's Content-Security-Policy nonce, or absent for none.
|
|
681
|
+
*
|
|
682
|
+
* Absent is what every render had before nonces existed and what every
|
|
683
|
+
* render still has in a project that has not asked for one, so a document
|
|
684
|
+
* written without it is byte-for-byte the document uf has always written.
|
|
685
|
+
*
|
|
686
|
+
* Present, it reaches three places, and it has to reach all three or the
|
|
687
|
+
* page is broken rather than merely unprotected: React's own option, which
|
|
688
|
+
* nonces every inline script React emits — the runtime that reveals a
|
|
689
|
+
* `<Suspense>` boundary, and the bootstrap; `shell.open`/`shell.body`, which
|
|
690
|
+
* carry the client entry; and the payload chunk elements, which
|
|
691
|
+
* [`interleaved`] writes as text.
|
|
692
|
+
*
|
|
693
|
+
* `prerenderDocument` is deliberately never given one. See its own paragraph.
|
|
694
|
+
*/
|
|
695
|
+
readonly nonce?: string | null,
|
|
475
696
|
|};
|
|
476
697
|
|
|
698
|
+
/**
|
|
699
|
+
* `chunks`, recorded on the way out when `uf dev` asked for it.
|
|
700
|
+
*
|
|
701
|
+
* The wrapping goes here rather than around `assembled` in each of the callers
|
|
702
|
+
* so that what is recorded is unambiguous: these are the bytes the host is
|
|
703
|
+
* handed, head and all, and not React's own output on the way past.
|
|
704
|
+
*/
|
|
705
|
+
function outgoing(
|
|
706
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
707
|
+
onStream?: (record: StreamRecord) => void,
|
|
708
|
+
): AsyncGenerator<string, void, void> {
|
|
709
|
+
return onStream == null ? chunks : inspected(chunks, onStream, () => performance.now());
|
|
710
|
+
}
|
|
711
|
+
|
|
477
712
|
/**
|
|
478
713
|
* Stream `node` as a document, resolving once the shell is ready.
|
|
479
714
|
*
|
|
@@ -491,12 +726,31 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
491
726
|
const { pipe, abort } = ReactDOMServer.renderToPipeableStream(node, {
|
|
492
727
|
onShellReady() {
|
|
493
728
|
pipe(queueDestination(queue));
|
|
494
|
-
resolve(
|
|
729
|
+
resolve(
|
|
730
|
+
bodyOf(
|
|
731
|
+
outgoing(
|
|
732
|
+
withPayload(
|
|
733
|
+
queue.chunks(),
|
|
734
|
+
options.shell,
|
|
735
|
+
options.transformHead,
|
|
736
|
+
options.payload,
|
|
737
|
+
options.nonce,
|
|
738
|
+
),
|
|
739
|
+
options.onStream,
|
|
740
|
+
),
|
|
741
|
+
() => abort(),
|
|
742
|
+
),
|
|
743
|
+
);
|
|
495
744
|
},
|
|
496
745
|
onShellError(error: mixed) {
|
|
497
746
|
reject(error);
|
|
498
747
|
},
|
|
499
748
|
onError: options.onError,
|
|
749
|
+
// Every inline script React writes for this document, from one option:
|
|
750
|
+
// the streaming runtime that reveals a boundary and patches a segment,
|
|
751
|
+
// and the bootstrap. uf nonces the scripts it writes itself; these are
|
|
752
|
+
// React's, and there is no other way to reach them.
|
|
753
|
+
nonce: options.nonce ?? undefined,
|
|
500
754
|
});
|
|
501
755
|
return;
|
|
502
756
|
}
|
|
@@ -524,6 +778,7 @@ type ReadableStreamRenderer = (
|
|
|
524
778
|
settings: {|
|
|
525
779
|
readonly onError: (error: mixed) => void,
|
|
526
780
|
readonly signal: AbortSignal,
|
|
781
|
+
readonly nonce?: string,
|
|
527
782
|
|},
|
|
528
783
|
) => Promise<ByteSource>;
|
|
529
784
|
|
|
@@ -549,11 +804,26 @@ export function renderWithReadableStream(
|
|
|
549
804
|
options: RenderOptions,
|
|
550
805
|
): Promise<DocumentBody> {
|
|
551
806
|
const controller = new AbortController();
|
|
552
|
-
return render(node, {
|
|
553
|
-
|
|
554
|
-
|
|
807
|
+
return render(node, {
|
|
808
|
+
onError: options.onError,
|
|
809
|
+
signal: controller.signal,
|
|
810
|
+
nonce: options.nonce ?? undefined,
|
|
811
|
+
}).then((stream: ByteSource) =>
|
|
812
|
+
bodyOf(
|
|
813
|
+
outgoing(
|
|
814
|
+
withPayload(
|
|
815
|
+
decoded(stream),
|
|
816
|
+
options.shell,
|
|
817
|
+
options.transformHead,
|
|
818
|
+
options.payload,
|
|
819
|
+
options.nonce,
|
|
820
|
+
),
|
|
821
|
+
options.onStream,
|
|
822
|
+
),
|
|
823
|
+
() => {
|
|
555
824
|
controller.abort();
|
|
556
|
-
}
|
|
825
|
+
},
|
|
826
|
+
),
|
|
557
827
|
);
|
|
558
828
|
}
|
|
559
829
|
|
|
@@ -602,7 +872,470 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
|
|
|
602
872
|
typeof ReactDOMStatic.prerenderToNodeStream === "function"
|
|
603
873
|
? await ReactDOMStatic.prerenderToNodeStream(node, settings)
|
|
604
874
|
: await ReactDOMStatic.prerender(node, settings);
|
|
605
|
-
return bodyOf(
|
|
875
|
+
return bodyOf(
|
|
876
|
+
withPayload(
|
|
877
|
+
preludeChunks(result.prelude),
|
|
878
|
+
options.shell,
|
|
879
|
+
options.transformHead,
|
|
880
|
+
options.payload,
|
|
881
|
+
),
|
|
882
|
+
).text();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* A page's static shell, and what React needs to finish it per request.
|
|
887
|
+
*
|
|
888
|
+
* What `uf build` writes for a page it prerenders partially (`ppr`): the whole
|
|
889
|
+
* document React could render without a request, with each `<Suspense>`
|
|
890
|
+
* boundary that waited on one written as its fallback, and React's record of
|
|
891
|
+
* where those holes are.
|
|
892
|
+
*/
|
|
893
|
+
export type PrerenderedShell = {|
|
|
894
|
+
/**
|
|
895
|
+
* The document as far as a server sends it before it renders anything: the
|
|
896
|
+
* head, uf's tags in it, and every byte React finished. It stops where the
|
|
897
|
+
* request's part begins, which is before the closing tags.
|
|
898
|
+
*/
|
|
899
|
+
readonly html: string,
|
|
900
|
+
/**
|
|
901
|
+
* What follows the request's part: the closing tags of a document uf wraps
|
|
902
|
+
* around the application. Empty for a document the application renders
|
|
903
|
+
* itself, because React's `resume` writes `</body></html>` for that one.
|
|
904
|
+
*/
|
|
905
|
+
readonly close: string,
|
|
906
|
+
/** See [`Layout`]'s field of the same name. */
|
|
907
|
+
readonly rootDepth: number,
|
|
908
|
+
/**
|
|
909
|
+
* React's postponed state: which boundaries are holes and how to find them
|
|
910
|
+
* again. Plain JSON, which is what lets a build write it to a file and a
|
|
911
|
+
* server read it back. `null` when the prerender left nothing for the
|
|
912
|
+
* request, and `html` is then a whole document.
|
|
913
|
+
*/
|
|
914
|
+
readonly postponed: mixed,
|
|
915
|
+
|};
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Prerender `node` to a static shell, leaving whatever is still waiting when
|
|
919
|
+
* `settle` resolves as a hole.
|
|
920
|
+
*
|
|
921
|
+
* React's `prerender` with an abort. A boundary whose content is waiting on
|
|
922
|
+
* something when the render is stopped is written as its fallback, and React
|
|
923
|
+
* returns its postponed state beside the markup; [`resumeDocument`] hands that
|
|
924
|
+
* state back to React per request and streams only what was left.
|
|
925
|
+
*
|
|
926
|
+
* `settle` decides what is waiting, and it can only ever make a hole larger:
|
|
927
|
+
* stopped early, a boundary that would have finished is a hole instead, which
|
|
928
|
+
* the request then renders — slower, never wrong. The router waits until the
|
|
929
|
+
* payload it feeds this render has been read and every module it names has
|
|
930
|
+
* loaded, so what is left waiting is what was left out on purpose.
|
|
931
|
+
*
|
|
932
|
+
* Like [`prerenderDocument`], it is never given a nonce: what it writes is a
|
|
933
|
+
* file every request is sent.
|
|
934
|
+
*/
|
|
935
|
+
export async function prerenderShell(
|
|
936
|
+
node: React.Node,
|
|
937
|
+
options: {|
|
|
938
|
+
readonly shell: DocumentShell,
|
|
939
|
+
readonly onError: (error: mixed) => void,
|
|
940
|
+
readonly settle: () => Promise<void>,
|
|
941
|
+
|},
|
|
942
|
+
): Promise<PrerenderedShell> {
|
|
943
|
+
const controller = new AbortController();
|
|
944
|
+
// The reason every hole is reported with when the render stops. Stopping is
|
|
945
|
+
// the point rather than a failure, so it is not passed on.
|
|
946
|
+
const stopped = new Error("@uniflowed/router: the static shell is complete");
|
|
947
|
+
const settings = {
|
|
948
|
+
onError: (error: mixed) => {
|
|
949
|
+
if (error !== stopped) options.onError(error);
|
|
950
|
+
},
|
|
951
|
+
signal: controller.signal,
|
|
952
|
+
};
|
|
953
|
+
const pending =
|
|
954
|
+
typeof ReactDOMStatic.prerenderToNodeStream === "function"
|
|
955
|
+
? ReactDOMStatic.prerenderToNodeStream(node, settings)
|
|
956
|
+
: ReactDOMStatic.prerender(node, settings);
|
|
957
|
+
await options.settle();
|
|
958
|
+
controller.abort(stopped);
|
|
959
|
+
const result = await pending;
|
|
960
|
+
const layout: Layout = { rootDepth: 0 };
|
|
961
|
+
const text = await bodyOf(
|
|
962
|
+
assembled(preludeChunks(result.prelude), options.shell, undefined, layout),
|
|
963
|
+
).text();
|
|
964
|
+
const postponed = result.postponed ?? null;
|
|
965
|
+
if (postponed == null) {
|
|
966
|
+
return { html: text, close: "", rootDepth: layout.rootDepth, postponed: null };
|
|
967
|
+
}
|
|
968
|
+
// A shell uf wrapped ends in uf's closing tags, which `resume` does not write;
|
|
969
|
+
// a document React wrote ends in its own, which `resume` writes again.
|
|
970
|
+
if (text.endsWith(options.shell.close)) {
|
|
971
|
+
return {
|
|
972
|
+
html: text.slice(0, text.length - options.shell.close.length),
|
|
973
|
+
close: options.shell.close,
|
|
974
|
+
rootDepth: layout.rootDepth,
|
|
975
|
+
postponed,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
html: text.replace(/<\/body>\s*<\/html>\s*$/i, ""),
|
|
980
|
+
close: "",
|
|
981
|
+
rootDepth: layout.rootDepth,
|
|
982
|
+
postponed,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* A page's static shell, then what the request fills its holes with.
|
|
988
|
+
*
|
|
989
|
+
* The shell goes out as the first chunk, before React has rendered anything for
|
|
990
|
+
* this request — that is the whole of what partial prerendering buys, and it is
|
|
991
|
+
* why the shell is text the build wrote rather than a render. React's `resume`
|
|
992
|
+
* then renders the holes, and its output follows the shell unchanged: each
|
|
993
|
+
* hole's content in a hidden element and the script that moves it into place,
|
|
994
|
+
* which is what a streamed `<Suspense>` boundary always was.
|
|
995
|
+
*
|
|
996
|
+
* `payload` is the request's Flight payload, the one the holes were rendered
|
|
997
|
+
* from, and it is written into the document by [`interleaved`] under the same
|
|
998
|
+
* rules as a streamed render's. The shell ends between elements at the root's
|
|
999
|
+
* own level, so the payload may follow it as soon as it exists.
|
|
1000
|
+
*/
|
|
1001
|
+
export function resumeDocument(
|
|
1002
|
+
node: React.Node,
|
|
1003
|
+
shell: PrerenderedShell,
|
|
1004
|
+
options: {|
|
|
1005
|
+
readonly onError: (error: mixed) => void,
|
|
1006
|
+
readonly payload: ReadableStream<Uint8Array>,
|
|
1007
|
+
readonly nonce?: string | null,
|
|
1008
|
+
readonly onStream?: (record: StreamRecord) => void,
|
|
1009
|
+
|},
|
|
1010
|
+
): DocumentBody {
|
|
1011
|
+
const controller = new AbortController();
|
|
1012
|
+
// Begun now and awaited only after the shell has gone: the shell must not
|
|
1013
|
+
// wait for anything React does for this request, and React must not wait
|
|
1014
|
+
// for the shell to be read before it starts on the holes.
|
|
1015
|
+
const resuming: Promise<ByteSource> = ReactDOMServer.resume(node, shell.postponed, {
|
|
1016
|
+
onError: options.onError,
|
|
1017
|
+
signal: controller.signal,
|
|
1018
|
+
nonce: options.nonce ?? undefined,
|
|
1019
|
+
});
|
|
1020
|
+
// A resume that fails before its first byte fails the stream below, once it
|
|
1021
|
+
// is read; this keeps the rejection from being reported as unhandled while
|
|
1022
|
+
// the shell is still being written.
|
|
1023
|
+
resuming.catch(() => {});
|
|
1024
|
+
async function* chunks(): AsyncGenerator<string, void, void> {
|
|
1025
|
+
yield shell.html;
|
|
1026
|
+
yield* decoded(await resuming);
|
|
1027
|
+
if (shell.close !== "") {
|
|
1028
|
+
yield shell.close;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const layout: Layout = { rootDepth: shell.rootDepth };
|
|
1032
|
+
return bodyOf(
|
|
1033
|
+
outgoing(interleaved(chunks(), options.payload, options.nonce, layout), options.onStream),
|
|
1034
|
+
() => {
|
|
1035
|
+
controller.abort();
|
|
1036
|
+
},
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* What [`assembled`] learns about a document that [`interleaved`] needs.
|
|
1042
|
+
*
|
|
1043
|
+
* Which shape a document has is decided on its opening bytes, inside
|
|
1044
|
+
* `assembled`, and `interleaved` reads that document's chunks — so by the time
|
|
1045
|
+
* it has HTML to follow, the answer is here.
|
|
1046
|
+
*/
|
|
1047
|
+
type Layout = {|
|
|
1048
|
+
/**
|
|
1049
|
+
* How many elements are open inside `<body>` where React's root begins: none
|
|
1050
|
+
* for a document React wrote, and uf's own container for one uf wraps.
|
|
1051
|
+
*/
|
|
1052
|
+
rootDepth: number,
|
|
1053
|
+
|};
|
|
1054
|
+
|
|
1055
|
+
/** [`assembled`] alone when there is no payload, and [`interleaved`] with one. */
|
|
1056
|
+
function withPayload(
|
|
1057
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
1058
|
+
shell: DocumentShell,
|
|
1059
|
+
transformHead: ?(html: string) => Promise<string>,
|
|
1060
|
+
payload: ?ReadableStream<Uint8Array>,
|
|
1061
|
+
nonce?: string | null,
|
|
1062
|
+
): AsyncGenerator<string, void, void> {
|
|
1063
|
+
if (payload == null) {
|
|
1064
|
+
return assembled(chunks, shell, transformHead ?? undefined);
|
|
1065
|
+
}
|
|
1066
|
+
const layout: Layout = { rootDepth: 0 };
|
|
1067
|
+
return interleaved(
|
|
1068
|
+
assembled(chunks, shell, transformHead ?? undefined, layout),
|
|
1069
|
+
payload,
|
|
1070
|
+
nonce,
|
|
1071
|
+
layout,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* A document's chunks, with the Flight payload it was rendered from written
|
|
1077
|
+
* into it as it arrives.
|
|
1078
|
+
*
|
|
1079
|
+
* Six rules, and each is the answer to a way the obvious version is wrong.
|
|
1080
|
+
*
|
|
1081
|
+
* **Nothing before the head.** The first chunk this is handed is the whole
|
|
1082
|
+
* opening of the document — `assembled` does not let one go until the head is
|
|
1083
|
+
* complete — and a payload element written in front of it would sit before
|
|
1084
|
+
* `<head>`, where the parser would open a body for it and every tag after it
|
|
1085
|
+
* would land in the wrong element.
|
|
1086
|
+
*
|
|
1087
|
+
* **Written as soon as it exists.** A payload row usually exists before the
|
|
1088
|
+
* HTML rendered from it — React's client reads the row, then the boundary
|
|
1089
|
+
* renders — so waiting for the next HTML chunk would put the browser's copy
|
|
1090
|
+
* behind the markup it hydrates. Each HTML chunk that ends between elements is
|
|
1091
|
+
* followed by whatever payload is waiting, and a payload that arrives while the
|
|
1092
|
+
* HTML is idle there is written then, without a chunk of HTML to follow.
|
|
1093
|
+
*
|
|
1094
|
+
* **Only between elements.** React hands its HTML on through a fixed-size
|
|
1095
|
+
* buffer, so a chunk can end anywhere: inside a tag, an attribute's value, a
|
|
1096
|
+
* comment, a character reference or an inline `<script>`. A payload element
|
|
1097
|
+
* written after such a chunk is not an element. It is part of the attribute,
|
|
1098
|
+
* the comment or the text it landed in, the browser never reads it, and React's
|
|
1099
|
+
* client closes the payload with rows missing, which is the "Connection closed"
|
|
1100
|
+
* a page reports instead of hydrating. So a waiting payload goes out only where
|
|
1101
|
+
* the HTML written so far ends between elements, which [`advanced`] follows
|
|
1102
|
+
* from chunk to chunk, and otherwise waits for the HTML that finishes what is
|
|
1103
|
+
* open.
|
|
1104
|
+
*
|
|
1105
|
+
* **Only where React steps over it.** Between elements is where the *parser*
|
|
1106
|
+
* keeps a payload element. React is stricter: hydrating, it steps over an
|
|
1107
|
+
* element it did not render only among the children of its root and of
|
|
1108
|
+
* `<html>`, `<head>` and `<body>`. Anywhere deeper the element is a child the
|
|
1109
|
+
* tree has no fiber for, the hydration fails there, and React throws the
|
|
1110
|
+
* server's markup away and renders the document again. A shell that is one
|
|
1111
|
+
* large element — a frame around the page, all of it rendered on the server —
|
|
1112
|
+
* is written in many pieces, several of which end between two of its children,
|
|
1113
|
+
* and a payload that was already waiting used to go out at the first of them.
|
|
1114
|
+
* So [`advanced`] also follows how many elements are open inside `<body>`, and
|
|
1115
|
+
* a payload waits for the root's own level: between React's top-level
|
|
1116
|
+
* elements, which is where every streamed segment is written anyway.
|
|
1117
|
+
*
|
|
1118
|
+
* **Outside replaceable boundaries.** Even between tags, a payload inside a
|
|
1119
|
+
* Suspense fallback disappears when React reveals its content. A browser that
|
|
1120
|
+
* loads the Flight reader afterwards then sees an incomplete stream. React's
|
|
1121
|
+
* boundary comments keep those ranges closed to injection until their end.
|
|
1122
|
+
*
|
|
1123
|
+
* **`</body></html>` waits for the end of the payload.** The HTML can finish
|
|
1124
|
+
* first — the last boundary's markup is rendered from rows that are already
|
|
1125
|
+
* written — and a payload element after `</html>` is one the parser moves
|
|
1126
|
+
* rather than one React expects. So the closing tags are held back, the rest of
|
|
1127
|
+
* the payload and its end marker are written, and the tags go last. For a
|
|
1128
|
+
* document uf wraps, the closing run is `</div></body></html>`, and only the
|
|
1129
|
+
* part from `</body>` is held: the root element React hydrates must hold
|
|
1130
|
+
* nothing React did not render.
|
|
1131
|
+
*
|
|
1132
|
+
* A consumer that stops early stops the payload too: the reader is cancelled
|
|
1133
|
+
* in the `finally`, which is where `return()` on this generator lands.
|
|
1134
|
+
*/
|
|
1135
|
+
async function* interleaved(
|
|
1136
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
1137
|
+
payload: ReadableStream<Uint8Array>,
|
|
1138
|
+
nonce?: string | null,
|
|
1139
|
+
layout?: Layout,
|
|
1140
|
+
): AsyncGenerator<string, void, void> {
|
|
1141
|
+
const encoder = createChunkEncoder(nonce);
|
|
1142
|
+
const reader = payload.getReader();
|
|
1143
|
+
let written = "";
|
|
1144
|
+
let ended = false;
|
|
1145
|
+
let wake: ?() => void = null;
|
|
1146
|
+
const ring = () => {
|
|
1147
|
+
const resume = wake;
|
|
1148
|
+
wake = null;
|
|
1149
|
+
if (resume != null) {
|
|
1150
|
+
resume();
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
const pumping = (async () => {
|
|
1154
|
+
try {
|
|
1155
|
+
while (true) {
|
|
1156
|
+
const step = await reader.read();
|
|
1157
|
+
if (step.done === true) {
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
if (step.value != null) {
|
|
1161
|
+
written += encoder.encode(step.value);
|
|
1162
|
+
ring();
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
} catch {
|
|
1166
|
+
// A payload that failed partway is still ended, with the end marker, so
|
|
1167
|
+
// the browser's reader closes and React reports the rows it never got
|
|
1168
|
+
// rather than waiting for them for as long as the page is open.
|
|
1169
|
+
} finally {
|
|
1170
|
+
written += encoder.end();
|
|
1171
|
+
ended = true;
|
|
1172
|
+
ring();
|
|
1173
|
+
}
|
|
1174
|
+
})();
|
|
1175
|
+
|
|
1176
|
+
// Nothing written yet, and nothing may go before the head.
|
|
1177
|
+
let boundary: Boundary = NOTHING_WRITTEN;
|
|
1178
|
+
let closing = "";
|
|
1179
|
+
try {
|
|
1180
|
+
let next = chunks.next();
|
|
1181
|
+
while (true) {
|
|
1182
|
+
if (boundary.safe && written !== "") {
|
|
1183
|
+
const out = written;
|
|
1184
|
+
written = "";
|
|
1185
|
+
yield out;
|
|
1186
|
+
}
|
|
1187
|
+
const idle = new Promise<null>((resolve) => {
|
|
1188
|
+
wake = () => resolve(null);
|
|
1189
|
+
});
|
|
1190
|
+
const outcome = await Promise.race([next, idle]);
|
|
1191
|
+
if (outcome == null) {
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
if (outcome.done === true) {
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
let text = outcome.value;
|
|
1198
|
+
const close = text.search(/<\/body>\s*<\/html>\s*$/i);
|
|
1199
|
+
if (close !== -1) {
|
|
1200
|
+
closing = text.slice(close);
|
|
1201
|
+
text = text.slice(0, close);
|
|
1202
|
+
}
|
|
1203
|
+
if (text !== "") {
|
|
1204
|
+
yield text;
|
|
1205
|
+
boundary = advanced(boundary, text, layout?.rootDepth ?? 0);
|
|
1206
|
+
}
|
|
1207
|
+
next = chunks.next();
|
|
1208
|
+
}
|
|
1209
|
+
while (!ended || written !== "") {
|
|
1210
|
+
if (written !== "") {
|
|
1211
|
+
const out = written;
|
|
1212
|
+
written = "";
|
|
1213
|
+
yield out;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
await new Promise<void>((resolve) => {
|
|
1217
|
+
wake = resolve;
|
|
1218
|
+
if (ended || written !== "") {
|
|
1219
|
+
ring();
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
await pumping;
|
|
1224
|
+
yield closing;
|
|
1225
|
+
} finally {
|
|
1226
|
+
if (!ended) {
|
|
1227
|
+
void reader.cancel();
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Where the HTML written so far leaves the next payload element. */
|
|
1233
|
+
type Boundary = {|
|
|
1234
|
+
/** Whether a payload element may be written now. */
|
|
1235
|
+
readonly safe: boolean,
|
|
1236
|
+
/** The `script` or `style` element the HTML is inside, if it is inside one. */
|
|
1237
|
+
readonly rawText: string | null,
|
|
1238
|
+
/** Suspense/Activity ranges React can replace before hydration starts. */
|
|
1239
|
+
readonly replaceable: number,
|
|
1240
|
+
/** A tag, or a comment, the HTML has started and not yet finished. */
|
|
1241
|
+
readonly open: string,
|
|
1242
|
+
/** How many elements are open inside `<body>`; `null` until it has opened. */
|
|
1243
|
+
readonly depth: number | null,
|
|
1244
|
+
|};
|
|
1245
|
+
|
|
1246
|
+
const NOTHING_WRITTEN: Boundary = {
|
|
1247
|
+
safe: false,
|
|
1248
|
+
rawText: null,
|
|
1249
|
+
replaceable: 0,
|
|
1250
|
+
open: "",
|
|
1251
|
+
depth: null,
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
/** Elements with no closing tag, for markup whose `/>` React did not write. */
|
|
1255
|
+
const VOID_ELEMENTS: $ReadOnlySet<string> = new Set([
|
|
1256
|
+
"area",
|
|
1257
|
+
"base",
|
|
1258
|
+
"br",
|
|
1259
|
+
"col",
|
|
1260
|
+
"embed",
|
|
1261
|
+
"hr",
|
|
1262
|
+
"img",
|
|
1263
|
+
"input",
|
|
1264
|
+
"link",
|
|
1265
|
+
"meta",
|
|
1266
|
+
"param",
|
|
1267
|
+
"source",
|
|
1268
|
+
"track",
|
|
1269
|
+
"wbr",
|
|
1270
|
+
]);
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* `boundary`, once `html` has been written after it.
|
|
1274
|
+
*
|
|
1275
|
+
* A payload element may follow HTML that ends with a `>` outside a `<script>`
|
|
1276
|
+
* and a `<style>`. The test can be that short because React wrote the HTML: it
|
|
1277
|
+
* escapes `<` and `>` in text and in attribute values, so a `>` it wrote closes
|
|
1278
|
+
* a tag or a comment, and HTML that ends any other way ends inside one, or
|
|
1279
|
+
* inside a character reference or a run of text. What React does not escape is
|
|
1280
|
+
* the content of an inline script or stylesheet, where a `>` is code, so an
|
|
1281
|
+
* opening `<script>` or `<style>` is followed to its closing tag. A tag split
|
|
1282
|
+
* across two chunks is carried in `open` and read whole with the next one.
|
|
1283
|
+
*
|
|
1284
|
+
* And it may follow only HTML that leaves `rootDepth` elements open inside
|
|
1285
|
+
* `<body>`, or none: the children of React's root and of `<body>` are the ones
|
|
1286
|
+
* hydration steps over. Every tag from `<body>` on is counted for that, React's
|
|
1287
|
+
* void elements by the `/>` it writes them with. Markup React did not write —
|
|
1288
|
+
* `dangerouslySetInnerHTML` — can leave a tag open that the parser would close,
|
|
1289
|
+
* and the count then stays too deep for the rest of the document. That errs the
|
|
1290
|
+
* safe way: the payload waits, and goes out before `</body>` as it always can.
|
|
1291
|
+
*/
|
|
1292
|
+
function advanced(boundary: Boundary, html: string, rootDepth: number): Boundary {
|
|
1293
|
+
const text = boundary.open + html;
|
|
1294
|
+
let rawText = boundary.rawText;
|
|
1295
|
+
let replaceable = boundary.replaceable;
|
|
1296
|
+
let depth = boundary.depth;
|
|
1297
|
+
const tags = /<!--([\s\S]*?)-->|<(\/?)([a-z][^\s/>]*)[^>]*?(\/?)>/gi;
|
|
1298
|
+
let tag = tags.exec(text);
|
|
1299
|
+
while (tag != null) {
|
|
1300
|
+
const comment = tag[1];
|
|
1301
|
+
if (comment != null) {
|
|
1302
|
+
if (rawText == null) {
|
|
1303
|
+
if (/^[\$&][?!~]?$/.test(comment)) replaceable += 1;
|
|
1304
|
+
else if (comment === "/$" || comment === "/&") replaceable = Math.max(0, replaceable - 1);
|
|
1305
|
+
}
|
|
1306
|
+
} else {
|
|
1307
|
+
const name = tag[3].toLowerCase();
|
|
1308
|
+
const closes = tag[2] === "/";
|
|
1309
|
+
if (rawText != null) {
|
|
1310
|
+
// Inside a script or a stylesheet a `<` is code, up to its own end tag.
|
|
1311
|
+
if (closes && rawText === name) {
|
|
1312
|
+
rawText = null;
|
|
1313
|
+
if (depth != null) depth -= 1;
|
|
1314
|
+
}
|
|
1315
|
+
} else if (closes) {
|
|
1316
|
+
if (depth != null) depth -= 1;
|
|
1317
|
+
} else if (depth == null) {
|
|
1318
|
+
if (name === "body") depth = 0;
|
|
1319
|
+
else if (name === "script" || name === "style") rawText = name;
|
|
1320
|
+
} else if (tag[4] !== "/" && !VOID_ELEMENTS.has(name)) {
|
|
1321
|
+
depth += 1;
|
|
1322
|
+
if (name === "script" || name === "style") rawText = name;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
tag = tags.exec(text);
|
|
1326
|
+
}
|
|
1327
|
+
const start = text.lastIndexOf("<");
|
|
1328
|
+
return {
|
|
1329
|
+
safe:
|
|
1330
|
+
rawText == null &&
|
|
1331
|
+
replaceable === 0 &&
|
|
1332
|
+
text.endsWith(">") &&
|
|
1333
|
+
(depth === 0 || (depth != null && depth === rootDepth)),
|
|
1334
|
+
rawText,
|
|
1335
|
+
replaceable,
|
|
1336
|
+
open: start > text.lastIndexOf(">") ? text.slice(start) : "",
|
|
1337
|
+
depth,
|
|
1338
|
+
};
|
|
606
1339
|
}
|
|
607
1340
|
|
|
608
1341
|
/**
|