@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,189 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: what renders in place of a subtree that threw.
|
|
4
|
+
//
|
|
5
|
+
// The framework's error page, the component that picks between it and a
|
|
6
|
+
// project's `$error.js`, the page a route that resolved to its error boundary
|
|
7
|
+
// renders, and the class boundary that catches a throw while the browser
|
|
8
|
+
// renders. Every one of them is the browser's: a boundary is a class, and a
|
|
9
|
+
// retry is a navigation, so none of this can run in a graph resolved under
|
|
10
|
+
// React's `react-server` condition — which is why it is out of `./runtime.js`'s
|
|
11
|
+
// top half and in a module of its own. See ubugeeei-prod/uf#519.
|
|
12
|
+
|
|
13
|
+
"use client";
|
|
14
|
+
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
|
|
17
|
+
import type { RouteError } from "./routing.js";
|
|
18
|
+
import type { ErrorModule } from "./resolve.js";
|
|
19
|
+
import { errorTitle, renderable, routeErrorFor } from "./resolve.js";
|
|
20
|
+
import { useRouterState } from "./runtime.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The framework's error page, for a project that declares no `$error.js`.
|
|
24
|
+
*
|
|
25
|
+
* It says which of the three happened and offers the reset, and it does *not*
|
|
26
|
+
* print the thrown error: on the server that message is written for whoever
|
|
27
|
+
* deployed the application — a query, a path, a token in a stack — and this
|
|
28
|
+
* markup is sent to whoever asked for the page. `uf dev` reports the throw in
|
|
29
|
+
* the terminal and `uf build` fails the route, which are the places the person
|
|
30
|
+
* who can act on it is looking.
|
|
31
|
+
*/
|
|
32
|
+
component DefaultRouteError(error: RouteError, reset: () => void) {
|
|
33
|
+
const title = errorTitle(error);
|
|
34
|
+
const detail = match (error) {
|
|
35
|
+
{kind: "unauthorized"} => "This page needs you to be signed in.",
|
|
36
|
+
{kind: "forbidden"} => "You do not have access to this page.",
|
|
37
|
+
{kind: "thrown"} => "This page could not be rendered.",
|
|
38
|
+
};
|
|
39
|
+
return (
|
|
40
|
+
<main>
|
|
41
|
+
<title>{title}</title>
|
|
42
|
+
<h1>{title}</h1>
|
|
43
|
+
<p>{detail}</p>
|
|
44
|
+
<button type="button" onClick={reset}>
|
|
45
|
+
Try again
|
|
46
|
+
</button>
|
|
47
|
+
</main>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The component an error module renders: `default`, or the named `Error`. */
|
|
52
|
+
function errorComponent(module: ErrorModule): React.ComponentType<ErrorRenderProps> {
|
|
53
|
+
const component = module.default ?? module.Error;
|
|
54
|
+
if (component == null) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"@uniflowed/router: an error module must export a component as `default` or `Error`",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return renderable(component);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The props an error boundary's component receives. */
|
|
63
|
+
type ErrorRenderProps = {|
|
|
64
|
+
readonly error: RouteError,
|
|
65
|
+
readonly reset: () => void,
|
|
66
|
+
|};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The error UI, from whichever module is in scope.
|
|
70
|
+
*
|
|
71
|
+
* One component for both ways in — the class boundary below, which catches a
|
|
72
|
+
* throw while the browser renders, and `ResolvedErrorPage`, which is what the
|
|
73
|
+
* server renders because React's boundaries do not run in `renderToString`.
|
|
74
|
+
* Two paths to the same screen is exactly the pair that drifts.
|
|
75
|
+
*/
|
|
76
|
+
component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => void) {
|
|
77
|
+
if (module == null) {
|
|
78
|
+
return <DefaultRouteError error={error} reset={reset} />;
|
|
79
|
+
}
|
|
80
|
+
const Boundary = errorComponent(module);
|
|
81
|
+
return <Boundary error={error} reset={reset} />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The page of a route that resolved to an error.
|
|
86
|
+
*
|
|
87
|
+
* A resolved error route carries the error and the module on the route itself,
|
|
88
|
+
* so this is a static component rather than a closure the resolver builds:
|
|
89
|
+
* `RouteView` composes it in its layouts exactly like a page, which is what
|
|
90
|
+
* makes "inside the layouts above the boundary" one code path and not two.
|
|
91
|
+
*
|
|
92
|
+
* `reset()` here is `router.refresh()` — this route resolved to an error
|
|
93
|
+
* because a loader or an import threw, so re-running the resolution is what
|
|
94
|
+
* trying again means. On the server `refresh` does nothing, which is correct:
|
|
95
|
+
* a static render has nothing to re-run.
|
|
96
|
+
*/
|
|
97
|
+
export component ResolvedErrorPage() {
|
|
98
|
+
const { route, view, router } = useRouterState();
|
|
99
|
+
const reset = () => {
|
|
100
|
+
router.refresh().catch(() => {});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Unreachable otherwise: this is only ever the page of an error route that was
|
|
104
|
+
// resolved from its modules. A route React Server Components rendered has
|
|
105
|
+
// [`ErrorRoutePage`] instead, with the boundary's module handed over as a prop.
|
|
106
|
+
if (route.error == null || view.kind !== "modules") {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return (
|
|
110
|
+
<RouteErrorView module={view.resolved.errorBoundary.module} error={route.error} reset={reset} />
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The page of a route that resolved to an error, rendered by React Server
|
|
116
|
+
* Components.
|
|
117
|
+
*
|
|
118
|
+
* [`ResolvedErrorPage`] reads the error and the boundary's module out of the
|
|
119
|
+
* router, which holds a whole resolved route in a single-page application. The
|
|
120
|
+
* route a Flight payload hands the browser carries neither — the module is the
|
|
121
|
+
* server's, and what crosses is the part a hook reads — so the Flight renderer
|
|
122
|
+
* passes both as props: the boundary's module as `{ default: <client
|
|
123
|
+
* reference> }`, which is what a `"use client"` `$error.js` becomes on the way,
|
|
124
|
+
* and the error, which React serialises the way it serialises any error value.
|
|
125
|
+
* The retry is the same `router.refresh()`, because trying again still means
|
|
126
|
+
* resolving the route again. See ubugeeei-prod/uf#519.
|
|
127
|
+
*/
|
|
128
|
+
export component ErrorRoutePage(module: ?ErrorModule, error: RouteError) {
|
|
129
|
+
const { router } = useRouterState();
|
|
130
|
+
const reset = () => {
|
|
131
|
+
router.refresh().catch(() => {});
|
|
132
|
+
};
|
|
133
|
+
return <RouteErrorView module={module} error={error} reset={reset} />;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type RouteErrorBoundaryProps = {|
|
|
137
|
+
readonly module: ?ErrorModule,
|
|
138
|
+
readonly resetKey: string,
|
|
139
|
+
readonly children: React.Node,
|
|
140
|
+
|};
|
|
141
|
+
|
|
142
|
+
type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The boundary that catches a throw while the browser renders the subtree.
|
|
146
|
+
*
|
|
147
|
+
* A class, because `getDerivedStateFromError` is React's contract for this and
|
|
148
|
+
* there is no hook that does it — this is the one place in the router where
|
|
149
|
+
* following React's public contract means not using a function component.
|
|
150
|
+
*
|
|
151
|
+
* Recovering on navigation is `componentDidUpdate` watching `resetKey`, not
|
|
152
|
+
* `key={pathname}` on the boundary. Keying it remounts the subtree on *every*
|
|
153
|
+
* navigation, error or not, and everything below the boundary goes with it —
|
|
154
|
+
* which is the layouts, whose whole purpose is to survive navigation with
|
|
155
|
+
* their scroll position and their open sections intact.
|
|
156
|
+
*/
|
|
157
|
+
export class RouteErrorBoundary extends React.Component<
|
|
158
|
+
RouteErrorBoundaryProps,
|
|
159
|
+
RouteErrorBoundaryState,
|
|
160
|
+
> {
|
|
161
|
+
constructor(props: RouteErrorBoundaryProps) {
|
|
162
|
+
super(props);
|
|
163
|
+
this.state = { error: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static getDerivedStateFromError(error: mixed): RouteErrorBoundaryState {
|
|
167
|
+
return { error: routeErrorFor(error) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
componentDidUpdate(previous: RouteErrorBoundaryProps) {
|
|
171
|
+
if (this.state.error != null && previous.resetKey !== this.props.resetKey) {
|
|
172
|
+
this.setState({ error: null });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
render(): React.Node {
|
|
177
|
+
const { error } = this.state;
|
|
178
|
+
if (error == null) {
|
|
179
|
+
return this.props.children;
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
<RouteErrorView
|
|
183
|
+
module={this.props.module}
|
|
184
|
+
error={error}
|
|
185
|
+
reset={() => this.setState({ error: null })}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: React Server Components, in the browser.
|
|
4
|
+
//
|
|
5
|
+
// A route's tree reaches the browser as React's Flight payload
|
|
6
|
+
// (ubugeeei-prod/uf#519), by one of two doors. The first page arrives inside
|
|
7
|
+
// its document, as the chunks `./flight-chunks.js` describes, and is read here
|
|
8
|
+
// while the document is still streaming so that `hydrateRoot` can start before
|
|
9
|
+
// the slowest boundary has resolved. Every page after that is a `fetch` of the
|
|
10
|
+
// route's payload URL (`./flight.js`), read the same way by React's own client.
|
|
11
|
+
//
|
|
12
|
+
// # The one hook React's Parcel client asks for
|
|
13
|
+
//
|
|
14
|
+
// `react-server-dom-parcel` resolves a client reference through a global
|
|
15
|
+
// `parcelRequire`: `load(url)` for each chunk a reference names, then
|
|
16
|
+
// `parcelRequire(id)` for the module, synchronously, once they have loaded. uf
|
|
17
|
+
// has no Parcel; this module is that hook. A reference's id *is* its chunk's
|
|
18
|
+
// URL — `@uniflowed/vite` writes it that way — so loading a chunk and
|
|
19
|
+
// remembering its namespace under the same string is the whole of it.
|
|
20
|
+
//
|
|
21
|
+
// It imports same-origin URLs and nothing else. The payload comes from the
|
|
22
|
+
// page's own origin, so a URL that points somewhere else is not a URL uf wrote,
|
|
23
|
+
// and turning it into an `import()` would be letting bytes decide which script
|
|
24
|
+
// runs.
|
|
25
|
+
|
|
26
|
+
import { createFromFetch, createFromReadableStream } from "react-server-dom-parcel/client.browser";
|
|
27
|
+
|
|
28
|
+
import { FLIGHT_CHUNK_ATTRIBUTE, flightChunkBytes } from "./flight-chunks.js";
|
|
29
|
+
import { FLIGHT_CONTENT_TYPE, type FlightRoot, documentPathOf, flightUrl } from "./flight.js";
|
|
30
|
+
|
|
31
|
+
/** A module namespace, as far as the loader looks into one. */
|
|
32
|
+
type ModuleNamespace = { +[string]: mixed };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Install the module hook React's Flight client resolves references through.
|
|
36
|
+
*
|
|
37
|
+
* Once per page. Defined rather than assigned, for the reason
|
|
38
|
+
* `./boundaries.js` gives about its own global: a name a page already defined
|
|
39
|
+
* as an accessor cannot be assigned to, and hydration must not throw over it.
|
|
40
|
+
*/
|
|
41
|
+
export function installBrowserModules(): void {
|
|
42
|
+
const loaded: Map<string, ModuleNamespace> = new Map();
|
|
43
|
+
// A function with three properties, which is the shape React's Parcel client
|
|
44
|
+
// calls: `parcelRequire(id)` for a module, `parcelRequire.load(url)` for the
|
|
45
|
+
// chunk it lives in. Declared and then given its properties, so the hook is
|
|
46
|
+
// built rather than merged into something that already existed.
|
|
47
|
+
function parcelRequire(id: string): ModuleNamespace {
|
|
48
|
+
const namespace = loaded.get(id);
|
|
49
|
+
if (namespace == null) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`@uniflowed/router: the client module ${id} was required before it loaded. React's ` +
|
|
52
|
+
"Flight client loads every chunk a reference names before it asks for the module.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return namespace;
|
|
56
|
+
}
|
|
57
|
+
parcelRequire.load = (url: string): Promise<void> => {
|
|
58
|
+
const target = new URL(url, window.location.href);
|
|
59
|
+
if (target.origin !== window.location.origin) {
|
|
60
|
+
return Promise.reject(
|
|
61
|
+
new Error(
|
|
62
|
+
`@uniflowed/router: a payload named the client module ${url}, which is not on this ` +
|
|
63
|
+
"page's origin. uf writes same-origin chunk URLs only, so it is not loaded.",
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return import(target.href).then((namespace: ModuleNamespace) => {
|
|
68
|
+
loaded.set(url, namespace);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
parcelRequire.extendImportMap = (): void => {
|
|
72
|
+
throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
|
|
73
|
+
};
|
|
74
|
+
parcelRequire.meta = { publicUrl: "", devServer: null };
|
|
75
|
+
Object.defineProperty(globalThis, "parcelRequire", {
|
|
76
|
+
value: parcelRequire,
|
|
77
|
+
writable: true,
|
|
78
|
+
configurable: true,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The parts of a `Document` the reader uses. */
|
|
83
|
+
type DocumentLike = interface {
|
|
84
|
+
readonly querySelectorAll: (selector: string) => Iterable<ElementLike>,
|
|
85
|
+
readonly documentElement: mixed,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** The parts of an `Element` the reader uses. */
|
|
89
|
+
type ElementLike = interface {
|
|
90
|
+
readonly textContent: string | null,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The payload a document carries, as the byte stream React's client reads.
|
|
95
|
+
*
|
|
96
|
+
* Every chunk element already in the document is read at once, in document
|
|
97
|
+
* order, and a `MutationObserver` reads the ones the server has not written
|
|
98
|
+
* yet. The stream ends at the end marker and the observer is disconnected with
|
|
99
|
+
* it, so a prerendered document — every chunk already there — never installs
|
|
100
|
+
* one.
|
|
101
|
+
*
|
|
102
|
+
* `observe` is passed in rather than reached for, so the reader can be driven
|
|
103
|
+
* by a test with a document it mutates by hand; `domObserver` in
|
|
104
|
+
* `./payload-rows.js` is the browser's.
|
|
105
|
+
*/
|
|
106
|
+
export function documentPayload(
|
|
107
|
+
document: DocumentLike,
|
|
108
|
+
observe: ?(callback: () => void) => (() => void) | null,
|
|
109
|
+
): ReadableStream<Uint8Array> {
|
|
110
|
+
const seen: WeakSet<ElementLike> = new WeakSet();
|
|
111
|
+
let stop: (() => void) | null = null;
|
|
112
|
+
let done = false;
|
|
113
|
+
return new ReadableStream({
|
|
114
|
+
start(controller) {
|
|
115
|
+
const sweep = () => {
|
|
116
|
+
if (done) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const element of document.querySelectorAll(`script[${FLIGHT_CHUNK_ATTRIBUTE}]`)) {
|
|
120
|
+
if (seen.has(element)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
seen.add(element);
|
|
124
|
+
let bytes;
|
|
125
|
+
try {
|
|
126
|
+
bytes = flightChunkBytes(element.textContent ?? "null");
|
|
127
|
+
} catch (error) {
|
|
128
|
+
done = true;
|
|
129
|
+
stop?.();
|
|
130
|
+
controller.error(error);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (bytes == null) {
|
|
134
|
+
done = true;
|
|
135
|
+
stop?.();
|
|
136
|
+
controller.close();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
controller.enqueue(bytes);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
sweep();
|
|
143
|
+
if (!done && observe != null) {
|
|
144
|
+
stop = observe(sweep);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
cancel() {
|
|
148
|
+
done = true;
|
|
149
|
+
stop?.();
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Read the payload a document carries into its root value. */
|
|
155
|
+
export function readDocumentPayload(
|
|
156
|
+
document: DocumentLike,
|
|
157
|
+
observe: ?(callback: () => void) => (() => void) | null,
|
|
158
|
+
): Promise<FlightRoot> {
|
|
159
|
+
return createFromReadableStream(documentPayload(document, observe));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What fetching a route's payload turned into. */
|
|
163
|
+
export type FetchedFlight =
|
|
164
|
+
| {|
|
|
165
|
+
readonly kind: "flight",
|
|
166
|
+
/** The route the server answered for: a redirect's target, when there was one. */
|
|
167
|
+
readonly url: string,
|
|
168
|
+
readonly root: Promise<FlightRoot>,
|
|
169
|
+
|}
|
|
170
|
+
| {|
|
|
171
|
+
/**
|
|
172
|
+
* The answer was not a payload: a redirect off this origin, or a host
|
|
173
|
+
* that had no payload for the URL. The browser should load `url` as a
|
|
174
|
+
* document.
|
|
175
|
+
*/
|
|
176
|
+
readonly kind: "document",
|
|
177
|
+
readonly url: string,
|
|
178
|
+
|};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fetch `url`'s payload.
|
|
182
|
+
*
|
|
183
|
+
* A redirect is followed by `fetch`, and the route the payload is for is read
|
|
184
|
+
* back off the URL the answer came from — so a loader's `redirect()` during a
|
|
185
|
+
* navigation lands the history entry on the target, as a document request
|
|
186
|
+
* would have. Anything that is not a payload is handed back as a document to
|
|
187
|
+
* load instead of being fed to React's client.
|
|
188
|
+
*
|
|
189
|
+
* The status is not what decides, the content type is. A route that resolved
|
|
190
|
+
* to its not-found or error boundary is answered with a 404 or a 500 *and a
|
|
191
|
+
* payload*, and that payload is the page to show; what is not a payload — a
|
|
192
|
+
* static host's `404.html` for a file it does not have, a middleware's own
|
|
193
|
+
* refusal — is a document, whatever its status.
|
|
194
|
+
*/
|
|
195
|
+
export async function fetchFlight(url: string): Promise<FetchedFlight> {
|
|
196
|
+
let response: Response;
|
|
197
|
+
try {
|
|
198
|
+
response = await fetch(flightUrl(url), {
|
|
199
|
+
credentials: "same-origin",
|
|
200
|
+
headers: { accept: FLIGHT_CONTENT_TYPE },
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
// A request that could not be made or followed: a dropped connection, or a
|
|
204
|
+
// redirect to another origin — a sign-in page — which `fetch` may not
|
|
205
|
+
// follow without CORS. The browser can still load the document, and a
|
|
206
|
+
// top-level navigation follows any redirect, so that is what the router
|
|
207
|
+
// does rather than leave the click doing nothing.
|
|
208
|
+
return { kind: "document", url };
|
|
209
|
+
}
|
|
210
|
+
const landed = new URL(response.url, window.location.href);
|
|
211
|
+
const document = documentPathOf(landed.pathname);
|
|
212
|
+
const type = response.headers.get("content-type") ?? "";
|
|
213
|
+
if (
|
|
214
|
+
landed.origin !== window.location.origin ||
|
|
215
|
+
document == null ||
|
|
216
|
+
!type.startsWith(FLIGHT_CONTENT_TYPE)
|
|
217
|
+
) {
|
|
218
|
+
void response.body?.cancel();
|
|
219
|
+
// The URL the reader asked for when the redirect did not end on a payload,
|
|
220
|
+
// rather than the one it ended on. A middleware that answered with its
|
|
221
|
+
// sign-in page wrote a `next=` naming the payload URL; loading the document
|
|
222
|
+
// URL lets it see the document and name that instead.
|
|
223
|
+
return { kind: "document", url: document == null ? url : `${document}${landed.search}` };
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
kind: "flight",
|
|
227
|
+
url: `${document}${landed.search}`,
|
|
228
|
+
root: createFromFetch(Promise.resolve(response)),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a Flight payload, written into a document
|
|
4
|
+
// and read back out of one.
|
|
5
|
+
//
|
|
6
|
+
// A document a server streams carries the payload the browser hydrates from,
|
|
7
|
+
// beside the HTML rendered from it: every chunk React's Flight renderer wrote
|
|
8
|
+
// is copied into a `<script type="application/json" data-uf-flight>` element
|
|
9
|
+
// as the HTML streams, and the browser reads the elements back into the byte
|
|
10
|
+
// stream React's Flight client consumes. This module is the encoding of one
|
|
11
|
+
// chunk in each direction, and nothing else, so the writer (`./stream.js`) and
|
|
12
|
+
// the reader (`./flight-browser.js`) cannot disagree about it.
|
|
13
|
+
//
|
|
14
|
+
// # `application/json`, and not a script that runs
|
|
15
|
+
//
|
|
16
|
+
// The obvious mechanism is an inline script that pushes each chunk into a
|
|
17
|
+
// global. `./runtime.js` and `./payload-rows.js` have already made the case
|
|
18
|
+
// against it for uf's documents: a script that runs is a script a content
|
|
19
|
+
// security policy has to allow, and application data in a script element with
|
|
20
|
+
// no type is application data handed to the JavaScript parser. A JSON script
|
|
21
|
+
// runs nothing, and a `MutationObserver` notices each one as it lands.
|
|
22
|
+
//
|
|
23
|
+
// # Text, and bytes that are not text
|
|
24
|
+
//
|
|
25
|
+
// A payload is bytes. Almost all of it is UTF-8 text — rows of JSON — and it is
|
|
26
|
+
// written as a JSON string, which is what makes it readable in a document. A
|
|
27
|
+
// typed array a server component passed as a prop is a row of raw bytes that
|
|
28
|
+
// need not be valid UTF-8, and a decoder that replaced them would hand React a
|
|
29
|
+
// different array. So a run of bytes that does not decode strictly is written
|
|
30
|
+
// as base64 instead, and the reader turns each element back into exactly the
|
|
31
|
+
// bytes the writer was given.
|
|
32
|
+
//
|
|
33
|
+
// A chunk boundary can fall inside a multi-byte character, which is not an
|
|
34
|
+
// invalid sequence and must not be treated as one: the writer keeps an
|
|
35
|
+
// incomplete tail back and prepends it to the next chunk.
|
|
36
|
+
|
|
37
|
+
/** The attribute every payload chunk element carries. */
|
|
38
|
+
export const FLIGHT_CHUNK_ATTRIBUTE: string = "data-uf-flight";
|
|
39
|
+
|
|
40
|
+
/** What one element holds: text, bytes that are not text, or the end. */
|
|
41
|
+
export type FlightChunk = string | {| readonly bytes: string |} | null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An element's text: a JSON value with the three escapes an inline element
|
|
45
|
+
* needs.
|
|
46
|
+
*
|
|
47
|
+
* `<` so a payload holding `</script>` cannot close the element early, and
|
|
48
|
+
* U+2028 and U+2029 because JSON is sometimes read as JavaScript. The same
|
|
49
|
+
* escape `./payload.js` applies, for the same reasons.
|
|
50
|
+
*/
|
|
51
|
+
function elementJson(value: FlightChunk): string {
|
|
52
|
+
return JSON.stringify(value)
|
|
53
|
+
.replace(/</g, "\\u003c")
|
|
54
|
+
.replace(/\u2028/g, "\\u2028")
|
|
55
|
+
.replace(/\u2029/g, "\\u2029");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The element for one chunk, or the end marker for `null`. */
|
|
59
|
+
export function flightChunkElement(chunk: FlightChunk): string {
|
|
60
|
+
return `<script type="application/json" ${FLIGHT_CHUNK_ATTRIBUTE}>${elementJson(chunk)}</script>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A writer's state: the bytes of an incomplete character, carried to the next
|
|
65
|
+
* chunk.
|
|
66
|
+
*
|
|
67
|
+
* One per document, made by [`createChunkEncoder`], because the carry is a fact
|
|
68
|
+
* about the stream and two documents must never share one.
|
|
69
|
+
*/
|
|
70
|
+
export type ChunkEncoder = {|
|
|
71
|
+
/** The elements for one chunk of payload bytes; possibly none. */
|
|
72
|
+
readonly encode: (bytes: Uint8Array) => string,
|
|
73
|
+
/** The elements for whatever is carried, and the end marker. */
|
|
74
|
+
readonly end: () => string,
|
|
75
|
+
|};
|
|
76
|
+
|
|
77
|
+
export function createChunkEncoder(): ChunkEncoder {
|
|
78
|
+
const strict = new TextDecoder("utf-8", { fatal: true });
|
|
79
|
+
let carry: Uint8Array = new Uint8Array(0);
|
|
80
|
+
|
|
81
|
+
function encode(bytes: Uint8Array): string {
|
|
82
|
+
const joined = concat(carry, bytes);
|
|
83
|
+
const complete = completeLength(joined);
|
|
84
|
+
carry = joined.slice(complete);
|
|
85
|
+
if (complete === 0) {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
const body = joined.subarray(0, complete);
|
|
89
|
+
try {
|
|
90
|
+
return flightChunkElement(strict.decode(body));
|
|
91
|
+
} catch {
|
|
92
|
+
return flightChunkElement({ bytes: base64(body) });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function end(): string {
|
|
97
|
+
const rest = carry;
|
|
98
|
+
carry = new Uint8Array(0);
|
|
99
|
+
const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) });
|
|
100
|
+
return `${tail}${flightChunkElement(null)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { encode, end };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The bytes one element holds, or `null` for the end marker.
|
|
108
|
+
*
|
|
109
|
+
* Refuses anything that is not one of the three shapes the writer produces,
|
|
110
|
+
* rather than guessing: a document carrying some other value in an element
|
|
111
|
+
* with this attribute was not written by this module.
|
|
112
|
+
*/
|
|
113
|
+
export function flightChunkBytes(text: string): Uint8Array | null {
|
|
114
|
+
const value: mixed = JSON.parse(text);
|
|
115
|
+
if (value === null) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (typeof value === "string") {
|
|
119
|
+
return new TextEncoder().encode(value);
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
typeof value === "object" &&
|
|
123
|
+
!Array.isArray(value) &&
|
|
124
|
+
typeof value.bytes === "string" &&
|
|
125
|
+
Object.keys(value).length === 1
|
|
126
|
+
) {
|
|
127
|
+
return fromBase64(value.bytes);
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`@uniflowed/router: a ${FLIGHT_CHUNK_ATTRIBUTE} element holds no payload chunk`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* How many leading bytes of `bytes` end on a character boundary.
|
|
134
|
+
*
|
|
135
|
+
* Walks back over at most three continuation bytes to the lead byte of the last
|
|
136
|
+
* character and keeps it back when the character is not yet whole. A byte that
|
|
137
|
+
* is not a valid lead at all is left in — the strict decoder is what says so,
|
|
138
|
+
* and holding it back would carry it forever.
|
|
139
|
+
*/
|
|
140
|
+
function completeLength(bytes: Uint8Array): number {
|
|
141
|
+
const length = bytes.length;
|
|
142
|
+
let at = length - 1;
|
|
143
|
+
let continuation = 0;
|
|
144
|
+
while (at >= 0 && continuation < 3 && (bytes[at] & 0xc0) === 0x80) {
|
|
145
|
+
at -= 1;
|
|
146
|
+
continuation += 1;
|
|
147
|
+
}
|
|
148
|
+
if (at < 0) {
|
|
149
|
+
return length;
|
|
150
|
+
}
|
|
151
|
+
const lead = bytes[at];
|
|
152
|
+
const needed = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1;
|
|
153
|
+
return length - at >= needed ? length : at;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function concat(first: Uint8Array, second: Uint8Array): Uint8Array {
|
|
157
|
+
if (first.length === 0) {
|
|
158
|
+
return second;
|
|
159
|
+
}
|
|
160
|
+
const joined = new Uint8Array(first.length + second.length);
|
|
161
|
+
joined.set(first, 0);
|
|
162
|
+
joined.set(second, first.length);
|
|
163
|
+
return joined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function base64(bytes: Uint8Array): string {
|
|
167
|
+
let binary = "";
|
|
168
|
+
for (const byte of bytes) {
|
|
169
|
+
binary += String.fromCharCode(byte);
|
|
170
|
+
}
|
|
171
|
+
return btoa(binary);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function fromBase64(text: string): Uint8Array {
|
|
175
|
+
const binary = atob(text);
|
|
176
|
+
const bytes = new Uint8Array(binary.length);
|
|
177
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
178
|
+
bytes[index] = binary.charCodeAt(index);
|
|
179
|
+
}
|
|
180
|
+
return bytes;
|
|
181
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: React Server Components, while HTML renders.
|
|
4
|
+
//
|
|
5
|
+
// The HTML renderer does not render a route's modules. It reads the payload
|
|
6
|
+
// the Flight renderer wrote (`../rsc.js`) with React's own Flight client and
|
|
7
|
+
// renders what comes out, so the document is the payload's tree and not a
|
|
8
|
+
// second rendering of the same modules — which is what lets a Server Component
|
|
9
|
+
// be a module that neither this graph nor the browser ever evaluates.
|
|
10
|
+
//
|
|
11
|
+
// # Where a client component's server copy comes from
|
|
12
|
+
//
|
|
13
|
+
// A client reference in a payload names a chunk URL, which is the browser's
|
|
14
|
+
// module. HTML still has to be rendered from the component, on the server, so
|
|
15
|
+
// `@uniflowed/vite` builds a server copy of every client module and hands the
|
|
16
|
+
// renderer a table from the browser's URL to that copy. React's Parcel client
|
|
17
|
+
// resolves references through a global `parcelRequire`, exactly as in the
|
|
18
|
+
// browser (`./flight-browser.js`), and this module is that hook on a server.
|
|
19
|
+
//
|
|
20
|
+
// The same global serves one application per process, which is what a uf
|
|
21
|
+
// server is. `meta.publicUrl` is empty because the URLs a reference names are
|
|
22
|
+
// already absolute paths: React asks the document to preload each chunk while
|
|
23
|
+
// it renders, and those tags are right exactly as the payload spells them.
|
|
24
|
+
|
|
25
|
+
import { createFromReadableStream } from "react-server-dom-parcel/client.edge";
|
|
26
|
+
|
|
27
|
+
import type { FlightRoot } from "./flight.js";
|
|
28
|
+
|
|
29
|
+
/** A module namespace, as far as the hook looks into one. */
|
|
30
|
+
type ModuleNamespace = { +[string]: mixed };
|
|
31
|
+
|
|
32
|
+
/** The server copy of the client module at a browser chunk URL. */
|
|
33
|
+
export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Install the module hook React's Flight client resolves references through.
|
|
37
|
+
*
|
|
38
|
+
* Idempotent over the same loader and replaced by a different one, so a dev
|
|
39
|
+
* server that rebuilds its table installs the new table rather than keeping a
|
|
40
|
+
* stale one.
|
|
41
|
+
*/
|
|
42
|
+
export function installServerModules(load: ClientModuleLoader): void {
|
|
43
|
+
const loaded: Map<string, ModuleNamespace> = new Map();
|
|
44
|
+
// The browser's hook, on a server; `./flight-browser.js` has the shape.
|
|
45
|
+
function parcelRequire(id: string): ModuleNamespace {
|
|
46
|
+
const namespace = loaded.get(id);
|
|
47
|
+
if (namespace == null) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`@uniflowed/router: the client module ${id} was required before its server copy loaded`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return namespace;
|
|
53
|
+
}
|
|
54
|
+
parcelRequire.load = (url: string): Promise<void> =>
|
|
55
|
+
load(url).then((namespace) => {
|
|
56
|
+
loaded.set(url, namespace);
|
|
57
|
+
});
|
|
58
|
+
parcelRequire.extendImportMap = (): void => {
|
|
59
|
+
throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
|
|
60
|
+
};
|
|
61
|
+
parcelRequire.meta = { publicUrl: "", devServer: null };
|
|
62
|
+
Object.defineProperty(globalThis, "parcelRequire", {
|
|
63
|
+
value: parcelRequire,
|
|
64
|
+
writable: true,
|
|
65
|
+
configurable: true,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read a payload into its root value, as React's client does in the browser. */
|
|
70
|
+
export function readPayload(stream: ReadableStream<Uint8Array>): Promise<FlightRoot> {
|
|
71
|
+
return createFromReadableStream(stream);
|
|
72
|
+
}
|