@uniflowed/router 0.0.0-alpha.4 → 0.0.0-alpha.40

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.
Files changed (42) hide show
  1. package/action.js +301 -0
  2. package/client.js +295 -8
  3. package/handler.js +101 -17
  4. package/index.js +71 -4
  5. package/internal/action-endpoint.js +434 -0
  6. package/internal/action-wire.js +608 -0
  7. package/internal/base-path.js +175 -0
  8. package/internal/boundaries.js +481 -0
  9. package/internal/boundary-data.js +88 -0
  10. package/internal/compose.js +490 -0
  11. package/internal/devtools.js +131 -0
  12. package/internal/diagnostics.js +169 -0
  13. package/internal/error-view.js +193 -0
  14. package/internal/flight-browser.js +228 -0
  15. package/internal/flight-chunks.js +205 -0
  16. package/internal/flight-ssr.js +78 -0
  17. package/internal/flight.js +158 -0
  18. package/internal/head.js +219 -0
  19. package/internal/hydration.js +1085 -0
  20. package/internal/inspector.js +626 -0
  21. package/internal/navigation-cache.js +144 -0
  22. package/internal/payload-rows.js +270 -0
  23. package/internal/payload.js +685 -0
  24. package/internal/prepare-document.js +49 -0
  25. package/internal/react-version.js +77 -0
  26. package/internal/request.js +43 -0
  27. package/internal/resolve.js +1615 -0
  28. package/internal/resolved-summary.js +199 -0
  29. package/internal/routing.js +474 -0
  30. package/internal/runtime.js +1511 -543
  31. package/internal/server-route.js +58 -0
  32. package/internal/shell.js +115 -0
  33. package/internal/stream.js +1084 -0
  34. package/middleware.js +350 -0
  35. package/native.js +408 -0
  36. package/package.json +36 -7
  37. package/routing.js +51 -0
  38. package/rsc-client.js +120 -0
  39. package/rsc-ssr.js +440 -0
  40. package/rsc.js +334 -0
  41. package/server-components.js +159 -0
  42. package/server.js +448 -75
@@ -0,0 +1,228 @@
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 {
30
+ FLIGHT_CONTENT_TYPE,
31
+ type FetchedFlight,
32
+ type FlightRoot,
33
+ documentPathOf,
34
+ flightUrl,
35
+ } from "./flight.js";
36
+ import { requireServerComponentsReact } from "./react-version.js";
37
+
38
+ /** A module namespace, as far as the loader looks into one. */
39
+ type ModuleNamespace = { +[string]: mixed };
40
+
41
+ /** The entry a refusal names: the one an application reaches this module through. */
42
+ const ENTRY = "@uniflowed/router/rsc/client";
43
+
44
+ /**
45
+ * Install the module hook React's Flight client resolves references through.
46
+ *
47
+ * Once per page. Defined rather than assigned, for the reason
48
+ * `./boundaries.js` gives about its own global: a name a page already defined
49
+ * as an accessor cannot be assigned to, and hydration must not throw over it.
50
+ */
51
+ export function installBrowserModules(): void {
52
+ requireServerComponentsReact(ENTRY);
53
+ const loaded: Map<string, ModuleNamespace> = new Map();
54
+ // A function with three properties, which is the shape React's Parcel client
55
+ // calls: `parcelRequire(id)` for a module, `parcelRequire.load(url)` for the
56
+ // chunk it lives in. Declared and then given its properties, so the hook is
57
+ // built rather than merged into something that already existed.
58
+ function parcelRequire(id: string): ModuleNamespace {
59
+ const namespace = loaded.get(id);
60
+ if (namespace == null) {
61
+ throw new Error(
62
+ `@uniflowed/router: the client module ${id} was required before it loaded. React's ` +
63
+ "Flight client loads every chunk a reference names before it asks for the module.",
64
+ );
65
+ }
66
+ return namespace;
67
+ }
68
+ parcelRequire.load = (url: string): Promise<void> => {
69
+ const target = new URL(url, window.location.href);
70
+ if (target.origin !== window.location.origin) {
71
+ return Promise.reject(
72
+ new Error(
73
+ `@uniflowed/router: a payload named the client module ${url}, which is not on this ` +
74
+ "page's origin. uf writes same-origin chunk URLs only, so it is not loaded.",
75
+ ),
76
+ );
77
+ }
78
+ return import(target.href).then((namespace: ModuleNamespace) => {
79
+ loaded.set(url, namespace);
80
+ });
81
+ };
82
+ parcelRequire.extendImportMap = (): void => {
83
+ throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
84
+ };
85
+ parcelRequire.meta = { publicUrl: "", devServer: null };
86
+ Object.defineProperty(globalThis, "parcelRequire", {
87
+ value: parcelRequire,
88
+ writable: true,
89
+ configurable: true,
90
+ });
91
+ }
92
+
93
+ /** The parts of a `Document` the reader uses. */
94
+ type DocumentLike = interface {
95
+ readonly querySelectorAll: (selector: string) => Iterable<ElementLike>,
96
+ readonly documentElement: mixed,
97
+ };
98
+
99
+ /** The parts of an `Element` the reader uses. */
100
+ type ElementLike = interface {
101
+ readonly textContent: string | null,
102
+ };
103
+
104
+ /**
105
+ * The payload a document carries, as the byte stream React's client reads.
106
+ *
107
+ * Every chunk element already in the document is read at once, in document
108
+ * order, and a `MutationObserver` reads the ones the server has not written
109
+ * yet. The stream ends at the end marker and the observer is disconnected with
110
+ * it, so a prerendered document — every chunk already there — never installs
111
+ * one.
112
+ *
113
+ * `observe` is passed in rather than reached for, so the reader can be driven
114
+ * by a test with a document it mutates by hand; `domObserver` in
115
+ * `./payload-rows.js` is the browser's.
116
+ */
117
+ export function documentPayload(
118
+ document: DocumentLike,
119
+ observe: ?(callback: () => void) => (() => void) | null,
120
+ ): ReadableStream<Uint8Array> {
121
+ const seen: WeakSet<ElementLike> = new WeakSet();
122
+ let stop: (() => void) | null = null;
123
+ let done = false;
124
+ return new ReadableStream({
125
+ start(controller) {
126
+ const sweep = () => {
127
+ if (done) {
128
+ return;
129
+ }
130
+ for (const element of document.querySelectorAll(`script[${FLIGHT_CHUNK_ATTRIBUTE}]`)) {
131
+ if (seen.has(element)) {
132
+ continue;
133
+ }
134
+ seen.add(element);
135
+ let bytes;
136
+ try {
137
+ bytes = flightChunkBytes(element.textContent ?? "null");
138
+ } catch (error) {
139
+ done = true;
140
+ stop?.();
141
+ controller.error(error);
142
+ return;
143
+ }
144
+ if (bytes == null) {
145
+ done = true;
146
+ stop?.();
147
+ controller.close();
148
+ return;
149
+ }
150
+ controller.enqueue(bytes);
151
+ }
152
+ };
153
+ sweep();
154
+ if (!done && observe != null) {
155
+ stop = observe(sweep);
156
+ }
157
+ },
158
+ cancel() {
159
+ done = true;
160
+ stop?.();
161
+ },
162
+ });
163
+ }
164
+
165
+ /** Read the payload a document carries into its root value. */
166
+ export function readDocumentPayload(
167
+ document: DocumentLike,
168
+ observe: ?(callback: () => void) => (() => void) | null,
169
+ ): Promise<FlightRoot> {
170
+ requireServerComponentsReact(ENTRY);
171
+ return createFromReadableStream(documentPayload(document, observe));
172
+ }
173
+
174
+ /**
175
+ * Fetch `url`'s payload.
176
+ *
177
+ * A redirect is followed by `fetch`, and the route the payload is for is read
178
+ * back off the URL the answer came from — so a loader's `redirect()` during a
179
+ * navigation lands the history entry on the target, as a document request
180
+ * would have. Anything that is not a payload is handed back as a document to
181
+ * load instead of being fed to React's client.
182
+ *
183
+ * The status is not what decides, the content type is. A route that resolved
184
+ * to its not-found or error boundary is answered with a 404 or a 500 *and a
185
+ * payload*, and that payload is the page to show; what is not a payload — a
186
+ * static host's `404.html` for a file it does not have, a middleware's own
187
+ * refusal — is a document, whatever its status.
188
+ */
189
+ export async function fetchFlight(url: string): Promise<FetchedFlight> {
190
+ // Outside the `try` below, which answers every failure to fetch with a
191
+ // document load: too old a React is not a network failure, and a navigation
192
+ // that quietly reloaded the page would hide it.
193
+ requireServerComponentsReact(ENTRY);
194
+ let response: Response;
195
+ try {
196
+ response = await fetch(flightUrl(url), {
197
+ credentials: "same-origin",
198
+ headers: { accept: FLIGHT_CONTENT_TYPE },
199
+ });
200
+ } catch {
201
+ // A request that could not be made or followed: a dropped connection, or a
202
+ // redirect to another origin — a sign-in page — which `fetch` may not
203
+ // follow without CORS. The browser can still load the document, and a
204
+ // top-level navigation follows any redirect, so that is what the router
205
+ // does rather than leave the click doing nothing.
206
+ return { kind: "document", url };
207
+ }
208
+ const landed = new URL(response.url, window.location.href);
209
+ const document = documentPathOf(landed.pathname);
210
+ const type = response.headers.get("content-type") ?? "";
211
+ if (
212
+ landed.origin !== window.location.origin ||
213
+ document == null ||
214
+ !type.startsWith(FLIGHT_CONTENT_TYPE)
215
+ ) {
216
+ void response.body?.cancel();
217
+ // The URL the reader asked for when the redirect did not end on a payload,
218
+ // rather than the one it ended on. A middleware that answered with its
219
+ // sign-in page wrote a `next=` naming the payload URL; loading the document
220
+ // URL lets it see the document and name that instead.
221
+ return { kind: "document", url: document == null ? url : `${document}${landed.search}` };
222
+ }
223
+ return {
224
+ kind: "flight",
225
+ url: `${document}${landed.search}`,
226
+ root: createFromFetch(Promise.resolve(response)),
227
+ };
228
+ }
@@ -0,0 +1,205 @@
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
+ /**
59
+ * The element for one chunk, or the end marker for `null`.
60
+ *
61
+ * `nonce` is this response's, and it is carried even though the element is
62
+ * `application/json` and therefore a data block the browser never executes.
63
+ * Two reasons, and neither is "in case the spec changes". A uf document's
64
+ * invariant is that every `<script>` in it carries the nonce — that is what
65
+ * `packages/router/streaming.test.js` pins, and an invariant with an exception
66
+ * in it is one a reader has to check every new script against. And these are
67
+ * written as text into a stream rather than rendered by React, so unlike the
68
+ * payload rows in `./runtime.js` there is no second render on the client to
69
+ * disagree with; the attribute costs a comparison nobody makes.
70
+ */
71
+ export function flightChunkElement(chunk: FlightChunk, nonce?: string | null): string {
72
+ const carried = nonce == null ? "" : ` nonce="${escapeNonce(nonce)}"`;
73
+ return `<script type="application/json"${carried} ${FLIGHT_CHUNK_ATTRIBUTE}>${elementJson(chunk)}</script>`;
74
+ }
75
+
76
+ /**
77
+ * A nonce, as an attribute value.
78
+ *
79
+ * uf's own nonces are base64 and hold none of these, so this is about the one a
80
+ * project supplied: the value reaches a document that a browser parses as
81
+ * HTML, and a `"` in it would end the attribute and open whatever follows.
82
+ */
83
+ function escapeNonce(value: string): string {
84
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
85
+ }
86
+
87
+ /**
88
+ * A writer's state: the bytes of an incomplete character, carried to the next
89
+ * chunk.
90
+ *
91
+ * One per document, made by [`createChunkEncoder`], because the carry is a fact
92
+ * about the stream and two documents must never share one.
93
+ */
94
+ export type ChunkEncoder = {|
95
+ /** The elements for one chunk of payload bytes; possibly none. */
96
+ readonly encode: (bytes: Uint8Array) => string,
97
+ /** The elements for whatever is carried, and the end marker. */
98
+ readonly end: () => string,
99
+ |};
100
+
101
+ export function createChunkEncoder(nonce?: string | null): ChunkEncoder {
102
+ const strict = new TextDecoder("utf-8", { fatal: true });
103
+ let carry: Uint8Array = new Uint8Array(0);
104
+
105
+ function encode(bytes: Uint8Array): string {
106
+ const joined = concat(carry, bytes);
107
+ const complete = completeLength(joined);
108
+ carry = joined.slice(complete);
109
+ if (complete === 0) {
110
+ return "";
111
+ }
112
+ const body = joined.subarray(0, complete);
113
+ try {
114
+ return flightChunkElement(strict.decode(body), nonce);
115
+ } catch {
116
+ return flightChunkElement({ bytes: base64(body) }, nonce);
117
+ }
118
+ }
119
+
120
+ function end(): string {
121
+ const rest = carry;
122
+ carry = new Uint8Array(0);
123
+ const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) }, nonce);
124
+ return `${tail}${flightChunkElement(null, nonce)}`;
125
+ }
126
+
127
+ return { encode, end };
128
+ }
129
+
130
+ /**
131
+ * The bytes one element holds, or `null` for the end marker.
132
+ *
133
+ * Refuses anything that is not one of the three shapes the writer produces,
134
+ * rather than guessing: a document carrying some other value in an element
135
+ * with this attribute was not written by this module.
136
+ */
137
+ export function flightChunkBytes(text: string): Uint8Array | null {
138
+ const value: mixed = JSON.parse(text);
139
+ if (value === null) {
140
+ return null;
141
+ }
142
+ if (typeof value === "string") {
143
+ return new TextEncoder().encode(value);
144
+ }
145
+ if (
146
+ typeof value === "object" &&
147
+ !Array.isArray(value) &&
148
+ typeof value.bytes === "string" &&
149
+ Object.keys(value).length === 1
150
+ ) {
151
+ return fromBase64(value.bytes);
152
+ }
153
+ throw new Error(`@uniflowed/router: a ${FLIGHT_CHUNK_ATTRIBUTE} element holds no payload chunk`);
154
+ }
155
+
156
+ /**
157
+ * How many leading bytes of `bytes` end on a character boundary.
158
+ *
159
+ * Walks back over at most three continuation bytes to the lead byte of the last
160
+ * character and keeps it back when the character is not yet whole. A byte that
161
+ * is not a valid lead at all is left in — the strict decoder is what says so,
162
+ * and holding it back would carry it forever.
163
+ */
164
+ function completeLength(bytes: Uint8Array): number {
165
+ const length = bytes.length;
166
+ let at = length - 1;
167
+ let continuation = 0;
168
+ while (at >= 0 && continuation < 3 && (bytes[at] & 0xc0) === 0x80) {
169
+ at -= 1;
170
+ continuation += 1;
171
+ }
172
+ if (at < 0) {
173
+ return length;
174
+ }
175
+ const lead = bytes[at];
176
+ const needed = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1;
177
+ return length - at >= needed ? length : at;
178
+ }
179
+
180
+ function concat(first: Uint8Array, second: Uint8Array): Uint8Array {
181
+ if (first.length === 0) {
182
+ return second;
183
+ }
184
+ const joined = new Uint8Array(first.length + second.length);
185
+ joined.set(first, 0);
186
+ joined.set(second, first.length);
187
+ return joined;
188
+ }
189
+
190
+ function base64(bytes: Uint8Array): string {
191
+ let binary = "";
192
+ for (const byte of bytes) {
193
+ binary += String.fromCharCode(byte);
194
+ }
195
+ return btoa(binary);
196
+ }
197
+
198
+ function fromBase64(text: string): Uint8Array {
199
+ const binary = atob(text);
200
+ const bytes = new Uint8Array(binary.length);
201
+ for (let index = 0; index < binary.length; index += 1) {
202
+ bytes[index] = binary.charCodeAt(index);
203
+ }
204
+ return bytes;
205
+ }
@@ -0,0 +1,78 @@
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
+ import { requireServerComponentsReact } from "./react-version.js";
29
+
30
+ /** A module namespace, as far as the hook looks into one. */
31
+ type ModuleNamespace = { +[string]: mixed };
32
+
33
+ /** The server copy of the client module at a browser chunk URL. */
34
+ export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
35
+
36
+ /** The entry a refusal names: the one an application reaches this module through. */
37
+ const ENTRY = "@uniflowed/router/rsc/ssr";
38
+
39
+ /**
40
+ * Install the module hook React's Flight client resolves references through.
41
+ *
42
+ * Idempotent over the same loader and replaced by a different one, so a dev
43
+ * server that rebuilds its table installs the new table rather than keeping a
44
+ * stale one.
45
+ */
46
+ export function installServerModules(load: ClientModuleLoader): void {
47
+ requireServerComponentsReact(ENTRY);
48
+ const loaded: Map<string, ModuleNamespace> = new Map();
49
+ // The browser's hook, on a server; `./flight-browser.js` has the shape.
50
+ function parcelRequire(id: string): ModuleNamespace {
51
+ const namespace = loaded.get(id);
52
+ if (namespace == null) {
53
+ throw new Error(
54
+ `@uniflowed/router: the client module ${id} was required before its server copy loaded`,
55
+ );
56
+ }
57
+ return namespace;
58
+ }
59
+ parcelRequire.load = (url: string): Promise<void> =>
60
+ load(url).then((namespace) => {
61
+ loaded.set(url, namespace);
62
+ });
63
+ parcelRequire.extendImportMap = (): void => {
64
+ throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
65
+ };
66
+ parcelRequire.meta = { publicUrl: "", devServer: null };
67
+ Object.defineProperty(globalThis, "parcelRequire", {
68
+ value: parcelRequire,
69
+ writable: true,
70
+ configurable: true,
71
+ });
72
+ }
73
+
74
+ /** Read a payload into its root value, as React's client does in the browser. */
75
+ export function readPayload(stream: ReadableStream<Uint8Array>): Promise<FlightRoot> {
76
+ requireServerComponentsReact(ENTRY);
77
+ return createFromReadableStream(stream);
78
+ }
@@ -0,0 +1,158 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the names a Flight payload is spoken in.
4
+ //
5
+ // A route rendered by React Server Components travels as React's own Flight
6
+ // payload (ubugeeei-prod/uf#519). `react-server-dom-parcel` writes it on the
7
+ // server and reads it in the browser, and uf invents no format of its own —
8
+ // the one uf used to have for loader data, `./payload.js`'s numbered rows, was
9
+ // Flight-shaped precisely so that it could be replaced by this. What uf still
10
+ // decides is two things around the payload: what its root value *is*, and the
11
+ // URL a browser asks for one at. Every graph has to agree about both, so both
12
+ // are here.
13
+ //
14
+ // Pure data and string functions, and type imports only: the module graph
15
+ // that renders server components, the one that renders HTML and the browser's
16
+ // all import this file, and none of them may be handed anything the others
17
+ // could not evaluate.
18
+
19
+ import type { Node } from "react";
20
+
21
+ import type { RouteError, RouteParams, SearchParams } from "./routing.js";
22
+ import type { Metadata, ResolvedRoute } from "./resolve.js";
23
+
24
+ /**
25
+ * What a route resolved to, as the browser holds it.
26
+ *
27
+ * Everything a hook reads, and nothing that is a module. A resolved route
28
+ * carries the page, the layouts and the boundaries it loaded; none of those
29
+ * crosses, because what they rendered is already in the payload beside this
30
+ * value, and a component the browser does need crosses inside that tree as a
31
+ * client reference. So `useRoute()` and `useLoaderData()` read this, and
32
+ * `RouteView` renders the tree.
33
+ *
34
+ * `data` and `deferred` cross as Flight values. That is a narrower contract
35
+ * than the JSON the loader's answer used to be embedded as: Flight carries a
36
+ * `Date`, a `Map`, a `Set` and a promise, and it refuses a class instance by
37
+ * name rather than turning it into `{}` the way `JSON.stringify` did. A thrown
38
+ * error in `error` crosses the way React sends any error value — with its
39
+ * message in development and replaced by React's own sentence in a build.
40
+ */
41
+ export type RouteState = {|
42
+ readonly pathname: string,
43
+ readonly search: string,
44
+ readonly path: string,
45
+ readonly params: RouteParams,
46
+ readonly searchParams: SearchParams,
47
+ /** What the loader returned, once it has. `undefined` while `deferred` is set. */
48
+ readonly data: mixed,
49
+ /** The loader still running, as a promise the browser can `use`. */
50
+ readonly deferred: ?Promise<mixed>,
51
+ readonly metadata: Metadata,
52
+ readonly viewTransition: ?string,
53
+ readonly status: 200 | 401 | 403 | 404 | 500,
54
+ readonly error: ?RouteError,
55
+ |};
56
+
57
+ /** Row 0 of a route's payload: the route, and the tree it rendered. */
58
+ export type FlightRoot = {|
59
+ readonly route: RouteState,
60
+ readonly tree: Node,
61
+ |};
62
+
63
+ /**
64
+ * What fetching a route's payload turned into.
65
+ *
66
+ * Declared here rather than beside the fetch in `./flight-browser.js`, because
67
+ * the router's navigation names this type and must not import React's Flight
68
+ * client: `./runtime.js` is in every application's bundle, and that client is
69
+ * only in the bundles of applications that render Server Components
70
+ * (ubugeeei-prod/uf#992).
71
+ */
72
+ export type FetchedFlight =
73
+ | {|
74
+ readonly kind: "flight",
75
+ /** The route the server answered for: a redirect's target, when there was one. */
76
+ readonly url: string,
77
+ readonly root: Promise<FlightRoot>,
78
+ |}
79
+ | {|
80
+ /**
81
+ * The answer was not a payload: a redirect off this origin, or a host
82
+ * that had no payload for the URL. The browser should load `url` as a
83
+ * document.
84
+ */
85
+ readonly kind: "document",
86
+ readonly url: string,
87
+ |};
88
+
89
+ /** The part of a resolved route that crosses to the browser. */
90
+ export function routeState(resolved: ResolvedRoute): RouteState {
91
+ return {
92
+ pathname: resolved.pathname,
93
+ search: resolved.search,
94
+ path: resolved.path,
95
+ params: resolved.params,
96
+ searchParams: resolved.searchParams,
97
+ data: resolved.data,
98
+ deferred: resolved.deferred,
99
+ metadata: resolved.metadata,
100
+ viewTransition: resolved.viewTransition,
101
+ status: resolved.status,
102
+ error: resolved.error,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * The last path segment of the URL a route's payload is fetched from.
108
+ *
109
+ * A path rather than a header, for the two reasons a header would have been
110
+ * wrong. A static host answers files, and a file has one name per URL: `uf
111
+ * build` writes `dist/guide/__uf.flight` beside `dist/guide/index.html`, and a
112
+ * host that has never heard of uf serves both. And a shared cache that ignores
113
+ * `Vary` would hand a browser a payload where it asked for a document, which is
114
+ * the class of bug `docs/security.md` records for RSC caches — a different URL
115
+ * cannot be confused with the document's by anything that caches by URL.
116
+ *
117
+ * A segment rather than an extension, so that `/` and `/index` stay two URLs
118
+ * with two payloads, and so that a middleware guarding `/dashboard` guards the
119
+ * payload of `/dashboard` by the path rule it already has.
120
+ */
121
+ export const FLIGHT_SEGMENT: string = "__uf.flight";
122
+
123
+ /** The content type a payload is answered with. */
124
+ export const FLIGHT_CONTENT_TYPE: string = "text/x-component";
125
+
126
+ /**
127
+ * The URL of `url`'s payload: its pathname with [`FLIGHT_SEGMENT`] appended,
128
+ * and its query string unchanged.
129
+ *
130
+ * `url` is a path and query, as the router holds one; a hash never reaches a
131
+ * server and is dropped.
132
+ */
133
+ export function flightUrl(url: string): string {
134
+ const hash = url.indexOf("#");
135
+ const withoutHash = hash === -1 ? url : url.slice(0, hash);
136
+ const question = withoutHash.indexOf("?");
137
+ const pathname = question === -1 ? withoutHash : withoutHash.slice(0, question);
138
+ const search = question === -1 ? "" : withoutHash.slice(question);
139
+ const trimmed = pathname.replace(/\/+$/, "");
140
+ return `${trimmed}/${FLIGHT_SEGMENT}${search}`;
141
+ }
142
+
143
+ /**
144
+ * The document path a payload URL's pathname names, or `null` when the
145
+ * pathname is not a payload URL.
146
+ *
147
+ * `/__uf.flight` is the root's; `/guide/__uf.flight` is `/guide`'s. Anything
148
+ * else is `null`, and that includes a pathname that merely contains the segment
149
+ * somewhere in the middle — `/__uf.flight/x` is a URL some route may own.
150
+ */
151
+ export function documentPathOf(pathname: string): string | null {
152
+ const suffix = `/${FLIGHT_SEGMENT}`;
153
+ if (!pathname.endsWith(suffix)) {
154
+ return null;
155
+ }
156
+ const document = pathname.slice(0, pathname.length - suffix.length);
157
+ return document === "" ? "/" : document;
158
+ }