@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26
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 +294 -28
- package/dist/bin/rango.js +355 -47
- package/dist/vite/index.js +1658 -1239
- package/package.json +3 -3
- package/skills/cache-guide/SKILL.md +9 -5
- package/skills/caching/SKILL.md +4 -4
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/hooks/SKILL.md +40 -29
- package/skills/host-router/SKILL.md +218 -0
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +229 -15
- package/skills/middleware/SKILL.md +109 -30
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +189 -19
- package/skills/rango/SKILL.md +1 -2
- package/skills/response-routes/SKILL.md +3 -3
- package/skills/route/SKILL.md +44 -3
- package/skills/router-setup/SKILL.md +80 -3
- package/skills/theme/SKILL.md +5 -4
- package/skills/typesafety/SKILL.md +59 -16
- package/skills/use-cache/SKILL.md +16 -2
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +56 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +29 -48
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +19 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +66 -443
- package/src/browser/navigation-client.ts +34 -62
- package/src/browser/navigation-store.ts +4 -33
- package/src/browser/navigation-transaction.ts +295 -0
- package/src/browser/partial-update.ts +103 -151
- package/src/browser/prefetch/cache.ts +67 -0
- package/src/browser/prefetch/fetch.ts +137 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/prefetch/queue.ts +88 -0
- package/src/browser/rango-state.ts +112 -0
- package/src/browser/react/Link.tsx +154 -44
- package/src/browser/react/NavigationProvider.tsx +32 -0
- package/src/browser/react/context.ts +6 -0
- package/src/browser/react/filter-segment-order.ts +11 -0
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +29 -11
- package/src/browser/react/location-state.ts +6 -4
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/shallow-equal.ts +27 -0
- package/src/browser/react/use-action.ts +23 -45
- package/src/browser/react/use-client-cache.ts +5 -3
- package/src/browser/react/use-handle.ts +21 -64
- package/src/browser/react/use-navigation.ts +7 -32
- package/src/browser/react/use-params.ts +5 -34
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +3 -6
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +75 -114
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +46 -22
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +458 -405
- package/src/browser/types.ts +21 -35
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +38 -13
- package/src/build/generate-route-types.ts +4 -0
- package/src/build/index.ts +1 -0
- package/src/build/route-trie.ts +19 -3
- 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 +170 -18
- 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 +136 -123
- package/src/cache/cache-scope.ts +76 -83
- package/src/cache/cf/cf-cache-store.ts +12 -7
- package/src/cache/document-cache.ts +93 -69
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/index.ts +0 -15
- package/src/cache/memory-segment-store.ts +43 -69
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +140 -117
- package/src/cache/taint.ts +30 -3
- package/src/cache/types.ts +1 -115
- package/src/client.rsc.tsx +0 -1
- package/src/client.tsx +53 -76
- 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/index.ts +0 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +53 -10
- package/src/index.ts +73 -43
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +60 -18
- 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/index.ts +0 -3
- 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 +96 -17
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +6 -11
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +62 -54
- 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 +78 -10
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/match-result.ts +0 -9
- package/src/router/metrics.ts +233 -13
- package/src/router/middleware-types.ts +34 -39
- package/src/router/middleware.ts +290 -130
- package/src/router/pattern-matching.ts +61 -10
- package/src/router/prerender-match.ts +36 -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 +158 -40
- package/src/router/router-options.ts +223 -1
- package/src/router/router-registry.ts +5 -2
- package/src/router/segment-resolution/fresh.ts +165 -242
- package/src/router/segment-resolution/helpers.ts +263 -0
- package/src/router/segment-resolution/loader-cache.ts +102 -98
- package/src/router/segment-resolution/revalidation.ts +394 -272
- package/src/router/segment-resolution/static-store.ts +2 -2
- package/src/router/segment-resolution.ts +1 -3
- 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/trie-matching.ts +20 -2
- package/src/router/types.ts +7 -1
- package/src/router.ts +203 -18
- package/src/rsc/handler-context.ts +13 -2
- package/src/rsc/handler.ts +489 -438
- package/src/rsc/helpers.ts +125 -5
- package/src/rsc/index.ts +0 -20
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/manifest-init.ts +3 -2
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +245 -19
- package/src/rsc/response-route-handler.ts +347 -0
- package/src/rsc/rsc-rendering.ts +47 -43
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +166 -66
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +20 -2
- package/src/search-params.ts +38 -23
- package/src/server/context.ts +61 -7
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +84 -12
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +275 -49
- package/src/server.ts +6 -0
- package/src/ssr/index.tsx +67 -28
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +4 -18
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +6 -1
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +22 -0
- package/src/types/handler-context.ts +103 -16
- package/src/types/index.ts +1 -1
- package/src/types/loader-types.ts +9 -6
- package/src/types/route-config.ts +17 -26
- package/src/types/route-entry.ts +28 -0
- package/src/types/segments.ts +0 -5
- 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 +29 -7
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +27 -9
- package/src/vite/discovery/bundle-postprocess.ts +32 -52
- package/src/vite/discovery/discover-routers.ts +52 -26
- package/src/vite/discovery/prerender-collection.ts +58 -41
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/state.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/index.ts +10 -51
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/expose-internal-ids.ts +4 -3
- package/src/vite/plugins/refresh-cmd.ts +65 -0
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/plugins/version-plugin.ts +188 -18
- package/src/vite/rango.ts +61 -36
- package/src/vite/router-discovery.ts +173 -100
- package/src/vite/utils/prerender-utils.ts +81 -0
- package/src/vite/utils/shared-utils.ts +19 -9
- package/skills/testing/SKILL.md +0 -226
- package/src/browser/lru-cache.ts +0 -61
- package/src/browser/react/prefetch.ts +0 -27
- package/src/browser/request-controller.ts +0 -164
- package/src/cache/memory-store.ts +0 -253
- package/src/href-context.ts +0 -33
- package/src/route-definition/route-function.ts +0 -119
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/{CLAUDE.md → AGENTS.md} +0 -0
|
@@ -10,9 +10,33 @@ import {
|
|
|
10
10
|
requireRequestContext,
|
|
11
11
|
setRequestContextParams,
|
|
12
12
|
} from "../server/request-context.js";
|
|
13
|
+
import { getSSRSetup } from "./ssr-setup.js";
|
|
14
|
+
import type { MiddlewareFn } from "../router/middleware.js";
|
|
15
|
+
import { executeMiddleware } from "../router/middleware.js";
|
|
13
16
|
import type { RscPayload, ReactFormState } from "./types.js";
|
|
14
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createResponseWithMergedHeaders,
|
|
19
|
+
finalizeResponse,
|
|
20
|
+
buildRouteMiddlewareEntries,
|
|
21
|
+
} from "./helpers.js";
|
|
15
22
|
import type { HandlerContext } from "./handler-context.js";
|
|
23
|
+
import {
|
|
24
|
+
extractRedirectResponse,
|
|
25
|
+
warnNonRedirectPeResponse,
|
|
26
|
+
} from "./runtime-warnings.js";
|
|
27
|
+
|
|
28
|
+
export interface PeRouteMiddlewareInfo {
|
|
29
|
+
routeMiddleware?: Array<{
|
|
30
|
+
handler: MiddlewareFn;
|
|
31
|
+
params: Record<string, string>;
|
|
32
|
+
}>;
|
|
33
|
+
variables: Record<string, any>;
|
|
34
|
+
routeReverse?: (
|
|
35
|
+
name: string,
|
|
36
|
+
params?: Record<string, string>,
|
|
37
|
+
search?: Record<string, unknown>,
|
|
38
|
+
) => string;
|
|
39
|
+
}
|
|
16
40
|
|
|
17
41
|
export async function handleProgressiveEnhancement<TEnv>(
|
|
18
42
|
ctx: HandlerContext<TEnv>,
|
|
@@ -22,6 +46,7 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
22
46
|
isAction: boolean,
|
|
23
47
|
handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
|
|
24
48
|
nonce: string | undefined,
|
|
49
|
+
routeMwInfo?: PeRouteMiddlewareInfo,
|
|
25
50
|
): Promise<Response | null> {
|
|
26
51
|
const contentType = request.headers.get("content-type") || "";
|
|
27
52
|
const isFormSubmission =
|
|
@@ -32,8 +57,42 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
32
57
|
return null;
|
|
33
58
|
}
|
|
34
59
|
|
|
35
|
-
// Clone the request to read FormData without consuming it
|
|
36
|
-
|
|
60
|
+
// Clone the request to read FormData without consuming it.
|
|
61
|
+
// Wrap in try-catch so malformed POST bodies are reported as action
|
|
62
|
+
// errors, not routing errors from the outer catch in handler.ts.
|
|
63
|
+
let formData: FormData;
|
|
64
|
+
try {
|
|
65
|
+
formData = await request.clone().formData();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// Attempt error boundary rendering so the user sees a meaningful page.
|
|
68
|
+
const errorHtml = await renderPeErrorBoundary(
|
|
69
|
+
ctx,
|
|
70
|
+
request,
|
|
71
|
+
env,
|
|
72
|
+
url,
|
|
73
|
+
error,
|
|
74
|
+
handleStore,
|
|
75
|
+
nonce,
|
|
76
|
+
);
|
|
77
|
+
if (errorHtml) {
|
|
78
|
+
ctx.callOnError(error, "action", {
|
|
79
|
+
request,
|
|
80
|
+
url,
|
|
81
|
+
env,
|
|
82
|
+
handledByBoundary: true,
|
|
83
|
+
});
|
|
84
|
+
return errorHtml;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ctx.callOnError(error, "action", {
|
|
88
|
+
request,
|
|
89
|
+
url,
|
|
90
|
+
env,
|
|
91
|
+
handledByBoundary: false,
|
|
92
|
+
});
|
|
93
|
+
console.error("[RSC] Progressive enhancement form parse error:", error);
|
|
94
|
+
return createResponseWithMergedHeaders(null, { status: 400 });
|
|
95
|
+
}
|
|
37
96
|
|
|
38
97
|
// Look for React's progressive enhancement hidden fields
|
|
39
98
|
let isDirectAction = false;
|
|
@@ -58,14 +117,37 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
58
117
|
let reactFormState: ReactFormState | null = null;
|
|
59
118
|
|
|
60
119
|
if (isUseActionState) {
|
|
120
|
+
// Decode and extract action identity before execution so error
|
|
121
|
+
// handlers can report actionId even when the action throws.
|
|
122
|
+
let useActionStateId: string | undefined;
|
|
61
123
|
try {
|
|
62
124
|
const boundAction = await ctx.decodeAction(formData);
|
|
125
|
+
// React's custom .bind() preserves $$id on server references.
|
|
126
|
+
useActionStateId = (boundAction as { $$id?: string }).$$id ?? undefined;
|
|
63
127
|
actionResult = await boundAction();
|
|
64
128
|
} catch (error) {
|
|
129
|
+
// Handle thrown redirect (e.g., throw redirect('/path'))
|
|
130
|
+
const redirectResponse = extractRedirectResponse(error);
|
|
131
|
+
if (redirectResponse) return redirectResponse;
|
|
132
|
+
|
|
133
|
+
// Attempt error boundary rendering for the PE path
|
|
134
|
+
const errorHtml = await renderPeErrorBoundary(
|
|
135
|
+
ctx,
|
|
136
|
+
request,
|
|
137
|
+
env,
|
|
138
|
+
url,
|
|
139
|
+
error,
|
|
140
|
+
handleStore,
|
|
141
|
+
nonce,
|
|
142
|
+
useActionStateId,
|
|
143
|
+
);
|
|
144
|
+
if (errorHtml) return errorHtml;
|
|
145
|
+
|
|
65
146
|
ctx.callOnError(error, "action", {
|
|
66
147
|
request,
|
|
67
148
|
url,
|
|
68
149
|
env,
|
|
150
|
+
actionId: useActionStateId,
|
|
69
151
|
handledByBoundary: false,
|
|
70
152
|
});
|
|
71
153
|
console.error("[RSC] Progressive enhancement action error:", error);
|
|
@@ -84,6 +166,23 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
84
166
|
const loadedAction = await ctx.loadServerAction(directActionId);
|
|
85
167
|
actionResult = await loadedAction.apply(null, args);
|
|
86
168
|
} catch (error) {
|
|
169
|
+
// Handle thrown redirect (e.g., throw redirect('/path'))
|
|
170
|
+
const redirectResponse = extractRedirectResponse(error);
|
|
171
|
+
if (redirectResponse) return redirectResponse;
|
|
172
|
+
|
|
173
|
+
// Attempt error boundary rendering for the PE path
|
|
174
|
+
const errorHtml = await renderPeErrorBoundary(
|
|
175
|
+
ctx,
|
|
176
|
+
request,
|
|
177
|
+
env,
|
|
178
|
+
url,
|
|
179
|
+
error,
|
|
180
|
+
handleStore,
|
|
181
|
+
nonce,
|
|
182
|
+
directActionId,
|
|
183
|
+
);
|
|
184
|
+
if (errorHtml) return errorHtml;
|
|
185
|
+
|
|
87
186
|
ctx.callOnError(error, "action", {
|
|
88
187
|
request,
|
|
89
188
|
url,
|
|
@@ -95,6 +194,20 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
95
194
|
}
|
|
96
195
|
}
|
|
97
196
|
|
|
197
|
+
// Handle Response returned from action during PE.
|
|
198
|
+
// In the JS path, executeServerAction intercepts redirect Responses and
|
|
199
|
+
// short-circuits. The PE path must handle them too.
|
|
200
|
+
if (actionResult instanceof Response) {
|
|
201
|
+
const redirectResponse = extractRedirectResponse(actionResult);
|
|
202
|
+
if (redirectResponse) return redirectResponse;
|
|
203
|
+
// W3: Non-redirect Response — discard it so it doesn't flow into
|
|
204
|
+
// decodeFormState or the re-render payload.
|
|
205
|
+
if (process.env.NODE_ENV !== "production") {
|
|
206
|
+
warnNonRedirectPeResponse();
|
|
207
|
+
}
|
|
208
|
+
actionResult = undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
98
211
|
// Decode form state for useActionState progressive enhancement
|
|
99
212
|
try {
|
|
100
213
|
reactFormState = await ctx.decodeFormState(actionResult, formData);
|
|
@@ -108,28 +221,132 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
108
221
|
console.error("[RSC] Failed to decode form state:", error);
|
|
109
222
|
}
|
|
110
223
|
|
|
111
|
-
// Re-render the page and return HTML
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
224
|
+
// Re-render the page and return HTML.
|
|
225
|
+
// Route middleware wraps the render so context variables, headers, and
|
|
226
|
+
// cookies set by route middleware are available during re-render — matching
|
|
227
|
+
// the behavior of JS-enabled requests.
|
|
228
|
+
const renderPage = async (): Promise<Response> => {
|
|
229
|
+
const renderRequest = new Request(url.toString(), {
|
|
230
|
+
method: "GET",
|
|
231
|
+
headers: new Headers({ accept: "text/html" }),
|
|
232
|
+
});
|
|
116
233
|
|
|
117
|
-
|
|
234
|
+
const match = await ctx.router.match(renderRequest, { env });
|
|
118
235
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
236
|
+
if (match.redirect) {
|
|
237
|
+
return createResponseWithMergedHeaders(null, {
|
|
238
|
+
status: 308,
|
|
239
|
+
headers: { Location: match.redirect },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const payload: RscPayload = {
|
|
244
|
+
metadata: {
|
|
245
|
+
pathname: url.pathname,
|
|
246
|
+
segments: match.segments,
|
|
247
|
+
matched: match.matched,
|
|
248
|
+
diff: match.diff,
|
|
249
|
+
isPartial: false,
|
|
250
|
+
rootLayout: ctx.router.rootLayout,
|
|
251
|
+
handles: handleStore.stream(),
|
|
252
|
+
version: ctx.version,
|
|
253
|
+
themeConfig: ctx.router.themeConfig,
|
|
254
|
+
warmupEnabled: ctx.router.warmupEnabled,
|
|
255
|
+
initialTheme: requireRequestContext().theme,
|
|
256
|
+
},
|
|
257
|
+
formState: actionResult,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
|
|
261
|
+
// metricsStore=undefined is safe: the handler already stashed the early
|
|
262
|
+
// SSR setup promise on request variables, so getSSRSetup returns it
|
|
263
|
+
// without falling back to a fresh startSSRSetup.
|
|
264
|
+
const [ssrModule, streamMode] = await getSSRSetup(
|
|
265
|
+
ctx,
|
|
266
|
+
request,
|
|
267
|
+
env,
|
|
268
|
+
url,
|
|
269
|
+
undefined,
|
|
270
|
+
);
|
|
271
|
+
const htmlStream = await ssrModule.renderHTML(rscStream, {
|
|
272
|
+
formState: reactFormState,
|
|
273
|
+
nonce,
|
|
274
|
+
streamMode,
|
|
123
275
|
});
|
|
276
|
+
|
|
277
|
+
return createResponseWithMergedHeaders(htmlStream, {
|
|
278
|
+
headers: { "content-type": "text/html;charset=utf-8" },
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Execute route middleware wrapping the render, if any.
|
|
283
|
+
// finalizeResponse drains onResponse callbacks that middleware short-circuits
|
|
284
|
+
// may leave behind (executeMiddleware does not finalize them itself).
|
|
285
|
+
if (routeMwInfo?.routeMiddleware && routeMwInfo.routeMiddleware.length > 0) {
|
|
286
|
+
return finalizeResponse(
|
|
287
|
+
await executeMiddleware(
|
|
288
|
+
buildRouteMiddlewareEntries(routeMwInfo.routeMiddleware),
|
|
289
|
+
request,
|
|
290
|
+
env,
|
|
291
|
+
routeMwInfo.variables,
|
|
292
|
+
renderPage,
|
|
293
|
+
routeMwInfo.routeReverse,
|
|
294
|
+
),
|
|
295
|
+
);
|
|
124
296
|
}
|
|
125
297
|
|
|
298
|
+
return renderPage();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Attempt to render an error boundary as full HTML for the PE path.
|
|
303
|
+
* Returns null if no error boundary is found (caller falls through to
|
|
304
|
+
* normal page re-render).
|
|
305
|
+
*/
|
|
306
|
+
async function renderPeErrorBoundary<TEnv>(
|
|
307
|
+
ctx: HandlerContext<TEnv>,
|
|
308
|
+
request: Request,
|
|
309
|
+
env: TEnv,
|
|
310
|
+
url: URL,
|
|
311
|
+
error: unknown,
|
|
312
|
+
handleStore: ReturnType<typeof requireRequestContext>["_handleStore"],
|
|
313
|
+
nonce: string | undefined,
|
|
314
|
+
actionId?: string | null,
|
|
315
|
+
): Promise<Response | null> {
|
|
316
|
+
let errorResult;
|
|
317
|
+
try {
|
|
318
|
+
errorResult = await ctx.router.matchError(request, { env }, error, "route");
|
|
319
|
+
} catch (matchErr) {
|
|
320
|
+
ctx.callOnError(error, "action", {
|
|
321
|
+
request,
|
|
322
|
+
url,
|
|
323
|
+
env,
|
|
324
|
+
actionId: actionId ?? undefined,
|
|
325
|
+
handledByBoundary: false,
|
|
326
|
+
});
|
|
327
|
+
throw matchErr;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!errorResult) return null;
|
|
331
|
+
|
|
332
|
+
ctx.callOnError(error, "action", {
|
|
333
|
+
request,
|
|
334
|
+
url,
|
|
335
|
+
env,
|
|
336
|
+
actionId: actionId ?? undefined,
|
|
337
|
+
handledByBoundary: true,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
setRequestContextParams(errorResult.params, errorResult.routeName);
|
|
341
|
+
|
|
126
342
|
const payload: RscPayload = {
|
|
127
343
|
metadata: {
|
|
128
344
|
pathname: url.pathname,
|
|
129
|
-
segments:
|
|
130
|
-
matched:
|
|
131
|
-
diff:
|
|
345
|
+
segments: errorResult.segments,
|
|
346
|
+
matched: errorResult.matched,
|
|
347
|
+
diff: errorResult.diff,
|
|
132
348
|
isPartial: false,
|
|
349
|
+
isError: true,
|
|
133
350
|
rootLayout: ctx.router.rootLayout,
|
|
134
351
|
handles: handleStore.stream(),
|
|
135
352
|
version: ctx.version,
|
|
@@ -137,17 +354,26 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
137
354
|
warmupEnabled: ctx.router.warmupEnabled,
|
|
138
355
|
initialTheme: requireRequestContext().theme,
|
|
139
356
|
},
|
|
140
|
-
formState: actionResult,
|
|
141
357
|
};
|
|
142
358
|
|
|
143
359
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
|
|
144
|
-
|
|
360
|
+
// metricsStore=undefined is safe: the handler already stashed the early
|
|
361
|
+
// SSR setup promise on request variables, so getSSRSetup returns it
|
|
362
|
+
// without falling back to a fresh startSSRSetup.
|
|
363
|
+
const [ssrModule, streamMode] = await getSSRSetup(
|
|
364
|
+
ctx,
|
|
365
|
+
request,
|
|
366
|
+
env,
|
|
367
|
+
url,
|
|
368
|
+
undefined,
|
|
369
|
+
);
|
|
145
370
|
const htmlStream = await ssrModule.renderHTML(rscStream, {
|
|
146
|
-
formState: reactFormState,
|
|
147
371
|
nonce,
|
|
372
|
+
streamMode,
|
|
148
373
|
});
|
|
149
374
|
|
|
150
375
|
return createResponseWithMergedHeaders(htmlStream, {
|
|
376
|
+
status: 500,
|
|
151
377
|
headers: { "content-type": "text/html;charset=utf-8" },
|
|
152
378
|
});
|
|
153
379
|
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response Route Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles response routes (JSON, text, HTML, XML, markdown, image, stream)
|
|
5
|
+
* that bypass the RSC rendering pipeline entirely. Includes content-type
|
|
6
|
+
* dispatch, route middleware execution, and response caching with SWR.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { RouterError } from "../errors.js";
|
|
10
|
+
import { requireRequestContext } from "../server/request-context.js";
|
|
11
|
+
import { contextGet } from "../context-var.js";
|
|
12
|
+
import { NOCACHE_SYMBOL } from "../cache/taint.js";
|
|
13
|
+
import { traverseBack } from "../router/pattern-matching.js";
|
|
14
|
+
import { createCacheScope } from "../cache/cache-scope.js";
|
|
15
|
+
import { executeMiddleware } from "../router/middleware.js";
|
|
16
|
+
import {
|
|
17
|
+
createReverseFunction,
|
|
18
|
+
stripInternalParams,
|
|
19
|
+
} from "../router/handler-context.js";
|
|
20
|
+
import type { MiddlewareFn } from "../router/middleware.js";
|
|
21
|
+
import type { EntryData } from "../server/context.js";
|
|
22
|
+
import type { HandlerContext } from "./handler-context.js";
|
|
23
|
+
import { createResponseErrorPayload } from "./response-error.js";
|
|
24
|
+
import {
|
|
25
|
+
createResponseWithMergedHeaders,
|
|
26
|
+
finalizeResponse,
|
|
27
|
+
isCacheableStatus,
|
|
28
|
+
buildRouteMiddlewareEntries,
|
|
29
|
+
} from "./helpers.js";
|
|
30
|
+
|
|
31
|
+
export interface ResponseRouteMatch {
|
|
32
|
+
responseType: string;
|
|
33
|
+
handler: Function;
|
|
34
|
+
params?: Record<string, string>;
|
|
35
|
+
negotiated?: boolean;
|
|
36
|
+
manifestEntry?: EntryData;
|
|
37
|
+
routeMiddleware?: Array<{
|
|
38
|
+
handler: MiddlewareFn;
|
|
39
|
+
params: Record<string, string>;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Handle a response route (non-RSC). Dispatches by content type, wraps
|
|
45
|
+
* with route middleware and response caching when configured.
|
|
46
|
+
*
|
|
47
|
+
* For partial (client-side navigation) requests, returns X-RSC-Reload
|
|
48
|
+
* so the browser triggers a hard navigation to the response route URL.
|
|
49
|
+
*/
|
|
50
|
+
export async function handleResponseRoute<TEnv>(
|
|
51
|
+
handlerCtx: HandlerContext<TEnv>,
|
|
52
|
+
preview: ResponseRouteMatch,
|
|
53
|
+
request: Request,
|
|
54
|
+
env: TEnv,
|
|
55
|
+
url: URL,
|
|
56
|
+
variables: Record<string, any>,
|
|
57
|
+
): Promise<Response> {
|
|
58
|
+
const isPartial = url.searchParams.has("_rsc_partial");
|
|
59
|
+
|
|
60
|
+
// Partial requests (client-side navigation) to response routes
|
|
61
|
+
// get X-RSC-Reload to trigger hard navigation in the browser
|
|
62
|
+
if (isPartial) {
|
|
63
|
+
return createResponseWithMergedHeaders(null, {
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: {
|
|
66
|
+
"X-RSC-Reload": stripInternalParams(url).toString(),
|
|
67
|
+
"content-type": "text/x-component;charset=utf-8",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Build lightweight context for response handler
|
|
73
|
+
const reqCtx = requireRequestContext();
|
|
74
|
+
const cleanUrl = stripInternalParams(url);
|
|
75
|
+
const responseHandlerCtx = {
|
|
76
|
+
request,
|
|
77
|
+
params: preview.params || {},
|
|
78
|
+
env,
|
|
79
|
+
searchParams: cleanUrl.searchParams,
|
|
80
|
+
url: cleanUrl,
|
|
81
|
+
pathname: url.pathname,
|
|
82
|
+
reverse: createReverseFunction(handlerCtx.getRequiredRouteMap()),
|
|
83
|
+
get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
|
|
84
|
+
header: (name: string, value: string) => reqCtx.header(name, value),
|
|
85
|
+
_responseType: preview.responseType,
|
|
86
|
+
};
|
|
87
|
+
// Brand with taint symbol so "use cache" detects it as request-scoped
|
|
88
|
+
// and extracts route-identifying properties (params, pathname, _responseType)
|
|
89
|
+
(responseHandlerCtx as any)[NOCACHE_SYMBOL] = true;
|
|
90
|
+
|
|
91
|
+
// Call handler directly, wrapped by route middleware if present
|
|
92
|
+
const callHandler = async () => {
|
|
93
|
+
const errorCtx = { request, url, env };
|
|
94
|
+
|
|
95
|
+
// Re-wrap a handler-returned Response through createResponseWithMergedHeaders
|
|
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.
|
|
98
|
+
const rewrapResponse = (result: Response) => {
|
|
99
|
+
const headers = new Headers();
|
|
100
|
+
result.headers.forEach((value, key) => {
|
|
101
|
+
if (key.toLowerCase() === "set-cookie") {
|
|
102
|
+
headers.append(key, value);
|
|
103
|
+
} else {
|
|
104
|
+
headers.set(key, value);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return createResponseWithMergedHeaders(result.body, {
|
|
108
|
+
status: result.status,
|
|
109
|
+
headers,
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// JSON response routes: wrap in { data } / { error } envelope
|
|
114
|
+
if (preview.responseType === "json") {
|
|
115
|
+
try {
|
|
116
|
+
const result = await (preview.handler as Function)(responseHandlerCtx);
|
|
117
|
+
if (result instanceof Response) {
|
|
118
|
+
return rewrapResponse(result);
|
|
119
|
+
}
|
|
120
|
+
return createResponseWithMergedHeaders(
|
|
121
|
+
JSON.stringify({ data: result }),
|
|
122
|
+
{
|
|
123
|
+
status: 200,
|
|
124
|
+
headers: { "content-type": "application/json;charset=utf-8" },
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
handlerCtx.callOnError(error, "handler", errorCtx);
|
|
129
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
130
|
+
const status = error instanceof RouterError ? error.status : 500;
|
|
131
|
+
return createResponseWithMergedHeaders(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
error: createResponseErrorPayload(error, isDev),
|
|
134
|
+
}),
|
|
135
|
+
{
|
|
136
|
+
status,
|
|
137
|
+
headers: { "content-type": "application/json;charset=utf-8" },
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Non-JSON response routes: catch errors and return plain Response
|
|
144
|
+
try {
|
|
145
|
+
const result = await (preview.handler as Function)(responseHandlerCtx);
|
|
146
|
+
|
|
147
|
+
if (result instanceof Response) {
|
|
148
|
+
return rewrapResponse(result);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Auto-wrap based on response type tag
|
|
152
|
+
switch (preview.responseType) {
|
|
153
|
+
case "text":
|
|
154
|
+
return createResponseWithMergedHeaders(String(result), {
|
|
155
|
+
status: 200,
|
|
156
|
+
headers: { "content-type": "text/plain;charset=utf-8" },
|
|
157
|
+
});
|
|
158
|
+
case "html":
|
|
159
|
+
return createResponseWithMergedHeaders(String(result), {
|
|
160
|
+
status: 200,
|
|
161
|
+
headers: { "content-type": "text/html;charset=utf-8" },
|
|
162
|
+
});
|
|
163
|
+
case "xml":
|
|
164
|
+
return createResponseWithMergedHeaders(String(result), {
|
|
165
|
+
status: 200,
|
|
166
|
+
headers: { "content-type": "application/xml;charset=utf-8" },
|
|
167
|
+
});
|
|
168
|
+
case "md":
|
|
169
|
+
return createResponseWithMergedHeaders(String(result), {
|
|
170
|
+
status: 200,
|
|
171
|
+
headers: { "content-type": "text/markdown;charset=utf-8" },
|
|
172
|
+
});
|
|
173
|
+
default:
|
|
174
|
+
// image, stream, any -- must return Response
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Response route handler for "${preview.responseType}" must return a Response object, got ${typeof result}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
handlerCtx.callOnError(error, "handler", errorCtx);
|
|
181
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
182
|
+
const status = error instanceof RouterError ? error.status : 500;
|
|
183
|
+
const message =
|
|
184
|
+
error instanceof RouterError
|
|
185
|
+
? error.message
|
|
186
|
+
: isDev && error instanceof Error
|
|
187
|
+
? error.message
|
|
188
|
+
: "Internal Server Error";
|
|
189
|
+
return createResponseWithMergedHeaders(message, {
|
|
190
|
+
status,
|
|
191
|
+
headers: { "content-type": "text/plain;charset=utf-8" },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Wrap callHandler to append Vary: Accept on content-negotiated responses
|
|
197
|
+
const callHandlerWithVary = async () => {
|
|
198
|
+
const response = await callHandler();
|
|
199
|
+
if (preview.negotiated) {
|
|
200
|
+
response.headers.append("Vary", "Accept");
|
|
201
|
+
}
|
|
202
|
+
return response;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Wrap with route middleware if present
|
|
206
|
+
const executeHandler = async () => {
|
|
207
|
+
if (preview.routeMiddleware && preview.routeMiddleware.length > 0) {
|
|
208
|
+
return executeMiddleware(
|
|
209
|
+
buildRouteMiddlewareEntries<TEnv>(preview.routeMiddleware),
|
|
210
|
+
request,
|
|
211
|
+
env,
|
|
212
|
+
variables,
|
|
213
|
+
callHandlerWithVary,
|
|
214
|
+
createReverseFunction(handlerCtx.getRequiredRouteMap()),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return callHandlerWithVary();
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Resolve cache config from entry tree (same pattern as match-api.ts)
|
|
221
|
+
if (preview.manifestEntry) {
|
|
222
|
+
let cacheScope: ReturnType<typeof createCacheScope> = null;
|
|
223
|
+
for (const entry of traverseBack(preview.manifestEntry)) {
|
|
224
|
+
if (entry.cache) {
|
|
225
|
+
cacheScope = createCacheScope(entry.cache, cacheScope);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
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
|
+
|
|
240
|
+
const store = cacheScope.getStore() ?? reqCtx._cacheStore;
|
|
241
|
+
if (conditionPassed && store?.getResponse && store?.putResponse) {
|
|
242
|
+
// Build cache key with response:{type}: prefix to avoid collision
|
|
243
|
+
// with segment keys and differentiate between response types.
|
|
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}`;
|
|
247
|
+
|
|
248
|
+
// Priority 1: Route-level key function (full override)
|
|
249
|
+
if (cacheScope.config !== false && cacheScope.config.key) {
|
|
250
|
+
try {
|
|
251
|
+
const customKey = await cacheScope.config.key(reqCtx);
|
|
252
|
+
cacheKey = `response:${customKey}`;
|
|
253
|
+
} catch {
|
|
254
|
+
// Fall back to default key on route-level key failure
|
|
255
|
+
}
|
|
256
|
+
} else if (store.keyGenerator) {
|
|
257
|
+
// Priority 2: Store-level keyGenerator (modifies default key)
|
|
258
|
+
try {
|
|
259
|
+
cacheKey = await store.keyGenerator(reqCtx, cacheKey);
|
|
260
|
+
} catch {
|
|
261
|
+
// Fall back to default key on keyGenerator failure
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Save pre-handler callbacks (registered by app-level middleware
|
|
266
|
+
// before we reach the cache block) and clear the live array.
|
|
267
|
+
// createResponseWithMergedHeaders (inside the handler) eagerly
|
|
268
|
+
// executes any callbacks present in _onResponseCallbacks, so
|
|
269
|
+
// handler-registered callbacks are baked into the handler's
|
|
270
|
+
// response and the cached artifact. Pre-handler callbacks are
|
|
271
|
+
// NOT in the live array during execution, so they are applied
|
|
272
|
+
// once per serve on every path (hit + miss) below.
|
|
273
|
+
const savedCallbacks = reqCtx._onResponseCallbacks;
|
|
274
|
+
reqCtx._onResponseCallbacks = [];
|
|
275
|
+
|
|
276
|
+
const applyPreHandlerCallbacks = (response: Response): Response => {
|
|
277
|
+
let result = response;
|
|
278
|
+
for (const callback of savedCallbacks) {
|
|
279
|
+
result = callback(result) ?? result;
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const cached = await store.getResponse(cacheKey);
|
|
286
|
+
|
|
287
|
+
if (cached && isCacheableStatus(cached.response.status)) {
|
|
288
|
+
if (!cached.shouldRevalidate) {
|
|
289
|
+
// Fresh hit
|
|
290
|
+
return applyPreHandlerCallbacks(cached.response);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Stale hit (SWR) - return cached, revalidate in background
|
|
294
|
+
reqCtx.waitUntil(async () => {
|
|
295
|
+
try {
|
|
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());
|
|
300
|
+
if (isCacheableStatus(fresh.status)) {
|
|
301
|
+
await store.putResponse!(
|
|
302
|
+
cacheKey,
|
|
303
|
+
fresh.clone(),
|
|
304
|
+
cacheScope!.ttl,
|
|
305
|
+
cacheScope!.swr,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
} catch (error) {
|
|
309
|
+
console.error(`[ResponseCache] Revalidation failed:`, error);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return applyPreHandlerCallbacks(cached.response);
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
console.error(`[ResponseCache] Cache lookup failed:`, error);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Cache miss - execute handler and cache the result.
|
|
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());
|
|
325
|
+
|
|
326
|
+
if (isCacheableStatus(response.status)) {
|
|
327
|
+
reqCtx.waitUntil(async () => {
|
|
328
|
+
try {
|
|
329
|
+
await store.putResponse!(
|
|
330
|
+
cacheKey,
|
|
331
|
+
response.clone(),
|
|
332
|
+
cacheScope!.ttl,
|
|
333
|
+
cacheScope!.swr,
|
|
334
|
+
);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.error(`[ResponseCache] Cache write failed:`, error);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return applyPreHandlerCallbacks(response);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return executeHandler().then(finalizeResponse);
|
|
347
|
+
}
|