@rangojs/router 0.0.0-experimental.124 → 0.0.0-experimental.126

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 (235) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/rango.js +3 -4
  3. package/dist/vite/index.js +315 -68
  4. package/package.json +19 -18
  5. package/skills/breadcrumbs/SKILL.md +60 -0
  6. package/skills/hooks/SKILL.md +2 -2
  7. package/skills/route/SKILL.md +6 -0
  8. package/skills/server-actions/SKILL.md +25 -1
  9. package/skills/testing/SKILL.md +17 -17
  10. package/skills/testing/cache-prerender.md +29 -3
  11. package/skills/testing/flight.md +13 -10
  12. package/skills/testing/render-handler.md +3 -0
  13. package/skills/testing/server-tree.md +1 -1
  14. package/skills/testing/setup.md +1 -1
  15. package/src/__internal.ts +0 -65
  16. package/src/browser/action-coordinator.ts +1 -1
  17. package/src/browser/action-fence.ts +10 -0
  18. package/src/browser/event-controller.ts +1 -83
  19. package/src/browser/navigation-store-handle.ts +3 -4
  20. package/src/browser/navigation-store.ts +0 -39
  21. package/src/browser/navigation-transaction.ts +0 -32
  22. package/src/browser/partial-update.ts +23 -84
  23. package/src/browser/prefetch/cache.ts +6 -45
  24. package/src/browser/prefetch/queue.ts +6 -3
  25. package/src/browser/rango-state.ts +2 -23
  26. package/src/browser/react/Link.tsx +0 -2
  27. package/src/browser/react/NavigationProvider.tsx +2 -1
  28. package/src/browser/react/ScrollRestoration.tsx +10 -6
  29. package/src/browser/react/filter-segment-order.ts +0 -2
  30. package/src/browser/react/index.ts +0 -45
  31. package/src/browser/react/location-state-shared.ts +0 -13
  32. package/src/browser/react/location-state.ts +0 -1
  33. package/src/browser/react/use-action.ts +6 -15
  34. package/src/browser/react/use-handle.ts +0 -5
  35. package/src/browser/react/use-link-status.ts +0 -4
  36. package/src/browser/react/use-navigation.ts +0 -3
  37. package/src/browser/react/use-params.ts +0 -2
  38. package/src/browser/react/use-router.ts +2 -1
  39. package/src/browser/react/use-search-params.ts +0 -5
  40. package/src/browser/react/use-segments.ts +0 -13
  41. package/src/browser/rsc-router.tsx +10 -3
  42. package/src/browser/server-action-bridge.ts +51 -3
  43. package/src/browser/types.ts +23 -5
  44. package/src/browser/validate-redirect-origin.ts +43 -16
  45. package/src/build/index.ts +8 -9
  46. package/src/build/route-trie.ts +46 -11
  47. package/src/build/route-types/param-extraction.ts +6 -3
  48. package/src/build/route-types/router-processing.ts +0 -8
  49. package/src/cache/cache-policy.ts +0 -54
  50. package/src/cache/cache-runtime.ts +48 -24
  51. package/src/cache/cache-scope.ts +0 -27
  52. package/src/cache/cache-tag.ts +0 -37
  53. package/src/cache/cf/cf-cache-store.ts +72 -45
  54. package/src/cache/cf/index.ts +0 -24
  55. package/src/cache/document-cache.ts +10 -36
  56. package/src/cache/handle-snapshot.ts +0 -40
  57. package/src/cache/index.ts +0 -27
  58. package/src/cache/memory-segment-store.ts +0 -52
  59. package/src/cache/profile-registry.ts +6 -30
  60. package/src/cache/read-through-swr.ts +41 -11
  61. package/src/cache/segment-codec.ts +0 -16
  62. package/src/cache/types.ts +0 -98
  63. package/src/client.rsc.tsx +4 -22
  64. package/src/client.tsx +19 -32
  65. package/src/context-var.ts +12 -0
  66. package/src/defer.ts +196 -0
  67. package/src/deps/ssr.ts +0 -1
  68. package/src/handle.ts +2 -12
  69. package/src/handles/MetaTags.tsx +0 -14
  70. package/src/handles/breadcrumbs.ts +16 -5
  71. package/src/handles/meta.ts +0 -39
  72. package/src/host/cookie-handler.ts +0 -36
  73. package/src/host/errors.ts +0 -24
  74. package/src/host/index.ts +6 -0
  75. package/src/host/pattern-matcher.ts +7 -50
  76. package/src/host/router.ts +1 -65
  77. package/src/host/testing.ts +0 -16
  78. package/src/host/types.ts +6 -2
  79. package/src/href-client.ts +0 -4
  80. package/src/index.rsc.ts +27 -2
  81. package/src/index.ts +7 -0
  82. package/src/internal-debug.ts +2 -4
  83. package/src/loader.rsc.ts +4 -15
  84. package/src/loader.ts +3 -9
  85. package/src/network-error-thrower.tsx +1 -6
  86. package/src/outlet-provider.tsx +1 -5
  87. package/src/prerender/param-hash.ts +10 -11
  88. package/src/prerender/store.ts +23 -30
  89. package/src/prerender.ts +34 -0
  90. package/src/redirect-origin.ts +100 -0
  91. package/src/root-error-boundary.tsx +1 -19
  92. package/src/route-content-wrapper.tsx +1 -44
  93. package/src/route-definition/dsl-helpers.ts +7 -19
  94. package/src/route-definition/helpers-types.ts +3 -3
  95. package/src/route-definition/redirect.ts +43 -9
  96. package/src/route-definition/resolve-handler-use.ts +6 -0
  97. package/src/route-map-builder.ts +0 -16
  98. package/src/router/content-negotiation.ts +0 -13
  99. package/src/router/error-handling.ts +12 -16
  100. package/src/router/find-match.ts +4 -31
  101. package/src/router/intercept-resolution.ts +10 -1
  102. package/src/router/lazy-includes.ts +1 -57
  103. package/src/router/loader-resolution.ts +25 -23
  104. package/src/router/logging.ts +0 -6
  105. package/src/router/manifest.ts +1 -25
  106. package/src/router/match-api.ts +0 -20
  107. package/src/router/match-context.ts +0 -22
  108. package/src/router/match-handlers.ts +0 -43
  109. package/src/router/match-middleware/background-revalidation.ts +0 -7
  110. package/src/router/match-middleware/cache-lookup.ts +96 -179
  111. package/src/router/match-middleware/cache-store.ts +0 -31
  112. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  113. package/src/router/match-middleware/segment-resolution.ts +0 -22
  114. package/src/router/match-pipelines.ts +1 -42
  115. package/src/router/match-result.ts +1 -52
  116. package/src/router/metrics.ts +0 -34
  117. package/src/router/middleware-types.ts +0 -116
  118. package/src/router/middleware.ts +77 -60
  119. package/src/router/navigation-snapshot.ts +0 -51
  120. package/src/router/params-util.ts +23 -0
  121. package/src/router/pattern-matching.ts +5 -56
  122. package/src/router/prerender-match.ts +56 -51
  123. package/src/router/request-classification.ts +1 -38
  124. package/src/router/revalidation.ts +14 -62
  125. package/src/router/route-snapshot.ts +0 -1
  126. package/src/router/router-context.ts +0 -27
  127. package/src/router/router-interfaces.ts +10 -0
  128. package/src/router/segment-resolution/fresh.ts +25 -57
  129. package/src/router/segment-resolution/helpers.ts +34 -0
  130. package/src/router/segment-resolution/loader-cache.ts +35 -23
  131. package/src/router/segment-resolution/revalidation.ts +188 -283
  132. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  133. package/src/router/segment-resolution.ts +4 -1
  134. package/src/router/segment-wrappers.ts +0 -3
  135. package/src/router/telemetry-otel.ts +0 -20
  136. package/src/router/telemetry.ts +0 -22
  137. package/src/router/timeout.ts +0 -20
  138. package/src/router/trie-matching.ts +66 -45
  139. package/src/router/types.ts +1 -63
  140. package/src/router/url-params.ts +0 -5
  141. package/src/router.ts +8 -11
  142. package/src/rsc/handler-context.ts +1 -0
  143. package/src/rsc/handler.ts +20 -4
  144. package/src/rsc/helpers.ts +71 -3
  145. package/src/rsc/json-route-result.ts +38 -0
  146. package/src/rsc/origin-guard.ts +9 -15
  147. package/src/rsc/progressive-enhancement.ts +10 -1
  148. package/src/rsc/redirect-guard.ts +99 -0
  149. package/src/rsc/response-route-handler.ts +23 -18
  150. package/src/rsc/rsc-rendering.ts +2 -7
  151. package/src/rsc/runtime-warnings.ts +14 -0
  152. package/src/rsc/server-action.ts +34 -29
  153. package/src/rsc/types.ts +6 -3
  154. package/src/search-params.ts +0 -16
  155. package/src/segment-loader-promise.ts +14 -2
  156. package/src/segment-system.tsx +79 -88
  157. package/src/server/handle-store.ts +7 -24
  158. package/src/server/loader-registry.ts +5 -24
  159. package/src/server/request-context.ts +29 -92
  160. package/src/ssr/index.tsx +14 -14
  161. package/src/static-handler.ts +2 -27
  162. package/src/testing/cache-status.ts +44 -48
  163. package/src/testing/collect-handle.ts +1 -24
  164. package/src/testing/dispatch.ts +43 -6
  165. package/src/testing/e2e/index.ts +1 -22
  166. package/src/testing/e2e/matchers.ts +0 -16
  167. package/src/testing/flight-matchers.ts +0 -13
  168. package/src/testing/flight-normalize.ts +3 -30
  169. package/src/testing/flight.ts +46 -48
  170. package/src/testing/generated-routes.ts +1 -41
  171. package/src/testing/index.ts +1 -21
  172. package/src/testing/internal/context.ts +3 -45
  173. package/src/testing/internal/seed-vars.ts +0 -26
  174. package/src/testing/render-handler.ts +31 -61
  175. package/src/testing/render-route.tsx +75 -103
  176. package/src/testing/run-loader.ts +0 -96
  177. package/src/testing/run-middleware.ts +0 -26
  178. package/src/theme/ThemeProvider.tsx +0 -52
  179. package/src/theme/ThemeScript.tsx +0 -6
  180. package/src/theme/constants.ts +0 -12
  181. package/src/theme/index.ts +0 -7
  182. package/src/theme/theme-context.ts +1 -5
  183. package/src/theme/theme-script.ts +0 -14
  184. package/src/theme/use-theme.ts +0 -3
  185. package/src/types/boundaries.ts +0 -35
  186. package/src/types/error-types.ts +25 -89
  187. package/src/types/global-namespace.ts +4 -14
  188. package/src/types/handler-context.ts +28 -9
  189. package/src/types/index.ts +0 -10
  190. package/src/types/request-scope.ts +0 -19
  191. package/src/types/route-config.ts +6 -50
  192. package/src/types/route-entry.ts +0 -6
  193. package/src/types/segments.ts +0 -13
  194. package/src/urls/include-helper.ts +0 -4
  195. package/src/urls/index.ts +0 -6
  196. package/src/urls/path-helper-types.ts +2 -2
  197. package/src/urls/path-helper.ts +0 -54
  198. package/src/urls/urls-function.ts +0 -13
  199. package/src/use-loader.tsx +0 -186
  200. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  201. package/src/vite/discovery/discover-routers.ts +28 -18
  202. package/src/vite/discovery/prerender-collection.ts +2 -4
  203. package/src/vite/discovery/state.ts +5 -0
  204. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  205. package/src/vite/plugin-types.ts +35 -9
  206. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  207. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  208. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  209. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  210. package/src/vite/plugins/expose-action-id.ts +2 -73
  211. package/src/vite/plugins/expose-id-utils.ts +0 -55
  212. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  213. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  214. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  215. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  216. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  217. package/src/vite/plugins/performance-tracks.ts +0 -3
  218. package/src/vite/plugins/refresh-cmd.ts +1 -1
  219. package/src/vite/plugins/use-cache-transform.ts +21 -46
  220. package/src/vite/plugins/version-injector.ts +0 -20
  221. package/src/vite/plugins/version-plugin.ts +1 -49
  222. package/src/vite/plugins/virtual-entries.ts +0 -15
  223. package/src/vite/rango.ts +2 -108
  224. package/src/vite/router-discovery.ts +9 -1
  225. package/src/vite/utils/ast-handler-extract.ts +0 -16
  226. package/src/vite/utils/bundle-analysis.ts +6 -13
  227. package/src/vite/utils/client-chunks.ts +0 -6
  228. package/src/vite/utils/forward-user-plugins.ts +0 -22
  229. package/src/vite/utils/manifest-utils.ts +0 -4
  230. package/src/vite/utils/package-resolution.ts +1 -73
  231. package/src/vite/utils/prerender-utils.ts +0 -35
  232. package/src/vite/utils/shared-utils.ts +3 -35
  233. package/src/browser/shallow.ts +0 -40
  234. package/src/handles/index.ts +0 -7
  235. package/src/router/middleware-cookies.ts +0 -55
@@ -11,8 +11,14 @@ import {
11
11
  import type { RequestContext } from "../server/request-context.js";
12
12
  import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
13
13
  import { isRedirectResponse } from "../response-utils.js";
14
+ import {
15
+ EXTERNAL_REDIRECT_MARKER,
16
+ isExternalRedirect,
17
+ markExternalRedirect,
18
+ } from "../redirect-origin.js";
14
19
  import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
15
20
  import { formatCacheSignalHeader } from "../router/telemetry.js";
21
+ import type { RscPayload } from "./types.js";
16
22
 
17
23
  /**
18
24
  * DEVELOPMENT/TEST ONLY. When the debug cache signal gate is on,
@@ -40,6 +46,10 @@ function applyCacheSignalHeader(target: Headers, ctx: RequestContext): void {
40
46
  function applyStubHeaders(target: Headers, stub: Headers): void {
41
47
  stub.forEach((value, name) => {
42
48
  try {
49
+ // The reserved external-redirect marker is internal and never a trust
50
+ // signal; never copy a stub value (e.g. a stray ctx.header() call) onto a
51
+ // browser-facing response. The opt-in is the out-of-band brand.
52
+ if (name.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
43
53
  if (name.toLowerCase() === "set-cookie") {
44
54
  target.append(name, value);
45
55
  } else if (!target.has(name)) {
@@ -63,10 +73,19 @@ function drainOnResponseCallbacks(
63
73
  const callbacks = ctx._onResponseCallbacks;
64
74
  if (callbacks.length === 0) return response;
65
75
  ctx._onResponseCallbacks = [];
76
+ // An onResponse callback may return a NEW Response (e.g. to add a header),
77
+ // which drops the out-of-band external-redirect brand (brand is keyed on
78
+ // Response object identity). Preserve a redirect(url, { external: true })
79
+ // opt-in across that rebuild so a callback can't silently neutralize the
80
+ // off-host redirect at the guard chokepoint.
81
+ const wasExternal = isExternalRedirect(response);
66
82
  let result = response;
67
83
  for (const callback of callbacks) {
68
84
  result = callback(result) ?? result;
69
85
  }
86
+ if (wasExternal && !isExternalRedirect(result)) {
87
+ markExternalRedirect(result);
88
+ }
70
89
  return result;
71
90
  }
72
91
 
@@ -134,8 +153,20 @@ export function createSimpleRedirectResponse(redirectUrl: string): Response {
134
153
 
135
154
  /**
136
155
  * Carry over headers from a source redirect Response to a wrapper Response.
137
- * Skips Location and X-RSC-Redirect (intentionally replaced by the wrapper)
138
- * and appends Set-Cookie to avoid clobbering multiple cookie headers.
156
+ * Skips Location and X-RSC-Redirect (intentionally replaced by the wrapper) and
157
+ * appends Set-Cookie to avoid clobbering multiple cookie headers.
158
+ *
159
+ * This is a GENERIC copier used by every redirect-rebuild path (PE
160
+ * extractRedirectResponse, the SPA intercept below, the guard's neutralize
161
+ * rebuild), so it has two redirect-specific jobs:
162
+ *
163
+ * 1. NEVER copy the reserved external-redirect header: it is no longer a trust
164
+ * signal (the opt-in is the out-of-band brand), and a forged value from a
165
+ * proxied upstream must not ride a rebuilt response to the browser.
166
+ * 2. Transfer the out-of-band external brand: a rebuilt document-native redirect
167
+ * has to carry the opt-in to the guard chokepoint, which reads and clears it.
168
+ * Without this transfer, redirect(url, { external: true }) would be silently
169
+ * neutralized on any rebuild path (fail-closed, but a feature regression).
139
170
  */
140
171
  export function carryOverRedirectHeaders(
141
172
  source: Response,
@@ -144,12 +175,16 @@ export function carryOverRedirectHeaders(
144
175
  source.headers.forEach((value, name) => {
145
176
  const lower = name.toLowerCase();
146
177
  if (lower === "location" || lower === "x-rsc-redirect") return;
178
+ if (lower === EXTERNAL_REDIRECT_MARKER) return;
147
179
  if (lower === "set-cookie") {
148
180
  target.headers.append(name, value);
149
181
  } else if (!target.headers.has(name)) {
150
182
  target.headers.set(name, value);
151
183
  }
152
184
  });
185
+ if (isExternalRedirect(source)) {
186
+ markExternalRedirect(target);
187
+ }
153
188
  }
154
189
 
155
190
  /**
@@ -163,28 +198,62 @@ export function interceptRedirectForPartial(
163
198
  createRedirectFlightResponse: (
164
199
  redirectUrl: string,
165
200
  locationState?: Record<string, unknown>,
201
+ external?: boolean,
166
202
  ) => Response,
167
203
  ): Response | null {
168
204
  if (!isRedirectResponse(response)) {
169
205
  return null;
170
206
  }
171
207
  const redirectUrl = response.headers.get("Location")!;
208
+ // redirect(url, { external: true }) marks an explicit off-host redirect via
209
+ // the out-of-band brand (not a wire header). On the SPA/action channel the
210
+ // intent must travel as a Flight payload (metadata.redirect.external) so the
211
+ // client does a scheme-validated hard navigation (location.assign) rather than
212
+ // a partial fetch. The client re-validates the scheme; see partial-update.ts.
213
+ const external = isExternalRedirect(response);
172
214
  const locationState = getLocationState();
173
215
  let intercepted: Response;
174
216
  if (locationState) {
175
217
  intercepted = createRedirectFlightResponse(
176
218
  redirectUrl,
177
219
  resolveLocationStateEntries(locationState),
220
+ external,
178
221
  );
222
+ } else if (external) {
223
+ intercepted = createRedirectFlightResponse(redirectUrl, undefined, true);
179
224
  } else {
180
225
  intercepted = createSimpleRedirectResponse(redirectUrl);
181
226
  }
182
227
 
183
228
  carryOverRedirectHeaders(response, intercepted);
229
+ // Defense-in-depth at the SPA browser-facing exit: carryOverRedirectHeaders
230
+ // already refuses to copy the reserved marker, but strip any value that might
231
+ // exist on `intercepted` so a forged header can never ride the 200/204 to the
232
+ // browser. The external intent travels in metadata.redirect.external (Flight),
233
+ // where the client re-validates the scheme.
234
+ try {
235
+ intercepted.headers.delete(EXTERNAL_REDIRECT_MARKER);
236
+ } catch {
237
+ // Immutable headers: the marker was never copied here, so this is inert.
238
+ }
184
239
 
185
240
  return intercepted;
186
241
  }
187
242
 
243
+ /**
244
+ * Attach location state set during a request to a payload's metadata.
245
+ * No-op if no location state was set. Callers must ensure payload.metadata
246
+ * is populated (the non-null assertion holds for the partial/action payloads
247
+ * that reach this helper).
248
+ */
249
+ export function attachLocationStateIfPresent(payload: RscPayload): void {
250
+ const locationState = getLocationState();
251
+ if (locationState) {
252
+ payload.metadata!.locationState =
253
+ resolveLocationStateEntries(locationState);
254
+ }
255
+ }
256
+
188
257
  /**
189
258
  * Only cache successful responses. Non-200 statuses (errors, redirects) are
190
259
  * not cached -- notFound() produces 500 in response routes, and explicit
@@ -211,7 +280,6 @@ export function buildRouteMiddlewareEntries<TEnv>(
211
280
  regex: null,
212
281
  paramNames: [],
213
282
  handler: mw.handler,
214
- mountPrefix: null,
215
283
  } as MiddlewareEntry<TEnv>,
216
284
  params: mw.params,
217
285
  }));
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared serialization for `json()` response-route results.
3
+ *
4
+ * Kept in its own lightweight module (depends only on `errors.js`) so the
5
+ * `dispatch()` testing primitive can import it WITHOUT dragging in
6
+ * `response-route-handler.ts`'s heavy runtime graph, which transitively reaches
7
+ * a Vite virtual module and breaks a plain (non-Vite) vitest import.
8
+ */
9
+
10
+ import { RouterError } from "../errors.js";
11
+
12
+ /**
13
+ * Serialize a `json()` response-route result, rejecting a nested unresolved
14
+ * Promise (the forgotten-await footgun: `() => ({ data: fetchSomething() })`).
15
+ * `JSON.stringify` would silently emit `{}` for a Promise, shipping empty data;
16
+ * the RSC pipeline awaits nested promises but this path does not. Throwing
17
+ * `RESPONSE_NOT_SERIALIZABLE` makes the failure loud.
18
+ *
19
+ * Shared by the production response-route handler and the `dispatch()` testing
20
+ * primitive so a `dispatch` json test of a forgotten await fails exactly where
21
+ * production 500s, instead of going green.
22
+ */
23
+ export function stringifyJsonRouteResult(result: unknown): string {
24
+ return JSON.stringify(result, (_key, value) => {
25
+ if (
26
+ value != null &&
27
+ typeof (value as { then?: unknown }).then === "function"
28
+ ) {
29
+ throw new RouterError(
30
+ "RESPONSE_NOT_SERIALIZABLE",
31
+ "A json() response route returned a Promise (likely a forgotten " +
32
+ "await). Await async values before returning so they serialize, " +
33
+ "instead of emitting an empty {}.",
34
+ );
35
+ }
36
+ return value;
37
+ });
38
+ }
@@ -69,11 +69,8 @@ export type OriginCheckConfig<TEnv = any> =
69
69
  * Returns true to allow, false to reject.
70
70
  */
71
71
  export function defaultOriginCheck(request: Request, url: URL): boolean {
72
- // 1. Read Origin header (present on all cross-origin requests and
73
- // same-origin POST/PUT/PATCH/DELETE in modern browsers)
74
72
  let requestOrigin = request.headers.get("origin");
75
73
 
76
- // 2. Fallback to Referer if Origin is absent (some proxies strip it)
77
74
  if (!requestOrigin) {
78
75
  const referer = request.headers.get("referer");
79
76
  if (referer) {
@@ -85,23 +82,20 @@ export function defaultOriginCheck(request: Request, url: URL): boolean {
85
82
  }
86
83
  }
87
84
 
88
- // 3. No Origin or Referer — allow (can't be browser-initiated CSRF)
89
85
  if (!requestOrigin) return true;
90
86
 
91
- // "null" origin comes from privacy-sensitive contexts (data: URLs,
92
- // sandboxed iframes, cross-origin redirects). Reject it.
93
87
  if (requestOrigin === "null") return false;
94
88
 
95
- // 4. Determine expected host from Host header or URL.
96
- // X-Forwarded-Host/Proto are NOT used they are client-controllable
97
- // unless a trusted proxy strips them. On standard deployments (Cloudflare
98
- // Workers, Node behind nginx/caddy) the Host header is already correct.
99
- // For non-standard setups, use the custom function escape hatch.
100
- const expectedHost = request.headers.get("host") || url.host;
101
- const expectedProtocol = url.protocol;
89
+ // An Origin/Referer is present, so this is a browser request worth checking.
90
+ // Establish the expected origin from the Host header only -- browsers always
91
+ // send Host alongside Origin (runtimes synthesize it from the HTTP/2
92
+ // :authority), so a missing Host here is anomalous. Fail closed rather than
93
+ // fall back to url.host (derived from the request line) when the trusted Host
94
+ // cannot be established.
95
+ const expectedHost = request.headers.get("host");
96
+ if (!expectedHost) return false;
102
97
 
103
- // 5. Build expected origin and compare (case-insensitive)
104
- const expectedOrigin = `${expectedProtocol}//${expectedHost}`;
98
+ const expectedOrigin = `${url.protocol}//${expectedHost}`;
105
99
 
106
100
  return requestOrigin.toLowerCase() === expectedOrigin.toLowerCase();
107
101
  }
@@ -155,6 +155,14 @@ export async function handleProgressiveEnhancement<TEnv>(
155
155
  } else if (isDirectAction && directActionId) {
156
156
  const temporaryReferences = ctx.createTemporaryReferenceSet();
157
157
 
158
+ // INTENTIONAL JS/PE divergence (do NOT "fix" to match the JS reject path).
159
+ // On the JS path React Flight-encodes the action args, so decodeReply
160
+ // succeeds or a failure means a malformed body (rejected). On the no-JS PE
161
+ // path the browser submits a raw <form action={fn}> POST with NO encoded
162
+ // args, so decodeReply throws by design and the raw FormData IS the action
163
+ // argument (the React form-action convention: fn(formData)). Removing this
164
+ // fallback breaks every unbound no-JS form action (verified: it fails the
165
+ // progressive-enhancement dev+prod e2e suite). See #572 (decided: keep).
158
166
  let args: unknown[] = [];
159
167
  try {
160
168
  args = await ctx.decodeReply(formData, { temporaryReferences });
@@ -259,7 +267,6 @@ export async function handleProgressiveEnhancement<TEnv>(
259
267
  warmupEnabled: ctx.router.warmupEnabled,
260
268
  initialTheme: requireRequestContext().theme,
261
269
  },
262
- formState: actionResult,
263
270
  };
264
271
 
265
272
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
@@ -277,6 +284,8 @@ export async function handleProgressiveEnhancement<TEnv>(
277
284
  url,
278
285
  undefined,
279
286
  );
287
+ // reactFormState carries the useActionState payload via the SSR-option path
288
+ // (renderToReadableStream({ formState })); it does NOT travel on RscPayload.
280
289
  const htmlStream = await ssrModule.renderHTML(rscStream, {
281
290
  formState: reactFormState,
282
291
  nonce,
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Server-side open-redirect guard.
3
+ *
4
+ * Applied to the FINAL handler response (the single top-level return in
5
+ * `handler.ts`) so every browser-followed redirect honors the same same-origin
6
+ * rule the client enforces (`browser/validate-redirect-origin.ts`), via the one
7
+ * shared resolver in `redirect-origin.ts`. This is the server half of the
8
+ * client's existing guard: the client can only validate redirects its own JS
9
+ * navigates to (the SPA/fetch channel), so document-native redirects -- a no-JS
10
+ * PE form POST, a full-page GET `match.redirect`, a middleware `redirect()`
11
+ * short-circuit, a response-route 3xx -- reach the browser with no client in the
12
+ * loop. They all funnel through one handler return, so guarding there covers
13
+ * every one and any future redirect exit.
14
+ *
15
+ * Soft (SPA/Flight) redirects are 200/204 responses (`X-RSC-Redirect` header or
16
+ * `metadata.redirect` payload) and are NOT redirect Responses, so they never
17
+ * reach this guard -- they stay validated client-side.
18
+ *
19
+ * Behavior on a `Location` header:
20
+ * - same-origin / relative -> passes through unchanged
21
+ * - `redirect(url, { external: true })` (out-of-band brand present) and an
22
+ * http(s) target -> allowed (explicit, auditable, unforgeable opt-in)
23
+ * - branded but a non-http(s) target (e.g. `javascript:`) -> neutralized: the
24
+ * opt-in waives the same-origin rule, NOT scheme safety
25
+ * - cross-origin without the brand -> Location rewritten to the basename root
26
+ * (a safe same-origin landing, the document analog of the client's "stay put");
27
+ * dev logs the blocked target and points to `{ external: true }`.
28
+ *
29
+ * The opt-in is an out-of-band brand on the Response object (isExternalRedirect),
30
+ * never a wire header: a header is forgeable by an attacker-controlled upstream
31
+ * response a proxy-style response route copies through, which would defeat the
32
+ * guard without app code ever opting in. The reserved header name is stripped
33
+ * defensively so a forged value can never reach the browser.
34
+ */
35
+
36
+ import { isRedirectResponse } from "../response-utils.js";
37
+ import {
38
+ resolveSameOriginRedirect,
39
+ resolveExternalRedirect,
40
+ isExternalRedirect,
41
+ EXTERNAL_REDIRECT_MARKER,
42
+ } from "../redirect-origin.js";
43
+ import { carryOverRedirectHeaders } from "./helpers.js";
44
+
45
+ export function guardOutgoingRedirect(
46
+ response: Response,
47
+ requestOrigin: string,
48
+ basename: string | undefined,
49
+ ): Response {
50
+ // Only 3xx + Location responses (document-native redirects) are guarded.
51
+ if (!isRedirectResponse(response)) {
52
+ return response;
53
+ }
54
+
55
+ // The reserved marker is never a trust signal. Strip any value -- forged by a
56
+ // proxied upstream or otherwise -- so it can never reach the browser. Trust
57
+ // comes solely from the out-of-band brand below.
58
+ try {
59
+ response.headers.delete(EXTERNAL_REDIRECT_MARKER);
60
+ } catch {
61
+ // Some platform responses carry immutable headers; the header is inert on
62
+ // the browser, so a failed strip is harmless.
63
+ }
64
+
65
+ // isRedirectResponse guarantees a truthy Location.
66
+ const location = response.headers.get("Location")!;
67
+
68
+ // Explicit opt-in via redirect(url, { external: true }): allow an off-host
69
+ // target, but only an http(s) one. external waives the same-origin rule, not
70
+ // scheme safety -- a branded javascript:/data: target falls through to be
71
+ // neutralized so it can never become a scriptable navigation downstream.
72
+ if (isExternalRedirect(response)) {
73
+ if (resolveExternalRedirect(location, requestOrigin) !== null) {
74
+ return response;
75
+ }
76
+ } else if (resolveSameOriginRedirect(location, requestOrigin) !== null) {
77
+ return response;
78
+ }
79
+
80
+ // Cross-origin (or unsafe-scheme external): neutralize to a safe same-origin
81
+ // landing.
82
+ const safeTarget = basename && basename !== "/" ? basename : "/";
83
+ if (process.env.NODE_ENV !== "production") {
84
+ console.error(
85
+ `[rango] Blocked cross-origin redirect to "${location}"; sent to ` +
86
+ `"${safeTarget}" instead. To redirect off-host on purpose, use ` +
87
+ `redirect(url, { external: true }).`,
88
+ );
89
+ }
90
+
91
+ const blocked = new Response(null, {
92
+ status: response.status,
93
+ headers: { Location: safeTarget },
94
+ });
95
+ // Preserve cookies and any other headers (Set-Cookie, Server-Timing, ...);
96
+ // carryOverRedirectHeaders intentionally skips Location.
97
+ carryOverRedirectHeaders(response, blocked);
98
+ return blocked;
99
+ }
@@ -30,6 +30,12 @@ import {
30
30
  mergeStubHeadersAndFinalize,
31
31
  } from "./helpers.js";
32
32
  import { isWebSocketUpgradeResponse } from "../response-utils.js";
33
+ import { stringifyJsonRouteResult } from "./json-route-result.js";
34
+ import {
35
+ EXTERNAL_REDIRECT_MARKER,
36
+ isExternalRedirect,
37
+ markExternalRedirect,
38
+ } from "../redirect-origin.js";
33
39
 
34
40
  export interface ResponseRouteMatch {
35
41
  responseType: string;
@@ -110,16 +116,29 @@ export async function handleResponseRoute<TEnv>(
110
116
  }
111
117
  const headers = new Headers();
112
118
  result.headers.forEach((value, key) => {
119
+ // Never copy the reserved external-redirect marker off a handler result.
120
+ // It is not a trust signal -- the opt-in is the out-of-band brand below
121
+ // -- and a proxy-style route returning an attacker-controlled upstream
122
+ // response must not let a forged value ride through to the browser.
123
+ if (key.toLowerCase() === EXTERNAL_REDIRECT_MARKER) return;
113
124
  if (key.toLowerCase() === "set-cookie") {
114
125
  headers.append(key, value);
115
126
  } else {
116
127
  headers.set(key, value);
117
128
  }
118
129
  });
119
- return createResponseWithMergedHeaders(result.body, {
130
+ const rewrapped = createResponseWithMergedHeaders(result.body, {
120
131
  status: result.status,
121
132
  headers,
122
133
  });
134
+ // Transfer the out-of-band external brand only when the handler result is
135
+ // genuinely branded (a real redirect(url, { external: true })). A proxied
136
+ // upstream Response is never branded, so an attacker cannot opt a response
137
+ // route's redirect out of the same-origin guard by injecting the header.
138
+ if (isExternalRedirect(result)) {
139
+ markExternalRedirect(rewrapped);
140
+ }
141
+ return rewrapped;
123
142
  };
124
143
 
125
144
  try {
@@ -133,23 +152,9 @@ export async function handleResponseRoute<TEnv>(
133
152
  if (preview.responseType === "json") {
134
153
  // Runtime guard: the json() return type rejects nested Promises at
135
154
  // compile time, but an `as`-cast or untyped (JS) handler can still slip
136
- // one through. JSON.stringify would silently emit {} for it (the
137
- // forgotten-await footgun the RSC pipeline awaits nested promises, this
138
- // path does not). Throw a clear error instead of shipping empty data.
139
- const body = JSON.stringify(result, (_key, value) => {
140
- if (
141
- value != null &&
142
- typeof (value as { then?: unknown }).then === "function"
143
- ) {
144
- throw new RouterError(
145
- "RESPONSE_NOT_SERIALIZABLE",
146
- "A json() response route returned a Promise (likely a forgotten " +
147
- "await). Await async values before returning so they serialize, " +
148
- "instead of emitting an empty {}.",
149
- );
150
- }
151
- return value;
152
- });
155
+ // one through. stringifyJsonRouteResult throws a clear error instead of
156
+ // shipping empty data (shared with dispatch() so the two cannot drift).
157
+ const body = stringifyJsonRouteResult(result);
153
158
  return createResponseWithMergedHeaders(body, {
154
159
  status: 200,
155
160
  headers: { "content-type": "application/json;charset=utf-8" },
@@ -9,9 +9,7 @@
9
9
  import {
10
10
  requireRequestContext,
11
11
  setRequestContextParams,
12
- getLocationState,
13
12
  } from "../server/request-context.js";
14
- import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
15
13
  import { appendMetric } from "../router/metrics.js";
16
14
  import { getSSRSetup, isRscRequest } from "./ssr-setup.js";
17
15
  import type { RscPayload } from "./types.js";
@@ -19,6 +17,7 @@ import type { MatchResult } from "../types.js";
19
17
  import {
20
18
  createResponseWithMergedHeaders,
21
19
  createSimpleRedirectResponse,
20
+ attachLocationStateIfPresent,
22
21
  } from "./helpers.js";
23
22
  import type { HandlerContext } from "./handler-context.js";
24
23
 
@@ -155,11 +154,7 @@ export async function handleRscRendering<TEnv>(
155
154
  // SSR (full page) requests ignore location state since there's no history.state
156
155
  // to write to on a fresh page load.
157
156
  if (isPartial && payload.metadata) {
158
- const locationState = getLocationState();
159
- if (locationState) {
160
- payload.metadata.locationState =
161
- resolveLocationStateEntries(locationState);
162
- }
157
+ attachLocationStateIfPresent(payload);
163
158
  }
164
159
 
165
160
  const metricsStore = reqCtx._metricsStore;
@@ -39,3 +39,17 @@ export function warnNonRedirectPeResponse(): void {
39
39
  `ignored — the page will re-render at the current URL instead.`,
40
40
  );
41
41
  }
42
+
43
+ /**
44
+ * Warn when a non-redirect Response is returned (not thrown) from an action
45
+ * on the JS (fetch) path. A raw Response cannot be serialized into Flight, so
46
+ * it is discarded — mirroring the PE path. Use `throw redirect('/path')` for
47
+ * redirects.
48
+ */
49
+ export function warnNonRedirectActionResponse(actionId: string): void {
50
+ console.warn(
51
+ `[@rangojs/router] Server action "${actionId}" returned a Response ` +
52
+ `that is not a redirect. Non-redirect Responses cannot be serialized ` +
53
+ `and are ignored. Use \`throw redirect('/path')\` for redirects.`,
54
+ );
55
+ }
@@ -18,9 +18,7 @@
18
18
  import {
19
19
  requireRequestContext,
20
20
  setRequestContextParams,
21
- getLocationState,
22
21
  } from "../server/request-context.js";
23
- import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
24
22
  import { appendMetric } from "../router/metrics.js";
25
23
  import type { RscPayload } from "./types.js";
26
24
  import {
@@ -28,21 +26,11 @@ import {
28
26
  createResponseWithMergedHeaders,
29
27
  createSimpleRedirectResponse,
30
28
  interceptRedirectForPartial,
29
+ attachLocationStateIfPresent,
31
30
  } from "./helpers.js";
31
+ import { warnNonRedirectActionResponse } from "./runtime-warnings.js";
32
32
  import type { HandlerContext } from "./handler-context.js";
33
33
 
34
- /**
35
- * Attach location state set during the action to a payload's metadata.
36
- * No-op if no location state was set.
37
- */
38
- function attachLocationState(payload: RscPayload): void {
39
- const locationState = getLocationState();
40
- if (locationState) {
41
- payload.metadata!.locationState =
42
- resolveLocationStateEntries(locationState);
43
- }
44
- }
45
-
46
34
  /**
47
35
  * Data flowing from action execution to the revalidation phase.
48
36
  * When the action completes without redirect/error-boundary, the handler
@@ -97,7 +85,10 @@ export async function executeServerAction<TEnv>(
97
85
  args = await ctx.decodeReply(body, { temporaryReferences });
98
86
  }
99
87
  } catch (error) {
100
- throw new Error(`Failed to decode action arguments: ${error}`, {
88
+ // Keep the original error as `cause` for server-side logging, but do not
89
+ // interpolate it into the message: that string can surface to the client
90
+ // and may leak decode internals.
91
+ throw new Error("Failed to decode action arguments", {
101
92
  cause: error,
102
93
  });
103
94
  }
@@ -109,7 +100,7 @@ export async function executeServerAction<TEnv>(
109
100
 
110
101
  try {
111
102
  loadedAction = await ctx.loadServerAction(actionId);
112
- const data = await loadedAction!.apply(null, args);
103
+ let data = await loadedAction!.apply(null, args);
113
104
 
114
105
  // Intercept redirect Responses: serializing one as the action returnValue
115
106
  // would fail, and revalidation would run needlessly.
@@ -119,6 +110,14 @@ export async function executeServerAction<TEnv>(
119
110
  ctx.createRedirectFlightResponse,
120
111
  );
121
112
  if (intercepted) return intercepted;
113
+
114
+ // Non-redirect Response returned (not thrown): a raw Response cannot be
115
+ // serialized into Flight. Discard it and re-render — mirroring the PE
116
+ // path (progressive-enhancement.ts) so JS and no-JS behave identically.
117
+ if (process.env.NODE_ENV !== "production") {
118
+ warnNonRedirectActionResponse(actionId);
119
+ }
120
+ data = undefined;
122
121
  }
123
122
 
124
123
  returnValue = { ok: true, data };
@@ -224,18 +223,21 @@ export async function executeServerAction<TEnv>(
224
223
  }
225
224
 
226
225
  // Build continuation for the revalidation phase
227
- const resolvedActionId =
228
- (loadedAction as { $id?: string; $$id?: string } | undefined)?.$id ??
229
- (loadedAction as { $$id?: string } | undefined)?.$$id ??
230
- actionId;
226
+ const actionMeta = loadedAction as
227
+ | { $id?: string; $$id?: string }
228
+ | undefined;
229
+ const resolvedActionId = actionMeta?.$id ?? actionMeta?.$$id ?? actionId;
231
230
 
232
231
  return {
233
232
  returnValue,
234
233
  actionStatus,
235
234
  temporaryReferences,
236
235
  actionContext: {
236
+ // Defensive copy of the already-parsed url (avoids re-parsing
237
+ // request.url). actionUrl is persisted into the continuation and later
238
+ // flows into matchPartial, so it must not alias the handler's live url.
237
239
  actionId: resolvedActionId,
238
- actionUrl: new URL(request.url),
240
+ actionUrl: new URL(url),
239
241
  actionResult: returnValue.data,
240
242
  formData: actionFormData,
241
243
  },
@@ -274,8 +276,8 @@ export async function revalidateAfterAction<TEnv>(
274
276
  );
275
277
 
276
278
  if (!matchResult) {
277
- // matchPartial returns null when the route is a redirect or the request
278
- // is missing required headers (previousUrl). Check for redirect first.
279
+ // matchPartial returns null when the route is a redirect or no previous-URL
280
+ // context could be resolved. Check for redirect first.
279
281
  const fullMatch = await ctx.router.match(request, { env });
280
282
  setRequestContextParams(fullMatch.params, fullMatch.routeName);
281
283
 
@@ -286,14 +288,17 @@ export async function revalidateAfterAction<TEnv>(
286
288
  return createSimpleRedirectResponse(fullMatch.redirect);
287
289
  }
288
290
 
289
- // Non-redirect: this branch is only reachable when the action request
290
- // is missing the X-RSC-Router-Client-Path header (defensive). The
291
- // client requires isPartial for action responses, so producing a full
292
- // payload here would be rejected. Return 500 instead.
291
+ // Non-redirect: this branch is only reachable when no previous URL could
292
+ // be resolved (neither X-RSC-Router-Client-Path nor a usable Referer), or
293
+ // the previous URL was unparseable (defensive). The client requires
294
+ // isPartial for action responses, so producing a full payload here would
295
+ // be rejected. Return 500 instead.
293
296
  throw new Error(
294
297
  `[RSC] matchPartial returned null for a non-redirect route ` +
295
298
  `during action revalidation (${url.pathname}). This indicates ` +
296
- `a malformed action request (missing X-RSC-Router-Client-Path header).`,
299
+ `a malformed action request: no previous-URL context could be ` +
300
+ `resolved (neither X-RSC-Router-Client-Path nor a usable Referer), ` +
301
+ `or the previous URL was unparseable.`,
297
302
  );
298
303
  }
299
304
 
@@ -319,7 +324,7 @@ export async function revalidateAfterAction<TEnv>(
319
324
  returnValue,
320
325
  };
321
326
 
322
- attachLocationState(payload);
327
+ attachLocationStateIfPresent(payload);
323
328
 
324
329
  const renderStart = performance.now();
325
330
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
package/src/rsc/types.ts CHANGED
@@ -53,13 +53,16 @@ export interface RscPayload {
53
53
  basename?: string;
54
54
  /** Whether connection warmup is enabled */
55
55
  warmupEnabled?: boolean;
56
- /** Server-side redirect with optional state (for partial requests) */
57
- redirect?: { url: string };
56
+ /**
57
+ * Server-side redirect with optional state (for partial requests).
58
+ * `external: true` (from redirect(url, { external: true })) tells the client
59
+ * to hard-navigate to an off-host target instead of validating same-origin.
60
+ */
61
+ redirect?: { url: string; external?: boolean };
58
62
  /** Server-set location state to include in history.pushState */
59
63
  locationState?: Record<string, unknown>;
60
64
  };
61
65
  returnValue?: { ok: boolean; data: unknown };
62
- formState?: unknown;
63
66
  }
64
67
 
65
68
  /**