@rangojs/router 0.23.0 → 0.24.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.
Files changed (128) hide show
  1. package/dist/bin/rango.js +4 -3
  2. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  3. package/dist/types/browser/partial-update.d.ts +6 -4
  4. package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
  5. package/dist/types/browser/react/context.d.ts +3 -2
  6. package/dist/types/browser/types.d.ts +5 -1
  7. package/dist/types/build/route-types/scan-filter.d.ts +7 -1
  8. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  9. package/dist/types/cache/document-cache.d.ts +2 -10
  10. package/dist/types/context-var.d.ts +12 -0
  11. package/dist/types/errors.d.ts +10 -0
  12. package/dist/types/handles/pushed-by.d.ts +8 -0
  13. package/dist/types/index.d.ts +4 -0
  14. package/dist/types/index.rsc.d.ts +2 -0
  15. package/dist/types/response-utils.d.ts +7 -0
  16. package/dist/types/reverse.d.ts +8 -8
  17. package/dist/types/route-definition/rewrite.d.ts +56 -0
  18. package/dist/types/route-map-builder.d.ts +7 -2
  19. package/dist/types/router/basename.d.ts +7 -0
  20. package/dist/types/router/handler-context.d.ts +1 -5
  21. package/dist/types/router/internal-params.d.ts +8 -0
  22. package/dist/types/router/middleware-types.d.ts +15 -0
  23. package/dist/types/router/revalidation.d.ts +1 -2
  24. package/dist/types/router/route-trie-builder.d.ts +10 -0
  25. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  26. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  27. package/dist/types/rsc/full-payload.d.ts +11 -0
  28. package/dist/types/rsc/handler.d.ts +33 -0
  29. package/dist/types/rsc/helpers.d.ts +8 -0
  30. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  31. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  32. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  33. package/dist/types/server/request-context.d.ts +39 -2
  34. package/dist/types/server/rewrite-trail.d.ts +49 -0
  35. package/dist/types/server/slot-accessors.d.ts +29 -0
  36. package/dist/types/testing/index.d.ts +1 -1
  37. package/dist/types/testing/render-route.d.ts +8 -0
  38. package/dist/types/testing/run-middleware.d.ts +20 -0
  39. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  40. package/dist/types/thenable-status.d.ts +6 -0
  41. package/dist/types/types/request-scope.d.ts +7 -0
  42. package/dist/types/vite/discovery/state.d.ts +12 -9
  43. package/dist/vite/index.js +164 -103
  44. package/package.json +1 -1
  45. package/skills/caching/SKILL.md +12 -0
  46. package/skills/catalog.json +1 -1
  47. package/skills/client-urls/SKILL.md +84 -0
  48. package/skills/hooks/SKILL.md +11 -6
  49. package/skills/hooks/data.md +3 -1
  50. package/skills/loader/SKILL.md +7 -1
  51. package/skills/middleware/SKILL.md +183 -2
  52. package/skills/ppr/SKILL.md +9 -0
  53. package/skills/rango/SKILL.md +1 -1
  54. package/skills/server-actions/SKILL.md +27 -0
  55. package/skills/testing/client-components.md +1 -0
  56. package/skills/testing/middleware.md +31 -10
  57. package/skills/testing/response-routes.md +1 -0
  58. package/skills/testing/serve-shell-request.md +19 -1
  59. package/skills/use-cache/SKILL.md +4 -2
  60. package/src/__augment-tests__/augmented.check.ts +20 -0
  61. package/src/browser/event-controller.ts +3 -0
  62. package/src/browser/merge-segment-loaders.ts +31 -18
  63. package/src/browser/navigation-bridge.ts +19 -3
  64. package/src/browser/partial-update.ts +27 -12
  65. package/src/browser/react/Link.tsx +7 -9
  66. package/src/browser/react/NavigationProvider.tsx +46 -26
  67. package/src/browser/react/context.ts +3 -2
  68. package/src/browser/react/use-router.ts +2 -4
  69. package/src/browser/server-action-bridge.ts +6 -0
  70. package/src/browser/types.ts +5 -1
  71. package/src/build/route-types/scan-filter.ts +11 -4
  72. package/src/cache/document-cache-policy.ts +131 -0
  73. package/src/cache/document-cache.ts +12 -119
  74. package/src/context-var.ts +32 -2
  75. package/src/errors.ts +19 -0
  76. package/src/handles/pushed-by.ts +26 -0
  77. package/src/index.rsc.ts +8 -0
  78. package/src/index.ts +7 -0
  79. package/src/response-utils.ts +19 -0
  80. package/src/reverse.ts +17 -14
  81. package/src/route-definition/redirect.ts +2 -15
  82. package/src/route-definition/rewrite.ts +146 -0
  83. package/src/route-map-builder.ts +14 -11
  84. package/src/router/basename.ts +20 -0
  85. package/src/router/handler-context.ts +3 -13
  86. package/src/router/intercept-resolution.ts +29 -12
  87. package/src/router/internal-params.ts +16 -0
  88. package/src/router/loader-resolution.ts +2 -0
  89. package/src/router/match-middleware/background-revalidation.ts +11 -9
  90. package/src/router/match-middleware/cache-lookup.ts +20 -15
  91. package/src/router/match-middleware/cache-store.ts +9 -11
  92. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  93. package/src/router/middleware-types.ts +16 -0
  94. package/src/router/middleware.ts +127 -49
  95. package/src/router/revalidation.ts +1 -1
  96. package/src/router/route-trie-builder.ts +28 -1
  97. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  98. package/src/router/segment-resolution/mask-nested.ts +8 -4
  99. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  100. package/src/router/segment-resolution/revalidation.ts +57 -33
  101. package/src/rsc/full-payload.ts +37 -5
  102. package/src/rsc/handler.ts +82 -55
  103. package/src/rsc/helpers.ts +24 -0
  104. package/src/rsc/loader-fetch.ts +79 -24
  105. package/src/rsc/response-cache-serve.ts +18 -2
  106. package/src/rsc/response-route-handler.ts +4 -7
  107. package/src/rsc/rewrite-passes.ts +203 -0
  108. package/src/rsc/rsc-rendering.ts +4 -2
  109. package/src/rsc/ssr-setup.ts +11 -5
  110. package/src/server/cookie-parse.ts +36 -12
  111. package/src/server/cookie-store.ts +7 -3
  112. package/src/server/handle-store.ts +48 -17
  113. package/src/server/request-context.ts +154 -63
  114. package/src/server/request-headers.ts +4 -14
  115. package/src/server/rewrite-trail.ts +190 -0
  116. package/src/server/slot-accessors.ts +57 -0
  117. package/src/ssr/index.tsx +118 -16
  118. package/src/testing/dispatch.ts +138 -67
  119. package/src/testing/index.ts +1 -0
  120. package/src/testing/render-route.tsx +16 -3
  121. package/src/testing/run-loader.ts +2 -0
  122. package/src/testing/run-middleware.ts +56 -11
  123. package/src/testing/serve-shell-request.ts +15 -2
  124. package/src/thenable-status.ts +11 -0
  125. package/src/types/request-scope.ts +8 -0
  126. package/src/vite/discovery/state.ts +21 -13
  127. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  128. package/src/vite/router-discovery.ts +29 -27
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { requestHeaders } from "../server/request-headers.js";
15
+ import { FORBIDDEN_KEYS } from "../context-var.js";
15
16
  import { getLoaderLazy } from "../server/loader-registry.js";
16
17
  import { isDataNotFoundError } from "../errors.js";
17
18
  import { executeLoaderMiddleware } from "../router/middleware.js";
@@ -33,18 +34,57 @@ import {
33
34
  } from "./helpers.js";
34
35
  import type { HandlerContext } from "./handler-context.js";
35
36
 
37
+ export interface LoaderErrorMeta<TEnv> {
38
+ request: Request;
39
+ url: URL;
40
+ env: TEnv;
41
+ loaderId: string;
42
+ }
43
+
44
+ /**
45
+ * A text/x-component Response carrying `{ loaderResult: null, loaderError }`,
46
+ * the payload `load()` turns into a rejection with `loaderError.message`. A
47
+ * plain-text body would reject with React's opaque "Connection closed.".
48
+ * Render failures report through onError("rendering").
49
+ */
50
+ export function renderLoaderError<TEnv>(
51
+ ctx: HandlerContext<TEnv>,
52
+ loaderError: { message: string; name: string },
53
+ status: number,
54
+ meta: LoaderErrorMeta<TEnv>,
55
+ ): Response {
56
+ const { request, url, env, loaderId } = meta;
57
+ const rscStream = ctx.renderToReadableStream(
58
+ { loaderResult: null, loaderError },
59
+ {
60
+ onError: (renderError: unknown) => {
61
+ ctx.callOnError(renderError, "rendering", {
62
+ request,
63
+ url,
64
+ env,
65
+ loaderName: loaderId,
66
+ });
67
+ },
68
+ },
69
+ );
70
+
71
+ return createResponseWithMergedHeaders(rscStream, {
72
+ status,
73
+ headers: { "content-type": "text/x-component;charset=utf-8" },
74
+ });
75
+ }
76
+
36
77
  /**
37
78
  * Build the 500 RSC error Response for a failed fetchable loader. Shared by the
38
79
  * module-load-error catch (the import itself threw) and the loader-execution
39
- * error catch — both call onError("loader"), serialize the same dev-gated error
40
- * payload via renderToReadableStream (reporting render failures through
41
- * onError("rendering")), and return a 500 text/x-component Response. The only
42
- * per-site difference is the console.error label, passed in.
80
+ * error catch — both call onError("loader") and serialize the same dev-gated
81
+ * error payload. The only per-site difference is the console.error label,
82
+ * passed in.
43
83
  */
44
84
  function buildLoaderErrorResponse<TEnv>(
45
85
  ctx: HandlerContext<TEnv>,
46
86
  error: unknown,
47
- meta: { request: Request; url: URL; env: TEnv; loaderId: string },
87
+ meta: LoaderErrorMeta<TEnv>,
48
88
  logLabel: string,
49
89
  ): Response {
50
90
  const { request, url, env, loaderId } = meta;
@@ -61,9 +101,9 @@ function buildLoaderErrorResponse<TEnv>(
61
101
  });
62
102
 
63
103
  const err = error instanceof Error ? error : new Error(String(error));
64
- const errorPayload = {
65
- loaderResult: null,
66
- loaderError: {
104
+ return renderLoaderError(
105
+ ctx,
106
+ {
67
107
  message: isDev ? err.message : "An error occurred",
68
108
  // Gate err.name to dev. In production it leaks the consumer's error class
69
109
  // name (e.g. AuthError, PrismaClientKnownRequestError) to the client; the
@@ -71,22 +111,9 @@ function buildLoaderErrorResponse<TEnv>(
71
111
  // Matches sanitizeError's dev-only name contract.
72
112
  name: isDev ? err.name : "Error",
73
113
  },
74
- };
75
- const rscStream = ctx.renderToReadableStream(errorPayload, {
76
- onError: (renderError: unknown) => {
77
- ctx.callOnError(renderError, "rendering", {
78
- request,
79
- url,
80
- env,
81
- loaderName: loaderId,
82
- });
83
- },
84
- });
85
-
86
- return createResponseWithMergedHeaders(rscStream, {
87
- status: 500,
88
- headers: { "content-type": "text/x-component;charset=utf-8" },
89
- });
114
+ 500,
115
+ meta,
116
+ );
90
117
  }
91
118
 
92
119
  export async function handleLoaderFetch<TEnv>(
@@ -183,6 +210,21 @@ export async function handleLoaderFetch<TEnv>(
183
210
  }
184
211
  }
185
212
 
213
+ // The params become ctx.params, so they hold what a route param can: string
214
+ // keys and string values, never a reserved key (#1109). The message is the
215
+ // router's own, so production sends it too.
216
+ if (!isLoaderParams(loaderParams)) {
217
+ return renderLoaderError(
218
+ ctx,
219
+ {
220
+ message: `Invalid loader params: expected an object of string values without the reserved keys ${[...FORBIDDEN_KEYS].join(", ")}`,
221
+ name: "Error",
222
+ },
223
+ 400,
224
+ { request, url, env, loaderId },
225
+ );
226
+ }
227
+
186
228
  // Execute the loader with middleware.
187
229
  // finalizeResponse drains onResponse callbacks that middleware short-circuits
188
230
  // may leave behind (executeLoaderMiddleware does not finalize them itself).
@@ -304,3 +346,16 @@ export async function handleLoaderFetch<TEnv>(
304
346
  );
305
347
  }
306
348
  }
349
+
350
+ function isLoaderParams(value: unknown): value is Record<string, string> {
351
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
352
+ return false;
353
+ }
354
+ for (const key of Object.keys(value)) {
355
+ if (FORBIDDEN_KEYS.has(key)) return false;
356
+ if (typeof (value as Record<string, unknown>)[key] !== "string") {
357
+ return false;
358
+ }
359
+ }
360
+ return true;
361
+ }
@@ -19,6 +19,7 @@
19
19
  * type-only import (erased at build).
20
20
  */
21
21
 
22
+ import { isRewrite } from "../route-definition/rewrite.js";
22
23
  import type { CacheScope } from "../cache/cache-scope.js";
23
24
  import {
24
25
  _getRequestContext,
@@ -159,10 +160,13 @@ export async function serveResponseRouteWithCache(
159
160
  // Finding #3 as document-cache.ts shouldCacheResponse.
160
161
  // Only store GET: HEAD may branch on method; sharing a write slot with GET
161
162
  // can poison subsequent GET bodies. HEAD still reads the GET key below.
163
+ // Nor a body a rewritten request built from the visitor's URL
164
+ // (ctx.rewrittenFrom, server/rewrite-trail.ts): the key is the target's.
162
165
  const canStore = (response: Response): boolean =>
163
166
  method === "GET" &&
164
167
  isCacheableStatus(response.status) &&
165
- !hasPerClientSignal(response.headers);
168
+ !hasPerClientSignal(response.headers) &&
169
+ !reqCtx._payloadVisitorLocation;
166
170
 
167
171
  // Reject unsafe persisted entries on read (pre-upgrade poison / store bugs).
168
172
  const canServeCached = (response: Response): boolean =>
@@ -206,6 +210,8 @@ export async function serveResponseRouteWithCache(
206
210
  const fresh = finalizeResponse(await executeHandler());
207
211
  if (canStore(fresh)) await putFresh(store, fresh, start);
208
212
  } catch (error) {
213
+ // The route now rewrites: nothing to refresh this entry with.
214
+ if (isRewrite(error)) return;
209
215
  reportCacheError(
210
216
  error,
211
217
  "stale-revalidation",
@@ -227,7 +233,17 @@ export async function serveResponseRouteWithCache(
227
233
 
228
234
  // Cache miss: execute the handler and cache the result when storeable.
229
235
  const start = executionStart();
230
- const response = finalizeResponse(await executeHandler());
236
+ let response: Response;
237
+ try {
238
+ response = finalizeResponse(await executeHandler());
239
+ } catch (error) {
240
+ // Route middleware rewrote: the request runs again as the target, whose
241
+ // response runs these pre-handler callbacks.
242
+ if (isRewrite(error)) {
243
+ reqCtx._onResponseCallbacks.unshift(...savedCallbacks);
244
+ }
245
+ throw error;
246
+ }
231
247
  if (canStore(response)) {
232
248
  // Clone SYNCHRONOUSLY here, before returning. The original `response` is
233
249
  // handed back to the middleware chain, where mergeResponse rebuilds it as
@@ -10,6 +10,7 @@ import { RouterError } from "../errors.js";
10
10
  import { getRequestContext } from "../server/request-context.js";
11
11
  import { contextGet, isNonCacheable } from "../context-var.js";
12
12
  import { NOCACHE_SYMBOL } from "../cache/taint.js";
13
+ import { defineRewrittenFrom } from "../server/rewrite-trail.js";
13
14
  import { RESPONSE_TYPE_MIME } from "../router/content-negotiation.js";
14
15
  import { createCacheScope } from "../cache/cache-scope.js";
15
16
  import { serveResponseRouteWithCache } from "./response-cache-serve.js";
@@ -26,6 +27,7 @@ import {
26
27
  import type { HandlerContext } from "./handler-context.js";
27
28
  import { createProblemDetails } from "./response-error.js";
28
29
  import {
30
+ createReloadResponse,
29
31
  createResponseWithMergedHeaders,
30
32
  finalizeResponse,
31
33
  isCacheableStatus,
@@ -67,13 +69,7 @@ export async function handleResponseRoute<TEnv>(
67
69
  // Partial requests (client-side navigation) to response routes
68
70
  // get X-RSC-Reload to trigger hard navigation in the browser
69
71
  if (isPartial) {
70
- return createResponseWithMergedHeaders(null, {
71
- status: 200,
72
- headers: {
73
- "X-RSC-Reload": stripInternalParams(url).toString(),
74
- "content-type": "text/x-component;charset=utf-8",
75
- },
76
- });
72
+ return createReloadResponse(getRequestContext(), url);
77
73
  }
78
74
 
79
75
  // Build lightweight context for response handler
@@ -101,6 +97,7 @@ export async function handleResponseRoute<TEnv>(
101
97
  };
102
98
  // Brand with taint symbol so "use cache" detects it as request-scoped
103
99
  // and extracts route-identifying properties (params, pathname, _responseType)
100
+ defineRewrittenFrom(responseHandlerCtx, reqCtx);
104
101
  (responseHandlerCtx as any)[NOCACHE_SYMBOL] = true;
105
102
 
106
103
  // Call handler directly, wrapped by route middleware if present
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The pass loop that honors `throw rewrite()` (route-definition/rewrite.ts):
3
+ * one catch around the global middleware chain moves the same request
4
+ * context to the target (server/request-context.ts rebaseRequestContext) and
5
+ * runs the request again from the top, as a direct visit to the target
6
+ * would run it. Shared by the request handler (rsc/handler.ts) and the
7
+ * dispatch testing primitive, so the two cannot drift.
8
+ */
9
+
10
+ import { RewriteLoopError } from "../errors.js";
11
+ import {
12
+ describeRewriteTarget,
13
+ isRewrite,
14
+ rewriteNotSupported,
15
+ type Rewrite,
16
+ } from "../route-definition/rewrite.js";
17
+ import { prefixBasename } from "../router/basename.js";
18
+ import { createReverseFunction } from "../router/handler-context.js";
19
+ import { stripInternalParams } from "../router/internal-params.js";
20
+ import {
21
+ rebaseRequestContext,
22
+ type RequestContext,
23
+ } from "../server/request-context.js";
24
+ import { recordRewriteHop } from "../server/rewrite-trail.js";
25
+
26
+ /** Response header naming the route path a rewritten request rendered. */
27
+ export const REWRITE_HEADER = "x-rango-rewrite";
28
+
29
+ /**
30
+ * Resolve a rewrite's target against the router's route map (global names,
31
+ * no param auto-fill, as middleware `ctx.reverse`) and the request origin.
32
+ * A path is basename-prefixed like `redirect()` and must stay under the
33
+ * basename once normalized (`/../admin` cannot step out of it). The
34
+ * target's own `_rsc*` params are dropped: only the visitor's transport
35
+ * params carry over. Throws for an unknown name, a missing param, another
36
+ * origin or a path outside the basename.
37
+ */
38
+ export function resolveRewriteTarget(
39
+ rewrite: Rewrite,
40
+ routeMap: Record<string, string>,
41
+ base: URL,
42
+ basename?: string,
43
+ ): URL {
44
+ const { target } = rewrite;
45
+ const path =
46
+ "path" in target
47
+ ? prefixBasename(target.path, basename)
48
+ : // `{}`, not undefined: a route with params then throws for the
49
+ // missing ones instead of resolving to its raw pattern.
50
+ createReverseFunction(routeMap)(
51
+ target.name,
52
+ target.params ?? {},
53
+ target.search,
54
+ );
55
+ const resolved = stripInternalParams(new URL(path, base.origin));
56
+ if (resolved.origin !== base.origin) {
57
+ throw new TypeError(
58
+ `${describeRewriteTarget(target)} must name a path in this app, not another origin.`,
59
+ );
60
+ }
61
+ if (
62
+ basename &&
63
+ resolved.pathname !== basename &&
64
+ !resolved.pathname.startsWith(basename + "/")
65
+ ) {
66
+ throw new TypeError(
67
+ `${describeRewriteTarget(target)} resolves to ${resolved.pathname}, outside the router's basename ${basename}.`,
68
+ );
69
+ }
70
+ resolved.hash = "";
71
+ return resolved;
72
+ }
73
+
74
+ /**
75
+ * The raw URL of a rewritten pass: the target's path and search plus the
76
+ * visitor's internal `_rsc*` transport params, so a navigation stays a
77
+ * navigation (same segments, version, partial flag).
78
+ */
79
+ function rewriteTransportUrl(target: URL, visitorRaw: URL): URL {
80
+ const raw = new URL(target);
81
+ visitorRaw.searchParams.forEach((value, key) => {
82
+ if (key.startsWith("_rsc")) raw.searchParams.append(key, value);
83
+ });
84
+ return raw;
85
+ }
86
+
87
+ /** The next pass of a rewritten request. */
88
+ export interface RewritePass {
89
+ request: Request;
90
+ /** The pass's raw URL, `_rsc*` params included. */
91
+ url: URL;
92
+ /** The target's clean URL. */
93
+ target: URL;
94
+ hops: number;
95
+ }
96
+
97
+ /**
98
+ * Move the request context to `rewrite`'s target for the next pass: check
99
+ * that this request can be rewritten, resolve the target, count the hop,
100
+ * then rebase the context. Throws the site error for a mutation or a loader
101
+ * fetch, a resolution error, and RewriteLoopError.
102
+ */
103
+ export function applyRewrite(
104
+ rewrite: Rewrite,
105
+ passRequest: Request,
106
+ reqCtx: RequestContext<any>,
107
+ routeMap: Record<string, string>,
108
+ basename: string | undefined,
109
+ ): RewritePass {
110
+ // A loader fetch first: `load()` with a body is a POST, and its refusal
111
+ // is the loader fetch's.
112
+ if (reqCtx.originalUrl.searchParams.has("_rsc_loader")) {
113
+ throw rewriteNotSupported(rewrite, "loader-fetch");
114
+ }
115
+ if (passRequest.method !== "GET" && passRequest.method !== "HEAD") {
116
+ throw rewriteNotSupported(rewrite, "mutation");
117
+ }
118
+ const target = resolveRewriteTarget(rewrite, routeMap, reqCtx.url, basename);
119
+ const hops = recordRewriteHop(reqCtx, reqCtx.url, target);
120
+ const url = rewriteTransportUrl(target, reqCtx.originalUrl);
121
+ const request = new Request(url, passRequest);
122
+ rebaseRequestContext(reqCtx, request, url);
123
+ return { request, url, target, hops };
124
+ }
125
+
126
+ /** The response to a rewrite loop. */
127
+ export function loopDetectedResponse(): Response {
128
+ return new Response("Loop Detected", {
129
+ status: 508,
130
+ headers: { "content-type": "text/plain;charset=utf-8" },
131
+ });
132
+ }
133
+
134
+ export interface RewritePassesOptions {
135
+ reqCtx: RequestContext<any>;
136
+ /** The visitor's request and raw URL: the first pass. */
137
+ request: Request;
138
+ url: URL;
139
+ routeMap: () => Record<string, string>;
140
+ basename: string | undefined;
141
+ /** One pass: the global chain matched on `url`'s path around the core handler. */
142
+ runPass: (request: Request, url: URL) => Promise<Response>;
143
+ /** After a hop moved the context to its target. */
144
+ onHop?: (pass: RewritePass) => void;
145
+ /** Report a rewrite the request cannot take (onError, phase "routing"). */
146
+ report: (error: unknown, at: { request: Request; url: URL }) => void;
147
+ /**
148
+ * The response for a refused rewrite other than a loop (a loader fetch's
149
+ * error response); undefined rethrows the error.
150
+ */
151
+ refuse?: (error: unknown, rewrite: Rewrite) => Response | undefined;
152
+ }
153
+
154
+ export interface RewritePassesResult {
155
+ response: Response;
156
+ /** The pathname the final pass rendered, when a rewrite took the request there. */
157
+ rewrittenTo?: string;
158
+ }
159
+
160
+ /**
161
+ * Run the request pass by pass until one produces a response. A `Rewrite`
162
+ * that unwinds a pass moves the context to its target and runs the next;
163
+ * a loop answers 508; a rewrite the request cannot take is reported and
164
+ * becomes `refuse`'s response, or is thrown.
165
+ */
166
+ export async function runRewritePasses(
167
+ opts: RewritePassesOptions,
168
+ ): Promise<RewritePassesResult> {
169
+ let request = opts.request;
170
+ let url = opts.url;
171
+ let rewrittenTo: string | undefined;
172
+ for (;;) {
173
+ let rewrite: Rewrite;
174
+ try {
175
+ return { response: await opts.runPass(request, url), rewrittenTo };
176
+ } catch (error) {
177
+ if (!isRewrite(error)) throw error;
178
+ rewrite = error;
179
+ }
180
+ let pass: RewritePass;
181
+ try {
182
+ pass = applyRewrite(
183
+ rewrite,
184
+ request,
185
+ opts.reqCtx,
186
+ opts.routeMap(),
187
+ opts.basename,
188
+ );
189
+ } catch (error) {
190
+ opts.report(error, { request, url });
191
+ if (error instanceof RewriteLoopError) {
192
+ return { response: loopDetectedResponse() };
193
+ }
194
+ const refused = opts.refuse?.(error, rewrite);
195
+ if (refused) return { response: refused };
196
+ throw error;
197
+ }
198
+ request = pass.request;
199
+ url = pass.url;
200
+ rewrittenTo = pass.target.pathname;
201
+ opts.onHop?.(pass);
202
+ }
203
+ }
@@ -51,7 +51,7 @@ import {
51
51
  import type { HandlerContext } from "./handler-context.js";
52
52
  import { attachTransitionWhen } from "./attach-transition-when.js";
53
53
  import { publicRouteName } from "../route-name.js";
54
- import { buildFullPayload } from "./full-payload.js";
54
+ import { buildFullPayload, renderedLocation } from "./full-payload.js";
55
55
  import {
56
56
  capShellWindow,
57
57
  lastStoredCaptureSeq,
@@ -845,7 +845,7 @@ function* preparePayloadPlan<TEnv>(
845
845
  kind: "payload",
846
846
  payload: {
847
847
  metadata: {
848
- pathname: url.pathname,
848
+ pathname: renderedLocation(reqCtx, url).pathname,
849
849
  // routerId is serialized on every payload (including within-session
850
850
  // ones) so the frontend can read the current app/router identity. It
851
851
  // always equals the current app's id: a cross-app navigation is
@@ -1867,6 +1867,8 @@ function serveShellHit(
1867
1867
  payload.metadata.initialTheme = entry.initialTheme as
1868
1868
  | import("../theme/types.js").Theme
1869
1869
  | undefined;
1870
+ // Same for the location: the capture rendered the target's.
1871
+ payload.metadata.pathname = url.pathname;
1870
1872
  }
1871
1873
  // Full Flight render per request: hydration needs the whole payload (there
1872
1874
  // is no Flight-side resume — a React limitation, not ours).
@@ -22,6 +22,7 @@ import {
22
22
  RSC_WIRE_MIME,
23
23
  } from "../router/content-negotiation.js";
24
24
  import { _getRequestContext } from "../server/request-context.js";
25
+ import { renderedLocation } from "./full-payload.js";
25
26
 
26
27
  export type SSRSetup = readonly [SSRModule, SSRStreamMode];
27
28
 
@@ -38,6 +39,8 @@ export const SSR_SETUP_VAR = "__ssrSetup";
38
39
  * lazily so stores created after kickoff are still captured). Without a
39
40
  * getter the promises run bare — no `.then()` microtasks, no
40
41
  * `performance.now()` calls — keeping the non-debug hot path lean.
42
+ * `ssrModule` reuses a load already started (a rewritten request's earlier
43
+ * pass).
41
44
  */
42
45
  export function startSSRSetup<TEnv>(
43
46
  ctx: HandlerContext<TEnv>,
@@ -45,16 +48,17 @@ export function startSSRSetup<TEnv>(
45
48
  env: TEnv,
46
49
  url: URL,
47
50
  getMetricsStore?: () => MetricsStore | undefined,
51
+ ssrModule?: Promise<SSRModule>,
48
52
  ): Promise<SSRSetup> {
49
53
  if (!getMetricsStore) {
50
54
  return Promise.all([
51
- ctx.loadSSRModule(),
55
+ ssrModule ?? ctx.loadSSRModule(),
52
56
  ctx.resolveStreamMode(request, env, url),
53
57
  ]);
54
58
  }
55
59
  const start = performance.now();
56
60
  return Promise.all([
57
- ctx.loadSSRModule().then((mod) => {
61
+ (ssrModule ?? ctx.loadSSRModule()).then((mod) => {
58
62
  appendMetric(
59
63
  getMetricsStore(),
60
64
  "ssr:module-load",
@@ -110,7 +114,9 @@ export function createSsrHtmlStage<TEnv>(
110
114
  options.url,
111
115
  options.metricsStore,
112
116
  );
113
- const renderErrors = _getRequestContext()?._renderErrors;
117
+ const reqCtx = _getRequestContext();
118
+ const renderErrors = reqCtx?._renderErrors;
119
+ const location = renderedLocation(reqCtx, options.url);
114
120
  return {
115
121
  render: () =>
116
122
  // search: the LIVE request's query string, threaded out-of-band into
@@ -121,11 +127,11 @@ export function createSsrHtmlStage<TEnv>(
121
127
  ssrModule.renderHTML(flight.stream, {
122
128
  ...options.render,
123
129
  streamMode,
124
- search: options.url.search,
130
+ search: location.search,
125
131
  // origin: same out-of-band channel — seeds the SSR store location
126
132
  // so origin-dependent markup (Link's data-external) agrees with the
127
133
  // browser's window.location across hydration.
128
- origin: options.url.origin,
134
+ origin: location.origin,
129
135
  // A component that throws inside a Suspense boundary leaves an
130
136
  // errored boundary in a completed document; the document cache
131
137
  // reads the list before storing it.
@@ -10,23 +10,47 @@
10
10
  export function parseCookiesFromHeader(
11
11
  cookieHeader: string | null,
12
12
  ): Record<string, string> {
13
- if (!cookieHeader) return {};
14
-
15
13
  const cookies: Record<string, string> = {};
16
- const pairs = cookieHeader.split(";");
17
14
 
18
- for (const pair of pairs) {
19
- const [name, ...rest] = pair.trim().split("=");
20
- if (name) {
21
- const raw = rest.join("=");
22
- try {
23
- cookies[name] = decodeURIComponent(raw);
24
- } catch {
25
- // Malformed percent-encoding: fall back to raw value
26
- cookies[name] = raw;
15
+ if (cookieHeader) {
16
+ for (const pair of cookieHeader.split(";")) {
17
+ const [name, ...rest] = pair.trim().split("=");
18
+ if (name) {
19
+ const raw = rest.join("=");
20
+ let value: string;
21
+ try {
22
+ value = decodeURIComponent(raw);
23
+ } catch {
24
+ // Malformed percent-encoding: fall back to raw value
25
+ value = raw;
26
+ }
27
+ setCookie(cookies, name, value);
27
28
  }
28
29
  }
29
30
  }
30
31
 
32
+ // Null prototype (#1109): cookie names come from the request, so a lookup
33
+ // must never answer `constructor` or `toString` with an inherited member.
34
+ // Set once at the end: Object.create(null) starts in dictionary mode, which
35
+ // makes a spread of the jar several times slower.
36
+ Object.setPrototypeOf(cookies, null);
31
37
  return cookies;
32
38
  }
39
+
40
+ /** `cookies[name] = value`, a cookie named `__proto__` as an own key too. */
41
+ function setCookie(
42
+ cookies: Record<string, string>,
43
+ name: string,
44
+ value: string,
45
+ ): void {
46
+ if (name === "__proto__") {
47
+ Object.defineProperty(cookies, name, {
48
+ value,
49
+ writable: true,
50
+ enumerable: true,
51
+ configurable: true,
52
+ });
53
+ } else {
54
+ cookies[name] = value;
55
+ }
56
+ }
@@ -367,12 +367,16 @@ function createCookieStore(
367
367
 
368
368
  getAll(name?: string): Cookie[] {
369
369
  recordLoaderIdentityRead("cookies()");
370
- const all = ctx._readCookies();
371
370
  if (name !== undefined) {
372
- const value = all[name];
371
+ // The single-name read, not an index into the plain object
372
+ // _readCookies() returns: that would answer an inherited member.
373
+ const value = ctx._readCookie(name);
373
374
  return value !== undefined ? [{ name, value }] : [];
374
375
  }
375
- return Object.entries(all).map(([n, v]) => ({ name: n, value: v }));
376
+ return Object.entries(ctx._readCookies()).map(([n, v]) => ({
377
+ name: n,
378
+ value: v,
379
+ }));
376
380
  },
377
381
 
378
382
  has(name: string): boolean {