@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19
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 +46 -8
- package/dist/bin/rango.js +105 -18
- package/dist/vite/index.js +227 -93
- package/package.json +15 -14
- package/skills/hooks/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +94 -1
- package/skills/middleware/SKILL.md +81 -0
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +187 -17
- package/skills/route/SKILL.md +42 -1
- package/skills/router-setup/SKILL.md +77 -0
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +38 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +25 -27
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +0 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +46 -13
- package/src/browser/navigation-client.ts +32 -61
- package/src/browser/navigation-store.ts +1 -31
- package/src/browser/navigation-transaction.ts +46 -207
- package/src/browser/partial-update.ts +102 -150
- package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
- package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
- package/src/browser/react/Link.tsx +28 -23
- package/src/browser/react/NavigationProvider.tsx +9 -1
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +1 -1
- package/src/browser/react/location-state.ts +2 -0
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/use-action.ts +9 -1
- package/src/browser/react/use-handle.ts +3 -25
- package/src/browser/react/use-params.ts +2 -4
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +7 -60
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +29 -23
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +115 -96
- package/src/browser/types.ts +1 -31
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +5 -0
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-types/codegen.ts +13 -4
- package/src/build/route-types/include-resolution.ts +13 -0
- package/src/build/route-types/per-module-writer.ts +15 -3
- package/src/build/route-types/router-processing.ts +45 -3
- package/src/build/runtime-discovery.ts +13 -1
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +132 -96
- package/src/cache/cache-scope.ts +71 -73
- package/src/cache/cf/cf-cache-store.ts +9 -4
- package/src/cache/document-cache.ts +72 -47
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/memory-segment-store.ts +18 -7
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +101 -112
- package/src/cache/taint.ts +26 -0
- package/src/client.tsx +53 -30
- package/src/errors.ts +6 -1
- package/src/handle.ts +1 -1
- package/src/handles/MetaTags.tsx +5 -2
- package/src/host/cookie-handler.ts +8 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +33 -1
- package/src/index.ts +27 -0
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +4 -3
- package/src/prerender.ts +76 -18
- package/src/reverse.ts +11 -7
- package/src/root-error-boundary.tsx +30 -26
- package/src/route-definition/dsl-helpers.ts +9 -6
- package/src/route-definition/redirect.ts +15 -3
- package/src/route-map-builder.ts +38 -2
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +7 -0
- package/src/router/content-negotiation.ts +1 -1
- package/src/router/debug-manifest.ts +16 -3
- package/src/router/handler-context.ts +94 -15
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +1 -0
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +61 -7
- package/src/router/match-context.ts +3 -0
- package/src/router/match-handlers.ts +185 -11
- package/src/router/match-middleware/background-revalidation.ts +65 -85
- package/src/router/match-middleware/cache-lookup.ts +69 -4
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/middleware-types.ts +7 -0
- package/src/router/middleware.ts +93 -8
- package/src/router/pattern-matching.ts +41 -5
- package/src/router/prerender-match.ts +34 -6
- package/src/router/preview-match.ts +7 -1
- package/src/router/revalidation.ts +61 -2
- package/src/router/router-context.ts +15 -0
- package/src/router/router-interfaces.ts +34 -0
- package/src/router/router-options.ts +200 -0
- package/src/router/segment-resolution/fresh.ts +123 -30
- package/src/router/segment-resolution/helpers.ts +19 -0
- package/src/router/segment-resolution/loader-cache.ts +37 -146
- package/src/router/segment-resolution/revalidation.ts +358 -94
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/types.ts +7 -1
- package/src/router.ts +155 -11
- package/src/rsc/handler-context.ts +11 -0
- package/src/rsc/handler.ts +380 -88
- package/src/rsc/helpers.ts +25 -16
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +232 -19
- package/src/rsc/response-route-handler.ts +37 -26
- package/src/rsc/rsc-rendering.ts +12 -5
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +134 -58
- package/src/rsc/types.ts +8 -0
- package/src/search-params.ts +22 -10
- package/src/server/context.ts +53 -5
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +66 -9
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +90 -9
- package/src/ssr/index.tsx +63 -27
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +1 -6
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +5 -0
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +9 -0
- package/src/types/handler-context.ts +35 -13
- package/src/types/loader-types.ts +7 -0
- package/src/types/route-entry.ts +28 -0
- package/src/urls/include-helper.ts +49 -8
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +30 -12
- package/src/urls/path-helper.ts +17 -2
- package/src/urls/pattern-types.ts +21 -1
- package/src/urls/response-types.ts +27 -2
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +12 -4
- package/src/vite/discovery/bundle-postprocess.ts +12 -7
- package/src/vite/discovery/discover-routers.ts +30 -18
- package/src/vite/discovery/prerender-collection.ts +24 -27
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/rango.ts +3 -3
- package/src/vite/router-discovery.ts +99 -36
- package/src/vite/utils/prerender-utils.ts +21 -0
- package/src/vite/utils/shared-utils.ts +3 -1
- package/src/browser/request-controller.ts +0 -164
- package/src/href-context.ts +0 -33
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
|
@@ -23,6 +23,7 @@ import type { HandlerContext } from "./handler-context.js";
|
|
|
23
23
|
import { createResponseErrorPayload } from "./response-error.js";
|
|
24
24
|
import {
|
|
25
25
|
createResponseWithMergedHeaders,
|
|
26
|
+
finalizeResponse,
|
|
26
27
|
isCacheableStatus,
|
|
27
28
|
buildRouteMiddlewareEntries,
|
|
28
29
|
} from "./helpers.js";
|
|
@@ -70,25 +71,15 @@ export async function handleResponseRoute<TEnv>(
|
|
|
70
71
|
|
|
71
72
|
// Build lightweight context for response handler
|
|
72
73
|
const reqCtx = requireRequestContext();
|
|
74
|
+
const cleanUrl = stripInternalParams(url);
|
|
73
75
|
const responseHandlerCtx = {
|
|
74
76
|
request,
|
|
75
77
|
params: preview.params || {},
|
|
76
78
|
env,
|
|
77
|
-
searchParams:
|
|
78
|
-
url,
|
|
79
|
+
searchParams: cleanUrl.searchParams,
|
|
80
|
+
url: cleanUrl,
|
|
79
81
|
pathname: url.pathname,
|
|
80
|
-
|
|
81
|
-
if (name.startsWith("/")) {
|
|
82
|
-
if (!hrefParams) return name;
|
|
83
|
-
return name.replace(/:([^/]+)/g, (_, key) => {
|
|
84
|
-
const value = hrefParams[key];
|
|
85
|
-
if (value === undefined)
|
|
86
|
-
throw new Error(`Missing param "${key}" for path "${name}"`);
|
|
87
|
-
return encodeURIComponent(value);
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
return name;
|
|
91
|
-
},
|
|
82
|
+
reverse: createReverseFunction(handlerCtx.getRequiredRouteMap()),
|
|
92
83
|
get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
|
|
93
84
|
header: (name: string, value: string) => reqCtx.header(name, value),
|
|
94
85
|
_responseType: preview.responseType,
|
|
@@ -103,10 +94,15 @@ export async function handleResponseRoute<TEnv>(
|
|
|
103
94
|
|
|
104
95
|
// Re-wrap a handler-returned Response through createResponseWithMergedHeaders
|
|
105
96
|
// so that stub headers (cookies, custom headers set via ctx.header()) are included.
|
|
97
|
+
// Use Headers (not Record<string, string>) to preserve duplicate entries like Set-Cookie.
|
|
106
98
|
const rewrapResponse = (result: Response) => {
|
|
107
|
-
const headers
|
|
99
|
+
const headers = new Headers();
|
|
108
100
|
result.headers.forEach((value, key) => {
|
|
109
|
-
|
|
101
|
+
if (key.toLowerCase() === "set-cookie") {
|
|
102
|
+
headers.append(key, value);
|
|
103
|
+
} else {
|
|
104
|
+
headers.set(key, value);
|
|
105
|
+
}
|
|
110
106
|
});
|
|
111
107
|
return createResponseWithMergedHeaders(result.body, {
|
|
112
108
|
status: result.status,
|
|
@@ -231,12 +227,23 @@ export async function handleResponseRoute<TEnv>(
|
|
|
231
227
|
}
|
|
232
228
|
|
|
233
229
|
if (cacheScope?.enabled) {
|
|
230
|
+
// Evaluate condition — skip response cache when condition returns false
|
|
231
|
+
let conditionPassed = true;
|
|
232
|
+
if (cacheScope.config !== false && cacheScope.config.condition) {
|
|
233
|
+
try {
|
|
234
|
+
conditionPassed = !!cacheScope.config.condition(reqCtx);
|
|
235
|
+
} catch {
|
|
236
|
+
conditionPassed = false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
234
240
|
const store = cacheScope.getStore() ?? reqCtx._cacheStore;
|
|
235
|
-
if (store?.getResponse && store?.putResponse) {
|
|
241
|
+
if (conditionPassed && store?.getResponse && store?.putResponse) {
|
|
236
242
|
// Build cache key with response:{type}: prefix to avoid collision
|
|
237
243
|
// with segment keys and differentiate between response types.
|
|
238
|
-
// Include url.search so query-driven
|
|
239
|
-
|
|
244
|
+
// Include host and url.search so query-driven and multi-host
|
|
245
|
+
// responses cache separately.
|
|
246
|
+
let cacheKey = `response:${preview.responseType}:${url.host}${url.pathname}${url.search}`;
|
|
240
247
|
|
|
241
248
|
// Priority 1: Route-level key function (full override)
|
|
242
249
|
if (cacheScope.config !== false && cacheScope.config.key) {
|
|
@@ -286,11 +293,14 @@ export async function handleResponseRoute<TEnv>(
|
|
|
286
293
|
// Stale hit (SWR) - return cached, revalidate in background
|
|
287
294
|
reqCtx.waitUntil(async () => {
|
|
288
295
|
try {
|
|
289
|
-
|
|
296
|
+
// finalizeResponse drains any onResponse callbacks registered
|
|
297
|
+
// during middleware execution (e.g. middleware short-circuit)
|
|
298
|
+
// that createResponseWithMergedHeaders didn't reach.
|
|
299
|
+
const fresh = finalizeResponse(await executeHandler());
|
|
290
300
|
if (isCacheableStatus(fresh.status)) {
|
|
291
301
|
await store.putResponse!(
|
|
292
302
|
cacheKey,
|
|
293
|
-
fresh,
|
|
303
|
+
fresh.clone(),
|
|
294
304
|
cacheScope!.ttl,
|
|
295
305
|
cacheScope!.swr,
|
|
296
306
|
);
|
|
@@ -307,10 +317,11 @@ export async function handleResponseRoute<TEnv>(
|
|
|
307
317
|
}
|
|
308
318
|
|
|
309
319
|
// Cache miss - execute handler and cache the result.
|
|
310
|
-
// createResponseWithMergedHeaders inside the handler
|
|
311
|
-
//
|
|
312
|
-
// (
|
|
313
|
-
|
|
320
|
+
// createResponseWithMergedHeaders inside the handler drains callbacks
|
|
321
|
+
// registered during handler execution. finalizeResponse catches any
|
|
322
|
+
// remaining callbacks (e.g. from middleware short-circuit where the
|
|
323
|
+
// handler never ran) so the cached artifact includes all transforms.
|
|
324
|
+
const response = finalizeResponse(await executeHandler());
|
|
314
325
|
|
|
315
326
|
if (isCacheableStatus(response.status)) {
|
|
316
327
|
reqCtx.waitUntil(async () => {
|
|
@@ -332,5 +343,5 @@ export async function handleResponseRoute<TEnv>(
|
|
|
332
343
|
}
|
|
333
344
|
}
|
|
334
345
|
|
|
335
|
-
return executeHandler();
|
|
346
|
+
return executeHandler().then(finalizeResponse);
|
|
336
347
|
}
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -111,6 +111,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
111
111
|
const nonLoaderSegments = match.segments.filter(
|
|
112
112
|
(s) => s.type !== "loader",
|
|
113
113
|
);
|
|
114
|
+
handleStore.seal();
|
|
114
115
|
await handleStore.settled;
|
|
115
116
|
const { serializeSegments } = await import("../cache/segment-codec.js");
|
|
116
117
|
const serializedSegments = await serializeSegments(nonLoaderSegments);
|
|
@@ -213,13 +214,19 @@ export async function handleRscRendering<TEnv>(
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
// Delegate to SSR for HTML response
|
|
216
|
-
const
|
|
217
|
-
const ssrModule = await
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
const ssrSetupStart = performance.now();
|
|
218
|
+
const [ssrModule, streamMode] = await Promise.all([
|
|
219
|
+
ctx.loadSSRModule(),
|
|
220
|
+
ctx.resolveStreamMode(request, env, url),
|
|
221
|
+
]);
|
|
222
|
+
const ssrSetupDur = performance.now() - ssrSetupStart;
|
|
223
|
+
timingParts.push(`ssr-setup;dur=${ssrSetupDur.toFixed(2)}`);
|
|
220
224
|
|
|
221
225
|
const ssrRenderStart = performance.now();
|
|
222
|
-
const htmlStream = await ssrModule.renderHTML(rscStream, {
|
|
226
|
+
const htmlStream = await ssrModule.renderHTML(rscStream, {
|
|
227
|
+
nonce,
|
|
228
|
+
streamMode,
|
|
229
|
+
});
|
|
223
230
|
const ssrRenderDur = performance.now() - ssrRenderStart;
|
|
224
231
|
timingParts.push(`ssr-render-html;dur=${ssrRenderDur.toFixed(2)}`);
|
|
225
232
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime guardrail warnings (dev-only).
|
|
3
|
+
*
|
|
4
|
+
* W3: PE action redirect / Response handling.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createResponseWithMergedHeaders,
|
|
9
|
+
carryOverRedirectHeaders,
|
|
10
|
+
} from "./helpers.js";
|
|
11
|
+
|
|
12
|
+
// W3 -----------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extract a redirect Response from a thrown or returned value.
|
|
16
|
+
* Returns a redirect Response to send to the client, or null if the value
|
|
17
|
+
* is not a redirect Response.
|
|
18
|
+
*/
|
|
19
|
+
export function extractRedirectResponse(value: unknown): Response | null {
|
|
20
|
+
if (!(value instanceof Response)) return null;
|
|
21
|
+
const location = value.headers.get("Location");
|
|
22
|
+
if (value.status >= 300 && value.status < 400 && location) {
|
|
23
|
+
const redirect = createResponseWithMergedHeaders(null, {
|
|
24
|
+
status: value.status,
|
|
25
|
+
headers: { Location: location },
|
|
26
|
+
});
|
|
27
|
+
carryOverRedirectHeaders(value, redirect);
|
|
28
|
+
return redirect;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Warn when a non-redirect Response is returned from an action during PE.
|
|
35
|
+
*/
|
|
36
|
+
export function warnNonRedirectPeResponse(): void {
|
|
37
|
+
console.warn(
|
|
38
|
+
`[rango] Server action returned a non-redirect Response during ` +
|
|
39
|
+
`progressive enhancement (no-JS) request. The Response will be ` +
|
|
40
|
+
`ignored — the page will re-render at the current URL instead.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Server Action Handler
|
|
3
3
|
*
|
|
4
|
-
* Handles server action execution and post-action revalidation
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Handles server action execution and post-action revalidation as two
|
|
5
|
+
* separate phases:
|
|
6
|
+
*
|
|
7
|
+
* 1. executeServerAction — decodes args, runs the action, handles redirects
|
|
8
|
+
* and error boundaries. Returns either a final Response (redirect/error)
|
|
9
|
+
* or an ActionContinuation for the revalidation phase.
|
|
10
|
+
*
|
|
11
|
+
* 2. revalidateAfterAction — takes the continuation, matches affected
|
|
12
|
+
* segments, builds the RSC payload, and returns the Flight response.
|
|
13
|
+
*
|
|
14
|
+
* The handler (handler.ts) runs the action BEFORE route middleware, then
|
|
15
|
+
* wraps revalidation inside route middleware — identical to a normal render.
|
|
7
16
|
*/
|
|
8
17
|
|
|
9
18
|
import {
|
|
@@ -17,6 +26,7 @@ import {
|
|
|
17
26
|
hasBodyContent,
|
|
18
27
|
createResponseWithMergedHeaders,
|
|
19
28
|
createSimpleRedirectResponse,
|
|
29
|
+
carryOverRedirectHeaders,
|
|
20
30
|
} from "./helpers.js";
|
|
21
31
|
import type { HandlerContext } from "./handler-context.js";
|
|
22
32
|
|
|
@@ -32,14 +42,40 @@ function attachLocationState(payload: RscPayload): void {
|
|
|
32
42
|
}
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Data flowing from action execution to the revalidation phase.
|
|
47
|
+
* When the action completes without redirect/error-boundary, the handler
|
|
48
|
+
* passes this to route middleware → revalidateAfterAction.
|
|
49
|
+
*/
|
|
50
|
+
export interface ActionContinuation {
|
|
51
|
+
returnValue: { ok: boolean; data: unknown };
|
|
52
|
+
actionStatus: number;
|
|
53
|
+
temporaryReferences: ReturnType<
|
|
54
|
+
HandlerContext["createTemporaryReferenceSet"]
|
|
55
|
+
>;
|
|
56
|
+
actionContext: {
|
|
57
|
+
actionId: string;
|
|
58
|
+
actionUrl: URL;
|
|
59
|
+
actionResult: unknown;
|
|
60
|
+
formData?: FormData;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Phase 1: Execute the server action.
|
|
66
|
+
*
|
|
67
|
+
* Decodes arguments, runs the action, handles redirects and error
|
|
68
|
+
* boundaries. Returns a final Response (redirect, error boundary render)
|
|
69
|
+
* or an ActionContinuation for the revalidation phase.
|
|
70
|
+
*/
|
|
71
|
+
export async function executeServerAction<TEnv>(
|
|
36
72
|
ctx: HandlerContext<TEnv>,
|
|
37
73
|
request: Request,
|
|
38
74
|
env: TEnv,
|
|
39
75
|
url: URL,
|
|
40
76
|
actionId: string,
|
|
41
77
|
handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
|
|
42
|
-
): Promise<Response> {
|
|
78
|
+
): Promise<Response | ActionContinuation> {
|
|
43
79
|
const temporaryReferences = ctx.createTemporaryReferenceSet();
|
|
44
80
|
|
|
45
81
|
// Decode action arguments from request body
|
|
@@ -82,15 +118,17 @@ export async function handleServerAction<TEnv>(
|
|
|
82
118
|
const isRedirect = data.status >= 300 && data.status < 400 && redirectUrl;
|
|
83
119
|
if (isRedirect) {
|
|
84
120
|
const locationState = getLocationState();
|
|
121
|
+
let redirect: Response;
|
|
85
122
|
if (locationState) {
|
|
86
|
-
|
|
87
|
-
return ctx.createRedirectFlightResponse(
|
|
123
|
+
redirect = ctx.createRedirectFlightResponse(
|
|
88
124
|
redirectUrl,
|
|
89
125
|
resolveLocationStateEntries(locationState),
|
|
90
126
|
);
|
|
127
|
+
} else {
|
|
128
|
+
redirect = createSimpleRedirectResponse(redirectUrl);
|
|
91
129
|
}
|
|
92
|
-
|
|
93
|
-
return
|
|
130
|
+
carryOverRedirectHeaders(data, redirect);
|
|
131
|
+
return redirect;
|
|
94
132
|
}
|
|
95
133
|
}
|
|
96
134
|
|
|
@@ -103,28 +141,58 @@ export async function handleServerAction<TEnv>(
|
|
|
103
141
|
error.status >= 300 && error.status < 400 && redirectUrl;
|
|
104
142
|
if (isRedirect) {
|
|
105
143
|
const locationState = getLocationState();
|
|
144
|
+
let redirect: Response;
|
|
106
145
|
if (locationState) {
|
|
107
|
-
|
|
146
|
+
redirect = ctx.createRedirectFlightResponse(
|
|
108
147
|
redirectUrl,
|
|
109
148
|
resolveLocationStateEntries(locationState),
|
|
110
149
|
);
|
|
150
|
+
} else {
|
|
151
|
+
redirect = createSimpleRedirectResponse(redirectUrl);
|
|
111
152
|
}
|
|
112
|
-
|
|
153
|
+
carryOverRedirectHeaders(error, redirect);
|
|
154
|
+
return redirect;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Non-redirect Response thrown from action — this will be treated
|
|
158
|
+
// as a regular error and routed to the error boundary. Warn in dev
|
|
159
|
+
// since the intent is likely a redirect with a missing Location header.
|
|
160
|
+
if (process.env.NODE_ENV !== "production") {
|
|
161
|
+
console.warn(
|
|
162
|
+
`[@rangojs/router] Server action "${actionId}" threw a Response ` +
|
|
163
|
+
`(status ${error.status}) that is not a redirect. ` +
|
|
164
|
+
`Non-redirect Responses are treated as errors. ` +
|
|
165
|
+
`Use \`throw redirect('/path')\` for redirects.`,
|
|
166
|
+
);
|
|
113
167
|
}
|
|
114
168
|
}
|
|
115
169
|
|
|
116
170
|
returnValue = { ok: false, data: error };
|
|
117
171
|
actionStatus = 500;
|
|
118
172
|
|
|
119
|
-
// Try to render error boundary
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
173
|
+
// Try to render error boundary.
|
|
174
|
+
// Report the action error first so it is not lost if matchError throws.
|
|
175
|
+
let errorResult;
|
|
176
|
+
try {
|
|
177
|
+
errorResult = await ctx.router.matchError(
|
|
178
|
+
request,
|
|
179
|
+
{ env },
|
|
180
|
+
error,
|
|
181
|
+
"route",
|
|
182
|
+
);
|
|
183
|
+
} catch (matchErr) {
|
|
184
|
+
// matchError failed — report the original action error as unhandled,
|
|
185
|
+
// then let the matchError failure propagate.
|
|
186
|
+
ctx.callOnError(error, "action", {
|
|
187
|
+
request,
|
|
188
|
+
url,
|
|
189
|
+
env,
|
|
190
|
+
actionId,
|
|
191
|
+
handledByBoundary: false,
|
|
192
|
+
});
|
|
193
|
+
throw matchErr;
|
|
194
|
+
}
|
|
126
195
|
|
|
127
|
-
// Report the action error (handledByBoundary indicates if error boundary will render)
|
|
128
196
|
ctx.callOnError(error, "action", {
|
|
129
197
|
request,
|
|
130
198
|
url,
|
|
@@ -165,17 +233,47 @@ export async function handleServerAction<TEnv>(
|
|
|
165
233
|
}
|
|
166
234
|
}
|
|
167
235
|
|
|
168
|
-
//
|
|
236
|
+
// Build continuation for the revalidation phase
|
|
169
237
|
const resolvedActionId =
|
|
170
238
|
(loadedAction as { $id?: string; $$id?: string } | undefined)?.$id ??
|
|
171
239
|
(loadedAction as { $$id?: string } | undefined)?.$$id ??
|
|
172
240
|
actionId;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
returnValue,
|
|
244
|
+
actionStatus,
|
|
245
|
+
temporaryReferences,
|
|
246
|
+
actionContext: {
|
|
247
|
+
actionId: resolvedActionId,
|
|
248
|
+
actionUrl: new URL(request.url),
|
|
249
|
+
actionResult: returnValue.data,
|
|
250
|
+
formData: actionFormData,
|
|
251
|
+
},
|
|
178
252
|
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Phase 2: Revalidate after action.
|
|
257
|
+
*
|
|
258
|
+
* Matches affected segments, builds the RSC payload, and returns the
|
|
259
|
+
* Flight response. Called inside route middleware (same as a normal render).
|
|
260
|
+
*
|
|
261
|
+
* Invariant: the response payload MUST have isPartial: true. The client
|
|
262
|
+
* (server-action-bridge) rejects non-partial payloads because partial
|
|
263
|
+
* reconciliation requires matched/diff semantics that full renders don't
|
|
264
|
+
* provide. Redirects are the only non-partial outcome and are handled via
|
|
265
|
+
* X-RSC-Redirect headers before Flight deserialization.
|
|
266
|
+
*/
|
|
267
|
+
export async function revalidateAfterAction<TEnv>(
|
|
268
|
+
ctx: HandlerContext<TEnv>,
|
|
269
|
+
request: Request,
|
|
270
|
+
env: TEnv,
|
|
271
|
+
url: URL,
|
|
272
|
+
handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
|
|
273
|
+
continuation: ActionContinuation,
|
|
274
|
+
): Promise<Response> {
|
|
275
|
+
const { returnValue, actionStatus, temporaryReferences, actionContext } =
|
|
276
|
+
continuation;
|
|
179
277
|
|
|
180
278
|
const matchResult = await ctx.router.matchPartial(
|
|
181
279
|
request,
|
|
@@ -184,7 +282,8 @@ export async function handleServerAction<TEnv>(
|
|
|
184
282
|
);
|
|
185
283
|
|
|
186
284
|
if (!matchResult) {
|
|
187
|
-
//
|
|
285
|
+
// matchPartial returns null when the route is a redirect or the request
|
|
286
|
+
// is missing required headers (previousUrl). Check for redirect first.
|
|
188
287
|
const fullMatch = await ctx.router.match(request, { env });
|
|
189
288
|
setRequestContextParams(fullMatch.params, fullMatch.routeName);
|
|
190
289
|
|
|
@@ -195,38 +294,15 @@ export async function handleServerAction<TEnv>(
|
|
|
195
294
|
return createSimpleRedirectResponse(fullMatch.redirect);
|
|
196
295
|
}
|
|
197
296
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
handles: handleStore.stream(),
|
|
208
|
-
version: ctx.version,
|
|
209
|
-
},
|
|
210
|
-
returnValue,
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
attachLocationState(payload);
|
|
214
|
-
|
|
215
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
216
|
-
temporaryReferences,
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
const headers: Record<string, string> = {
|
|
220
|
-
"content-type": "text/x-component;charset=utf-8",
|
|
221
|
-
};
|
|
222
|
-
if (serverTiming) {
|
|
223
|
-
headers["Server-Timing"] = serverTiming;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return createResponseWithMergedHeaders(rscStream, {
|
|
227
|
-
status: actionStatus,
|
|
228
|
-
headers,
|
|
229
|
-
});
|
|
297
|
+
// Non-redirect: this branch is only reachable when the action request
|
|
298
|
+
// is missing the X-RSC-Router-Client-Path header (defensive). The
|
|
299
|
+
// client requires isPartial for action responses, so producing a full
|
|
300
|
+
// payload here would be rejected. Return 500 instead.
|
|
301
|
+
throw new Error(
|
|
302
|
+
`[RSC] matchPartial returned null for a non-redirect route ` +
|
|
303
|
+
`during action revalidation (${url.pathname}). This indicates ` +
|
|
304
|
+
`a malformed action request (missing X-RSC-Router-Client-Path header).`,
|
|
305
|
+
);
|
|
230
306
|
}
|
|
231
307
|
|
|
232
308
|
// Return updated segments
|
package/src/rsc/types.ts
CHANGED
|
@@ -114,6 +114,14 @@ export interface SSRRenderOptions {
|
|
|
114
114
|
* Nonce for Content Security Policy (CSP)
|
|
115
115
|
*/
|
|
116
116
|
nonce?: string;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* SSR stream mode.
|
|
120
|
+
*
|
|
121
|
+
* - `"stream"` (default) — start flushing HTML immediately.
|
|
122
|
+
* - `"allReady"` — await `stream.allReady` before returning.
|
|
123
|
+
*/
|
|
124
|
+
streamMode?: import("../router/router-options.js").SSRStreamMode;
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
/**
|
package/src/search-params.ts
CHANGED
|
@@ -127,20 +127,32 @@ type ExtractRouteParamsFromMap<TRouteMap, TName> = TName extends keyof TRouteMap
|
|
|
127
127
|
: {}
|
|
128
128
|
: {};
|
|
129
129
|
|
|
130
|
+
/** Parse "a|b|c" into "a" | "b" | "c" */
|
|
131
|
+
type ParseConstraint<T extends string> =
|
|
132
|
+
T extends `${infer First}|${infer Rest}` ? First | ParseConstraint<Rest> : T;
|
|
133
|
+
|
|
130
134
|
/** Minimal inline param extraction (avoids importing from types.ts to prevent circular deps). */
|
|
131
135
|
type ExtractParamsFromPattern<T extends string> =
|
|
132
136
|
T extends `${string}:${infer Param}/${infer Rest}`
|
|
133
|
-
? Param extends `${infer Name}?`
|
|
134
|
-
? {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
? Param extends `${infer Name}(${infer C})?`
|
|
138
|
+
? {
|
|
139
|
+
[K in Name]?: ParseConstraint<C>;
|
|
140
|
+
} & ExtractParamsFromPattern<`/${Rest}`>
|
|
141
|
+
: Param extends `${infer Name}(${infer C})`
|
|
142
|
+
? {
|
|
143
|
+
[K in Name]: ParseConstraint<C>;
|
|
144
|
+
} & ExtractParamsFromPattern<`/${Rest}`>
|
|
145
|
+
: Param extends `${infer Name}?`
|
|
146
|
+
? { [K in Name]?: string } & ExtractParamsFromPattern<`/${Rest}`>
|
|
147
|
+
: { [K in Param]: string } & ExtractParamsFromPattern<`/${Rest}`>
|
|
138
148
|
: T extends `${string}:${infer Param}`
|
|
139
|
-
? Param extends `${infer Name}?`
|
|
140
|
-
? { [K in Name]?:
|
|
141
|
-
: Param extends `${infer Name}(${
|
|
142
|
-
? { [K in Name]:
|
|
143
|
-
:
|
|
149
|
+
? Param extends `${infer Name}(${infer C})?`
|
|
150
|
+
? { [K in Name]?: ParseConstraint<C> }
|
|
151
|
+
: Param extends `${infer Name}(${infer C})`
|
|
152
|
+
? { [K in Name]: ParseConstraint<C> }
|
|
153
|
+
: Param extends `${infer Name}?`
|
|
154
|
+
? { [K in Name]?: string }
|
|
155
|
+
: { [K in Param]: string }
|
|
144
156
|
: {};
|
|
145
157
|
|
|
146
158
|
// ============================================================================
|
package/src/server/context.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
TransitionConfig,
|
|
12
12
|
} from "../types";
|
|
13
13
|
import { invariant } from "../errors";
|
|
14
|
+
import type { DefaultRouteName } from "../types/global-namespace.js";
|
|
14
15
|
|
|
15
16
|
// ============================================================================
|
|
16
17
|
// Performance Metrics Types
|
|
@@ -120,6 +121,8 @@ export type InterceptSelectorContext<TEnv = any> = {
|
|
|
120
121
|
request: Request; // The HTTP request object
|
|
121
122
|
env: TEnv; // Platform bindings (Cloudflare env, etc.)
|
|
122
123
|
segments: InterceptSegmentsState; // Client's current segments (where navigating FROM)
|
|
124
|
+
fromRouteName?: DefaultRouteName; // Named route being navigated away from (undefined for unnamed routes)
|
|
125
|
+
toRouteName?: DefaultRouteName; // Named route being navigated to (undefined for unnamed routes)
|
|
123
126
|
};
|
|
124
127
|
|
|
125
128
|
/**
|
|
@@ -254,10 +257,18 @@ interface HelperContext {
|
|
|
254
257
|
urlPrefix?: string;
|
|
255
258
|
/** Name prefix from include() - applied to all named routes */
|
|
256
259
|
namePrefix?: string;
|
|
260
|
+
/** True when this scope is at root level (no named include boundary above).
|
|
261
|
+
* Routes at root scope allow dot-local reverse to fall back to bare names. */
|
|
262
|
+
rootScoped?: boolean;
|
|
257
263
|
/** Run helper for cleaner middleware code */
|
|
258
264
|
run?: <T>(fn: () => T | Promise<T>) => T | Promise<T>;
|
|
259
265
|
/** Tracked includes for build-time manifest generation */
|
|
260
266
|
trackedIncludes?: TrackedInclude[];
|
|
267
|
+
/** Cache profiles for DSL-time cache("profileName") resolution */
|
|
268
|
+
cacheProfiles?: Record<
|
|
269
|
+
string,
|
|
270
|
+
import("../cache/profile-registry.js").CacheProfile
|
|
271
|
+
>;
|
|
261
272
|
}
|
|
262
273
|
// Use a global symbol key so the AsyncLocalStorage instance survives HMR
|
|
263
274
|
// module re-evaluation. Without this, Vite's RSC module runner may create
|
|
@@ -399,7 +410,9 @@ export const getContext = (): {
|
|
|
399
410
|
searchSchemas: store.searchSchemas,
|
|
400
411
|
urlPrefix: store.urlPrefix,
|
|
401
412
|
namePrefix: store.namePrefix,
|
|
413
|
+
rootScoped: store.rootScoped,
|
|
402
414
|
trackedIncludes: store.trackedIncludes,
|
|
415
|
+
cacheProfiles: store.cacheProfiles,
|
|
403
416
|
},
|
|
404
417
|
callback,
|
|
405
418
|
);
|
|
@@ -436,7 +449,9 @@ export const getContext = (): {
|
|
|
436
449
|
searchSchemas,
|
|
437
450
|
urlPrefix: store?.urlPrefix,
|
|
438
451
|
namePrefix: store?.namePrefix,
|
|
452
|
+
rootScoped: store?.rootScoped,
|
|
439
453
|
trackedIncludes: store?.trackedIncludes,
|
|
454
|
+
cacheProfiles: store?.cacheProfiles,
|
|
440
455
|
},
|
|
441
456
|
callback,
|
|
442
457
|
);
|
|
@@ -469,17 +484,41 @@ export function runWithPrefixes<T>(
|
|
|
469
484
|
} else {
|
|
470
485
|
combinedUrlPrefix = urlPrefix;
|
|
471
486
|
}
|
|
472
|
-
const combinedNamePrefix =
|
|
473
|
-
|
|
474
|
-
?
|
|
475
|
-
|
|
476
|
-
|
|
487
|
+
const combinedNamePrefix =
|
|
488
|
+
namePrefix !== undefined
|
|
489
|
+
? namePrefix === ""
|
|
490
|
+
? store.namePrefix
|
|
491
|
+
: store.namePrefix
|
|
492
|
+
? `${store.namePrefix}.${namePrefix}`
|
|
493
|
+
: namePrefix
|
|
494
|
+
: store.namePrefix;
|
|
495
|
+
|
|
496
|
+
// Track root scope for dot-local reverse resolution.
|
|
497
|
+
//
|
|
498
|
+
// The flag answers: "can this route reach bare names at root scope?"
|
|
499
|
+
// It propagates through the include chain:
|
|
500
|
+
//
|
|
501
|
+
// { name: "" } — transparent: inherit parent, default true
|
|
502
|
+
// { name: "foo" } — inherit parent if already set, else create boundary (false)
|
|
503
|
+
// no name — inherit parent unchanged
|
|
504
|
+
//
|
|
505
|
+
// This means { name: "" } + nested { name: "sub" } keeps rootScoped=true
|
|
506
|
+
// (the outer transparent include establishes root access, and the inner
|
|
507
|
+
// named include inherits it). But a direct { name: "sub" } at root gets
|
|
508
|
+
// rootScoped=false (no prior root-access grant, so it creates a boundary).
|
|
509
|
+
const combinedRootScoped =
|
|
510
|
+
namePrefix === ""
|
|
511
|
+
? (store.rootScoped ?? true)
|
|
512
|
+
: namePrefix !== undefined
|
|
513
|
+
? (store.rootScoped ?? false)
|
|
514
|
+
: store.rootScoped;
|
|
477
515
|
|
|
478
516
|
return RSCRouterContext.run(
|
|
479
517
|
{
|
|
480
518
|
...store,
|
|
481
519
|
urlPrefix: combinedUrlPrefix,
|
|
482
520
|
namePrefix: combinedNamePrefix,
|
|
521
|
+
rootScoped: combinedRootScoped,
|
|
483
522
|
},
|
|
484
523
|
callback,
|
|
485
524
|
);
|
|
@@ -501,6 +540,15 @@ export function getNamePrefix(): string | undefined {
|
|
|
501
540
|
return store?.namePrefix;
|
|
502
541
|
}
|
|
503
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Get whether the current scope is at root level (no named include boundary above).
|
|
545
|
+
* Returns true at root or inside { name: "" } includes, false inside named includes.
|
|
546
|
+
*/
|
|
547
|
+
export function getRootScoped(): boolean {
|
|
548
|
+
const store = RSCRouterContext.getStore();
|
|
549
|
+
return store?.rootScoped ?? true;
|
|
550
|
+
}
|
|
551
|
+
|
|
504
552
|
// Export HelperContext type for use in other modules
|
|
505
553
|
export type { HelperContext };
|
|
506
554
|
|