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

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/handler.js CHANGED
@@ -71,6 +71,7 @@
71
71
 
72
72
  import { asResponder, noteRoute } from "@uniflowed/server/host";
73
73
 
74
+ import { matchRoute } from "./internal/routing.js";
74
75
  import { requireRequest } from "./internal/request.js";
75
76
  import type { RouteParams } from "./internal/runtime.js";
76
77
 
@@ -135,61 +136,55 @@ export const HANDLER_METHODS: $ReadOnlyArray<string> = Object.freeze([
135
136
  export function createDispatcher(options: {|
136
137
  readonly handlers: $ReadOnlyArray<HandlerRecord>,
137
138
  |}): (request: Request) => Promise<Response | null> {
138
- // Longest path first, so `/api/users/new` wins over `/api/users/[id]` and a
139
- // catch-all is the last thing tried.
140
- const table = [...options.handlers].sort((a, b) => specificity(b.path) - specificity(a.path));
139
+ const table = [...options.handlers];
141
140
 
142
141
  return async function dispatch(request: Request): Promise<Response | null> {
143
142
  // The host's half of the contract, checked rather than assumed; see
144
143
  // `./internal/request.js`.
145
144
  requireRequest("dispatch");
146
145
  const url = new URL(request.url);
147
- for (const record of table) {
148
- const params = matchPath(record.path, url.pathname);
149
- if (params == null) {
150
- continue;
151
- }
152
-
153
- // Before the module is loaded and before the method is checked, because
154
- // this is the answer to "what was this request" and a `405` is as much
155
- // this route's answer as a `200` is. A log of `/api/users/:id 405` is
156
- // actionable; the same line with the path in it is a million lines.
157
- noteRoute(record.path);
158
-
159
- const module = await record.load();
160
- const method = request.method.toUpperCase();
161
- const handler = pick(module, method);
162
- if (handler == null) {
163
- return methodNotAllowed(module);
164
- }
165
-
166
- // In the host's request, so a handler that calls `headers()`,
167
- // `cookies()` or `after()` answers about the same one its guard did, and
168
- // what it defers is drained once, by the host, after the bytes are out.
169
- //
170
- // And inside `asResponder`, which is the other half: a route handler is
171
- // one of the two things that owns a response, so it is one of the two
172
- // places `draftMode().enable()` is allowed — and the `Set-Cookie` it
173
- // decided on is written onto the response below rather than left on an
174
- // object the host is about to discard. See ubugeeei-prod/uf#282.
175
- const response = await asResponder("a route handler", async () =>
176
- handler(request, { params, searchParams: url.searchParams }),
177
- );
146
+ const match = matchRoute(table, url.pathname);
147
+ if (match == null) return null;
148
+ const { route: record, params } = match;
149
+
150
+ // Before the module is loaded and before the method is checked, because
151
+ // this is the answer to "what was this request" and a `405` is as much
152
+ // this route's answer as a `200` is. A log of `/api/users/:id 405` is
153
+ // actionable; the same line with the path in it is a million lines.
154
+ noteRoute(record.path);
155
+
156
+ const module = await record.load();
157
+ const method = request.method.toUpperCase();
158
+ const handler = pick(module, method);
159
+ if (handler == null) {
160
+ return methodNotAllowed(module);
161
+ }
178
162
 
179
- // A `HEAD` answered by `GET` must not carry the body. The test is
180
- // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
181
- // `GET`, so asking it whether a `HEAD` exists always said yes and the
182
- // body went out anyway.
183
- if (method === "HEAD" && typeof module.HEAD !== "function") {
184
- return new Response(null, {
185
- status: response.status,
186
- statusText: response.statusText,
187
- headers: response.headers,
188
- });
189
- }
190
- return response;
163
+ // In the host's request, so a handler that calls `headers()`,
164
+ // `cookies()` or `after()` answers about the same one its guard did, and
165
+ // what it defers is drained once, by the host, after the bytes are out.
166
+ //
167
+ // And inside `asResponder`, which is the other half: a route handler is
168
+ // one of the two things that owns a response, so it is one of the two
169
+ // places `draftMode().enable()` is allowed — and the `Set-Cookie` it
170
+ // decided on is written onto the response below rather than left on an
171
+ // object the host is about to discard. See ubugeeei-prod/uf#282.
172
+ const response = await asResponder("a route handler", async () =>
173
+ handler(request, { params, searchParams: url.searchParams }),
174
+ );
175
+
176
+ // A `HEAD` answered by `GET` must not carry the body. The test is
177
+ // against the module's own `HEAD`, not `pick`'s — `pick` falls back to
178
+ // `GET`, so asking it whether a `HEAD` exists always said yes and the
179
+ // body went out anyway.
180
+ if (method === "HEAD" && typeof module.HEAD !== "function") {
181
+ return new Response(null, {
182
+ status: response.status,
183
+ statusText: response.statusText,
184
+ headers: response.headers,
185
+ });
191
186
  }
192
- return null;
187
+ return response;
193
188
  };
194
189
  }
195
190
 
@@ -234,61 +229,3 @@ function methodNotAllowed(module: HandlerModule): Response {
234
229
  headers: { allow: HANDLER_METHODS.filter((method) => own.has(method)).join(", ") },
235
230
  });
236
231
  }
237
-
238
- /**
239
- * Match one route path against a pathname, returning its parameters.
240
- *
241
- * `null` rather than an empty object when it does not match, so a route with
242
- * no parameters is still distinguishable from a miss.
243
- */
244
- function matchPath(routePath: string, pathname: string): RouteParams | null {
245
- const wanted = segmentsOf(routePath);
246
- const given = segmentsOf(pathname);
247
- const params: { [string]: string | Array<string> } = {};
248
-
249
- for (let index = 0; index < wanted.length; index += 1) {
250
- const segment = wanted[index];
251
- if (segment.startsWith(":") && segment.endsWith("*")) {
252
- // A catch-all takes the rest, and matches zero segments as well as many.
253
- params[segment.slice(1, -1)] = given.slice(index);
254
- return params as $FlowFixMe;
255
- }
256
- if (index >= given.length) {
257
- return null;
258
- }
259
- if (segment.startsWith(":")) {
260
- params[segment.slice(1)] = given[index];
261
- continue;
262
- }
263
- if (segment !== given[index]) {
264
- return null;
265
- }
266
- }
267
-
268
- return wanted.length === given.length ? (params as $FlowFixMe) : null;
269
- }
270
-
271
- function segmentsOf(value: string): Array<string> {
272
- return value.split("/").filter((segment) => segment !== "");
273
- }
274
-
275
- /**
276
- * How specific a path is, so the table can be tried in the right order.
277
- *
278
- * A literal segment is worth more than a parameter and a parameter more than a
279
- * catch-all, and a longer path outranks a shorter one — which is what makes
280
- * `/api/users/new` win over `/api/users/[id]`.
281
- */
282
- function specificity(routePath: string): number {
283
- let score = 0;
284
- for (const segment of segmentsOf(routePath)) {
285
- if (segment.startsWith(":") && segment.endsWith("*")) {
286
- score += 1;
287
- } else if (segment.startsWith(":")) {
288
- score += 10;
289
- } else {
290
- score += 100;
291
- }
292
- }
293
- return score;
294
- }
package/http-client.js ADDED
@@ -0,0 +1,104 @@
1
+ // @flow
2
+ import {
3
+ ACTION_CONTENT_TYPE,
4
+ ACTION_HEADER,
5
+ decodeActionResult,
6
+ encodeActionArguments,
7
+ isActionId,
8
+ type ActionValue,
9
+ } from "./internal/action-wire.js";
10
+
11
+ export type RouteClientOptions = {|
12
+ readonly origin: string,
13
+ readonly getToken?: () => Promise<string>,
14
+ readonly fetch?: (url: string, options: RequestOptions) => Promise<Response>,
15
+ readonly allowInsecureDevelopment?: boolean,
16
+ |};
17
+
18
+ export type RouteRequest = {|
19
+ readonly method?: string,
20
+ readonly headers?: { readonly [string]: string },
21
+ readonly body?: string,
22
+ readonly signal?: AbortSignal,
23
+ |};
24
+
25
+ type RequestOptions = {|
26
+ ...RouteRequest,
27
+ readonly credentials: "omit",
28
+ readonly redirect: "error",
29
+ |};
30
+
31
+ /** Fetch transport shared by generated typed route clients and native actions. */
32
+ export function createRouteClient(
33
+ options: RouteClientOptions,
34
+ ): (path: string, request?: RouteRequest) => Promise<Response> {
35
+ const origin = new URL(options.origin);
36
+ if (
37
+ origin.username ||
38
+ origin.password ||
39
+ origin.pathname !== "/" ||
40
+ origin.search ||
41
+ origin.hash
42
+ ) {
43
+ throw new TypeError("createRouteClient origin must contain only a scheme and host");
44
+ }
45
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname);
46
+ if (
47
+ origin.protocol !== "https:" &&
48
+ !(origin.protocol === "http:" && (loopback || options.allowInsecureDevelopment === true))
49
+ ) {
50
+ throw new TypeError(
51
+ "createRouteClient requires HTTPS outside explicit development connections",
52
+ );
53
+ }
54
+ const send = options.fetch ?? globalThis.fetch;
55
+ return async (path, request = {}) => {
56
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\")) {
57
+ throw new TypeError("route client needs a same-origin absolute path");
58
+ }
59
+ const url = new URL(path, origin);
60
+ if (url.origin !== origin.origin) throw new TypeError("route client cannot change origin");
61
+ const headers: { [string]: string } = {};
62
+ for (const [name, value] of Object.entries(request.headers ?? {})) {
63
+ const lower = name.toLowerCase();
64
+ if (
65
+ ["cookie", "authorization", "origin", "host"].includes(lower) ||
66
+ lower.startsWith("sec-fetch-")
67
+ ) {
68
+ throw new TypeError(`route client does not accept the ${name} header`);
69
+ }
70
+ if (typeof value !== "string") throw new TypeError(`route header ${name} must be a string`);
71
+ headers[name] = value;
72
+ }
73
+ if (options.getToken != null) {
74
+ const token = await options.getToken();
75
+ if (!/^[A-Za-z0-9\-._~+/]+=*$/.test(token) || token.length > 8192) {
76
+ throw new TypeError("route client received an invalid bearer credential");
77
+ }
78
+ headers.authorization = `Bearer ${token}`;
79
+ }
80
+ return send(url.href, { ...request, headers, credentials: "omit", redirect: "error" });
81
+ };
82
+ }
83
+
84
+ /** Explicit action channel for clients holding application-issued bearer tokens. */
85
+ export function createNativeActionClient(options: {|
86
+ ...RouteClientOptions,
87
+ readonly getToken: () => Promise<string>,
88
+ |}): (id: string, args: $ReadOnlyArray<mixed>, path?: string) => Promise<ActionValue | void> {
89
+ const send = createRouteClient(options);
90
+ return async (id, args, path = "/") => {
91
+ if (!isActionId(id)) throw new TypeError("native action needs an ID from the current build");
92
+ const response = await send(path, {
93
+ method: "POST",
94
+ headers: {
95
+ [ACTION_HEADER]: id,
96
+ "uf-native-action": "bearer-v1",
97
+ "content-type": ACTION_CONTENT_TYPE,
98
+ },
99
+ body: encodeActionArguments(args),
100
+ });
101
+ if (!response.ok) throw new Error(`Native action failed with status ${response.status}`);
102
+ return decodeActionResult(await response.text());
103
+ };
104
+ }
package/index.js CHANGED
@@ -89,6 +89,7 @@ export {
89
89
  splitUrl,
90
90
  unauthorized,
91
91
  useIsServer,
92
+ useLinkStatus,
92
93
  useLoaderData,
93
94
  useRoute,
94
95
  useRouter,
@@ -88,7 +88,7 @@
88
88
  // it. Written here because this is the file somebody reads before deciding
89
89
  // otherwise.
90
90
 
91
- import { asResponder } from "@uniflowed/server/host";
91
+ import { asResponder, nativeActionAllowed } from "@uniflowed/server/host";
92
92
 
93
93
  import {
94
94
  ACTION_CONTENT_TYPE,
@@ -163,7 +163,8 @@ export function createActionDispatcher(options: {|
163
163
  if (request.method.toUpperCase() !== "POST") {
164
164
  return new Response(null, { status: 405, headers: { ...ANSWER_HEADERS, allow: "POST" } });
165
165
  }
166
- if (!sameOrigin(request)) {
166
+ const native = request.headers.has("uf-native-action");
167
+ if (native ? !nativeActionAllowed(request) : !sameOrigin(request)) {
167
168
  return refusal(403);
168
169
  }
169
170
  if (!isJson(request.headers.get("content-type"))) {
@@ -28,7 +28,9 @@ import { createFromFetch, createFromReadableStream } from "react-server-dom-parc
28
28
  import { FLIGHT_CHUNK_ATTRIBUTE, flightChunkBytes } from "./flight-chunks.js";
29
29
  import {
30
30
  FLIGHT_CONTENT_TYPE,
31
+ INTERCEPTED_FROM_HEADER,
31
32
  type FetchedFlight,
33
+ type FlightFetchOptions,
32
34
  type FlightRoot,
33
35
  documentPathOf,
34
36
  flightUrl,
@@ -186,16 +188,24 @@ export function readDocumentPayload(
186
188
  * static host's `404.html` for a file it does not have, a middleware's own
187
189
  * refusal — is a document, whatever its status.
188
190
  */
189
- export async function fetchFlight(url: string): Promise<FetchedFlight> {
191
+ export async function fetchFlight(
192
+ url: string,
193
+ options?: FlightFetchOptions,
194
+ ): Promise<FetchedFlight> {
190
195
  // Outside the `try` below, which answers every failure to fetch with a
191
196
  // document load: too old a React is not a network failure, and a navigation
192
197
  // that quietly reloaded the page would hide it.
193
198
  requireServerComponentsReact(ENTRY);
199
+ const headers = new Headers({ accept: FLIGHT_CONTENT_TYPE });
200
+ const interceptedFrom = options?.interceptedFrom;
201
+ if (interceptedFrom != null) {
202
+ headers.set(INTERCEPTED_FROM_HEADER, interceptedFrom);
203
+ }
194
204
  let response: Response;
195
205
  try {
196
206
  response = await fetch(flightUrl(url), {
197
207
  credentials: "same-origin",
198
- headers: { accept: FLIGHT_CONTENT_TYPE },
208
+ headers,
199
209
  });
200
210
  } catch {
201
211
  // A request that could not be made or followed: a dropped connection, or a
@@ -52,6 +52,7 @@ export type RouteState = {|
52
52
  readonly viewTransition: ?string,
53
53
  readonly status: 200 | 401 | 403 | 404 | 500,
54
54
  readonly error: ?RouteError,
55
+ readonly interception?: ?FlightInterception,
55
56
  |};
56
57
 
57
58
  /** Row 0 of a route's payload: the route, and the tree it rendered. */
@@ -60,6 +61,18 @@ export type FlightRoot = {|
60
61
  readonly tree: Node,
61
62
  |};
62
63
 
64
+ /** The intercepted URL a Flight payload rendered, and the page it rendered over. */
65
+ export type FlightInterception = {|
66
+ readonly pathname: string,
67
+ readonly search: string,
68
+ readonly from: string,
69
+ |};
70
+
71
+ /** What the browser may send with a payload request. */
72
+ export type FlightFetchOptions = {|
73
+ readonly interceptedFrom?: string,
74
+ |};
75
+
63
76
  /**
64
77
  * What fetching a route's payload turned into.
65
78
  *
@@ -88,6 +101,7 @@ export type FetchedFlight =
88
101
 
89
102
  /** The part of a resolved route that crosses to the browser. */
90
103
  export function routeState(resolved: ResolvedRoute): RouteState {
104
+ const interception = resolved.interception;
91
105
  return {
92
106
  pathname: resolved.pathname,
93
107
  search: resolved.search,
@@ -100,6 +114,14 @@ export function routeState(resolved: ResolvedRoute): RouteState {
100
114
  viewTransition: resolved.viewTransition,
101
115
  status: resolved.status,
102
116
  error: resolved.error,
117
+ interception:
118
+ interception == null
119
+ ? null
120
+ : {
121
+ pathname: interception.pathname,
122
+ search: interception.search,
123
+ from: interception.base.pathname + interception.base.search,
124
+ },
103
125
  };
104
126
  }
105
127
 
@@ -123,6 +145,9 @@ export const FLIGHT_SEGMENT: string = "__uf.flight";
123
145
  /** The content type a payload is answered with. */
124
146
  export const FLIGHT_CONTENT_TYPE: string = "text/x-component";
125
147
 
148
+ /** The request header that carries the page an intercepted payload is rendered over. */
149
+ export const INTERCEPTED_FROM_HEADER: string = "uf-intercepted-from";
150
+
126
151
  /**
127
152
  * The URL of `url`'s payload: its pathname with [`FLIGHT_SEGMENT`] appended,
128
153
  * and its query string unchanged.
@@ -0,0 +1,67 @@
1
+ // @flow
2
+ import type { RouteTable } from "./routing.js";
3
+ import { hasClientPage, matchRoute } from "./routing.js";
4
+
5
+ type Table = RouteTable<mixed, mixed, mixed, mixed, mixed>;
6
+
7
+ /** One parser for cold starts, warm deliveries and programmatic navigation. */
8
+ export function nativeLinkHref(table: Table, to: string): string | null {
9
+ const links = table.nativeLinks;
10
+ if (links == null || !/^https:\/\//i.test(to) || /[\\\u0000-\u0020]/.test(to)) return null;
11
+ try {
12
+ const url = new URL(to);
13
+ if (url.username !== "" || url.password !== "" || url.hash !== "") return null;
14
+ // URL does not reject malformed percent escapes. Encoded separators must
15
+ // not change the route when another layer decodes the path a second time.
16
+ decodeURIComponent(url.pathname + url.search);
17
+ if (/%(?:2f|5c)/i.test(url.pathname)) return null;
18
+ if (!links.origins.some((origin) => new URL(origin).origin === url.origin)) return null;
19
+ const match = matchRoute(table.routes, url.pathname);
20
+ if (match == null || !hasClientPage(match.route) || !links.routes.includes(match.route.path))
21
+ return null;
22
+ return url.pathname + url.search;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export type NativeLinkSource = {|
29
+ readonly getInitialURL: () => Promise<string | null>,
30
+ readonly addEventListener: (
31
+ event: "url",
32
+ listener: (event: {| url: string |}) => void,
33
+ ) => {| remove: () => void |},
34
+ |};
35
+
36
+ /** Pass React Native's Linking; rejected links stay with the OS/browser. */
37
+ export function createNativeLinking(
38
+ table: Table,
39
+ source: NativeLinkSource,
40
+ ): {|
41
+ getInitialURL: () => Promise<string | null>,
42
+ subscribe: (listener: (href: string) => void) => () => void,
43
+ |} {
44
+ let last: string | null = null;
45
+ let deliveredAt = -Infinity;
46
+ function receive(url: string): string | null {
47
+ const href = nativeLinkHref(table, url);
48
+ const now = Date.now();
49
+ if (href == null || (href === last && now - deliveredAt < 1000)) return null;
50
+ last = href;
51
+ deliveredAt = now;
52
+ return href;
53
+ }
54
+ return {
55
+ async getInitialURL() {
56
+ const url = await source.getInitialURL();
57
+ return url == null ? null : receive(url);
58
+ },
59
+ subscribe(listener) {
60
+ const subscription = source.addEventListener("url", ({ url }) => {
61
+ const href = receive(url);
62
+ if (href != null) listener(href);
63
+ });
64
+ return () => subscription.remove();
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,89 @@
1
+ // @flow
2
+ import type { RouteModule, RouteTable } from "./routing.js";
3
+
4
+ export type NativeLayout = {
5
+ readonly segment: string,
6
+ readonly file: string,
7
+ readonly module: RouteModule<>,
8
+ };
9
+ export type NativeNode = {
10
+ name: string,
11
+ module: ?RouteModule<>,
12
+ layout: boolean,
13
+ children: Array<NativeNode>,
14
+ };
15
+ export type NativeTree = { root: NativeNode, paths: Map<string, $ReadOnlyArray<string>> };
16
+
17
+ /** Module identity comes from the generated table; group names never become URLs. */
18
+ export function nativeTree(table: RouteTable<>, layouts: $ReadOnlyArray<NativeLayout>): NativeTree {
19
+ const rootLayout = layouts.find((layout) => layout.segment === "/");
20
+ const root: NativeNode = { name: "root", module: rootLayout?.module, layout: true, children: [] };
21
+ const metadata = new Map(layouts.map((layout) => [layout.module, layout]));
22
+ const paths = new Map<string, $ReadOnlyArray<string>>();
23
+ for (const route of table.routes) {
24
+ if (route.page == null) continue;
25
+ let parent = root;
26
+ const path = [];
27
+ for (const load of route.layouts) {
28
+ if (load === rootLayout?.module) continue;
29
+ const layout = metadata.get(load);
30
+ if (layout == null)
31
+ throw new Error(`Native navigation: missing layout metadata for ${route.path}`);
32
+ const name = `layout:${layout.segment}`;
33
+ let child = parent.children.find((node) => node.name === name);
34
+ if (child == null) {
35
+ child = { name, module: load, layout: true, children: [] };
36
+ parent.children.push(child);
37
+ }
38
+ parent = child;
39
+ path.push(name);
40
+ }
41
+ parent.children.push({ name: route.path, module: route.page, layout: false, children: [] });
42
+ path.push(route.path);
43
+ paths.set(route.path, path);
44
+ }
45
+ return { root, paths };
46
+ }
47
+
48
+ export type NavigationPayload = {
49
+ readonly ufHref: string,
50
+ readonly ufParams: { readonly [string]: string | $ReadOnlyArray<string> },
51
+ };
52
+ export type NavigationState = {
53
+ readonly key?: string,
54
+ readonly type?: string,
55
+ readonly index?: number,
56
+ readonly routes: $ReadOnlyArray<{
57
+ readonly name: string,
58
+ readonly params?: NavigationPayload,
59
+ readonly state?: NavigationState,
60
+ }>,
61
+ };
62
+
63
+ export function stateForPath(
64
+ names: $ReadOnlyArray<string>,
65
+ payload: NavigationPayload,
66
+ ): NavigationState {
67
+ const [name, ...rest] = names;
68
+ if (name == null) throw new Error("Native navigation: an empty screen path cannot be opened");
69
+ return {
70
+ routes: [
71
+ {
72
+ name,
73
+ ...(rest.length === 0 ? { params: payload } : { state: stateForPath(rest, payload) }),
74
+ },
75
+ ],
76
+ };
77
+ }
78
+
79
+ export function hrefFromState(state: NavigationState): string | null {
80
+ const route = state.routes[state.index ?? state.routes.length - 1];
81
+ if (route == null) return null;
82
+ return route.state == null ? (route.params?.ufHref ?? null) : hrefFromState(route.state);
83
+ }
84
+
85
+ /** React Navigation's nested navigate payload, separate from user route params. */
86
+ export function paramsForPath(names: $ReadOnlyArray<string>, payload: NavigationPayload): mixed {
87
+ const [screen, ...rest] = names;
88
+ return screen == null ? payload : { screen, params: paramsForPath(rest, payload) };
89
+ }
@@ -61,6 +61,12 @@ let staleTimeMs: number = 0;
61
61
  */
62
62
  export function installStaleTime(seconds: number): void {
63
63
  staleTimeMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
64
+ if (import.meta.hot != null) {
65
+ Object.defineProperty((globalThis: $FlowFixMe), "__UF_NAVIGATION_CACHE__", {
66
+ configurable: true,
67
+ value: inspectNavigationCache,
68
+ });
69
+ }
64
70
  if (staleTimeMs === 0) {
65
71
  clearNavigationCache();
66
72
  }
@@ -87,6 +93,15 @@ export type NavigationCache<T> = {|
87
93
  readonly clear: () => void,
88
94
  /** How many routes are kept, fresh or not. */
89
95
  readonly size: () => number,
96
+ readonly inspect: () => $ReadOnlyArray<NavigationCacheEntry>,
97
+ |};
98
+
99
+ /** Metadata only: inspecting the cache never exposes loader or Flight data. */
100
+ export type NavigationCacheEntry = {|
101
+ readonly key: string,
102
+ readonly cachedAt: number,
103
+ readonly staleAt: number,
104
+ readonly fresh: boolean,
90
105
  |};
91
106
 
92
107
  function createNavigationCache<T>(): NavigationCache<T> {
@@ -128,6 +143,15 @@ function createNavigationCache<T>(): NavigationCache<T> {
128
143
  size() {
129
144
  return entries.size;
130
145
  },
146
+ inspect() {
147
+ const now = Date.now();
148
+ return Array.from(entries, ([key, entry]) => ({
149
+ key,
150
+ cachedAt: entry.at,
151
+ staleAt: entry.at + staleTimeMs,
152
+ fresh: now - entry.at < staleTimeMs,
153
+ }));
154
+ },
131
155
  };
132
156
  }
133
157
 
@@ -137,6 +161,19 @@ export const flightNavigations: NavigationCache<Promise<FetchedFlight>> = create
137
161
  /** Resolved routes, for an application rendered from its modules. */
138
162
  export const routeNavigations: NavigationCache<Promise<ResolvedRoute>> = createNavigationCache();
139
163
 
164
+ /** A fresh snapshot for the development console, without reading an entry. */
165
+ export function inspectNavigationCache(): {|
166
+ readonly staleTime: number,
167
+ readonly flight: $ReadOnlyArray<NavigationCacheEntry>,
168
+ readonly routes: $ReadOnlyArray<NavigationCacheEntry>,
169
+ |} {
170
+ return {
171
+ staleTime: staleTimeMs / 1000,
172
+ flight: flightNavigations.inspect(),
173
+ routes: routeNavigations.inspect(),
174
+ };
175
+ }
176
+
140
177
  /** Forget every kept route. `router.refresh()` and each server action call this. */
141
178
  export function clearNavigationCache(): void {
142
179
  flightNavigations.clear();
@@ -1043,15 +1043,13 @@ export function interceptingRoutes(
1043
1043
  * something to show for one URL, the way two slots each match one URL by their
1044
1044
  * own routes.
1045
1045
  *
1046
- * # Never on a server
1046
+ * # Never for a document
1047
1047
  *
1048
- * Nothing on the server calls this. A document request for an intercepted URL
1049
- * resolves the ordinary page, because a request carries where it is going and
1050
- * not what was on screen when it was made — which is what a reload, a shared
1051
- * link and a crawler all are. That includes the Flight renderer, so a browser
1052
- * holding a payload rather than a resolved route navigates to the page the URL
1053
- * names: interception is a feature of the router that resolves routes from
1054
- * their modules.
1048
+ * A document request for an intercepted URL resolves the ordinary page,
1049
+ * because a request carries where it is going and not what was on screen when
1050
+ * it was made — which is what a reload, a shared link and a crawler all are.
1051
+ * A Flight payload request may carry the page the browser is navigating from,
1052
+ * and the React Server Components renderer calls this for that request only.
1055
1053
  *
1056
1054
  * # When the interception cannot render
1057
1055
  *
@@ -139,6 +139,10 @@ export type RouteTable<
139
139
  TError = mixed,
140
140
  > = {|
141
141
  readonly routes: $ReadOnlyArray<RouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
142
+ readonly nativeLinks?: {|
143
+ readonly origins: $ReadOnlyArray<string>,
144
+ readonly routes: $ReadOnlyArray<string>,
145
+ |},
142
146
  readonly notFound: $ReadOnlyArray<NotFoundBoundary<TPage, TLayout>>,
143
147
  readonly errors: $ReadOnlyArray<ErrorBoundary<TError, TLayout>>,
144
148
  |};