@uniflowed/router 0.0.0-alpha.34 → 0.0.0-alpha.35
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 +65 -0
- package/internal/boundaries.js +48 -25
- package/internal/compose.js +478 -0
- package/internal/error-view.js +189 -0
- package/internal/flight-browser.js +230 -0
- package/internal/flight-chunks.js +181 -0
- package/internal/flight-ssr.js +72 -0
- package/internal/flight.js +132 -0
- package/internal/head.js +219 -0
- package/internal/resolve.js +1615 -0
- package/internal/runtime.js +522 -2452
- package/internal/server-route.js +52 -0
- package/internal/stream.js +156 -3
- package/package.json +18 -6
- package/rsc.js +323 -0
- package/server-components.js +155 -0
- package/server.js +428 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the route a server component is rendering in.
|
|
4
|
+
//
|
|
5
|
+
// `useRoute()` in the browser reads the router's context, and a server
|
|
6
|
+
// component has no context to read. The graph it renders in resolves `react`
|
|
7
|
+
// under the `react-server` condition, which is the build with no `useContext`
|
|
8
|
+
// in it (ubugeeei-prod/uf#519), and a layout that highlights the section it is
|
|
9
|
+
// in — this repository's own documentation site has two — would otherwise have
|
|
10
|
+
// to become a client component to find out where it is.
|
|
11
|
+
//
|
|
12
|
+
// So the Flight renderer runs each render inside a store holding the route it
|
|
13
|
+
// resolved, and the server half of the hooks reads it back. `AsyncLocalStorage`
|
|
14
|
+
// and not React's `cache`: `cache` finds its render through React's own request
|
|
15
|
+
// storage, which the build React ships for Deno does not have, so after the
|
|
16
|
+
// first `await` in an async server component it would stop finding the render
|
|
17
|
+
// at all. A store of this module's own follows every continuation of the
|
|
18
|
+
// render on every host `@uniflowed/server` already runs on, and it is one value
|
|
19
|
+
// per render, so two requests in flight cannot read each other's route.
|
|
20
|
+
//
|
|
21
|
+
// Server-only by construction: nothing in the browser's graph imports this.
|
|
22
|
+
|
|
23
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24
|
+
|
|
25
|
+
import type { RouteState } from "./flight.js";
|
|
26
|
+
|
|
27
|
+
const storage: AsyncLocalStorage<RouteState> = new AsyncLocalStorage();
|
|
28
|
+
|
|
29
|
+
/** Run `body` as a render of `route`. Everything it starts sees the route. */
|
|
30
|
+
export function withServerRoute<T>(route: RouteState, body: () => T): T {
|
|
31
|
+
return storage.run(route, body);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The route being rendered, or a refusal naming the caller.
|
|
36
|
+
*
|
|
37
|
+
* A refusal rather than `null`, because the only way to arrive here without a
|
|
38
|
+
* route is to call a router hook from a server module that is not being
|
|
39
|
+
* rendered by the router — a script, a route handler, a module evaluated at
|
|
40
|
+
* import time — and each of those is a mistake worth a sentence.
|
|
41
|
+
*/
|
|
42
|
+
export function serverRoute(caller: string): RouteState {
|
|
43
|
+
const route = storage.getStore();
|
|
44
|
+
if (route == null) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`@uniflowed/router: ${caller}() was called in a server module outside a route the router ` +
|
|
47
|
+
"is rendering. Server Components read the route they render in; a route handler reads " +
|
|
48
|
+
"the request it was given, and a module evaluated at import time has no route at all.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return route;
|
|
52
|
+
}
|
package/internal/stream.js
CHANGED
|
@@ -43,6 +43,7 @@ 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";
|
|
46
47
|
import { type StreamRecord, inspected } from "./inspector.js";
|
|
47
48
|
|
|
48
49
|
/**
|
|
@@ -656,6 +657,16 @@ export type RenderOptions = {|
|
|
|
656
657
|
* everything before it writes a byte, so there is no order to report.
|
|
657
658
|
*/
|
|
658
659
|
readonly onStream?: (record: StreamRecord) => void,
|
|
660
|
+
/**
|
|
661
|
+
* The Flight payload this document's tree was read from, to write into the
|
|
662
|
+
* document for the browser to hydrate from.
|
|
663
|
+
*
|
|
664
|
+
* Absent for a document rendered from the route's modules, which is what
|
|
665
|
+
* every document was before ubugeeei-prod/uf#519 and what `app.rsc: false`
|
|
666
|
+
* still asks for. Present, it is copied into the document as it arrives, by
|
|
667
|
+
* [`interleaved`], which says where it may and may not go.
|
|
668
|
+
*/
|
|
669
|
+
readonly payload?: ReadableStream<Uint8Array>,
|
|
659
670
|
|};
|
|
660
671
|
|
|
661
672
|
/**
|
|
@@ -692,7 +703,10 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
692
703
|
resolve(
|
|
693
704
|
bodyOf(
|
|
694
705
|
outgoing(
|
|
695
|
-
|
|
706
|
+
withPayload(
|
|
707
|
+
assembled(queue.chunks(), options.shell, options.transformHead),
|
|
708
|
+
options.payload,
|
|
709
|
+
),
|
|
696
710
|
options.onStream,
|
|
697
711
|
),
|
|
698
712
|
() => abort(),
|
|
@@ -759,7 +773,10 @@ export function renderWithReadableStream(
|
|
|
759
773
|
(stream: ByteSource) =>
|
|
760
774
|
bodyOf(
|
|
761
775
|
outgoing(
|
|
762
|
-
|
|
776
|
+
withPayload(
|
|
777
|
+
assembled(decoded(stream), options.shell, options.transformHead),
|
|
778
|
+
options.payload,
|
|
779
|
+
),
|
|
763
780
|
options.onStream,
|
|
764
781
|
),
|
|
765
782
|
() => {
|
|
@@ -815,10 +832,146 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
|
|
|
815
832
|
? await ReactDOMStatic.prerenderToNodeStream(node, settings)
|
|
816
833
|
: await ReactDOMStatic.prerender(node, settings);
|
|
817
834
|
return bodyOf(
|
|
818
|
-
|
|
835
|
+
withPayload(
|
|
836
|
+
assembled(preludeChunks(result.prelude), options.shell, options.transformHead),
|
|
837
|
+
options.payload,
|
|
838
|
+
),
|
|
819
839
|
).text();
|
|
820
840
|
}
|
|
821
841
|
|
|
842
|
+
/** `chunks` unchanged when there is no payload, and [`interleaved`] with one. */
|
|
843
|
+
function withPayload(
|
|
844
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
845
|
+
payload: ?ReadableStream<Uint8Array>,
|
|
846
|
+
): AsyncGenerator<string, void, void> {
|
|
847
|
+
return payload == null ? chunks : interleaved(chunks, payload);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* A document's chunks, with the Flight payload it was rendered from written
|
|
852
|
+
* into it as it arrives.
|
|
853
|
+
*
|
|
854
|
+
* Three rules, and each is the answer to a way the obvious version is wrong.
|
|
855
|
+
*
|
|
856
|
+
* **Nothing before the head.** The first chunk this is handed is the whole
|
|
857
|
+
* opening of the document — `assembled` does not let one go until the head is
|
|
858
|
+
* complete — and a payload element written in front of it would sit before
|
|
859
|
+
* `<head>`, where the parser would open a body for it and every tag after it
|
|
860
|
+
* would land in the wrong element.
|
|
861
|
+
*
|
|
862
|
+
* **Written as soon as it exists.** A payload row usually exists before the
|
|
863
|
+
* HTML rendered from it — React's client reads the row, then the boundary
|
|
864
|
+
* renders — so waiting for the next HTML chunk would put the browser's copy
|
|
865
|
+
* behind the markup it hydrates. Each HTML chunk is followed by whatever
|
|
866
|
+
* payload is waiting, and a payload that arrives while the HTML is idle is
|
|
867
|
+
* written then, without a chunk of HTML to follow.
|
|
868
|
+
*
|
|
869
|
+
* **`</body></html>` waits for the end of the payload.** The HTML can finish
|
|
870
|
+
* first — the last boundary's markup is rendered from rows that are already
|
|
871
|
+
* written — and a payload element after `</html>` is one the parser moves
|
|
872
|
+
* rather than one React expects. So the closing tags are held back, the rest of
|
|
873
|
+
* the payload and its end marker are written, and the tags go last. For a
|
|
874
|
+
* document uf wraps, the closing run is `</div></body></html>`, and only the
|
|
875
|
+
* part from `</body>` is held: the root element React hydrates must hold
|
|
876
|
+
* nothing React did not render.
|
|
877
|
+
*
|
|
878
|
+
* A consumer that stops early stops the payload too: the reader is cancelled
|
|
879
|
+
* in the `finally`, which is where `return()` on this generator lands.
|
|
880
|
+
*/
|
|
881
|
+
async function* interleaved(
|
|
882
|
+
chunks: AsyncGenerator<string, void, void>,
|
|
883
|
+
payload: ReadableStream<Uint8Array>,
|
|
884
|
+
): AsyncGenerator<string, void, void> {
|
|
885
|
+
const encoder = createChunkEncoder();
|
|
886
|
+
const reader = payload.getReader();
|
|
887
|
+
let written = "";
|
|
888
|
+
let ended = false;
|
|
889
|
+
let wake: ?() => void = null;
|
|
890
|
+
const ring = () => {
|
|
891
|
+
const resume = wake;
|
|
892
|
+
wake = null;
|
|
893
|
+
if (resume != null) {
|
|
894
|
+
resume();
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
const pumping = (async () => {
|
|
898
|
+
try {
|
|
899
|
+
while (true) {
|
|
900
|
+
const step = await reader.read();
|
|
901
|
+
if (step.done === true) {
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
if (step.value != null) {
|
|
905
|
+
written += encoder.encode(step.value);
|
|
906
|
+
ring();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
// A payload that failed partway is still ended, with the end marker, so
|
|
911
|
+
// the browser's reader closes and React reports the rows it never got
|
|
912
|
+
// rather than waiting for them for as long as the page is open.
|
|
913
|
+
} finally {
|
|
914
|
+
written += encoder.end();
|
|
915
|
+
ended = true;
|
|
916
|
+
ring();
|
|
917
|
+
}
|
|
918
|
+
})();
|
|
919
|
+
|
|
920
|
+
let opened = false;
|
|
921
|
+
let closing = "";
|
|
922
|
+
try {
|
|
923
|
+
let next = chunks.next();
|
|
924
|
+
while (true) {
|
|
925
|
+
if (opened && written !== "") {
|
|
926
|
+
const out = written;
|
|
927
|
+
written = "";
|
|
928
|
+
yield out;
|
|
929
|
+
}
|
|
930
|
+
const idle = new Promise<null>((resolve) => {
|
|
931
|
+
wake = () => resolve(null);
|
|
932
|
+
});
|
|
933
|
+
const outcome = await Promise.race([next, idle]);
|
|
934
|
+
if (outcome == null) {
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (outcome.done === true) {
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
940
|
+
let text = outcome.value;
|
|
941
|
+
const close = text.search(/<\/body>\s*<\/html>\s*$/i);
|
|
942
|
+
if (close !== -1) {
|
|
943
|
+
closing = text.slice(close);
|
|
944
|
+
text = text.slice(0, close);
|
|
945
|
+
}
|
|
946
|
+
if (text !== "") {
|
|
947
|
+
yield text;
|
|
948
|
+
}
|
|
949
|
+
opened = true;
|
|
950
|
+
next = chunks.next();
|
|
951
|
+
}
|
|
952
|
+
while (!ended || written !== "") {
|
|
953
|
+
if (written !== "") {
|
|
954
|
+
const out = written;
|
|
955
|
+
written = "";
|
|
956
|
+
yield out;
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
await new Promise<void>((resolve) => {
|
|
960
|
+
wake = resolve;
|
|
961
|
+
if (ended || written !== "") {
|
|
962
|
+
ring();
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
await pumping;
|
|
967
|
+
yield closing;
|
|
968
|
+
} finally {
|
|
969
|
+
if (!ended) {
|
|
970
|
+
void reader.cancel();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
822
975
|
/**
|
|
823
976
|
* The prelude of a static prerender, whichever stream this build produced.
|
|
824
977
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.35",
|
|
4
4
|
"description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
"directory": "packages/router"
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
|
-
".":
|
|
14
|
+
".": {
|
|
15
|
+
"react-server": "./server-components.js",
|
|
16
|
+
"default": "./index.js"
|
|
17
|
+
},
|
|
15
18
|
"./action": "./action.js",
|
|
16
19
|
"./client": "./client.js",
|
|
17
20
|
"./native": "./native.js",
|
|
21
|
+
"./rsc": "./rsc.js",
|
|
18
22
|
"./server": "./server.js",
|
|
19
23
|
"./routing": "./routing.js",
|
|
20
24
|
"./package.json": "./package.json",
|
|
@@ -30,15 +34,23 @@
|
|
|
30
34
|
"middleware.js",
|
|
31
35
|
"native.js",
|
|
32
36
|
"routing.js",
|
|
37
|
+
"rsc.js",
|
|
38
|
+
"server-components.js",
|
|
33
39
|
"server.js",
|
|
34
40
|
"!*.test.js"
|
|
35
41
|
],
|
|
36
42
|
"peerDependencies": {
|
|
37
|
-
"react": ">=19",
|
|
38
|
-
"react-dom": ">=19"
|
|
43
|
+
"react": ">=19.3.0",
|
|
44
|
+
"react-dom": ">=19.3.0",
|
|
45
|
+
"react-server-dom-parcel": ">=19.3.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react-dom": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
39
51
|
},
|
|
40
52
|
"dependencies": {
|
|
41
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
42
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
53
|
+
"@uniflowed/hooks": "0.0.0-alpha.35",
|
|
54
|
+
"@uniflowed/server": "0.0.0-alpha.35"
|
|
43
55
|
}
|
|
44
56
|
}
|
package/rsc.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router/rsc`: a route, rendered as React Server Components.
|
|
4
|
+
//
|
|
5
|
+
// This module runs in the module graph uf resolves under the `react-server`
|
|
6
|
+
// export condition, which is the graph where `react` is the build with no
|
|
7
|
+
// `useState` in it and where React's Flight renderer is allowed to load
|
|
8
|
+
// (ubugeeei-prod/uf#519). It does three things, and each is a piece that
|
|
9
|
+
// already existed somewhere else:
|
|
10
|
+
//
|
|
11
|
+
// 1. **Resolve the URL** — `resolveMatch`, the same resolution a single-page
|
|
12
|
+
// application runs in the browser: the match, the modules, the loader, the
|
|
13
|
+
// metadata, the boundaries.
|
|
14
|
+
// 2. **Compose the tree** — `composeRoute`, the same walk `RouteView` makes,
|
|
15
|
+
// so a layout, a fallback, a template and a slot land where they would in
|
|
16
|
+
// any other render.
|
|
17
|
+
// 3. **Render it as a Flight payload** — `renderToReadableStream` from
|
|
18
|
+
// `react-server-dom-parcel`, which is React's own renderer and React's own
|
|
19
|
+
// wire format. uf writes no serialiser here and no decoder anywhere.
|
|
20
|
+
//
|
|
21
|
+
// The payload's root value is a [`FlightRoot`]: the route, as the part of it a
|
|
22
|
+
// hook reads, and the tree. The HTML renderer (`./server.js`) reads it to write
|
|
23
|
+
// the document, and the browser (`./client.js`) reads the same bytes to hydrate
|
|
24
|
+
// and, after that, to navigate — so what the browser renders is exactly what
|
|
25
|
+
// the server rendered, and no page, layout or loader module is ever the
|
|
26
|
+
// browser's to evaluate. A `"use client"` module is the one thing that crosses,
|
|
27
|
+
// as a reference to a chunk the browser loads.
|
|
28
|
+
//
|
|
29
|
+
// # What leaves before the payload does
|
|
30
|
+
//
|
|
31
|
+
// A redirect, and the status. Both are decided while the route resolves, which
|
|
32
|
+
// is before anything renders, so `renderFlight` answers a redirect instead of a
|
|
33
|
+
// stream and a route with the status it resolved to. That is the head-before-
|
|
34
|
+
// the-first-byte property `./internal/stream.js` depends on, kept from the
|
|
35
|
+
// other side: nothing the renderer learns while streaming can change the
|
|
36
|
+
// response line.
|
|
37
|
+
//
|
|
38
|
+
// # Error boundaries have to be client modules
|
|
39
|
+
//
|
|
40
|
+
// An `$error.js` catches a throw while the *browser* renders, so its component
|
|
41
|
+
// is the browser's, and a server component cannot be passed to a client one —
|
|
42
|
+
// Flight refuses a function where a reference was expected. So each boundary's
|
|
43
|
+
// component crosses as the client reference its `"use client"` directive made it
|
|
44
|
+
// in this graph, and a boundary module without the directive is refused here,
|
|
45
|
+
// with its file named, rather than by React with a sentence about functions.
|
|
46
|
+
|
|
47
|
+
import * as React from "react";
|
|
48
|
+
import { use } from "react";
|
|
49
|
+
// React's Flight server. Only this graph can load it: it refuses to evaluate
|
|
50
|
+
// unless `react` resolved under the `react-server` condition.
|
|
51
|
+
import { renderToReadableStream } from "react-server-dom-parcel/server";
|
|
52
|
+
|
|
53
|
+
import { noteRoute } from "@uniflowed/server/host";
|
|
54
|
+
|
|
55
|
+
import { BoundaryReporter } from "./internal/boundaries.js";
|
|
56
|
+
import { routeBoundaries } from "./internal/boundary-data.js";
|
|
57
|
+
import { composeRoute, pageComponent } from "./internal/compose.js";
|
|
58
|
+
import { ErrorRoutePage } from "./internal/error-view.js";
|
|
59
|
+
import { type FlightRoot, routeState } from "./internal/flight.js";
|
|
60
|
+
import type { ErrorModule, PageModule, ResolvedRoute, ResolvedSlot } from "./internal/resolve.js";
|
|
61
|
+
import { resolveFailure, resolveMatch } from "./internal/resolve.js";
|
|
62
|
+
import type { RouteParams, RouteTable, SearchParams } from "./internal/routing.js";
|
|
63
|
+
import { RedirectError, nearestBoundary } from "./internal/routing.js";
|
|
64
|
+
import { withServerRoute } from "./internal/server-route.js";
|
|
65
|
+
|
|
66
|
+
export type { FlightRoot, RouteState } from "./internal/flight.js";
|
|
67
|
+
|
|
68
|
+
/** What a host may tell the renderer about one render. */
|
|
69
|
+
export type FlightOptions = {|
|
|
70
|
+
/**
|
|
71
|
+
* Whether a loader may be left running into a `$loading.js` boundary.
|
|
72
|
+
*
|
|
73
|
+
* On by default: a payload streams, so a slow loader is a fallback now and
|
|
74
|
+
* its answer later. A prerender turns it off, because a file has no "later".
|
|
75
|
+
*/
|
|
76
|
+
readonly defer?: boolean,
|
|
77
|
+
/**
|
|
78
|
+
* Every exception the render recovered from, including the late ones.
|
|
79
|
+
*
|
|
80
|
+
* What it returns becomes the exception's digest. React writes the digest
|
|
81
|
+
* into the row that stands in for the part of the tree that failed, and sets
|
|
82
|
+
* it on the error its Flight client rebuilds from that row — which is how
|
|
83
|
+
* `./server.js` recognises, in the HTML renderer, the copy of an exception
|
|
84
|
+
* this renderer has already reported.
|
|
85
|
+
*/
|
|
86
|
+
readonly onError?: (error: mixed) => ?string,
|
|
87
|
+
/**
|
|
88
|
+
* Render the error boundary for this exception instead of resolving the URL.
|
|
89
|
+
*
|
|
90
|
+
* What the HTML renderer asks for when the shell it was streaming from the
|
|
91
|
+
* payload threw before its first byte: the route resolved, and rendering it
|
|
92
|
+
* did not.
|
|
93
|
+
*/
|
|
94
|
+
readonly failure?: {| readonly error: mixed |},
|
|
95
|
+
/** Stops the render, for a reader that went away. */
|
|
96
|
+
readonly signal?: AbortSignal,
|
|
97
|
+
|};
|
|
98
|
+
|
|
99
|
+
/** A render: a redirect to answer with, or a route and its payload. */
|
|
100
|
+
export type FlightRender =
|
|
101
|
+
| {| readonly kind: "redirect", readonly status: 307 | 308, readonly location: string |}
|
|
102
|
+
| {|
|
|
103
|
+
readonly kind: "route",
|
|
104
|
+
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
105
|
+
/** The payload, as React writes it. Read exactly once. */
|
|
106
|
+
readonly stream: ReadableStream<Uint8Array>,
|
|
107
|
+
/**
|
|
108
|
+
* The exception this route resolved to its error boundary for, when it did.
|
|
109
|
+
*
|
|
110
|
+
* The same field `./server.js` has always carried as `error`: `uf build`
|
|
111
|
+
* fails a route that set it and `uf dev` reports it. `forbidden()` and
|
|
112
|
+
* `unauthorized()` do not set it.
|
|
113
|
+
*/
|
|
114
|
+
readonly failure: mixed,
|
|
115
|
+
|};
|
|
116
|
+
|
|
117
|
+
/** Render one URL. */
|
|
118
|
+
export type FlightRenderer = (url: string, options?: FlightOptions) => Promise<FlightRender>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether this bundle marks the boundaries it renders.
|
|
122
|
+
*
|
|
123
|
+
* `BOUNDARY_MARKS` in `./internal/runtime.js` has the argument; a build replaces
|
|
124
|
+
* `import.meta.hot` with `undefined` and every use below folds away.
|
|
125
|
+
*/
|
|
126
|
+
const BOUNDARY_MARKS: boolean = import.meta.hot != null;
|
|
127
|
+
|
|
128
|
+
/** How a client reference says what it is. React's symbol, not uf's. */
|
|
129
|
+
const CLIENT_REFERENCE: symbol = Symbol.for("react.client.reference");
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The renderer for one route table.
|
|
133
|
+
*
|
|
134
|
+
* The table is the server's whole one — every page, layout and boundary module
|
|
135
|
+
* — and it is `virtual:uf/routes` as this graph generates it.
|
|
136
|
+
*/
|
|
137
|
+
export function createFlightRenderer(options: {|
|
|
138
|
+
readonly routes: RouteTable["routes"],
|
|
139
|
+
readonly notFound: RouteTable["notFound"],
|
|
140
|
+
readonly errors: RouteTable["errors"],
|
|
141
|
+
|}): FlightRenderer {
|
|
142
|
+
const table: RouteTable = {
|
|
143
|
+
routes: options.routes,
|
|
144
|
+
notFound: options.notFound,
|
|
145
|
+
errors: options.errors,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return async function renderFlight(url: string, settings?: FlightOptions): Promise<FlightRender> {
|
|
149
|
+
let resolved: ResolvedRoute;
|
|
150
|
+
try {
|
|
151
|
+
const failure = settings?.failure;
|
|
152
|
+
resolved =
|
|
153
|
+
failure == null
|
|
154
|
+
? // `onMatch` records the route pattern on the request before the
|
|
155
|
+
// loader runs, so every line the loader logs names its route.
|
|
156
|
+
await resolveMatch(table, url, { defer: settings?.defer !== false, onMatch: noteRoute })
|
|
157
|
+
: await resolveFailure(table, url, failure.error);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error instanceof RedirectError) {
|
|
160
|
+
return { kind: "redirect", status: error.permanent ? 308 : 307, location: error.to };
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const errorFile = nearestBoundary(table.errors, resolved.pathname)?.file ?? null;
|
|
166
|
+
const route = forTheBrowser(resolved, errorFile);
|
|
167
|
+
const state = routeState(route);
|
|
168
|
+
const marks = BOUNDARY_MARKS ? routeBoundaries(route, errorFile) : null;
|
|
169
|
+
const tree = (
|
|
170
|
+
<>
|
|
171
|
+
{composeRoute(route, { page: pageElement(route), marks })}
|
|
172
|
+
{BOUNDARY_MARKS && marks != null ? (
|
|
173
|
+
<BoundaryReporter path={route.path} boundaries={marks} />
|
|
174
|
+
) : null}
|
|
175
|
+
</>
|
|
176
|
+
);
|
|
177
|
+
const root: FlightRoot = { route: state, tree };
|
|
178
|
+
const report = settings?.onError;
|
|
179
|
+
// Inside the route's store, so a server component's `useRoute()` finds the
|
|
180
|
+
// route however many `await`s into the render it asks.
|
|
181
|
+
const stream = withServerRoute(state, () =>
|
|
182
|
+
renderToReadableStream(root, {
|
|
183
|
+
// Handed over as it is, so that the digest it returns reaches the row.
|
|
184
|
+
// Absent, React logs the exception to the console itself.
|
|
185
|
+
onError: report,
|
|
186
|
+
signal: settings?.signal,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
return { kind: "route", status: route.status, stream, failure: renderFailure(route) };
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The page element: the route's page with its data, the deferred page that
|
|
195
|
+
* waits for its loader, or the error route's page.
|
|
196
|
+
*
|
|
197
|
+
* An element for a component rather than a call, so that a page module with no
|
|
198
|
+
* component in it throws while React renders — inside the boundaries the
|
|
199
|
+
* composition placed — rather than before the render has begun.
|
|
200
|
+
*/
|
|
201
|
+
function pageElement(route: ResolvedRoute): React.Node {
|
|
202
|
+
const error = route.error;
|
|
203
|
+
if (error != null) {
|
|
204
|
+
return <ErrorRoutePage module={route.errorBoundary.module} error={error} />;
|
|
205
|
+
}
|
|
206
|
+
const deferred = route.deferred;
|
|
207
|
+
if (deferred != null) {
|
|
208
|
+
return (
|
|
209
|
+
<DeferredPage
|
|
210
|
+
page={route.page}
|
|
211
|
+
params={route.params}
|
|
212
|
+
searchParams={route.searchParams}
|
|
213
|
+
loader={deferred}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return (
|
|
218
|
+
<RoutePage
|
|
219
|
+
page={route.page}
|
|
220
|
+
params={route.params}
|
|
221
|
+
searchParams={route.searchParams}
|
|
222
|
+
data={route.data}
|
|
223
|
+
/>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A route's page, with its loader's answer. */
|
|
228
|
+
component RoutePage(
|
|
229
|
+
page: PageModule,
|
|
230
|
+
params: RouteParams,
|
|
231
|
+
searchParams: SearchParams,
|
|
232
|
+
data: mixed,
|
|
233
|
+
) {
|
|
234
|
+
const Page = pageComponent(page);
|
|
235
|
+
return <Page params={params} searchParams={searchParams} data={data} />;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The same page, once the loader the router deferred has answered.
|
|
240
|
+
*
|
|
241
|
+
* `use` rather than an `async` component: it suspends at the same point, inside
|
|
242
|
+
* the innermost `$loading.js` boundary, and it is the one spelling the
|
|
243
|
+
* browser's `AwaitedPage` already uses for the same wait.
|
|
244
|
+
*/
|
|
245
|
+
component DeferredPage(
|
|
246
|
+
page: PageModule,
|
|
247
|
+
params: RouteParams,
|
|
248
|
+
searchParams: SearchParams,
|
|
249
|
+
loader: Promise<mixed>,
|
|
250
|
+
) {
|
|
251
|
+
return <RoutePage page={page} params={params} searchParams={searchParams} data={use(loader)} />;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The resolved route with every error boundary as the browser receives it.
|
|
256
|
+
*
|
|
257
|
+
* An error module is the server's module; what the browser needs is its
|
|
258
|
+
* component, and in this graph a `"use client"` component *is* a client
|
|
259
|
+
* reference. So each boundary becomes `{ default: <reference> }` — a plain
|
|
260
|
+
* object Flight can carry — and a boundary whose component is not a reference
|
|
261
|
+
* is refused with its file named.
|
|
262
|
+
*/
|
|
263
|
+
function forTheBrowser(resolved: ResolvedRoute, file: string | null): ResolvedRoute {
|
|
264
|
+
const boundary = resolved.errorBoundary;
|
|
265
|
+
return {
|
|
266
|
+
...resolved,
|
|
267
|
+
errorBoundary: { above: boundary.above, module: clientErrorModule(boundary.module, file) },
|
|
268
|
+
slots: resolved.slots.map(slotForTheBrowser),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function slotForTheBrowser(slot: ResolvedSlot): ResolvedSlot {
|
|
273
|
+
const boundary = slot.errorBoundary;
|
|
274
|
+
return {
|
|
275
|
+
...slot,
|
|
276
|
+
errorBoundary:
|
|
277
|
+
boundary == null
|
|
278
|
+
? null
|
|
279
|
+
: { above: boundary.above, module: clientErrorModule(boundary.module, null) },
|
|
280
|
+
slots: slot.slots.map(slotForTheBrowser),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function clientErrorModule(module: ?ErrorModule, file: string | null): ?ErrorModule {
|
|
285
|
+
if (module == null) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const component = module.default ?? module.Error;
|
|
289
|
+
if (component == null) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
"@uniflowed/router: an error module must export a component as `default` or `Error`",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (!isClientReference(component)) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`@uniflowed/router: ${file ?? "an `$error.js` inside a slot"} is an error boundary, and an ` +
|
|
297
|
+
"error boundary catches a throw while the browser renders — so its component runs in " +
|
|
298
|
+
"the browser, and the module has to open with the use client directive, as its first " +
|
|
299
|
+
"statement.",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return { default: component };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isClientReference(value: mixed): boolean {
|
|
306
|
+
if (value == null || (typeof value !== "object" && typeof value !== "function")) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
const tagged: { +$$typeof?: mixed, ... } = (value: $FlowFixMe);
|
|
310
|
+
return tagged.$$typeof === CLIENT_REFERENCE;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The exception a resolved route fell back to its error boundary for. */
|
|
314
|
+
function renderFailure(resolved: ResolvedRoute): mixed {
|
|
315
|
+
if (resolved.error == null) {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
return match (resolved.error) {
|
|
319
|
+
{kind: "thrown", error: const error} => error,
|
|
320
|
+
{kind: "unauthorized"} => undefined,
|
|
321
|
+
{kind: "forbidden"} => undefined,
|
|
322
|
+
};
|
|
323
|
+
}
|