@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.39
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 +6 -0
- package/client.js +29 -105
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/compose.js +12 -0
- package/internal/error-view.js +4 -0
- package/internal/flight-browser.js +17 -19
- package/internal/flight-chunks.js +32 -8
- package/internal/flight-ssr.js +6 -0
- package/internal/flight.js +26 -0
- package/internal/navigation-cache.js +144 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/runtime.js +208 -26
- package/internal/server-route.js +6 -0
- package/internal/shell.js +115 -0
- package/internal/stream.js +111 -21
- package/middleware.js +144 -14
- package/package.json +12 -5
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +440 -0
- package/rsc.js +11 -0
- package/server-components.js +4 -0
- package/server.js +20 -484
package/server.js
CHANGED
|
@@ -24,14 +24,11 @@
|
|
|
24
24
|
// which.
|
|
25
25
|
|
|
26
26
|
import { noteRoute } from "@uniflowed/server/host";
|
|
27
|
-
import { ROOT_ID } from "./internal/document.js";
|
|
28
27
|
import * as React from "react";
|
|
29
28
|
|
|
30
29
|
import {
|
|
31
30
|
type DocumentBody,
|
|
32
|
-
type DocumentShell,
|
|
33
31
|
type WritableLike,
|
|
34
|
-
bodyOfText,
|
|
35
32
|
prerenderDocument,
|
|
36
33
|
renderDocument,
|
|
37
34
|
} from "./internal/stream.js";
|
|
@@ -46,15 +43,10 @@ import {
|
|
|
46
43
|
resolveMatch,
|
|
47
44
|
} from "./internal/runtime.js";
|
|
48
45
|
|
|
49
|
-
import { type StreamDiagnostic,
|
|
46
|
+
import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
|
|
47
|
+
import { currentNonce } from "@uniflowed/server/host";
|
|
50
48
|
|
|
51
|
-
import {
|
|
52
|
-
type ClientModuleLoader,
|
|
53
|
-
installServerModules,
|
|
54
|
-
readPayload,
|
|
55
|
-
} from "./internal/flight-ssr.js";
|
|
56
|
-
import { FLIGHT_CONTENT_TYPE, flightUrl } from "./internal/flight.js";
|
|
57
|
-
import type { FlightRenderer } from "./rsc.js";
|
|
49
|
+
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
58
50
|
|
|
59
51
|
/** Asset URLs to reference from the document. */
|
|
60
52
|
export type RenderAssets = {|
|
|
@@ -241,6 +233,14 @@ export type {
|
|
|
241
233
|
} from "./middleware.js";
|
|
242
234
|
export { createMiddlewareRunner } from "./middleware.js";
|
|
243
235
|
|
|
236
|
+
/**
|
|
237
|
+
* `app.router.basePath` and `trailingSlash`, for `virtual:uf/server` to install
|
|
238
|
+
* before the first render, and `basePath()` for a middleware or a route handler
|
|
239
|
+
* that builds an address itself. See `./internal/base-path.js`.
|
|
240
|
+
*/
|
|
241
|
+
export type { RoutingSettings, TrailingSlash } from "./internal/base-path.js";
|
|
242
|
+
export { basePath, installRouting } from "./internal/base-path.js";
|
|
243
|
+
|
|
244
244
|
/**
|
|
245
245
|
* The endpoint a `"use server"` export is dialled at.
|
|
246
246
|
*
|
|
@@ -270,15 +270,6 @@ type Resolution =
|
|
|
270
270
|
| {| readonly kind: "route", readonly route: ResolvedRoute |}
|
|
271
271
|
| {| readonly kind: "redirect", readonly error: RedirectError |};
|
|
272
272
|
|
|
273
|
-
/** A redirect, as the finished document `prerender` answers with. */
|
|
274
|
-
async function redirectResult(document: RenderResult): Promise<PrerenderResult> {
|
|
275
|
-
return {
|
|
276
|
-
status: document.status,
|
|
277
|
-
headers: document.headers,
|
|
278
|
-
html: await document.text(),
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
273
|
/** The two ways one app answers for a URL. */
|
|
283
274
|
export type Renderer = {|
|
|
284
275
|
readonly render: (
|
|
@@ -368,6 +359,11 @@ export function createRenderer(options: {|
|
|
|
368
359
|
// browser was actually sent rather than the one that was abandoned.
|
|
369
360
|
const send = settings?.onStream;
|
|
370
361
|
const onStream = send == null ? undefined : streamReporter(url, send);
|
|
362
|
+
// Read rather than minted: a project that has not asked for a nonce gets
|
|
363
|
+
// `null` and the document it has always had. Read once for both renders
|
|
364
|
+
// below, so the error document a failed shell falls back to carries the
|
|
365
|
+
// same nonce as the policy already on the response.
|
|
366
|
+
const nonce = currentNonce();
|
|
371
367
|
|
|
372
368
|
// React reports an exception to `onError` *and*, if it was in the shell, to
|
|
373
369
|
// `onShellError` — so forwarding both would tell the host about one failure
|
|
@@ -388,10 +384,11 @@ export function createRenderer(options: {|
|
|
|
388
384
|
let body: DocumentBody;
|
|
389
385
|
try {
|
|
390
386
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
391
|
-
shell: shellFor(assets),
|
|
387
|
+
shell: shellFor(assets, nonce),
|
|
392
388
|
onError,
|
|
393
389
|
transformHead: settings?.transformHead,
|
|
394
390
|
onStream,
|
|
391
|
+
nonce,
|
|
395
392
|
});
|
|
396
393
|
streaming = true;
|
|
397
394
|
// Recovered before the shell was ready: a `<Suspense>` boundary whose
|
|
@@ -420,10 +417,11 @@ export function createRenderer(options: {|
|
|
|
420
417
|
// where somebody can fix it.
|
|
421
418
|
streaming = true;
|
|
422
419
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
423
|
-
shell: shellFor(assets),
|
|
420
|
+
shell: shellFor(assets, nonce),
|
|
424
421
|
onError,
|
|
425
422
|
transformHead: settings?.transformHead,
|
|
426
423
|
onStream,
|
|
424
|
+
nonce,
|
|
427
425
|
});
|
|
428
426
|
}
|
|
429
427
|
|
|
@@ -471,392 +469,6 @@ export function createRenderer(options: {|
|
|
|
471
469
|
return { render, prerender };
|
|
472
470
|
}
|
|
473
471
|
|
|
474
|
-
/** What `virtual:uf/server` hands the renderer for React Server Components. */
|
|
475
|
-
export type DocumentRendererOptions = {|
|
|
476
|
-
readonly App: React.ComponentType<AppProps>,
|
|
477
|
-
/** The Flight renderer, from the module graph resolved under `react-server`. */
|
|
478
|
-
readonly renderFlight: FlightRenderer,
|
|
479
|
-
/** The server copy of the client module at a browser chunk URL. */
|
|
480
|
-
readonly loadClientModule: ClientModuleLoader,
|
|
481
|
-
|};
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* The renderer for an application rendered by React Server Components.
|
|
485
|
-
*
|
|
486
|
-
* [`createRenderer`] renders a route's modules into HTML. This one renders no
|
|
487
|
-
* route module at all: the Flight renderer (`./rsc.js`), in the module graph
|
|
488
|
-
* resolved under `react-server`, renders the route into a payload, and this
|
|
489
|
-
* reads that payload with React's own Flight client and renders what comes
|
|
490
|
-
* out. The same bytes are written into the document as they arrive, so the
|
|
491
|
-
* browser hydrates the tree the server rendered rather than rendering the
|
|
492
|
-
* route's modules a second time — which is what keeps a Server Component's
|
|
493
|
-
* module out of the browser. See ubugeeei-prod/uf#519.
|
|
494
|
-
*
|
|
495
|
-
* The contract with a host is [`Renderer`]'s, unchanged: `render` resolves
|
|
496
|
-
* when the shell is ready with a status and a body, `prerender` resolves with a
|
|
497
|
-
* finished document, and `error` is the exception a route fell back to its
|
|
498
|
-
* error boundary for. It adds `flight`, which answers a browser that is
|
|
499
|
-
* navigating, and `prerender` adds the payload a static host serves for one.
|
|
500
|
-
*
|
|
501
|
-
* # When the shell throws
|
|
502
|
-
*
|
|
503
|
-
* The HTML renderer only ever sees React's serialisation of what a Server
|
|
504
|
-
* Component threw — in a build, a sentence and a digest — and resolving the
|
|
505
|
-
* error boundary needs the exception itself: `forbidden()` is a 403 and a
|
|
506
|
-
* `RedirectError` is a redirect. So the first exception the Flight renderer
|
|
507
|
-
* reports is kept, as thrown, and it is what the error route is resolved for.
|
|
508
|
-
* An exception only the HTML renderer saw — a client component that threw
|
|
509
|
-
* while it rendered on the server — is resolved for as it is.
|
|
510
|
-
*/
|
|
511
|
-
export function createDocumentRenderer(options: DocumentRendererOptions): Renderer {
|
|
512
|
-
const { App, renderFlight } = options;
|
|
513
|
-
installServerModules(options.loadClientModule);
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* The document for one payload: React's Flight client reads one copy of the
|
|
517
|
-
* stream while the other copy is written into the document.
|
|
518
|
-
*/
|
|
519
|
-
async function documentOf(
|
|
520
|
-
stream: ReadableStream<Uint8Array>,
|
|
521
|
-
url: string,
|
|
522
|
-
assets: RenderAssets,
|
|
523
|
-
settings: {|
|
|
524
|
-
readonly onError: (error: mixed) => void,
|
|
525
|
-
readonly transformHead?: (html: string) => Promise<string>,
|
|
526
|
-
readonly onStream?: (record: StreamRecord) => void,
|
|
527
|
-
|},
|
|
528
|
-
): Promise<DocumentBody> {
|
|
529
|
-
const [forHtml, forBrowser] = stream.tee();
|
|
530
|
-
try {
|
|
531
|
-
return await renderDocument(<App url={url} flight={readPayload(forHtml)} />, {
|
|
532
|
-
shell: shellFor(assets),
|
|
533
|
-
onError: settings.onError,
|
|
534
|
-
transformHead: settings.transformHead,
|
|
535
|
-
onStream: settings.onStream,
|
|
536
|
-
payload: forBrowser,
|
|
537
|
-
});
|
|
538
|
-
} catch (error) {
|
|
539
|
-
void forBrowser.cancel();
|
|
540
|
-
throw error;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
async function render(
|
|
545
|
-
url: string,
|
|
546
|
-
assets: RenderAssets,
|
|
547
|
-
settings?: RenderOptions,
|
|
548
|
-
): Promise<RenderResult> {
|
|
549
|
-
const report = settings?.onError ?? (() => {});
|
|
550
|
-
const send = settings?.onStream;
|
|
551
|
-
const onStream = send == null ? undefined : streamReporter(url, send);
|
|
552
|
-
const transformHead = settings?.transformHead;
|
|
553
|
-
const ledger = createErrorLedger();
|
|
554
|
-
|
|
555
|
-
// Held until the shell is known to have survived, for the reason
|
|
556
|
-
// [`createRenderer`] holds them — and dropped rather than reported once the
|
|
557
|
-
// render they came from has been given up for the error boundary. That
|
|
558
|
-
// render goes on reporting while it stops, and nothing it says then is
|
|
559
|
-
// about the response.
|
|
560
|
-
let streaming = false;
|
|
561
|
-
let abandoned = false;
|
|
562
|
-
let held: Array<mixed> = [];
|
|
563
|
-
const onError = (error: mixed) => {
|
|
564
|
-
if (abandoned) {
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
if (streaming) {
|
|
568
|
-
report(error);
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
held.push(error);
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
const stop = new AbortController();
|
|
575
|
-
const rendered = await renderFlight(url, {
|
|
576
|
-
onError: (error: mixed) => {
|
|
577
|
-
onError(error);
|
|
578
|
-
return ledger.record(error);
|
|
579
|
-
},
|
|
580
|
-
signal: stop.signal,
|
|
581
|
-
});
|
|
582
|
-
if (rendered.kind === "redirect") {
|
|
583
|
-
return redirectDocument(new RedirectError(rendered.location, rendered.status === 308));
|
|
584
|
-
}
|
|
585
|
-
let status = rendered.status;
|
|
586
|
-
let failure = rendered.failure;
|
|
587
|
-
let body: DocumentBody;
|
|
588
|
-
try {
|
|
589
|
-
body = await documentOf(rendered.stream, url, assets, {
|
|
590
|
-
onError: (error: mixed) => {
|
|
591
|
-
if (!ledger.isCopy(error)) {
|
|
592
|
-
onError(error);
|
|
593
|
-
}
|
|
594
|
-
},
|
|
595
|
-
transformHead,
|
|
596
|
-
onStream,
|
|
597
|
-
});
|
|
598
|
-
streaming = true;
|
|
599
|
-
for (const error of held) {
|
|
600
|
-
report(error);
|
|
601
|
-
}
|
|
602
|
-
held = [];
|
|
603
|
-
} catch (error) {
|
|
604
|
-
abandoned = true;
|
|
605
|
-
held = [];
|
|
606
|
-
stop.abort();
|
|
607
|
-
const cause = ledger.originalOf(error);
|
|
608
|
-
if (cause instanceof RedirectError) {
|
|
609
|
-
return redirectDocument(cause);
|
|
610
|
-
}
|
|
611
|
-
// Deliberately not caught again: this render is the boundary's own, and a
|
|
612
|
-
// boundary that throws has nothing left to answer with. It reaches
|
|
613
|
-
// `uf dev`'s overlay and fails `uf build`'s route, which is where somebody
|
|
614
|
-
// can fix it.
|
|
615
|
-
const recovered = await renderFlight(url, {
|
|
616
|
-
failure: { error: cause },
|
|
617
|
-
onError: (late: mixed) => {
|
|
618
|
-
report(late);
|
|
619
|
-
return ledger.record(late);
|
|
620
|
-
},
|
|
621
|
-
});
|
|
622
|
-
if (recovered.kind === "redirect") {
|
|
623
|
-
return redirectDocument(new RedirectError(recovered.location, recovered.status === 308));
|
|
624
|
-
}
|
|
625
|
-
status = recovered.status;
|
|
626
|
-
failure = recovered.failure;
|
|
627
|
-
try {
|
|
628
|
-
body = await documentOf(recovered.stream, url, assets, {
|
|
629
|
-
onError: (late: mixed) => {
|
|
630
|
-
if (!ledger.isCopy(late)) {
|
|
631
|
-
report(late);
|
|
632
|
-
}
|
|
633
|
-
},
|
|
634
|
-
transformHead,
|
|
635
|
-
onStream,
|
|
636
|
-
});
|
|
637
|
-
} catch (thrown) {
|
|
638
|
-
// The boundary's own render threw; see `prerender` for why what leaves
|
|
639
|
-
// is the exception as thrown rather than React's copy of it.
|
|
640
|
-
throw ledger.originalOf(thrown);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
return { status, pipe: body.pipe, stream: body.stream, text: body.text, error: failure };
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
async function prerender(
|
|
648
|
-
url: string,
|
|
649
|
-
assets: RenderAssets,
|
|
650
|
-
settings?: RenderOptions,
|
|
651
|
-
): Promise<PrerenderResult> {
|
|
652
|
-
const report = settings?.onError ?? (() => {});
|
|
653
|
-
const ledger = createErrorLedger();
|
|
654
|
-
// The Flight renderer's copy of an exception is the one reported, because it
|
|
655
|
-
// is the exception as thrown; the HTML renderer's copy of the same one is
|
|
656
|
-
// recognised by its digest and dropped. See [`createErrorLedger`].
|
|
657
|
-
const onServerError = (error: mixed): string => {
|
|
658
|
-
report(error);
|
|
659
|
-
return ledger.record(error);
|
|
660
|
-
};
|
|
661
|
-
const onHtmlError = (error: mixed) => {
|
|
662
|
-
if (!ledger.isCopy(error)) {
|
|
663
|
-
report(error);
|
|
664
|
-
}
|
|
665
|
-
};
|
|
666
|
-
|
|
667
|
-
// `defer: false`, for the reason [`createRenderer`]'s prerender passes it:
|
|
668
|
-
// a file has no fallback to show first.
|
|
669
|
-
const rendered = await renderFlight(url, { defer: false, onError: onServerError });
|
|
670
|
-
if (rendered.kind === "redirect") {
|
|
671
|
-
return redirectResult(
|
|
672
|
-
redirectDocument(new RedirectError(rendered.location, rendered.status === 308)),
|
|
673
|
-
);
|
|
674
|
-
}
|
|
675
|
-
let status = rendered.status;
|
|
676
|
-
let failure = rendered.failure;
|
|
677
|
-
let payload: Uint8Array;
|
|
678
|
-
let html: string;
|
|
679
|
-
try {
|
|
680
|
-
// Inside the `try`, with the document: a payload the Flight renderer
|
|
681
|
-
// could not finish — a function handed to a client component, say —
|
|
682
|
-
// fails the stream itself, and that is this route's failure like any
|
|
683
|
-
// other.
|
|
684
|
-
payload = await bytesOf(rendered.stream);
|
|
685
|
-
html = await staticDocumentOf(payload, url, assets, onHtmlError);
|
|
686
|
-
} catch (error) {
|
|
687
|
-
const cause = ledger.originalOf(error);
|
|
688
|
-
if (cause instanceof RedirectError) {
|
|
689
|
-
return redirectResult(redirectDocument(cause));
|
|
690
|
-
}
|
|
691
|
-
const recovered = await renderFlight(url, {
|
|
692
|
-
defer: false,
|
|
693
|
-
failure: { error: cause },
|
|
694
|
-
onError: onServerError,
|
|
695
|
-
});
|
|
696
|
-
if (recovered.kind === "redirect") {
|
|
697
|
-
return redirectResult(
|
|
698
|
-
redirectDocument(new RedirectError(recovered.location, recovered.status === 308)),
|
|
699
|
-
);
|
|
700
|
-
}
|
|
701
|
-
status = recovered.status;
|
|
702
|
-
failure = recovered.failure;
|
|
703
|
-
try {
|
|
704
|
-
payload = await bytesOf(recovered.stream);
|
|
705
|
-
html = await staticDocumentOf(payload, url, assets, onHtmlError);
|
|
706
|
-
} catch (thrown) {
|
|
707
|
-
// The boundary's own render threw, and nothing is left to answer with.
|
|
708
|
-
// What leaves is the exception as thrown rather than the copy React's
|
|
709
|
-
// client rebuilt from its row, whose message in a build says only that
|
|
710
|
-
// the real one was omitted — which is all `uf build` would then print.
|
|
711
|
-
throw ledger.originalOf(thrown);
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
return { status, html, error: failure, payload };
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/** A finished document for a finished payload, with the payload written into it. */
|
|
718
|
-
function staticDocumentOf(
|
|
719
|
-
payload: Uint8Array,
|
|
720
|
-
url: string,
|
|
721
|
-
assets: RenderAssets,
|
|
722
|
-
onError: (error: mixed) => void,
|
|
723
|
-
): Promise<string> {
|
|
724
|
-
return prerenderDocument(<App url={url} flight={readPayload(streamOf(payload))} />, {
|
|
725
|
-
shell: shellFor(assets),
|
|
726
|
-
onError,
|
|
727
|
-
payload: streamOf(payload),
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
async function flight(
|
|
732
|
-
url: string,
|
|
733
|
-
settings?: {| readonly onError?: (error: mixed) => void |},
|
|
734
|
-
): Promise<FlightResponse> {
|
|
735
|
-
const rendered = await renderFlight(url, { onError: settings?.onError });
|
|
736
|
-
if (rendered.kind === "redirect") {
|
|
737
|
-
const { location } = rendered;
|
|
738
|
-
// A redirect on this origin points at its target's payload, so the
|
|
739
|
-
// browser's `fetch` follows it and lands on one. A redirect elsewhere is
|
|
740
|
-
// left as written: what the browser finds there is not a payload, and it
|
|
741
|
-
// loads the URL as a document instead.
|
|
742
|
-
const onThisOrigin = location.startsWith("/") && !location.startsWith("//");
|
|
743
|
-
return {
|
|
744
|
-
status: rendered.status,
|
|
745
|
-
headers: { location: onThisOrigin ? flightUrl(location) : location },
|
|
746
|
-
stream: null,
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
|
-
return {
|
|
750
|
-
status: rendered.status,
|
|
751
|
-
headers: { "content-type": FLIGHT_CONTENT_TYPE },
|
|
752
|
-
stream: rendered.stream,
|
|
753
|
-
error: rendered.failure,
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
return { render, prerender, flight };
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* Pairs each exception the Flight renderer reports with the copy of it the HTML
|
|
762
|
-
* renderer sees.
|
|
763
|
-
*
|
|
764
|
-
* A Server Component that throws is seen twice. The Flight renderer catches the
|
|
765
|
-
* exception as it was thrown and writes an error row where that part of the
|
|
766
|
-
* tree would have been; React's Flight client reads the row and throws an error
|
|
767
|
-
* rebuilt from it, which is what the HTML renderer sees at the same spot — and
|
|
768
|
-
* in a build, that error's message is a sentence saying the real one was
|
|
769
|
-
* omitted. Reporting both tells a host about one failure twice, once uselessly.
|
|
770
|
-
* Resolving the error boundary for the rebuilt one is worse: `forbidden()`
|
|
771
|
-
* would become a 500 and `redirect()` an error page, because neither survives
|
|
772
|
-
* being rebuilt as a plain `Error`.
|
|
773
|
-
*
|
|
774
|
-
* The row carries a digest, and that is React's field for exactly this: what
|
|
775
|
-
* the Flight renderer's `onError` returns is written into the row and set on the
|
|
776
|
-
* rebuilt error as `digest`. So every exception is recorded here under a digest
|
|
777
|
-
* of its own, and an error the HTML renderer reports with a recorded digest is a
|
|
778
|
-
* copy — dropped from the report, and exchanged for the original when the
|
|
779
|
-
* boundary is resolved.
|
|
780
|
-
*
|
|
781
|
-
* The digest reaches the browser inside the payload, and says nothing: it counts
|
|
782
|
-
* the exceptions one render has recorded.
|
|
783
|
-
*/
|
|
784
|
-
function createErrorLedger(): ErrorLedger {
|
|
785
|
-
const originals: Map<string, mixed> = new Map();
|
|
786
|
-
const recorded = (error: mixed): string | null => {
|
|
787
|
-
const digest = digestOf(error);
|
|
788
|
-
return digest != null && originals.has(digest) ? digest : null;
|
|
789
|
-
};
|
|
790
|
-
return {
|
|
791
|
-
record(error: mixed): string {
|
|
792
|
-
const digest = `uf:${originals.size + 1}`;
|
|
793
|
-
originals.set(digest, error);
|
|
794
|
-
return digest;
|
|
795
|
-
},
|
|
796
|
-
isCopy(error: mixed): boolean {
|
|
797
|
-
return recorded(error) != null;
|
|
798
|
-
},
|
|
799
|
-
originalOf(error: mixed): mixed {
|
|
800
|
-
const digest = recorded(error);
|
|
801
|
-
return digest == null ? error : originals.get(digest);
|
|
802
|
-
},
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/** See [`createErrorLedger`]. */
|
|
807
|
-
type ErrorLedger = {|
|
|
808
|
-
/** Keep an exception the Flight renderer reported, and return its digest. */
|
|
809
|
-
readonly record: (error: mixed) => string,
|
|
810
|
-
/** Whether an error the HTML renderer reported is the copy of a kept one. */
|
|
811
|
-
readonly isCopy: (error: mixed) => boolean,
|
|
812
|
-
/** The exception as thrown, for a copy; any other error, as it is. */
|
|
813
|
-
readonly originalOf: (error: mixed) => mixed,
|
|
814
|
-
|};
|
|
815
|
-
|
|
816
|
-
/** The digest React set on an error, when it set one. */
|
|
817
|
-
function digestOf(error: mixed): string | null {
|
|
818
|
-
if (error == null || typeof error !== "object") {
|
|
819
|
-
return null;
|
|
820
|
-
}
|
|
821
|
-
const tagged: { +digest?: mixed, ... } = (error: $FlowFixMe);
|
|
822
|
-
return typeof tagged.digest === "string" ? tagged.digest : null;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/** Every byte of a stream, as one array. */
|
|
826
|
-
async function bytesOf(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
|
827
|
-
const reader = stream.getReader();
|
|
828
|
-
const chunks: Array<Uint8Array> = [];
|
|
829
|
-
let total = 0;
|
|
830
|
-
while (true) {
|
|
831
|
-
const step = await reader.read();
|
|
832
|
-
if (step.done === true) {
|
|
833
|
-
break;
|
|
834
|
-
}
|
|
835
|
-
const chunk = step.value;
|
|
836
|
-
if (chunk != null) {
|
|
837
|
-
chunks.push(chunk);
|
|
838
|
-
total += chunk.byteLength;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
const bytes = new Uint8Array(total);
|
|
842
|
-
let at = 0;
|
|
843
|
-
for (const chunk of chunks) {
|
|
844
|
-
bytes.set(chunk, at);
|
|
845
|
-
at += chunk.byteLength;
|
|
846
|
-
}
|
|
847
|
-
return bytes;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
/** A stream of one array, read as many times as a caller constructs one. */
|
|
851
|
-
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
852
|
-
return new ReadableStream({
|
|
853
|
-
start(controller) {
|
|
854
|
-
controller.enqueue(bytes);
|
|
855
|
-
controller.close();
|
|
856
|
-
},
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
|
|
860
472
|
/** The exception a resolved route fell back to its error boundary for. */
|
|
861
473
|
function renderFailure(resolved: ResolvedRoute): mixed {
|
|
862
474
|
if (resolved.error == null) {
|
|
@@ -869,54 +481,6 @@ function renderFailure(resolved: ResolvedRoute): mixed {
|
|
|
869
481
|
};
|
|
870
482
|
}
|
|
871
483
|
|
|
872
|
-
function redirectDocument(error: RedirectError): RenderResult {
|
|
873
|
-
const target = escapeAttribute(error.to);
|
|
874
|
-
// A document rather than an empty body, because a redirect is still an answer
|
|
875
|
-
// a browser may be shown; it goes through the same three methods as a
|
|
876
|
-
// rendered one so that a host has one shape to write, not two.
|
|
877
|
-
const body = bodyOfText(
|
|
878
|
-
`<!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`,
|
|
879
|
-
);
|
|
880
|
-
return {
|
|
881
|
-
status: error.permanent ? 308 : 307,
|
|
882
|
-
headers: { Location: error.to },
|
|
883
|
-
pipe: body.pipe,
|
|
884
|
-
stream: body.stream,
|
|
885
|
-
text: body.text,
|
|
886
|
-
};
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
/**
|
|
890
|
-
* The document uf writes around the app's markup.
|
|
891
|
-
*
|
|
892
|
-
* The same two shapes `assemble` chose between, decided from the same evidence
|
|
893
|
-
* — whether the markup opens with `<html>` — but stated up front instead of
|
|
894
|
-
* afterwards, because a stream has no "afterwards" in which to splice a head.
|
|
895
|
-
* An app whose root layout renders `<html>` owns the whole document and the
|
|
896
|
-
* client hydrates `document`, so uf contributes only the tags that go before
|
|
897
|
-
* `</head>`. An app that renders only content is wrapped in a minimal shell
|
|
898
|
-
* around `<div id="uf-root">`, which is what the client hydrates instead.
|
|
899
|
-
*
|
|
900
|
-
* `internal/stream.js` picks between them on the opening bytes React writes;
|
|
901
|
-
* everything either shape is made of is here, so what a uf document contains is
|
|
902
|
-
* still readable in one place.
|
|
903
|
-
*
|
|
904
|
-
* # Why the shell is three strings and not one
|
|
905
|
-
*
|
|
906
|
-
* Because uf's own `<head>` has to still be open when React's head tags arrive.
|
|
907
|
-
* React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
|
|
908
|
-
* itself, and here it wrote none — so with one string this shell closed its
|
|
909
|
-
* head before the app had rendered a byte, and every `og:` tag and the
|
|
910
|
-
* `<link rel="canonical">` landed in the body, where a crawler ignores them.
|
|
911
|
-
* `open` is uf's head up to that point, `body` is the rest of it and the
|
|
912
|
-
* wrapper, and what goes between them is whatever `assembled` lifts out of the
|
|
913
|
-
* app's own markup. See ubugeeei-prod/uf#547.
|
|
914
|
-
*
|
|
915
|
-
* That is also why no `<title>` is written here any more. It was, from
|
|
916
|
-
* `resolved.metadata.title` — the same string `Head` renders — so a document
|
|
917
|
-
* carried two of them, one in each place, and only one was where a browser
|
|
918
|
-
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
919
|
-
*/
|
|
920
484
|
/**
|
|
921
485
|
* The document a single-page build writes, and the only one it writes.
|
|
922
486
|
*
|
|
@@ -946,31 +510,3 @@ export function shellDocument(assets: RenderAssets): string {
|
|
|
946
510
|
const shell = shellFor(assets);
|
|
947
511
|
return `${shell.open}${shell.body}${shell.close}`;
|
|
948
512
|
}
|
|
949
|
-
|
|
950
|
-
function shellFor(assets: RenderAssets): DocumentShell {
|
|
951
|
-
const head = headTags(assets);
|
|
952
|
-
return {
|
|
953
|
-
head,
|
|
954
|
-
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
955
|
-
body: `${head}</head><body><div id="${ROOT_ID}">`,
|
|
956
|
-
close: `</div></body></html>\n`,
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
function headTags(assets: RenderAssets): string {
|
|
961
|
-
let tags = "";
|
|
962
|
-
for (const href of assets.styles) {
|
|
963
|
-
tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
964
|
-
}
|
|
965
|
-
for (const href of assets.preloads) {
|
|
966
|
-
tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
|
|
967
|
-
}
|
|
968
|
-
for (const src of assets.scripts) {
|
|
969
|
-
tags += `<script type="module" src="${escapeAttribute(src)}"></script>`;
|
|
970
|
-
}
|
|
971
|
-
return tags;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
function escapeAttribute(value: string): string {
|
|
975
|
-
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
976
|
-
}
|