@rangojs/router 0.0.0-experimental.ede38110 → 0.0.0-experimental.f2d1a2f1
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/README.md +50 -20
- package/dist/vite/index.js +353 -49
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +5 -3
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/hooks/SKILL.md +28 -20
- package/skills/links/SKILL.md +88 -16
- package/skills/loader/SKILL.md +35 -2
- package/skills/migrate-react-router/SKILL.md +1 -0
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +3 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/navigation-bridge.ts +51 -2
- package/src/browser/navigation-client.ts +33 -10
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +20 -1
- package/src/browser/prefetch/cache.ts +124 -26
- package/src/browser/prefetch/fetch.ts +114 -38
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +18 -13
- package/src/browser/react/NavigationProvider.tsx +50 -11
- package/src/browser/react/use-navigation.ts +30 -11
- package/src/browser/react/use-params.ts +11 -1
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/types.ts +13 -0
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +3 -0
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +3 -2
- package/src/route-definition/dsl-helpers.ts +16 -3
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/router/handler-context.ts +20 -3
- package/src/router/lazy-includes.ts +1 -1
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/match-api.ts +3 -3
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +32 -4
- package/src/router/pattern-matching.ts +60 -9
- package/src/router/trie-matching.ts +10 -4
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +8 -4
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +7 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/server/request-context.ts +10 -42
- package/src/types/handler-context.ts +2 -34
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/urls/response-types.ts +2 -10
- package/src/vite/debug.ts +86 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/performance-tracks.ts +4 -6
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +161 -23
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
package/src/router/match-api.ts
CHANGED
|
@@ -22,10 +22,10 @@ import { collectRouteMiddleware } from "./middleware.js";
|
|
|
22
22
|
import { traverseBack } from "./pattern-matching.js";
|
|
23
23
|
import { DefaultErrorFallback } from "../default-error-boundary.js";
|
|
24
24
|
import {
|
|
25
|
-
EntryData,
|
|
26
|
-
LoaderEntry,
|
|
25
|
+
type EntryData,
|
|
26
|
+
type LoaderEntry,
|
|
27
27
|
getContext,
|
|
28
|
-
InterceptSelectorContext,
|
|
28
|
+
type InterceptSelectorContext,
|
|
29
29
|
} from "../server/context";
|
|
30
30
|
import type { ErrorBoundaryHandler, ErrorInfo, MatchResult } from "../types";
|
|
31
31
|
import type { ReactNode } from "react";
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
import type { ScopedReverseFunction } from "../reverse.js";
|
|
15
15
|
import type { Theme } from "../theme/types.js";
|
|
16
16
|
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
17
|
+
import type { RequestScope } from "../types/request-scope.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Get variable function type
|
|
@@ -57,28 +58,7 @@ export interface CookieOptions {
|
|
|
57
58
|
export interface MiddlewareContext<
|
|
58
59
|
TEnv = any,
|
|
59
60
|
TParams = Record<string, string>,
|
|
60
|
-
> {
|
|
61
|
-
/** Original request */
|
|
62
|
-
request: Request;
|
|
63
|
-
|
|
64
|
-
/** Parsed URL (with internal `_rsc*` params stripped) */
|
|
65
|
-
url: URL;
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The original request URL with all parameters intact, including
|
|
69
|
-
* internal `_rsc*` transport params.
|
|
70
|
-
*/
|
|
71
|
-
originalUrl: URL;
|
|
72
|
-
|
|
73
|
-
/** URL pathname */
|
|
74
|
-
pathname: string;
|
|
75
|
-
|
|
76
|
-
/** URL search params */
|
|
77
|
-
searchParams: URLSearchParams;
|
|
78
|
-
|
|
79
|
-
/** Platform bindings (Cloudflare, etc.) */
|
|
80
|
-
env: TEnv;
|
|
81
|
-
|
|
61
|
+
> extends RequestScope<TEnv> {
|
|
82
62
|
/** URL params extracted from route/middleware pattern */
|
|
83
63
|
params: TParams;
|
|
84
64
|
|
package/src/router/middleware.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { contextGet, contextSet } from "../context-var.js";
|
|
13
|
+
import { safeDecodeURIComponent } from "./url-params.js";
|
|
14
|
+
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
13
15
|
import type {
|
|
14
16
|
CollectedMiddleware,
|
|
15
17
|
MiddlewareCollectableEntry,
|
|
@@ -22,6 +24,7 @@ import { _getRequestContext } from "../server/request-context.js";
|
|
|
22
24
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
23
25
|
import { appendMetric, createMetricsStore } from "./metrics.js";
|
|
24
26
|
import { stripInternalParams } from "./handler-context.js";
|
|
27
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
25
28
|
|
|
26
29
|
// Re-export types and cookie utilities for backward compatibility
|
|
27
30
|
export type {
|
|
@@ -112,7 +115,12 @@ function escapeRegex(str: string): string {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
/**
|
|
115
|
-
* Extract params from a pathname using a pattern's regex and param names
|
|
118
|
+
* Extract params from a pathname using a pattern's regex and param names.
|
|
119
|
+
*
|
|
120
|
+
* Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
|
|
121
|
+
* instead of the percent-encoded form ("ivo%40example.com"). This matches the
|
|
122
|
+
* contract assumed by ctx.reverse (which re-encodes) and aligns with
|
|
123
|
+
* Express/React Router/Fastify/Koa.
|
|
116
124
|
*/
|
|
117
125
|
export function extractParams(
|
|
118
126
|
pathname: string,
|
|
@@ -124,7 +132,7 @@ export function extractParams(
|
|
|
124
132
|
|
|
125
133
|
const params: Record<string, string> = {};
|
|
126
134
|
for (let i = 0; i < paramNames.length; i++) {
|
|
127
|
-
params[paramNames[i]] = match[i + 1] || "";
|
|
135
|
+
params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
|
|
128
136
|
}
|
|
129
137
|
return params;
|
|
130
138
|
}
|
|
@@ -179,14 +187,22 @@ export function createMiddlewareContext<TEnv>(
|
|
|
179
187
|
return responseHolder.response;
|
|
180
188
|
};
|
|
181
189
|
|
|
190
|
+
// Capture reqCtx once: the request-scoped platform fields
|
|
191
|
+
// (originalUrl, executionContext, waitUntil) are immutable per request,
|
|
192
|
+
// so snapshotting beats re-reading ALS on every access. The lazy getters
|
|
193
|
+
// below (routeName, theme, setTheme) stay lazy because those can change
|
|
194
|
+
// during `await next()`.
|
|
195
|
+
const reqCtx = _getRequestContext();
|
|
182
196
|
return {
|
|
183
197
|
request,
|
|
184
198
|
url,
|
|
185
|
-
originalUrl: new URL(request.url),
|
|
199
|
+
originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
|
|
186
200
|
pathname: url.pathname,
|
|
187
201
|
searchParams: url.searchParams,
|
|
188
202
|
env: env as MiddlewareContext<TEnv>["env"],
|
|
189
203
|
params,
|
|
204
|
+
executionContext: reqCtx?.executionContext,
|
|
205
|
+
waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
|
|
190
206
|
// Getter: re-derives from request context on each access so that global
|
|
191
207
|
// middleware sees the matched route name after await next().
|
|
192
208
|
get routeName(): MiddlewareContext<TEnv>["routeName"] {
|
|
@@ -360,6 +376,11 @@ export async function executeMiddleware<TEnv>(
|
|
|
360
376
|
});
|
|
361
377
|
}
|
|
362
378
|
|
|
379
|
+
if (isWebSocketUpgradeResponse(response)) {
|
|
380
|
+
responseHolder.response = response;
|
|
381
|
+
return response;
|
|
382
|
+
}
|
|
383
|
+
|
|
363
384
|
// Clone response with merged headers (mutable for post-next() modifications)
|
|
364
385
|
responseHolder.response = new Response(response.body, {
|
|
365
386
|
status: response.status,
|
|
@@ -451,6 +472,10 @@ export async function executeMiddleware<TEnv>(
|
|
|
451
472
|
// RequestContext stub headers (from ctx.setCookie) into the
|
|
452
473
|
// returned Response so they are not lost.
|
|
453
474
|
if (result instanceof Response) {
|
|
475
|
+
if (isWebSocketUpgradeResponse(result)) {
|
|
476
|
+
responseHolder.response = result;
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
454
479
|
const mergedHeaders = new Headers(result.headers);
|
|
455
480
|
stubResponse.headers.forEach((value, name) => {
|
|
456
481
|
if (name.toLowerCase() === "set-cookie") {
|
|
@@ -527,8 +552,11 @@ export async function executeMiddleware<TEnv>(
|
|
|
527
552
|
// last merge point (e.g. cookies().set() called after await next()).
|
|
528
553
|
// The reqCtx stub may have already been partially merged during finalHandler
|
|
529
554
|
// or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
|
|
555
|
+
//
|
|
556
|
+
// Skip for upgrade responses: upgrade headers are semantically immutable and
|
|
557
|
+
// set-cookie on an upgrade is not meaningful.
|
|
530
558
|
const reqCtx = _getRequestContext();
|
|
531
|
-
if (reqCtx) {
|
|
559
|
+
if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
|
|
532
560
|
const stubCookies = reqCtx.res.headers.getSetCookie();
|
|
533
561
|
if (stubCookies.length > 0) {
|
|
534
562
|
const existingCookies = new Set(finalResponse.headers.getSetCookie());
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { RouteEntry, TrailingSlashMode } from "../types";
|
|
8
8
|
import type { EntryData } from "../server/context";
|
|
9
9
|
import { debugLog, isRouterDebugEnabled } from "./logging.js";
|
|
10
|
+
import { safeDecodeURIComponent } from "./url-params.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Parsed segment info
|
|
@@ -82,6 +83,13 @@ export interface CompiledPattern {
|
|
|
82
83
|
paramNames: string[];
|
|
83
84
|
optionalParams: Set<string>;
|
|
84
85
|
hasTrailingSlash: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
|
|
88
|
+
* Validated against the **decoded** param value after regex extraction so
|
|
89
|
+
* a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
|
|
90
|
+
* path's behavior (trie-matching.ts:validateAndBuild).
|
|
91
|
+
*/
|
|
92
|
+
constraints?: Record<string, string[]>;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
// Module-level cache for compiled patterns. Route patterns are a finite set
|
|
@@ -142,6 +150,7 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
142
150
|
const segments = parsePattern(normalizedPattern);
|
|
143
151
|
const paramNames: string[] = [];
|
|
144
152
|
const optionalParams = new Set<string>();
|
|
153
|
+
let constraints: Record<string, string[]> | undefined;
|
|
145
154
|
|
|
146
155
|
let regexPattern = "";
|
|
147
156
|
|
|
@@ -152,11 +161,14 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
152
161
|
} else if (segment.type === "param") {
|
|
153
162
|
paramNames.push(segment.value);
|
|
154
163
|
const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
164
|
+
// Constrained params capture anything here; the allowed values are
|
|
165
|
+
// checked post-decode in findMatch so URL-encoded constraint values
|
|
166
|
+
// (e.g. `:lang(en GB)` via `/en%20GB`) still match.
|
|
167
|
+
const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
|
|
168
|
+
|
|
169
|
+
if (segment.constraint) {
|
|
170
|
+
(constraints ??= {})[segment.value] = segment.constraint;
|
|
171
|
+
}
|
|
160
172
|
|
|
161
173
|
if (segment.optional) {
|
|
162
174
|
optionalParams.add(segment.value);
|
|
@@ -186,9 +198,33 @@ export function compilePattern(pattern: string): CompiledPattern {
|
|
|
186
198
|
paramNames,
|
|
187
199
|
optionalParams,
|
|
188
200
|
hasTrailingSlash,
|
|
201
|
+
...(constraints ? { constraints } : {}),
|
|
189
202
|
};
|
|
190
203
|
}
|
|
191
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Validate decoded params against a compiled pattern's constraints.
|
|
207
|
+
* Returns false if any constrained param has a non-empty value not in the
|
|
208
|
+
* allowed list (empty-string = absent optional, which is allowed).
|
|
209
|
+
*/
|
|
210
|
+
function satisfiesConstraints(
|
|
211
|
+
params: Record<string, string>,
|
|
212
|
+
constraints: Record<string, string[]> | undefined,
|
|
213
|
+
): boolean {
|
|
214
|
+
if (!constraints) return true;
|
|
215
|
+
for (const name in constraints) {
|
|
216
|
+
const value = params[name];
|
|
217
|
+
if (
|
|
218
|
+
value !== undefined &&
|
|
219
|
+
value !== "" &&
|
|
220
|
+
!constraints[name].includes(value)
|
|
221
|
+
) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
192
228
|
/**
|
|
193
229
|
* Escape special regex characters in a string
|
|
194
230
|
*/
|
|
@@ -392,8 +428,13 @@ export function findMatch<TEnv>(
|
|
|
392
428
|
fullPattern = entry.prefix + pattern;
|
|
393
429
|
}
|
|
394
430
|
|
|
395
|
-
const {
|
|
396
|
-
|
|
431
|
+
const {
|
|
432
|
+
regex,
|
|
433
|
+
paramNames,
|
|
434
|
+
optionalParams,
|
|
435
|
+
hasTrailingSlash,
|
|
436
|
+
constraints,
|
|
437
|
+
} = getCompiledPattern(fullPattern);
|
|
397
438
|
|
|
398
439
|
// Get trailing slash mode for this route (per-route config or pattern-based)
|
|
399
440
|
const trailingSlashMode: TrailingSlashMode | undefined =
|
|
@@ -412,9 +453,15 @@ export function findMatch<TEnv>(
|
|
|
412
453
|
if (match) {
|
|
413
454
|
const params: Record<string, string> = {};
|
|
414
455
|
paramNames.forEach((name, index) => {
|
|
415
|
-
params[name] = match[index + 1] ?? "";
|
|
456
|
+
params[name] = safeDecodeURIComponent(match[index + 1] ?? "");
|
|
416
457
|
});
|
|
417
458
|
|
|
459
|
+
// Validate constraints against decoded values; a failure falls
|
|
460
|
+
// through to the next route so other patterns can still match.
|
|
461
|
+
if (!satisfiesConstraints(params, constraints)) {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
418
465
|
if (effectiveDebug) {
|
|
419
466
|
debugLog("findMatch", "matched route", {
|
|
420
467
|
routeKey,
|
|
@@ -467,9 +514,13 @@ export function findMatch<TEnv>(
|
|
|
467
514
|
if (altMatch) {
|
|
468
515
|
const params: Record<string, string> = {};
|
|
469
516
|
paramNames.forEach((name, index) => {
|
|
470
|
-
params[name] = altMatch[index + 1] ?? "";
|
|
517
|
+
params[name] = safeDecodeURIComponent(altMatch[index + 1] ?? "");
|
|
471
518
|
});
|
|
472
519
|
|
|
520
|
+
if (!satisfiesConstraints(params, constraints)) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
473
524
|
// Determine redirect behavior based on mode
|
|
474
525
|
if (trailingSlashMode === "ignore") {
|
|
475
526
|
// Match without redirect
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { TrieNode, TrieLeaf } from "../build/route-trie.js";
|
|
9
|
+
import { safeDecodeURIComponent } from "./url-params.js";
|
|
9
10
|
|
|
10
11
|
export interface TrieMatchResult {
|
|
11
12
|
/** Route name */
|
|
@@ -173,20 +174,25 @@ function validateAndBuild(
|
|
|
173
174
|
originalPathname: string,
|
|
174
175
|
pathnameHasTrailingSlash: boolean,
|
|
175
176
|
): TrieMatchResult | null {
|
|
176
|
-
// Build named params by zipping leaf.pa with positional paramValues
|
|
177
|
+
// Build named params by zipping leaf.pa with positional paramValues.
|
|
178
|
+
// Params are URL-decoded at this boundary so ctx.params holds the values
|
|
179
|
+
// apps expect (matching Express/React Router) and round-trip cleanly
|
|
180
|
+
// through ctx.reverse.
|
|
177
181
|
const params: Record<string, string> = {};
|
|
178
182
|
if (leaf.pa) {
|
|
179
183
|
for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
|
|
180
|
-
params[leaf.pa[i]] = paramValues[i];
|
|
184
|
+
params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
|
|
181
185
|
}
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
// Add wildcard param (wildcard leaves have pn from TrieNode.w type)
|
|
185
189
|
if (wildcardValue !== undefined && "pn" in leaf) {
|
|
186
|
-
params[(leaf as TrieLeaf & { pn: string }).pn] =
|
|
190
|
+
params[(leaf as TrieLeaf & { pn: string }).pn] =
|
|
191
|
+
safeDecodeURIComponent(wildcardValue);
|
|
187
192
|
}
|
|
188
193
|
|
|
189
|
-
// Validate constraints
|
|
194
|
+
// Validate constraints against decoded values so constraint lists can be
|
|
195
|
+
// written in decoded form (e.g. ["en-GB", "en US"]).
|
|
190
196
|
if (leaf.cv) {
|
|
191
197
|
for (const paramName in leaf.cv) {
|
|
192
198
|
const allowed = leaf.cv[paramName]!;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL param encode/decode at the route boundary.
|
|
3
|
+
*
|
|
4
|
+
* Extraction (decode): regex/trie matchers keep param values URL-encoded;
|
|
5
|
+
* `safeDecodeURIComponent` turns them back into raw strings so `ctx.params`
|
|
6
|
+
* matches the contract apps expect (Express/React Router/Fastify/Koa) and
|
|
7
|
+
* round-trips through reverse stay stable. Malformed %-encoding is
|
|
8
|
+
* preserved as-is so a broken URL doesn't crash matching.
|
|
9
|
+
*
|
|
10
|
+
* Reversal (encode): `encodePathSegment` escapes only what RFC 3986
|
|
11
|
+
* requires for a path segment — `/`, `?`, `#`, space, control chars,
|
|
12
|
+
* non-ASCII — and leaves pchar sub-delims (`@ : $ & + , ; =` and friends)
|
|
13
|
+
* readable. `encodeURIComponent` over-encodes for path segments, which
|
|
14
|
+
* makes generated URLs harder for humans to read in the address bar
|
|
15
|
+
* (e.g. mailbox IDs like `ivo@example.com` would become
|
|
16
|
+
* `ivo%40example.com` even though `@` is path-legal).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function safeDecodeURIComponent(raw: string): string {
|
|
20
|
+
if (raw === "" || raw.indexOf("%") === -1) return raw;
|
|
21
|
+
try {
|
|
22
|
+
return decodeURIComponent(raw);
|
|
23
|
+
} catch {
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// encodeURIComponent over-encodes for path segments. After running it,
|
|
29
|
+
// un-encode the pchar sub-delims + (`:` / `@`) so the resulting URL
|
|
30
|
+
// keeps human-readable characters that are legal in a path segment.
|
|
31
|
+
// Everything dangerous — `/ ? # %` and space/control/non-ASCII — stays
|
|
32
|
+
// encoded.
|
|
33
|
+
const PATH_SAFE_ESCAPES: Record<string, string> = {
|
|
34
|
+
"%3A": ":",
|
|
35
|
+
"%40": "@",
|
|
36
|
+
"%24": "$",
|
|
37
|
+
"%26": "&",
|
|
38
|
+
"%2B": "+",
|
|
39
|
+
"%2C": ",",
|
|
40
|
+
"%3B": ";",
|
|
41
|
+
"%3D": "=",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function encodePathSegment(value: string): string {
|
|
45
|
+
return encodeURIComponent(value).replace(
|
|
46
|
+
/%(?:3A|40|24|26|2B|2C|3B|3D)/gi,
|
|
47
|
+
(match) => PATH_SAFE_ESCAPES[match.toUpperCase()] ?? match,
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -22,8 +22,7 @@ import type { UrlPatterns } from "./urls.js";
|
|
|
22
22
|
import type { UrlBuilder } from "./urls/pattern-types.js";
|
|
23
23
|
import { urls } from "./urls.js";
|
|
24
24
|
import {
|
|
25
|
-
EntryData,
|
|
26
|
-
InterceptSelectorContext,
|
|
25
|
+
type EntryData,
|
|
27
26
|
getContext,
|
|
28
27
|
RSCRouterContext,
|
|
29
28
|
type MetricsStore,
|
package/src/rsc/handler.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
interceptRedirectForPartial,
|
|
32
32
|
buildRouteMiddlewareEntries,
|
|
33
33
|
} from "./helpers.js";
|
|
34
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
34
35
|
import {
|
|
35
36
|
handleResponseRoute,
|
|
36
37
|
type ResponseRouteMatch,
|
|
@@ -56,6 +57,7 @@ import {
|
|
|
56
57
|
getRouterTrie,
|
|
57
58
|
} from "../route-map-builder.js";
|
|
58
59
|
import type { HandlerContext } from "./handler-context.js";
|
|
60
|
+
import type { SegmentCacheStore } from "../cache/types.js";
|
|
59
61
|
import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
|
|
60
62
|
import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
|
|
61
63
|
import {
|
|
@@ -352,7 +354,7 @@ export function createRSCHandler<
|
|
|
352
354
|
// Resolve cache store configuration
|
|
353
355
|
// Priority: options.cache (handler override) > router.cache (router default)
|
|
354
356
|
// Store is enabled only if: config provided, enabled, and no ?__no_cache query param
|
|
355
|
-
let cacheStore
|
|
357
|
+
let cacheStore: SegmentCacheStore | undefined;
|
|
356
358
|
const cacheOption = options.cache ?? router.cache;
|
|
357
359
|
if (cacheOption && !url.searchParams.has("__no_cache")) {
|
|
358
360
|
const cacheConfig =
|
|
@@ -533,7 +535,9 @@ export function createRSCHandler<
|
|
|
533
535
|
}
|
|
534
536
|
|
|
535
537
|
const fullTiming = timingParts.join(", ");
|
|
536
|
-
if (fullTiming
|
|
538
|
+
if (fullTiming && !isWebSocketUpgradeResponse(response)) {
|
|
539
|
+
response.headers.set("Server-Timing", fullTiming);
|
|
540
|
+
}
|
|
537
541
|
|
|
538
542
|
return response;
|
|
539
543
|
});
|
|
@@ -804,7 +808,7 @@ export function createRSCHandler<
|
|
|
804
808
|
);
|
|
805
809
|
}
|
|
806
810
|
const response = responseOutcome.result;
|
|
807
|
-
if (plan.negotiated) {
|
|
811
|
+
if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
|
|
808
812
|
response.headers.append("Vary", "Accept");
|
|
809
813
|
}
|
|
810
814
|
return response;
|
|
@@ -1014,7 +1018,7 @@ export function createRSCHandler<
|
|
|
1014
1018
|
nonce,
|
|
1015
1019
|
);
|
|
1016
1020
|
}
|
|
1017
|
-
if (negotiated) {
|
|
1021
|
+
if (negotiated && !isWebSocketUpgradeResponse(response)) {
|
|
1018
1022
|
response.headers.append("Vary", "Accept");
|
|
1019
1023
|
}
|
|
1020
1024
|
return response;
|
package/src/rsc/helpers.ts
CHANGED
|
@@ -8,9 +8,49 @@ import {
|
|
|
8
8
|
_getRequestContext,
|
|
9
9
|
getLocationState,
|
|
10
10
|
} from "../server/request-context.js";
|
|
11
|
+
import type { RequestContext } from "../server/request-context.js";
|
|
11
12
|
import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
|
|
12
13
|
import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Copy stub headers from the request context onto a target Headers instance:
|
|
17
|
+
* append Set-Cookie entries, set everything else only if absent. Header
|
|
18
|
+
* mutation failures are swallowed so the same logic works against Response
|
|
19
|
+
* headers that may be immutable (e.g. Cloudflare protocol-switch responses).
|
|
20
|
+
*/
|
|
21
|
+
function applyStubHeaders(target: Headers, stub: Headers): void {
|
|
22
|
+
stub.forEach((value, name) => {
|
|
23
|
+
try {
|
|
24
|
+
if (name.toLowerCase() === "set-cookie") {
|
|
25
|
+
target.append(name, value);
|
|
26
|
+
} else if (!target.has(name)) {
|
|
27
|
+
target.set(name, value);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Headers immutable — skip.
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Drain ctx._onResponseCallbacks onto a response. Swapping the array before
|
|
37
|
+
* iteration prevents re-entrant registrations from double-firing and matches
|
|
38
|
+
* the contract that each callback runs at most once per request.
|
|
39
|
+
*/
|
|
40
|
+
function drainOnResponseCallbacks(
|
|
41
|
+
ctx: RequestContext,
|
|
42
|
+
response: Response,
|
|
43
|
+
): Response {
|
|
44
|
+
const callbacks = ctx._onResponseCallbacks;
|
|
45
|
+
if (callbacks.length === 0) return response;
|
|
46
|
+
ctx._onResponseCallbacks = [];
|
|
47
|
+
let result = response;
|
|
48
|
+
for (const callback of callbacks) {
|
|
49
|
+
result = callback(result) ?? result;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
14
54
|
/**
|
|
15
55
|
* Check if a request body has content to decode
|
|
16
56
|
*/
|
|
@@ -39,40 +79,23 @@ export function createResponseWithMergedHeaders(
|
|
|
39
79
|
return new Response(body, init);
|
|
40
80
|
}
|
|
41
81
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// merge points (e.g. executeMiddleware) do not duplicate them.
|
|
82
|
+
// Delete Set-Cookie from the stub after consuming so downstream merge
|
|
83
|
+
// points (e.g. executeMiddleware) don't duplicate them.
|
|
45
84
|
const mergedHeaders = new Headers(init.headers);
|
|
46
|
-
ctx.res.headers
|
|
47
|
-
if (name.toLowerCase() === "set-cookie") {
|
|
48
|
-
mergedHeaders.append(name, value);
|
|
49
|
-
} else if (!mergedHeaders.has(name)) {
|
|
50
|
-
// Only set if not already present in init.headers
|
|
51
|
-
mergedHeaders.set(name, value);
|
|
52
|
-
}
|
|
53
|
-
});
|
|
85
|
+
applyStubHeaders(mergedHeaders, ctx.res.headers);
|
|
54
86
|
ctx.res.headers.delete("set-cookie");
|
|
55
87
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
88
|
+
// ctx.res.status overrides init.status when explicitly set (e.g. 404 for
|
|
89
|
+
// notFound, 500 for error). Default ctx.res.status is 200.
|
|
58
90
|
const status = ctx.res.status !== 200 ? ctx.res.status : init.status;
|
|
59
91
|
|
|
60
|
-
|
|
92
|
+
const response = new Response(body, {
|
|
61
93
|
...init,
|
|
62
94
|
status,
|
|
63
95
|
headers: mergedHeaders,
|
|
64
96
|
});
|
|
65
97
|
|
|
66
|
-
|
|
67
|
-
// Drain the array so that downstream callers (e.g. finalizeResponse)
|
|
68
|
-
// do not re-execute the same callbacks on this response.
|
|
69
|
-
const callbacks = ctx._onResponseCallbacks;
|
|
70
|
-
ctx._onResponseCallbacks = [];
|
|
71
|
-
for (const callback of callbacks) {
|
|
72
|
-
response = callback(response) ?? response;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return response;
|
|
98
|
+
return drainOnResponseCallbacks(ctx, response);
|
|
76
99
|
}
|
|
77
100
|
|
|
78
101
|
/**
|
|
@@ -175,24 +198,29 @@ export function buildRouteMiddlewareEntries<TEnv>(
|
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
201
|
+
* Merge stub headers from the request context onto an existing Response in
|
|
202
|
+
* place, then drain onResponse callbacks. Used when a Response cannot flow
|
|
203
|
+
* through `new Response()` — status 101 is outside the constructor's
|
|
204
|
+
* 200-599 range, and the Cloudflare-specific `webSocket` property would be
|
|
205
|
+
* lost on reconstruction.
|
|
183
206
|
*/
|
|
184
|
-
export function
|
|
207
|
+
export function mergeStubHeadersAndFinalize(response: Response): Response {
|
|
185
208
|
const ctx = _getRequestContext();
|
|
186
|
-
if (!ctx
|
|
187
|
-
return response;
|
|
188
|
-
}
|
|
209
|
+
if (!ctx) return response;
|
|
189
210
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
211
|
+
applyStubHeaders(response.headers, ctx.res.headers);
|
|
212
|
+
ctx.res.headers.delete("set-cookie");
|
|
213
|
+
|
|
214
|
+
return drainOnResponseCallbacks(ctx, response);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Run onResponse callbacks on an existing Response. Used by code paths that
|
|
219
|
+
* bypass createResponseWithMergedHeaders (e.g. middleware short-circuits)
|
|
220
|
+
* but still need ctx.onResponse() callbacks to fire.
|
|
221
|
+
*/
|
|
222
|
+
export function finalizeResponse(response: Response): Response {
|
|
223
|
+
const ctx = _getRequestContext();
|
|
224
|
+
if (!ctx) return response;
|
|
225
|
+
return drainOnResponseCallbacks(ctx, response);
|
|
198
226
|
}
|
|
@@ -248,6 +248,7 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
248
248
|
segments: match.segments,
|
|
249
249
|
matched: match.matched,
|
|
250
250
|
diff: match.diff,
|
|
251
|
+
params: match.params,
|
|
251
252
|
isPartial: false,
|
|
252
253
|
rootLayout: ctx.router.rootLayout,
|
|
253
254
|
handles: handleStore.stream(),
|
|
@@ -353,6 +354,7 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
353
354
|
segments: errorResult.segments,
|
|
354
355
|
matched: errorResult.matched,
|
|
355
356
|
diff: errorResult.diff,
|
|
357
|
+
params: errorResult.params,
|
|
356
358
|
isPartial: false,
|
|
357
359
|
isError: true,
|
|
358
360
|
rootLayout: ctx.router.rootLayout,
|
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
finalizeResponse,
|
|
27
27
|
isCacheableStatus,
|
|
28
28
|
buildRouteMiddlewareEntries,
|
|
29
|
+
mergeStubHeadersAndFinalize,
|
|
29
30
|
} from "./helpers.js";
|
|
31
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
30
32
|
|
|
31
33
|
export interface ResponseRouteMatch {
|
|
32
34
|
responseType: string;
|
|
@@ -78,10 +80,13 @@ export async function handleResponseRoute<TEnv>(
|
|
|
78
80
|
env,
|
|
79
81
|
searchParams: cleanUrl.searchParams,
|
|
80
82
|
url: cleanUrl,
|
|
83
|
+
originalUrl: reqCtx.originalUrl,
|
|
81
84
|
pathname: url.pathname,
|
|
82
85
|
reverse: createReverseFunction(handlerCtx.getRequiredRouteMap()),
|
|
83
86
|
get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
|
|
84
87
|
header: (name: string, value: string) => reqCtx.header(name, value),
|
|
88
|
+
waitUntil: reqCtx.waitUntil.bind(reqCtx),
|
|
89
|
+
executionContext: reqCtx.executionContext,
|
|
85
90
|
_responseType: preview.responseType,
|
|
86
91
|
};
|
|
87
92
|
// Brand with taint symbol so "use cache" detects it as request-scoped
|
|
@@ -96,6 +101,12 @@ export async function handleResponseRoute<TEnv>(
|
|
|
96
101
|
// so that stub headers (cookies, custom headers set via ctx.header()) are included.
|
|
97
102
|
// Use Headers (not Record<string, string>) to preserve duplicate entries like Set-Cookie.
|
|
98
103
|
const rewrapResponse = (result: Response) => {
|
|
104
|
+
// 204/205/304 are NOT short-circuited — they're valid for the Response
|
|
105
|
+
// constructor and must honor ctx.setStatus() overrides. Only upgrade
|
|
106
|
+
// responses (status 101 / `webSocket` property) bypass reconstruction.
|
|
107
|
+
if (isWebSocketUpgradeResponse(result)) {
|
|
108
|
+
return mergeStubHeadersAndFinalize(result);
|
|
109
|
+
}
|
|
99
110
|
const headers = new Headers();
|
|
100
111
|
result.headers.forEach((value, key) => {
|
|
101
112
|
if (key.toLowerCase() === "set-cookie") {
|
|
@@ -196,7 +207,9 @@ export async function handleResponseRoute<TEnv>(
|
|
|
196
207
|
// Wrap callHandler to append Vary: Accept on content-negotiated responses
|
|
197
208
|
const callHandlerWithVary = async () => {
|
|
198
209
|
const response = await callHandler();
|
|
199
|
-
if (preview.negotiated) {
|
|
210
|
+
if (preview.negotiated && !isWebSocketUpgradeResponse(response)) {
|
|
211
|
+
// Skip Vary on upgrade responses: headers are semantically immutable
|
|
212
|
+
// on some runtimes, and Vary is meaningless for a 101 response.
|
|
200
213
|
response.headers.append("Vary", "Accept");
|
|
201
214
|
}
|
|
202
215
|
return response;
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -204,6 +204,13 @@ export async function handleRscRendering<TEnv>(
|
|
|
204
204
|
"content-type": "text/x-component;charset=utf-8",
|
|
205
205
|
vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
|
|
206
206
|
};
|
|
207
|
+
// Tell the client's prefetch cache to scope this response to its source
|
|
208
|
+
// URL (instead of the default source-agnostic wildcard). Intercept
|
|
209
|
+
// responses depend on the source page matching an intercept rule, so
|
|
210
|
+
// they must not be reused for navigations from other sources.
|
|
211
|
+
if (hasInterceptSlots) {
|
|
212
|
+
rscHeaders["x-rsc-prefetch-scope"] = "source";
|
|
213
|
+
}
|
|
207
214
|
// Enable browser HTTP caching for prefetch responses only.
|
|
208
215
|
// Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
|
|
209
216
|
// non-intercept context (intercept responses depend on source page),
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -213,6 +213,7 @@ export async function executeServerAction<TEnv>(
|
|
|
213
213
|
isPartial: true,
|
|
214
214
|
matched: errorResult.matched,
|
|
215
215
|
diff: errorResult.diff,
|
|
216
|
+
params: errorResult.params,
|
|
216
217
|
isError: true,
|
|
217
218
|
handles: handleStore.stream(),
|
|
218
219
|
version: ctx.version,
|
|
@@ -323,6 +324,7 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
323
324
|
isPartial: true,
|
|
324
325
|
matched: matchResult.matched,
|
|
325
326
|
diff: matchResult.diff,
|
|
327
|
+
params: matchResult.params,
|
|
326
328
|
slots: matchResult.slots,
|
|
327
329
|
handles: handleStore.stream(),
|
|
328
330
|
version: ctx.version,
|