@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/action.js CHANGED
@@ -111,6 +111,7 @@ import {
111
111
  decodeActionResult,
112
112
  encodeActionArguments,
113
113
  } from "./internal/action-wire.js";
114
+ import { clearNavigationCache } from "./internal/navigation-cache.js";
114
115
 
115
116
  export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
116
117
  export {
@@ -264,6 +265,11 @@ export function createServerReference(id: string, name: string): ServerActionFun
264
265
  headers,
265
266
  body,
266
267
  });
268
+ // A route a navigation kept may no longer show what this action wrote, and
269
+ // which routes is the server's to know, so every kept route is asked for
270
+ // again. Whatever the status: an action that failed part-way may have
271
+ // written before it failed. See `./internal/navigation-cache.js`.
272
+ clearNavigationCache();
267
273
  if (!response.ok) {
268
274
  throw new ServerActionError(name, response.status);
269
275
  }
package/client.js CHANGED
@@ -14,6 +14,13 @@
14
14
  // *before* `hydrateRoot`, so the first client render is synchronous and
15
15
  // matches the server's markup exactly.
16
16
  //
17
+ // An application whose routes render as React Server Components, the default,
18
+ // starts from neither: `hydrateFlight` in `./rsc-client.js` hydrates the Flight
19
+ // payload its document carries. That is a separate entry so that this one,
20
+ // which every other web application imports, never names React's Flight
21
+ // client. The client is an optional peer that needs React 19.3, and a project
22
+ // on React 19.2 does not install it (ubugeeei-prod/uf#992).
23
+ //
17
24
  // # What "its loader data" means once the loader can defer
18
25
  //
19
26
  // It is a payload rather than a value: `internal/payload.js` writes the model
@@ -101,6 +108,8 @@ import {
101
108
  hasClientPage,
102
109
  installNavigation,
103
110
  installRoutes,
111
+ installRouting,
112
+ installStaleTime,
104
113
  matchRoute,
105
114
  resolveFailure,
106
115
  resolveMatch,
@@ -108,7 +117,8 @@ import {
108
117
  import { DATA_ID, ROOT_ID } from "./internal/document.js";
109
118
  import { decodePayload } from "./internal/payload.js";
110
119
  import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
111
- import { installBrowserModules, readDocumentPayload } from "./internal/flight-browser.js";
120
+ import { prepareDocumentForHydration } from "./internal/prepare-document.js";
121
+ import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/base-path.js";
112
122
 
113
123
  /**
114
124
  * Hydrate the current document.
@@ -124,6 +134,9 @@ export async function hydrate(options: {|
124
134
  readonly errors: RouteTable["errors"],
125
135
  readonly strictMode?: boolean,
126
136
  readonly navigation?: Navigation,
137
+ readonly basePath?: string,
138
+ readonly trailingSlash?: TrailingSlash,
139
+ readonly staleTime?: number,
127
140
  |}): Promise<void> {
128
141
  const table: RouteTable = {
129
142
  routes: options.routes,
@@ -136,15 +149,19 @@ export async function hydrate(options: {|
136
149
  // `app.rendering.navigation` existed and what a hand-written entry still
137
150
  // means: the default is the behaviour, not the absence of one.
138
151
  installNavigation(options.navigation ?? "client");
152
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
153
+ installStaleTime(options.staleTime ?? 0);
154
+ // The route table has no base path in it, and the address bar does.
155
+ const applicationPath = applicationPathOf(window.location.pathname) ?? window.location.pathname;
139
156
 
140
157
  // Before the loader data is read and before `resolveMatch` is called: both
141
158
  // would go looking for a page module that is not in this bundle.
142
- const matched = matchRoute(table.routes, window.location.pathname);
159
+ const matched = matchRoute(table.routes, applicationPath);
143
160
  if (matched != null && !hasClientPage(matched.route)) {
144
161
  return;
145
162
  }
146
163
 
147
- const url = window.location.pathname + window.location.search;
164
+ const url = applicationPath + window.location.search;
148
165
  // Row 0 of the payload, and the reader that will fill in the rows it refers
149
166
  // to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
150
167
  // tells the reader which rows the page is waiting for, and `watch` is what
@@ -218,106 +235,6 @@ export async function hydrate(options: {|
218
235
  }
219
236
  }
220
237
 
221
- /**
222
- * Hydrate a document React Server Components rendered.
223
- *
224
- * [`hydrate`] resolves the route from its modules and renders it again over the
225
- * server's markup. This one resolves nothing and imports no route module: the
226
- * document carries the Flight payload its tree was rendered from, React's own
227
- * client reads it, and the tree the browser hydrates is the tree the server
228
- * rendered — a Server Component is markup and a reference, and a client
229
- * component is the one kind of module this page loads. See
230
- * ubugeeei-prod/uf#519.
231
- *
232
- * The payload is read while the document is still arriving. Row 0 is in the
233
- * shell, so hydration starts as soon as the module script runs, and every row
234
- * after it lands in a later chunk that the reader picks up as it is parsed — so
235
- * a boundary the server completes after hydration began resolves then, with no
236
- * second request.
237
- *
238
- * Everything else is [`hydrate`]'s, for the reasons written there: the
239
- * navigation mode is installed before the first render, the development
240
- * hydration report captures the server's markup before React repairs it, and
241
- * Strict Mode wraps the root.
242
- */
243
- export async function hydrateFlight(options: {|
244
- readonly App: React.ComponentType<AppProps>,
245
- readonly strictMode?: boolean,
246
- readonly navigation?: Navigation,
247
- |}): Promise<void> {
248
- installNavigation(options.navigation ?? "client");
249
- installBrowserModules();
250
- const flight = readDocumentPayload(document, domObserver(document));
251
-
252
- const url = window.location.pathname + window.location.search;
253
- const { App } = options;
254
- const container = document.getElementById(ROOT_ID) ?? document;
255
- prepareDocumentForHydration(document);
256
-
257
- let recovery = null;
258
- let restoreDevHead = null;
259
- if (import.meta.hot != null) {
260
- const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
261
- await import("./internal/hydration.js");
262
- restoreDevHead = prepareDevHeadForHydration(document);
263
- recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
264
- }
265
-
266
- const tree = <App url={url} flight={flight} />;
267
-
268
- startTransition(() => {
269
- hydrateRoot(
270
- container,
271
- options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
272
- recovery == null ? undefined : { onRecoverableError: recovery },
273
- );
274
- if (restoreDevHead != null) {
275
- setTimeout(restoreDevHead, 250);
276
- }
277
- });
278
-
279
- if (import.meta.hot != null) {
280
- const { reportDevtools } = await import("./internal/devtools.js");
281
- reportDevtools(window);
282
- }
283
- }
284
-
285
- function prepareDocumentForHydration(document: Document): void {
286
- const head = document.head;
287
- const envelope = head.querySelector('meta[name="uf:render"]');
288
- if (envelope != null && head.firstChild !== envelope) {
289
- head.insertBefore(envelope, head.firstChild);
290
- }
291
- moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
292
- moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
293
- document.getElementById("_R_")?.remove();
294
- normalizeReactFormActions(document);
295
- }
296
-
297
- function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
298
- if (meta == null) {
299
- return;
300
- }
301
- const colorScheme = head.querySelector('meta[name="color-scheme"]');
302
- if (colorScheme != null && colorScheme !== meta) {
303
- head.insertBefore(meta, colorScheme);
304
- return;
305
- }
306
- head.appendChild(meta);
307
- }
308
-
309
- const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
310
- const CLIENT_FORM_PLACEHOLDER =
311
- "javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')";
312
-
313
- function normalizeReactFormActions(document: Document): void {
314
- for (const form of document.querySelectorAll("form")) {
315
- if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
316
- form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
317
- }
318
- }
319
- }
320
-
321
238
  /**
322
239
  * Render the current route into an empty shell.
323
240
  *
@@ -354,6 +271,9 @@ export async function render(options: {|
354
271
  readonly errors: RouteTable["errors"],
355
272
  readonly strictMode?: boolean,
356
273
  readonly navigation?: Navigation,
274
+ readonly basePath?: string,
275
+ readonly trailingSlash?: TrailingSlash,
276
+ readonly staleTime?: number,
357
277
  |}): Promise<void> {
358
278
  const table: RouteTable = {
359
279
  routes: options.routes,
@@ -362,14 +282,18 @@ export async function render(options: {|
362
282
  };
363
283
  installRoutes(table);
364
284
  installNavigation(options.navigation ?? "client");
285
+ installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
286
+ installStaleTime(options.staleTime ?? 0);
365
287
 
366
- const url = window.location.pathname + window.location.search;
288
+ const url =
289
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
290
+ window.location.search;
367
291
  let resolved;
368
292
  try {
369
293
  resolved = await resolveMatch(table, url);
370
294
  } catch (error) {
371
295
  if (error instanceof RedirectError) {
372
- window.location.replace(error.to);
296
+ window.location.replace(addressOf(error.to));
373
297
  return;
374
298
  }
375
299
  // The error boundary, chosen the same way the server chooses it. A throw
package/index.js CHANGED
@@ -73,6 +73,7 @@ export {
73
73
  RouteView,
74
74
  RouterProvider,
75
75
  UnauthorizedError,
76
+ basePath,
76
77
  buildRoute,
77
78
  forbidden,
78
79
  hasClientPage,
@@ -0,0 +1,175 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: `app.router.basePath` and
4
+ // `app.router.trailingSlash`, as the router reads them.
5
+ //
6
+ // The route table has no base in it — `/guide` is `/guide` whether the site is
7
+ // served at `/` or at `/docs` — and the browser's address bar has one. This
8
+ // module is the one place those two spellings meet: a `Link` and a navigation
9
+ // turn an application path into the address, and hydration, `popstate` and a
10
+ // refresh turn the address back into an application path before the table is
11
+ // asked. Every other part of the router speaks application paths.
12
+ //
13
+ // The policy decides the spelling of a path a link writes, so that a page is
14
+ // linked by the address its server answers without a redirect. The server's
15
+ // half — the `308` for the other spelling, and the `404` outside the base — is
16
+ // `@uniflowed/server`'s `internal/routing.js`.
17
+ //
18
+ // # Installed, like navigation
19
+ //
20
+ // Module state beside `installNavigation`, set once by the entry that started
21
+ // the application: `virtual:uf/client` in the browser and `virtual:uf/server`
22
+ // in the graph that renders documents. `@uniflowed/vite` writes both calls from
23
+ // `uf.config.js`, so a dev server and a build link the same way. A test that
24
+ // installs nothing gets the root and `"ignore"`, which is what every
25
+ // application had before the setting existed.
26
+
27
+ /** Which spelling of a path is the page; see `app.router.trailingSlash`. */
28
+ export type TrailingSlash = "never" | "always" | "ignore";
29
+
30
+ /** What an entry installs. */
31
+ export type RoutingSettings = {|
32
+ readonly basePath?: string,
33
+ readonly trailingSlash?: TrailingSlash,
34
+ |};
35
+
36
+ let installedBase: string = "";
37
+ let installedSlash: TrailingSlash = "ignore";
38
+
39
+ /**
40
+ * Say where this application is served and how its paths are spelled. Called
41
+ * once, by the entry that starts it.
42
+ */
43
+ export function installRouting(settings: RoutingSettings): void {
44
+ installedBase = normalizeBase(settings.basePath ?? "");
45
+ installedSlash = settings.trailingSlash ?? "ignore";
46
+ }
47
+
48
+ /**
49
+ * The path this application is served under: `""` at the root, `"/docs"`
50
+ * otherwise.
51
+ *
52
+ * For code that builds an absolute address the router did not build for it —
53
+ * a middleware's `Response.redirect(new URL(`${basePath()}/sign-in`, request.url))`
54
+ * — because a route handler and a middleware are handed the application path,
55
+ * and an address built from `/sign-in` alone leaves the base behind.
56
+ */
57
+ export function basePath(): string {
58
+ return installedBase;
59
+ }
60
+
61
+ /** How this application spells a path. */
62
+ export function trailingSlash(): TrailingSlash {
63
+ return installedSlash;
64
+ }
65
+
66
+ /**
67
+ * The address for an application path: the base in front, the policy's
68
+ * spelling, the query and the fragment kept.
69
+ *
70
+ * Only a path that starts with a single `/` is an application path. Anything
71
+ * else — `https://…`, `//cdn…`, `mailto:`, `?page=2`, `#top`, `../up` — is
72
+ * returned as it was written, because the browser resolves it against the
73
+ * address that is already there.
74
+ */
75
+ export function addressOf(to: string): string {
76
+ if (!to.startsWith("/") || to.startsWith("//")) {
77
+ return to;
78
+ }
79
+ const { path, rest } = splitPath(to);
80
+ return `${installedBase}${spellPath(path, installedSlash, installedBase !== "")}${rest}`;
81
+ }
82
+
83
+ /**
84
+ * The application path for an address's pathname, or `null` when the address
85
+ * is outside the base.
86
+ *
87
+ * `/docs/guide` is `/guide` under `/docs`, and `/docs` and `/docs/` are both
88
+ * `/`. `/docsx` is not under `/docs`: a base is whole segments.
89
+ */
90
+ export function applicationPathOf(pathname: string): string | null {
91
+ if (installedBase === "") {
92
+ return pathname;
93
+ }
94
+ if (pathname === installedBase) {
95
+ return "/";
96
+ }
97
+ if (pathname.startsWith(`${installedBase}/`)) {
98
+ return pathname.slice(installedBase.length);
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * A browser pathname in this application's spelling: the base kept, the
105
+ * application path under it spelled by the policy. A pathname outside the
106
+ * base is returned as it was.
107
+ *
108
+ * For an address the router read back rather than wrote — the document path a
109
+ * payload URL names has lost its trailing slash, and the history entry a
110
+ * navigation writes should be the address the server answers without a
111
+ * redirect.
112
+ */
113
+ export function canonicalAddress(pathname: string): string {
114
+ const application = applicationPathOf(pathname);
115
+ if (application == null) {
116
+ return pathname;
117
+ }
118
+ const spelled = spellPath(application, installedSlash, installedBase !== "");
119
+ return `${installedBase}${spelled}`;
120
+ }
121
+
122
+ /**
123
+ * `path` in `policy`'s spelling.
124
+ *
125
+ * The root of an application at the root is `/` whatever the policy says. The
126
+ * root of an application under a base is the base itself — `/docs` — unless
127
+ * the policy is `"always"`, which spells it `/docs/`. A path whose last segment
128
+ * looks like a file keeps what it was written with, because `/robots.txt/` is
129
+ * not a page.
130
+ */
131
+ export function spellPath(path: string, policy: TrailingSlash, underBase: boolean): string {
132
+ let end = path.length;
133
+ while (end > 0 && path.charCodeAt(end - 1) === 47) {
134
+ end -= 1;
135
+ }
136
+ const trimmed = path.slice(0, end);
137
+ if (trimmed === "") {
138
+ return policy === "always" || !underBase ? "/" : "";
139
+ }
140
+ if (policy === "ignore" || looksLikeAFile(trimmed)) {
141
+ return path;
142
+ }
143
+ return policy === "always" ? `${trimmed}/` : trimmed;
144
+ }
145
+
146
+ /** Whether a path's last segment has an extension. */
147
+ function looksLikeAFile(path: string): boolean {
148
+ const last = path.slice(path.lastIndexOf("/") + 1);
149
+ return last.includes(".");
150
+ }
151
+
152
+ function splitPath(to: string): {| readonly path: string, readonly rest: string |} {
153
+ let end = to.length;
154
+ for (let index = 0; index < to.length; index += 1) {
155
+ const code = to.charCodeAt(index);
156
+ // `?` and `#`.
157
+ if (code === 63 || code === 35) {
158
+ end = index;
159
+ break;
160
+ }
161
+ }
162
+ return { path: to.slice(0, end), rest: to.slice(end) };
163
+ }
164
+
165
+ /**
166
+ * A base as `uf_config` accepts one, with a trailing slash forgiven: `""`,
167
+ * `"/"` and absent are the root.
168
+ */
169
+ function normalizeBase(base: string): string {
170
+ let end = base.length;
171
+ while (end > 0 && base.charCodeAt(end - 1) === 47) {
172
+ end -= 1;
173
+ }
174
+ return base.slice(0, end);
175
+ }
@@ -412,6 +412,11 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
412
412
  const at = slot.intercepted?.pathname ?? pathname;
413
413
  const query = slot.intercepted?.searchParams ?? searchParams;
414
414
  const Page = pageComponent(page);
415
+ // The slot module's own export, looked up by slot: `pageComponent` hands back
416
+ // its `default` or `Page` as it is, so this is the same component on every
417
+ // render of the same slot. The React Compiler cannot see through the lookup
418
+ // and reports a component created during render.
419
+ // uf-lint-disable-next-line react-compiler/static-components
415
420
  let element: React.Node = <Page params={slot.params} searchParams={query} data={undefined} />;
416
421
  const templateContext = {
417
422
  pathname: at,
@@ -425,6 +430,10 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
425
430
  continue;
426
431
  }
427
432
  const Fallback = loadingComponent(boundary.module);
433
+ // The same lookup as `Page` above: `loadingComponent` hands back the
434
+ // boundary module's own export as it is, so this is the same component
435
+ // on every render of that boundary.
436
+ // uf-lint-disable-next-line react-compiler/static-components
428
437
  element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
429
438
  }
430
439
  const errorBoundary = slot.errorBoundary;
@@ -439,6 +448,9 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
439
448
  if (depth > 0) {
440
449
  const Layout = layoutComponent(slot.layouts[depth - 1]);
441
450
  element = (
451
+ // The layout module's own export, looked up by depth, for the same
452
+ // reason as `Page` above.
453
+ // uf-lint-disable-next-line react-compiler/static-components
442
454
  <Layout {...slotsAt(slot.slots, depth, { pathname, searchParams })} params={slot.params}>
443
455
  {element}
444
456
  </Layout>
@@ -78,6 +78,10 @@ component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => v
78
78
  return <DefaultRouteError error={error} reset={reset} />;
79
79
  }
80
80
  const Boundary = errorComponent(module);
81
+ // The error module's own export, looked up by route, for the same reason as
82
+ // the page component in `compose.js`: the same component on every render of
83
+ // that route, through a lookup the compiler cannot see into.
84
+ // uf-lint-disable-next-line react-compiler/static-components
81
85
  return <Boundary error={error} reset={reset} />;
82
86
  }
83
87
 
@@ -26,11 +26,21 @@
26
26
  import { createFromFetch, createFromReadableStream } from "react-server-dom-parcel/client.browser";
27
27
 
28
28
  import { FLIGHT_CHUNK_ATTRIBUTE, flightChunkBytes } from "./flight-chunks.js";
29
- import { FLIGHT_CONTENT_TYPE, type FlightRoot, documentPathOf, flightUrl } from "./flight.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";
30
37
 
31
38
  /** A module namespace, as far as the loader looks into one. */
32
39
  type ModuleNamespace = { +[string]: mixed };
33
40
 
41
+ /** The entry a refusal names: the one an application reaches this module through. */
42
+ const ENTRY = "@uniflowed/router/rsc/client";
43
+
34
44
  /**
35
45
  * Install the module hook React's Flight client resolves references through.
36
46
  *
@@ -39,6 +49,7 @@ type ModuleNamespace = { +[string]: mixed };
39
49
  * as an accessor cannot be assigned to, and hydration must not throw over it.
40
50
  */
41
51
  export function installBrowserModules(): void {
52
+ requireServerComponentsReact(ENTRY);
42
53
  const loaded: Map<string, ModuleNamespace> = new Map();
43
54
  // A function with three properties, which is the shape React's Parcel client
44
55
  // calls: `parcelRequire(id)` for a module, `parcelRequire.load(url)` for the
@@ -156,27 +167,10 @@ export function readDocumentPayload(
156
167
  document: DocumentLike,
157
168
  observe: ?(callback: () => void) => (() => void) | null,
158
169
  ): Promise<FlightRoot> {
170
+ requireServerComponentsReact(ENTRY);
159
171
  return createFromReadableStream(documentPayload(document, observe));
160
172
  }
161
173
 
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
174
  /**
181
175
  * Fetch `url`'s payload.
182
176
  *
@@ -193,6 +187,10 @@ export type FetchedFlight =
193
187
  * refusal — is a document, whatever its status.
194
188
  */
195
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);
196
194
  let response: Response;
197
195
  try {
198
196
  response = await fetch(flightUrl(url), {
@@ -55,9 +55,33 @@ function elementJson(value: FlightChunk): string {
55
55
  .replace(/\u2029/g, "\\u2029");
56
56
  }
57
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>`;
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;");
61
85
  }
62
86
 
63
87
  /**
@@ -74,7 +98,7 @@ export type ChunkEncoder = {|
74
98
  readonly end: () => string,
75
99
  |};
76
100
 
77
- export function createChunkEncoder(): ChunkEncoder {
101
+ export function createChunkEncoder(nonce?: string | null): ChunkEncoder {
78
102
  const strict = new TextDecoder("utf-8", { fatal: true });
79
103
  let carry: Uint8Array = new Uint8Array(0);
80
104
 
@@ -87,17 +111,17 @@ export function createChunkEncoder(): ChunkEncoder {
87
111
  }
88
112
  const body = joined.subarray(0, complete);
89
113
  try {
90
- return flightChunkElement(strict.decode(body));
114
+ return flightChunkElement(strict.decode(body), nonce);
91
115
  } catch {
92
- return flightChunkElement({ bytes: base64(body) });
116
+ return flightChunkElement({ bytes: base64(body) }, nonce);
93
117
  }
94
118
  }
95
119
 
96
120
  function end(): string {
97
121
  const rest = carry;
98
122
  carry = new Uint8Array(0);
99
- const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) });
100
- return `${tail}${flightChunkElement(null)}`;
123
+ const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) }, nonce);
124
+ return `${tail}${flightChunkElement(null, nonce)}`;
101
125
  }
102
126
 
103
127
  return { encode, end };
@@ -25,6 +25,7 @@
25
25
  import { createFromReadableStream } from "react-server-dom-parcel/client.edge";
26
26
 
27
27
  import type { FlightRoot } from "./flight.js";
28
+ import { requireServerComponentsReact } from "./react-version.js";
28
29
 
29
30
  /** A module namespace, as far as the hook looks into one. */
30
31
  type ModuleNamespace = { +[string]: mixed };
@@ -32,6 +33,9 @@ type ModuleNamespace = { +[string]: mixed };
32
33
  /** The server copy of the client module at a browser chunk URL. */
33
34
  export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
34
35
 
36
+ /** The entry a refusal names: the one an application reaches this module through. */
37
+ const ENTRY = "@uniflowed/router/rsc/ssr";
38
+
35
39
  /**
36
40
  * Install the module hook React's Flight client resolves references through.
37
41
  *
@@ -40,6 +44,7 @@ export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
40
44
  * stale one.
41
45
  */
42
46
  export function installServerModules(load: ClientModuleLoader): void {
47
+ requireServerComponentsReact(ENTRY);
43
48
  const loaded: Map<string, ModuleNamespace> = new Map();
44
49
  // The browser's hook, on a server; `./flight-browser.js` has the shape.
45
50
  function parcelRequire(id: string): ModuleNamespace {
@@ -68,5 +73,6 @@ export function installServerModules(load: ClientModuleLoader): void {
68
73
 
69
74
  /** Read a payload into its root value, as React's client does in the browser. */
70
75
  export function readPayload(stream: ReadableStream<Uint8Array>): Promise<FlightRoot> {
76
+ requireServerComponentsReact(ENTRY);
71
77
  return createFromReadableStream(stream);
72
78
  }
@@ -60,6 +60,32 @@ export type FlightRoot = {|
60
60
  readonly tree: Node,
61
61
  |};
62
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
+
63
89
  /** The part of a resolved route that crosses to the browser. */
64
90
  export function routeState(resolved: ResolvedRoute): RouteState {
65
91
  return {