@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.
- package/dist/bin/rango.js +4 -3
- package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
- package/dist/types/browser/partial-update.d.ts +6 -4
- package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
- package/dist/types/browser/react/context.d.ts +3 -2
- package/dist/types/browser/types.d.ts +5 -1
- package/dist/types/build/route-types/scan-filter.d.ts +7 -1
- package/dist/types/cache/document-cache-policy.d.ts +24 -0
- package/dist/types/cache/document-cache.d.ts +2 -10
- package/dist/types/context-var.d.ts +12 -0
- package/dist/types/errors.d.ts +10 -0
- package/dist/types/handles/pushed-by.d.ts +8 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.rsc.d.ts +2 -0
- package/dist/types/response-utils.d.ts +7 -0
- package/dist/types/reverse.d.ts +8 -8
- package/dist/types/route-definition/rewrite.d.ts +56 -0
- package/dist/types/route-map-builder.d.ts +7 -2
- package/dist/types/router/basename.d.ts +7 -0
- package/dist/types/router/handler-context.d.ts +1 -5
- package/dist/types/router/internal-params.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +15 -0
- package/dist/types/router/revalidation.d.ts +1 -2
- package/dist/types/router/route-trie-builder.d.ts +10 -0
- package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
- package/dist/types/rsc/full-payload.d.ts +11 -0
- package/dist/types/rsc/handler.d.ts +33 -0
- package/dist/types/rsc/helpers.d.ts +8 -0
- package/dist/types/rsc/loader-fetch.d.ts +16 -0
- package/dist/types/rsc/rewrite-passes.d.ts +74 -0
- package/dist/types/rsc/ssr-setup.d.ts +3 -1
- package/dist/types/server/request-context.d.ts +39 -2
- package/dist/types/server/rewrite-trail.d.ts +49 -0
- package/dist/types/server/slot-accessors.d.ts +29 -0
- package/dist/types/testing/index.d.ts +1 -1
- package/dist/types/testing/render-route.d.ts +8 -0
- package/dist/types/testing/run-middleware.d.ts +20 -0
- package/dist/types/testing/serve-shell-request.d.ts +2 -1
- package/dist/types/thenable-status.d.ts +6 -0
- package/dist/types/types/request-scope.d.ts +7 -0
- package/dist/types/vite/discovery/state.d.ts +12 -9
- package/dist/vite/index.js +164 -103
- package/package.json +1 -1
- package/skills/caching/SKILL.md +12 -0
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +84 -0
- package/skills/hooks/SKILL.md +11 -6
- package/skills/hooks/data.md +3 -1
- package/skills/loader/SKILL.md +7 -1
- package/skills/middleware/SKILL.md +183 -2
- package/skills/ppr/SKILL.md +9 -0
- package/skills/rango/SKILL.md +1 -1
- package/skills/server-actions/SKILL.md +27 -0
- package/skills/testing/client-components.md +1 -0
- package/skills/testing/middleware.md +31 -10
- package/skills/testing/response-routes.md +1 -0
- package/skills/testing/serve-shell-request.md +19 -1
- package/skills/use-cache/SKILL.md +4 -2
- package/src/__augment-tests__/augmented.check.ts +20 -0
- package/src/browser/event-controller.ts +3 -0
- package/src/browser/merge-segment-loaders.ts +31 -18
- package/src/browser/navigation-bridge.ts +19 -3
- package/src/browser/partial-update.ts +27 -12
- package/src/browser/react/Link.tsx +7 -9
- package/src/browser/react/NavigationProvider.tsx +46 -26
- package/src/browser/react/context.ts +3 -2
- package/src/browser/react/use-router.ts +2 -4
- package/src/browser/server-action-bridge.ts +6 -0
- package/src/browser/types.ts +5 -1
- package/src/build/route-types/scan-filter.ts +11 -4
- package/src/cache/document-cache-policy.ts +131 -0
- package/src/cache/document-cache.ts +12 -119
- package/src/context-var.ts +32 -2
- package/src/errors.ts +19 -0
- package/src/handles/pushed-by.ts +26 -0
- package/src/index.rsc.ts +8 -0
- package/src/index.ts +7 -0
- package/src/response-utils.ts +19 -0
- package/src/reverse.ts +17 -14
- package/src/route-definition/redirect.ts +2 -15
- package/src/route-definition/rewrite.ts +146 -0
- package/src/route-map-builder.ts +14 -11
- package/src/router/basename.ts +20 -0
- package/src/router/handler-context.ts +3 -13
- package/src/router/intercept-resolution.ts +29 -12
- package/src/router/internal-params.ts +16 -0
- package/src/router/loader-resolution.ts +2 -0
- package/src/router/match-middleware/background-revalidation.ts +11 -9
- package/src/router/match-middleware/cache-lookup.ts +20 -15
- package/src/router/match-middleware/cache-store.ts +9 -11
- package/src/router/match-middleware/intercept-resolution.ts +4 -2
- package/src/router/middleware-types.ts +16 -0
- package/src/router/middleware.ts +127 -49
- package/src/router/revalidation.ts +1 -1
- package/src/router/route-trie-builder.ts +28 -1
- package/src/router/segment-resolution/loader-snapshot.ts +18 -4
- package/src/router/segment-resolution/mask-nested.ts +8 -4
- package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
- package/src/router/segment-resolution/revalidation.ts +57 -33
- package/src/rsc/full-payload.ts +37 -5
- package/src/rsc/handler.ts +82 -55
- package/src/rsc/helpers.ts +24 -0
- package/src/rsc/loader-fetch.ts +79 -24
- package/src/rsc/response-cache-serve.ts +18 -2
- package/src/rsc/response-route-handler.ts +4 -7
- package/src/rsc/rewrite-passes.ts +203 -0
- package/src/rsc/rsc-rendering.ts +4 -2
- package/src/rsc/ssr-setup.ts +11 -5
- package/src/server/cookie-parse.ts +36 -12
- package/src/server/cookie-store.ts +7 -3
- package/src/server/handle-store.ts +48 -17
- package/src/server/request-context.ts +154 -63
- package/src/server/request-headers.ts +4 -14
- package/src/server/rewrite-trail.ts +190 -0
- package/src/server/slot-accessors.ts +57 -0
- package/src/ssr/index.tsx +118 -16
- package/src/testing/dispatch.ts +138 -67
- package/src/testing/index.ts +1 -0
- package/src/testing/render-route.tsx +16 -3
- package/src/testing/run-loader.ts +2 -0
- package/src/testing/run-middleware.ts +56 -11
- package/src/testing/serve-shell-request.ts +15 -2
- package/src/thenable-status.ts +11 -0
- package/src/types/request-scope.ts +8 -0
- package/src/vite/discovery/state.ts +21 -13
- package/src/vite/plugins/expose-internal-ids.ts +2 -3
- package/src/vite/router-discovery.ts +29 -27
package/src/rsc/loader-fetch.ts
CHANGED
|
@@ -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")
|
|
40
|
-
* payload
|
|
41
|
-
*
|
|
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:
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -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).
|
package/src/rsc/ssr-setup.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|