@uniflowed/router 0.2.0 → 0.4.0

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.
@@ -39,7 +39,7 @@ import {
39
39
  import { requireServerComponentsReact } from "./react-version.js";
40
40
 
41
41
  /** A module namespace, as far as the loader looks into one. */
42
- type ModuleNamespace = { +[string]: mixed };
42
+ type ModuleNamespace = { readonly [string]: mixed };
43
43
 
44
44
  /** The entry a refusal names: the one an application reaches this module through. */
45
45
  const ENTRY = "@uniflowed/router/rsc/client";
@@ -86,16 +86,24 @@ export function installBrowserModules(): void {
86
86
  throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
87
87
  };
88
88
  parcelRequire.meta = { publicUrl: "", devServer: null };
89
- Object.defineProperty(globalThis, "parcelRequire", {
89
+ // `Reflect`, because Flow reads a property name given to
90
+ // `Object.defineProperty` as one the target must already declare, and
91
+ // `parcelRequire` is exactly the global nothing declares. `Object`'s throws
92
+ // where this answers `false`, so the throw is kept.
93
+ const defined = Reflect.defineProperty(globalThis, "parcelRequire", {
90
94
  value: parcelRequire,
91
95
  writable: true,
92
96
  configurable: true,
93
97
  });
98
+ if (!defined) {
99
+ throw new TypeError("@uniflowed/router: could not install parcelRequire on the global object");
100
+ }
94
101
  }
95
102
 
96
103
  /** The parts of a `Document` the reader uses. */
97
104
  type DocumentLike = interface {
98
- readonly querySelectorAll: (selector: string) => Iterable<ElementLike>,
105
+ // A method, as a `Document`'s is: a method cannot be read off as a property.
106
+ querySelectorAll(selector: string): Iterable<ElementLike>,
99
107
  readonly documentElement: mixed,
100
108
  };
101
109
 
@@ -222,12 +230,16 @@ export async function fetchFlight(
222
230
  const landed = new URL(response.url, window.location.href);
223
231
  const document = documentPathOf(landed.pathname);
224
232
  const type = response.headers.get("content-type") ?? "";
225
- if (
226
- landed.origin !== window.location.origin ||
227
- document == null ||
228
- !type.startsWith(FLIGHT_CONTENT_TYPE)
229
- ) {
230
- void response.body?.cancel();
233
+ // A payload file a static host could not type (#1495) is taken for what it
234
+ // is only after its first bytes say so; see [`untypedPayload`].
235
+ const payload =
236
+ landed.origin === window.location.origin && document != null
237
+ ? type.startsWith(FLIGHT_CONTENT_TYPE)
238
+ ? response
239
+ : await untypedPayload(response, type)
240
+ : null;
241
+ if (payload == null || document == null) {
242
+ void response.body?.cancel().catch(() => {});
231
243
  // The URL the reader asked for when the redirect did not end on a payload,
232
244
  // rather than the one it ended on. A middleware that answered with its
233
245
  // sign-in page wrote a `next=` naming the payload URL; loading the document
@@ -237,6 +249,70 @@ export async function fetchFlight(
237
249
  return {
238
250
  kind: "flight",
239
251
  url: `${document}${landed.search}`,
240
- root: createFromFetch(Promise.resolve(response)),
252
+ root: createFromFetch(Promise.resolve(payload)),
241
253
  };
242
254
  }
255
+
256
+ /** The types a host sends for a file it does not know, or no type at all. */
257
+ const UNTYPED: $ReadOnlyArray<string> = Object.freeze([
258
+ "",
259
+ "application/octet-stream",
260
+ "binary/octet-stream",
261
+ ]);
262
+
263
+ /** How far into a body to look for the first row before giving up. */
264
+ const SNIFF_LIMIT = 256;
265
+
266
+ /**
267
+ * `response` as a payload, when a host served one without saying so.
268
+ *
269
+ * A prerendered page's payload is a file, `<route>/__uf.flight`, and a static
270
+ * host that does not know the extension sends it with no `Content-Type` (the
271
+ * Workers asset server, Pages) or as `application/octet-stream`. Refusing it
272
+ * made every client navigation on such a host a full page load. The type
273
+ * check is not dropped for it, it is replaced by one that cannot be fooled the
274
+ * same way: the answer has to be a `200` for the payload URL itself (no
275
+ * redirect — a sign-in page reached by one is what the type check is for),
276
+ * untyped rather than typed as something else (`text/html` is a document,
277
+ * whatever its bytes), and its first line has to be a Flight row, `<hex id>:`.
278
+ * Anything else answers `null` and the router loads the document, as before.
279
+ *
280
+ * The bytes read to decide are put back in front of the rest, so React reads
281
+ * the whole payload.
282
+ */
283
+ async function untypedPayload(response: Response, type: string): Promise<Response | null> {
284
+ const media = type.split(";")[0].trim().toLowerCase();
285
+ if (!UNTYPED.includes(media) || response.status !== 200 || response.redirected) return null;
286
+ const body = response.body;
287
+ if (body == null) return null;
288
+ const reader = body.getReader();
289
+ const read: Array<Uint8Array> = [];
290
+ let seen = "";
291
+ const decoder = new TextDecoder();
292
+ while (!seen.includes("\n") && seen.length < SNIFF_LIMIT) {
293
+ const step = await reader.read();
294
+ if (step.done === true) break;
295
+ read.push(step.value);
296
+ seen += decoder.decode(step.value, { stream: true });
297
+ }
298
+ if (!/^[0-9a-f]+:/i.test(seen)) {
299
+ void reader.cancel().catch(() => {});
300
+ return null;
301
+ }
302
+ const replayed = new ReadableStream({
303
+ start(controller) {
304
+ for (const chunk of read) controller.enqueue(chunk);
305
+ },
306
+ async pull(controller) {
307
+ const step = await reader.read();
308
+ if (step.done === true) controller.close();
309
+ else controller.enqueue(step.value);
310
+ },
311
+ cancel(reason) {
312
+ return reader.cancel(reason);
313
+ },
314
+ });
315
+ const headers = new Headers(response.headers);
316
+ headers.set("content-type", FLIGHT_CONTENT_TYPE);
317
+ return new Response(replayed, { status: 200, headers });
318
+ }
@@ -142,13 +142,13 @@ export function flightChunkBytes(text: string): Uint8Array | null {
142
142
  if (typeof value === "string") {
143
143
  return new TextEncoder().encode(value);
144
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);
145
+ if (typeof value === "object" && !Array.isArray(value)) {
146
+ // Read once into a local: a refinement of `value.bytes` does not survive
147
+ // to the call, a `const` does.
148
+ const bytes = value.bytes;
149
+ if (typeof bytes === "string" && Object.keys(value).length === 1) {
150
+ return fromBase64(bytes);
151
+ }
152
152
  }
153
153
  throw new Error(`@uniflowed/router: a ${FLIGHT_CHUNK_ATTRIBUTE} element holds no payload chunk`);
154
154
  }
@@ -39,7 +39,12 @@ const NEWLINE = 0x0a;
39
39
  const ERROR_TAG = "E".charCodeAt(0);
40
40
 
41
41
  /** One row's place in the payload. */
42
- type Row = {| +start: number, +end: number, +tag: number, +body: number |};
42
+ type Row = {|
43
+ readonly start: number,
44
+ readonly end: number,
45
+ readonly tag: number,
46
+ readonly body: number,
47
+ |};
43
48
 
44
49
  /**
45
50
  * Every row of a finished payload, in order.
@@ -92,7 +97,7 @@ function rowsOf(bytes: Uint8Array): Array<Row> {
92
97
  export function withoutErrorRows(
93
98
  payload: Uint8Array,
94
99
  left: (digest: string) => boolean,
95
- ): {| +payload: Uint8Array, +removed: number |} {
100
+ ): {| readonly payload: Uint8Array, readonly removed: number |} {
96
101
  const decoder = new TextDecoder();
97
102
  const kept: Array<Uint8Array> = [];
98
103
  let removed = 0;
@@ -28,7 +28,7 @@ import type { FlightRoot } from "./flight.js";
28
28
  import { requireServerComponentsReact } from "./react-version.js";
29
29
 
30
30
  /** A module namespace, as far as the hook looks into one. */
31
- type ModuleNamespace = { +[string]: mixed };
31
+ type ModuleNamespace = { readonly [string]: mixed };
32
32
 
33
33
  /** The server copy of the client module at a browser chunk URL. */
34
34
  export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
@@ -64,11 +64,18 @@ export function installServerModules(load: ClientModuleLoader): void {
64
64
  throw new Error("@uniflowed/router: a payload asked for an import map, which uf never writes");
65
65
  };
66
66
  parcelRequire.meta = { publicUrl: "", devServer: null };
67
- Object.defineProperty(globalThis, "parcelRequire", {
67
+ // `Reflect`, because Flow reads a property name given to
68
+ // `Object.defineProperty` as one the target must already declare, and
69
+ // `parcelRequire` is exactly the global nothing declares. `Object`'s throws
70
+ // where this answers `false`, so the throw is kept.
71
+ const defined = Reflect.defineProperty(globalThis, "parcelRequire", {
68
72
  value: parcelRequire,
69
73
  writable: true,
70
74
  configurable: true,
71
75
  });
76
+ if (!defined) {
77
+ throw new TypeError("@uniflowed/router: could not install parcelRequire on the global object");
78
+ }
72
79
  }
73
80
 
74
81
  /**
@@ -82,7 +89,7 @@ export function installServerModules(load: ClientModuleLoader): void {
82
89
  */
83
90
  export function readPayload(
84
91
  stream: ReadableStream<Uint8Array>,
85
- options?: {| +partial?: boolean |},
92
+ options?: {| readonly partial?: boolean |},
86
93
  ): Promise<FlightRoot> {
87
94
  requireServerComponentsReact(ENTRY);
88
95
  return options?.partial === true
@@ -108,6 +108,38 @@ export type FetchedFlight =
108
108
  readonly url: string,
109
109
  |};
110
110
 
111
+ /**
112
+ * `error` as it may cross into a Flight payload: a thrown value that is not an
113
+ * `Error` becomes one.
114
+ *
115
+ * The route's error travels to the browser as a prop of the error page and as
116
+ * part of the route state, and React serialises it the way it serialises any
117
+ * prop. For an `Error` that is safe by React's own rule: a production payload
118
+ * carries the digest and none of the message or the stack. A thrown value
119
+ * that is *not* an `Error` gets no such rule. A string, or a plain object
120
+ * such as an API client's `{ message, details, hint }`, would be serialised
121
+ * as the data it is, and a query, a table name or a token in it would be
122
+ * published to whoever asked for the page.
123
+ *
124
+ * So it is wrapped. The message is the value's `String()`, which `uf dev`
125
+ * shows and which a production payload drops along with every other `Error`
126
+ * message. The original value is still what the server reported: this is
127
+ * applied only to the copy that crosses. `unauthorized` and `forbidden` carry
128
+ * nothing and pass through as they are.
129
+ */
130
+ export function crossableRouteError(error: ?RouteError): ?RouteError {
131
+ if (error == null || error.kind !== "thrown" || error.error instanceof Error) {
132
+ return error;
133
+ }
134
+ let text = "a value that is not an Error was thrown";
135
+ try {
136
+ text = String(error.error);
137
+ } catch {
138
+ // A value whose `toString` throws: the sentence above is all it gets.
139
+ }
140
+ return { kind: "thrown", error: new Error(text) };
141
+ }
142
+
111
143
  /** The part of a resolved route that crosses to the browser. */
112
144
  export function routeState(resolved: ResolvedRoute): RouteState {
113
145
  const interception = resolved.interception;
@@ -195,21 +195,26 @@ type DetachedHeadStyle = {|
195
195
  * puts them back in the same order.
196
196
  */
197
197
  export function prepareDevHeadForHydration(document: Document): () => void {
198
- for (const script of document.head.querySelectorAll("script")) {
198
+ // A document without a head has nothing of uf's in it to hide.
199
+ const head = document.head;
200
+ if (head == null) {
201
+ return () => {};
202
+ }
203
+ for (const script of head.querySelectorAll("script")) {
199
204
  if (isDevHeadScript(script)) {
200
205
  script.remove();
201
206
  }
202
207
  }
203
- for (const child of Array.from(document.head.childNodes)) {
208
+ for (const child of Array.from(head.childNodes)) {
204
209
  if (isIgnorableHeadWhitespace(child)) {
205
- child.remove();
210
+ head.removeChild(child);
206
211
  }
207
212
  }
208
213
  const detached = [];
209
- for (const style of document.head.querySelectorAll("style")) {
214
+ for (const style of head.querySelectorAll("style")) {
210
215
  if (isViteDevStyle(style)) {
211
216
  const anchor = document.createComment("uf dev style");
212
- document.head.insertBefore(anchor, style);
217
+ head.insertBefore(anchor, style);
213
218
  style.remove();
214
219
  detached.push({ anchor, style });
215
220
  }
@@ -243,7 +248,7 @@ function restoreDevHeadStyles(
243
248
  parent.insertBefore(style, anchor);
244
249
  parent.removeChild(anchor);
245
250
  } else {
246
- document.head.appendChild(style);
251
+ document.head?.appendChild(style);
247
252
  }
248
253
  }
249
254
  }
@@ -62,7 +62,7 @@ let staleTimeMs: number = 0;
62
62
  export function installStaleTime(seconds: number): void {
63
63
  staleTimeMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
64
64
  if (import.meta.hot != null) {
65
- Object.defineProperty((globalThis: $FlowFixMe), "__UF_NAVIGATION_CACHE__", {
65
+ Object.defineProperty(globalThis as $FlowFixMe, "__UF_NAVIGATION_CACHE__", {
66
66
  configurable: true,
67
67
  value: inspectNavigationCache,
68
68
  });
@@ -81,13 +81,15 @@ export type PayloadReader = {|
81
81
 
82
82
  /** The parts of a `Document` this module uses, so it needs no DOM lib. */
83
83
  type DocumentLike = interface {
84
- readonly querySelectorAll: (selector: string) => Iterable<ElementLike>,
85
- readonly documentElement: mixed,
84
+ // Methods rather than function-valued properties: a `Document`'s are
85
+ // methods, and a method cannot be read off its object as a property.
86
+ querySelectorAll(selector: string): Iterable<ElementLike>,
87
+ readonly documentElement: ?Node,
86
88
  };
87
89
 
88
90
  /** The parts of an `Element` this module uses. */
89
91
  type ElementLike = interface {
90
- readonly getAttribute: (name: string) => string | null,
92
+ getAttribute(name: string): ?string,
91
93
  readonly textContent: string | null,
92
94
  };
93
95
 
@@ -261,7 +263,6 @@ export function domObserver(
261
263
  const observer = new MutationObserver(() => {
262
264
  callback();
263
265
  });
264
- // $FlowFixMe[incompatible-call] `documentElement` is a `Node`; the interface above says only what is read.
265
266
  observer.observe(root, { childList: true, subtree: true });
266
267
  return () => {
267
268
  observer.disconnect();
@@ -241,8 +241,8 @@ function asThenable(value: mixed): Promise<mixed> | null {
241
241
  if (value == null || (typeof value !== "object" && typeof value !== "function")) {
242
242
  return null;
243
243
  }
244
- const then: mixed = (value: $FlowFixMe).then;
245
- return typeof then === "function" ? (value: $FlowFixMe) : null;
244
+ const then: mixed = (value as $FlowFixMe).then;
245
+ return typeof then === "function" ? (value as $FlowFixMe) : null;
246
246
  }
247
247
 
248
248
  /** Whether a string would be read as a reference and so has to be escaped. */
@@ -315,7 +315,7 @@ function needsEncoding(root: mixed, label: string): boolean {
315
315
  continue;
316
316
  }
317
317
  if (isPlainObject(value)) {
318
- const source: { +[string]: mixed } = (value: $FlowFixMe);
318
+ const source: { readonly [string]: mixed } = value as $FlowFixMe;
319
319
  for (const key of Object.keys(source)) {
320
320
  stack.push({ value: source[key], path: `${path}.${key}`, depth: depth + 1 });
321
321
  }
@@ -381,7 +381,7 @@ export function encodePayload(root: mixed, label: string): EncodedPayload {
381
381
  continue;
382
382
  }
383
383
  if (Array.isArray(value)) {
384
- const encoded: Array<mixed> = new Array(value.length).fill(null);
384
+ const encoded: Array<mixed> = new Array<mixed>(value.length).fill(null);
385
385
  emit(encoded);
386
386
  // Pushed in reverse so that popping visits index 0 first: the id a
387
387
  // promise is given has to follow the order the payload reads in.
@@ -403,7 +403,7 @@ export function encodePayload(root: mixed, label: string): EncodedPayload {
403
403
  emit(value);
404
404
  continue;
405
405
  }
406
- const source: { +[string]: mixed } = (value: $FlowFixMe);
406
+ const source: { readonly [string]: mixed } = value as $FlowFixMe;
407
407
  const encoded: { [string]: mixed } = {};
408
408
  emit(encoded);
409
409
  const keys = Object.keys(source);
@@ -491,7 +491,7 @@ export function decodePayload(root: mixed, resolve: RowResolver, label: string):
491
491
  continue;
492
492
  }
493
493
  if (Array.isArray(value)) {
494
- const rebuilt: Array<mixed> = new Array(value.length).fill(null);
494
+ const rebuilt: Array<mixed> = new Array<mixed>(value.length).fill(null);
495
495
  emit(rebuilt);
496
496
  for (let index = value.length - 1; index >= 0; index -= 1) {
497
497
  stack.push({
@@ -509,7 +509,7 @@ export function decodePayload(root: mixed, resolve: RowResolver, label: string):
509
509
  emit(value);
510
510
  continue;
511
511
  }
512
- const source: { +[string]: mixed } = (value: $FlowFixMe);
512
+ const source: { readonly [string]: mixed } = value as $FlowFixMe;
513
513
  const rebuilt: { [string]: mixed } = {};
514
514
  emit(rebuilt);
515
515
  const keys = Object.keys(source);
@@ -553,7 +553,7 @@ function hasReference(root: mixed, label: string): boolean {
553
553
  continue;
554
554
  }
555
555
  if (isPlainObject(value)) {
556
- const source: { +[string]: mixed } = (value: $FlowFixMe);
556
+ const source: { readonly [string]: mixed } = value as $FlowFixMe;
557
557
  for (const key of Object.keys(source)) {
558
558
  stack.push({ value: source[key], path: `${path}.${key}`, depth: depth + 1 });
559
559
  }
@@ -632,7 +632,13 @@ function parsePayloadRowId(digits: string): number | null {
632
632
  * only one of them.
633
633
  */
634
634
  export function payloadJson(value: mixed): string {
635
- return JSON.stringify(value)
635
+ const text = JSON.stringify(value);
636
+ if (text === undefined) {
637
+ // `undefined`, a function or a symbol: nothing JSON can say. This threw
638
+ // before too, as a `TypeError` about `replace`; now it says what it is.
639
+ throw new TypeError("@uniflowed/router: a payload value has no JSON form");
640
+ }
641
+ return text
636
642
  .replace(/</g, "\\u003c")
637
643
  .replace(/\u2028/g, "\\u2028")
638
644
  .replace(/\u2029/g, "\\u2029");
@@ -660,9 +666,9 @@ export function parseRowMessage(text: string, id: number): PayloadRowMessage {
660
666
  if (!isPlainObject(parsed)) {
661
667
  throw new PayloadValueError(label, "is not a row message");
662
668
  }
663
- const message: { +[string]: mixed } = (parsed: $FlowFixMe);
664
- const hasValue = Object.prototype.hasOwnProperty.call(message, "value");
665
- const hasError = Object.prototype.hasOwnProperty.call(message, "error");
669
+ const message: { readonly [string]: mixed } = parsed as $FlowFixMe;
670
+ const hasValue = Object.hasOwn(message, "value");
671
+ const hasError = Object.hasOwn(message, "error");
666
672
  if (hasValue === hasError) {
667
673
  throw new PayloadValueError(label, "must carry exactly one of `value` and `error`");
668
674
  }
@@ -19,6 +19,12 @@ export function prepareDocumentForHydration(document: Document): void {
19
19
  // document that arrived. See `./deployment.js`.
20
20
  rememberDeployment(document);
21
21
  const head = document.head;
22
+ if (head == null) {
23
+ // Nothing a server wrote into a head to put right; the forms still are.
24
+ document.getElementById("_R_")?.remove();
25
+ normalizeReactFormActions(document);
26
+ return;
27
+ }
22
28
  const envelope = head.querySelector('meta[name="uf:render"]');
23
29
  if (envelope != null && head.firstChild !== envelope) {
24
30
  head.insertBefore(envelope, head.firstChild);
@@ -639,7 +639,7 @@ export function loadOnce<T>(load: () => Promise<T>): Promise<T> {
639
639
  pending = load();
640
640
  moduleCache.set(load, pending);
641
641
  }
642
- // $FlowFixMe[incompatible-return] the cache is keyed by the loader, whose result type it stores.
642
+ // $FlowFixMe[incompatible-type] the cache is keyed by the loader, whose result type it stores.
643
643
  return pending;
644
644
  }
645
645
 
@@ -755,9 +755,12 @@ async function resolveRoute(
755
755
  "client JavaScript, so the browser navigates to it rather than rendering it",
756
756
  );
757
757
  }
758
- const [page, ...layouts] = await Promise.all([
758
+ // A pair rather than one spread array, so that the page stays a
759
+ // `PageModule` and the layouts `LayoutModule`s: `Promise.all` of a mixed
760
+ // list answers with the union of the two.
761
+ const [page, layouts] = await Promise.all([
759
762
  loadOnce(load),
760
- ...matched.route.layouts.map((layout) => loadOnce(layout)),
763
+ Promise.all(matched.route.layouts.map((layout) => loadOnce(layout))),
761
764
  ]);
762
765
  // Started here and awaited at the end: the boundary's module does not depend
763
766
  // on the loader, so importing it alongside costs a navigation nothing. It
@@ -1460,11 +1463,11 @@ async function resolveNotFound(
1460
1463
  ): Promise<ResolvedRoute> {
1461
1464
  const record = nearestBoundary(table.notFound, pathname);
1462
1465
  const load = record?.page;
1463
- const [page, ...layouts] = await Promise.all([
1466
+ const [page, layouts] = await Promise.all([
1464
1467
  load == null
1465
1468
  ? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
1466
1469
  : loadOnce(load),
1467
- ...(record?.layouts ?? []).map((layout) => loadOnce(layout)),
1470
+ Promise.all((record?.layouts ?? []).map((layout) => loadOnce(layout))),
1468
1471
  ]);
1469
1472
  const metadata = await resolveMetadata(page, layouts, {
1470
1473
  params: {},
@@ -1527,11 +1530,14 @@ async function resolveMetadata(
1527
1530
  }
1528
1531
  if (page.frontmatter != null) {
1529
1532
  const { title, description } = page.frontmatter;
1530
- merged = {
1531
- ...merged,
1532
- ...(title != null ? { title } : {}),
1533
- ...(description != null ? { description } : {}),
1534
- };
1533
+ // One field at a time: spreading two conditional objects is a union per
1534
+ // spread, and Flow refuses to multiply them out.
1535
+ if (title != null) {
1536
+ merged = { ...merged, title };
1537
+ }
1538
+ if (description != null) {
1539
+ merged = { ...merged, description };
1540
+ }
1535
1541
  }
1536
1542
  if (page.metadata != null) {
1537
1543
  take(page.metadata);
@@ -1587,7 +1593,7 @@ export function errorTitle(error: RouteError): string {
1587
1593
  return match (error) {
1588
1594
  {kind: "unauthorized"} => "Sign in required",
1589
1595
  {kind: "forbidden"} => "Not allowed",
1590
- {kind: "thrown"} => "Something went wrong",
1596
+ {kind: "thrown", ...} => "Something went wrong",
1591
1597
  };
1592
1598
  }
1593
1599
 
@@ -150,7 +150,7 @@ export type RouteTable<
150
150
  type UnknownRouteRecord = RouteRecord<mixed, mixed, mixed, mixed, mixed>;
151
151
 
152
152
  /** A URL matched against a table. */
153
- export type RouteMatch<TRoute: { +path: string, ... } = UnknownRouteRecord> = {|
153
+ export type RouteMatch<TRoute extends { readonly path: string, ... } = UnknownRouteRecord> = {|
154
154
  readonly route: TRoute,
155
155
  readonly params: RouteParams,
156
156
  |};
@@ -172,7 +172,7 @@ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
172
172
  return match (error) {
173
173
  {kind: "unauthorized"} => 401,
174
174
  {kind: "forbidden"} => 403,
175
- {kind: "thrown"} => 500,
175
+ {kind: "thrown", ...} => 500,
176
176
  };
177
177
  }
178
178
 
@@ -200,12 +200,77 @@ export class ForbiddenError extends Error {
200
200
  }
201
201
  }
202
202
 
203
- /** Thrown by `redirect()`; the renderer answers with a redirect. */
203
+ /**
204
+ * The URL schemes a navigation never follows, because following one runs
205
+ * script in the page rather than loading one.
206
+ *
207
+ * `javascript:` is the reason. `location.assign("javascript:…")` and
208
+ * `location.replace("javascript:…")` execute it in the current document, with
209
+ * the page's origin and its cookies, so a router that hands a caller's string
210
+ * to either is a DOM XSS sink the moment that string comes from a query
211
+ * parameter: `redirect(searchParams.get("next"))` is the sign-in flow in half
212
+ * the applications ever written. React 19 refuses the same scheme in `href`
213
+ * for the same reason, and `Link` renders through that; this is the half of
214
+ * the router React never sees. `vbscript:` is the same thing in an older
215
+ * browser, and `data:` a document of the sender's choosing that browsers now
216
+ * refuse to navigate to at the top level anyway — refused here too, so the
217
+ * answer does not depend on which browser was asked.
218
+ */
219
+ const SCRIPT_SCHEMES: $ReadOnlyArray<string> = ["javascript:", "vbscript:", "data:"];
220
+
221
+ /**
222
+ * The scheme `to` would run as script when navigated to, or `null` when it is
223
+ * a URL a navigation may follow.
224
+ *
225
+ * Parsed with the WHATWG URL parser rather than compared as text, because the
226
+ * browser is what decides and it forgives a great deal: leading spaces and
227
+ * control characters are stripped, tabs and newlines anywhere are dropped, and
228
+ * the scheme is case-insensitive — ` JaVa\tScRiPt:` is `javascript:`. The base
229
+ * only resolves a relative path, which is never one of these.
230
+ */
231
+ export function scriptSchemeOf(to: string): string | null {
232
+ let parsed: URL;
233
+ try {
234
+ parsed = new URL(to, "http://uf.invalid/");
235
+ } catch {
236
+ return null;
237
+ }
238
+ return SCRIPT_SCHEMES.includes(parsed.protocol) ? parsed.protocol : null;
239
+ }
240
+
241
+ /**
242
+ * Throw unless `to` is a URL a navigation may follow.
243
+ *
244
+ * Named after the call that asked (`redirect()`, `router.push()`), so the
245
+ * stack a developer reads points at the line that built the URL. The URL
246
+ * itself is not repeated: it is often a value a visitor chose, and an error
247
+ * message is something applications log.
248
+ */
249
+ export function refuseScriptUrl(to: string, caller: string): void {
250
+ const scheme = scriptSchemeOf(to);
251
+ if (scheme != null) {
252
+ throw new Error(
253
+ `@uniflowed/router: ${caller} refused a ${scheme} URL, which a browser would run as ` +
254
+ "script in this page rather than load. If the URL came from a query parameter, allow " +
255
+ "only paths on this site before navigating to it.",
256
+ );
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Thrown by `redirect()`; the renderer answers with a redirect.
262
+ *
263
+ * Refuses a script URL when it is built (see [`refuseScriptUrl`]), so every
264
+ * place that follows one — the server's `Location`, the browser's
265
+ * `location.replace` in a single-page application, a form's `303` — is
266
+ * covered by one check rather than by one each.
267
+ */
204
268
  export class RedirectError extends Error {
205
269
  to: string;
206
270
  permanent: boolean;
207
271
 
208
272
  constructor(to: string, permanent: boolean) {
273
+ refuseScriptUrl(to, permanent ? "permanentRedirect()" : "redirect()");
209
274
  super(`redirect to ${to}`);
210
275
  this.name = "RedirectError";
211
276
  this.to = to;
@@ -241,9 +306,9 @@ function specificity(segments: $ReadOnlyArray<Segment>): number {
241
306
  let score = 0;
242
307
  for (const segment of segments) {
243
308
  score += match (segment) {
244
- {kind: "static"} => 3,
245
- {kind: "param"} => 2,
246
- {kind: "catchAll"} => 1,
309
+ {kind: "static", ...} => 3,
310
+ {kind: "param", ...} => 2,
311
+ {kind: "catchAll", ...} => 1,
247
312
  };
248
313
  }
249
314
  return score;
@@ -338,12 +403,12 @@ function decodeSegment(segment: string): string {
338
403
  }
339
404
 
340
405
  /** Whether this table can render the route in the browser. */
341
- export function hasClientPage(route: { +page?: mixed, ... }): boolean {
406
+ export function hasClientPage(route: { readonly page?: mixed, ... }): boolean {
342
407
  return route.page != null;
343
408
  }
344
409
 
345
410
  /** Match a pathname against the table, preferring the most specific route. */
346
- export function matchRoute<TRoute: { +path: string, ... }>(
411
+ export function matchRoute<TRoute extends { readonly path: string, ... }>(
347
412
  routes: $ReadOnlyArray<TRoute>,
348
413
  pathname: string,
349
414
  ): ?RouteMatch<TRoute> {
@@ -356,7 +421,7 @@ export function matchRoute<TRoute: { +path: string, ... }>(
356
421
  * A slot is a second table matched against the same URL, and it has to be
357
422
  * matched by this function rather than by one of its own.
358
423
  */
359
- export function matchIn<TRoute: { +path: string, ... }>(
424
+ export function matchIn<TRoute extends { readonly path: string, ... }>(
360
425
  routes: $ReadOnlyArray<TRoute>,
361
426
  pathname: string,
362
427
  ): ?RouteMatch<TRoute> {
@@ -389,8 +454,8 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
389
454
  for (const segment of segments) {
390
455
  const next = match (segment) {
391
456
  {kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
392
- {kind: "param"} => index < parts.length ? index + 1 : -1,
393
- {kind: "catchAll"} => parts.length,
457
+ {kind: "param", ...} => index < parts.length ? index + 1 : -1,
458
+ {kind: "catchAll", ...} => parts.length,
394
459
  };
395
460
  if (next === -1) {
396
461
  return false;
@@ -401,7 +466,7 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
401
466
  }
402
467
 
403
468
  /** The nearest boundary above `pathname`, or `null` when none covers it. */
404
- export function nearestBoundary<TBoundary: { readonly path: string, ... }>(
469
+ export function nearestBoundary<TBoundary extends { readonly path: string, ... }>(
405
470
  boundaries: $ReadOnlyArray<TBoundary>,
406
471
  pathname: string,
407
472
  ): ?TBoundary {