@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1c0bdfad
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/AGENTS.md +17 -0
- package/README.md +291 -61
- package/dist/bin/rango.js +544 -143
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +3744 -1329
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +67 -13
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +312 -0
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +247 -23
- package/skills/caching/SKILL.md +322 -19
- package/skills/composability/SKILL.md +27 -2
- package/skills/css/SKILL.md +76 -0
- package/skills/debug-manifest/SKILL.md +4 -2
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +282 -60
- package/skills/host-router/SKILL.md +278 -0
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +50 -6
- package/skills/layout/SKILL.md +35 -9
- package/skills/links/SKILL.md +249 -17
- package/skills/loader/SKILL.md +297 -31
- package/skills/middleware/SKILL.md +52 -13
- package/skills/migrate-nextjs/SKILL.md +584 -0
- package/skills/migrate-react-router/SKILL.md +771 -0
- package/skills/mime-routes/SKILL.md +28 -1
- package/skills/observability/SKILL.md +172 -0
- package/skills/parallel/SKILL.md +203 -7
- package/skills/prerender/SKILL.md +155 -111
- package/skills/rango/SKILL.md +251 -23
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +123 -48
- package/skills/route/SKILL.md +104 -9
- package/skills/router-setup/SKILL.md +124 -11
- package/skills/scripts/SKILL.md +179 -0
- package/skills/server-actions/SKILL.md +775 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/tailwind/SKILL.md +27 -3
- package/skills/testing/SKILL.md +125 -222
- package/skills/testing/bindings.md +103 -0
- package/skills/testing/cache-prerender.md +127 -0
- package/skills/testing/client-components.md +124 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +91 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +121 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +123 -0
- package/skills/typesafety/SKILL.md +357 -52
- package/skills/use-cache/SKILL.md +46 -14
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/__internal.ts +67 -40
- package/src/bin/rango.ts +18 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/action-fence.ts +47 -0
- package/src/browser/app-shell.ts +39 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/connection-warmup.ts +134 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/event-controller.ts +197 -150
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +200 -30
- package/src/browser/navigation-client.ts +217 -58
- package/src/browser/navigation-store-handle.ts +38 -0
- package/src/browser/navigation-store.ts +76 -67
- package/src/browser/navigation-transaction.ts +18 -66
- package/src/browser/network-error-handler.ts +34 -7
- package/src/browser/partial-update.ts +187 -112
- package/src/browser/prefetch/cache.ts +312 -30
- package/src/browser/prefetch/fetch.ts +344 -47
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +126 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +158 -76
- package/src/browser/react/Link.tsx +125 -18
- package/src/browser/react/NavigationProvider.tsx +135 -120
- package/src/browser/react/ScrollRestoration.tsx +10 -6
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/filter-segment-order.ts +66 -7
- package/src/browser/react/index.ts +0 -48
- package/src/browser/react/location-state-shared.ts +178 -8
- package/src/browser/react/location-state.ts +39 -14
- package/src/browser/react/use-action.ts +6 -15
- package/src/browser/react/use-handle.ts +23 -69
- package/src/browser/react/use-href.tsx +8 -1
- package/src/browser/react/use-link-status.ts +33 -8
- package/src/browser/react/use-navigation.ts +32 -7
- package/src/browser/react/use-params.ts +20 -10
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +46 -11
- package/src/browser/react/use-search-params.ts +0 -5
- package/src/browser/react/use-segments.ts +11 -21
- package/src/browser/response-adapter.ts +80 -5
- package/src/browser/rsc-router.tsx +226 -75
- package/src/browser/scroll-restoration.ts +54 -42
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +619 -442
- package/src/browser/types.ts +115 -11
- package/src/browser/validate-redirect-origin.ts +43 -16
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +65 -40
- package/src/build/generate-route-types.ts +7 -1
- package/src/build/index.ts +8 -2
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +182 -37
- package/src/build/route-types/ast-route-extraction.ts +15 -8
- package/src/build/route-types/codegen.ts +16 -5
- package/src/build/route-types/include-resolution.ts +125 -24
- package/src/build/route-types/param-extraction.ts +6 -3
- package/src/build/route-types/per-module-writer.ts +22 -6
- package/src/build/route-types/router-processing.ts +392 -106
- package/src/build/route-types/scan-filter.ts +9 -2
- package/src/build/route-types/source-scan.ts +216 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-key-utils.ts +29 -13
- package/src/cache/cache-policy.ts +108 -34
- package/src/cache/cache-runtime.ts +214 -48
- package/src/cache/cache-scope.ts +236 -89
- package/src/cache/cache-tag.ts +103 -0
- package/src/cache/cf/cf-base64.ts +33 -0
- package/src/cache/cf/cf-cache-constants.ts +127 -0
- package/src/cache/cf/cf-cache-store.ts +2224 -171
- package/src/cache/cf/cf-cache-types.ts +349 -0
- package/src/cache/cf/cf-kv-utils.ts +46 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
- package/src/cache/cf/index.ts +11 -17
- package/src/cache/document-cache.ts +89 -27
- package/src/cache/handle-snapshot.ts +70 -0
- package/src/cache/index.ts +11 -20
- package/src/cache/memory-segment-store.ts +136 -37
- package/src/cache/profile-registry.ts +31 -31
- package/src/cache/read-through-swr.ts +41 -11
- package/src/cache/segment-codec.ts +9 -17
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/taint.ts +55 -0
- package/src/cache/types.ts +37 -100
- package/src/client.rsc.tsx +45 -21
- package/src/client.tsx +120 -336
- package/src/cloudflare/index.ts +11 -0
- package/src/cloudflare/tracing.ts +109 -0
- package/src/component-utils.ts +19 -0
- package/src/components/DefaultDocument.tsx +8 -2
- package/src/context-var.ts +84 -2
- package/src/debug.ts +2 -2
- package/src/decode-loader-results.ts +52 -0
- package/src/defer.ts +196 -0
- package/src/deps/ssr.ts +0 -1
- package/src/encode-kv.ts +49 -0
- package/src/errors.ts +30 -4
- package/src/escape-script.ts +52 -0
- package/src/handle.ts +70 -22
- package/src/handles/MetaTags.tsx +56 -19
- package/src/handles/Scripts.tsx +183 -0
- package/src/handles/breadcrumbs.ts +95 -0
- package/src/handles/is-thenable.ts +19 -0
- package/src/handles/meta.ts +51 -40
- package/src/handles/script.ts +244 -0
- package/src/host/cookie-handler.ts +9 -60
- package/src/host/errors.ts +0 -24
- package/src/host/index.ts +8 -5
- package/src/host/pattern-matcher.ts +23 -52
- package/src/host/router.ts +107 -99
- package/src/host/testing.ts +40 -27
- package/src/host/types.ts +37 -4
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +137 -22
- package/src/index.rsc.ts +79 -29
- package/src/index.ts +149 -65
- package/src/internal-debug.ts +11 -10
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +20 -13
- package/src/loader.ts +12 -11
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/outlet-provider.tsx +1 -5
- package/src/prerender/param-hash.ts +16 -16
- package/src/prerender/store.ts +63 -26
- package/src/prerender.ts +198 -82
- package/src/redirect-origin.ts +100 -0
- package/src/regex-escape.ts +8 -0
- package/src/render-error-thrower.tsx +20 -0
- package/src/response-utils.ts +62 -0
- package/src/reverse.ts +65 -15
- package/src/root-error-boundary.tsx +1 -19
- package/src/route-content-wrapper.tsx +7 -72
- package/src/route-definition/dsl-helpers.ts +469 -276
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +113 -37
- package/src/route-definition/index.ts +3 -3
- package/src/route-definition/redirect.ts +53 -12
- package/src/route-definition/resolve-handler-use.ts +161 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-map-builder.ts +7 -17
- package/src/route-types.ts +37 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +164 -17
- package/src/router/error-handling.ts +45 -18
- package/src/router/find-match.ts +45 -22
- package/src/router/handler-context.ts +110 -39
- package/src/router/instrument.ts +350 -0
- package/src/router/intercept-resolution.ts +50 -24
- package/src/router/lazy-includes.ts +19 -53
- package/src/router/loader-resolution.ts +274 -56
- package/src/router/logging.ts +5 -8
- package/src/router/manifest.ts +49 -45
- package/src/router/match-api.ts +121 -205
- package/src/router/match-context.ts +0 -22
- package/src/router/match-handlers.ts +58 -58
- package/src/router/match-middleware/background-revalidation.ts +33 -6
- package/src/router/match-middleware/cache-lookup.ts +214 -263
- package/src/router/match-middleware/cache-store.ts +73 -33
- package/src/router/match-middleware/intercept-resolution.ts +8 -28
- package/src/router/match-middleware/segment-resolution.ts +52 -18
- package/src/router/match-pipelines.ts +1 -42
- package/src/router/match-result.ts +104 -49
- package/src/router/metrics.ts +217 -26
- package/src/router/middleware-types.ts +24 -110
- package/src/router/middleware.ts +384 -197
- package/src/router/navigation-snapshot.ts +131 -0
- package/src/router/params-util.ts +23 -0
- package/src/router/pattern-matching.ts +148 -91
- package/src/router/prefetch-cache-ttl.ts +51 -0
- package/src/router/prerender-match.ts +199 -56
- package/src/router/preview-match.ts +32 -102
- package/src/router/request-classification.ts +276 -0
- package/src/router/revalidation.ts +144 -74
- package/src/router/route-snapshot.ts +244 -0
- package/src/router/router-context.ts +8 -28
- package/src/router/router-interfaces.ts +129 -36
- package/src/router/router-options.ts +185 -23
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +281 -76
- package/src/router/segment-resolution/helpers.ts +116 -31
- package/src/router/segment-resolution/loader-cache.ts +63 -37
- package/src/router/segment-resolution/revalidation.ts +493 -391
- package/src/router/segment-resolution/static-store.ts +19 -5
- package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/segment-resolution.ts +5 -1
- package/src/router/segment-wrappers.ts +8 -5
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry-otel.ts +161 -199
- package/src/router/telemetry.ts +96 -19
- package/src/router/timeout.ts +0 -20
- package/src/router/tracing.ts +206 -0
- package/src/router/trie-matching.ts +180 -58
- package/src/router/types.ts +10 -63
- package/src/router/url-params.ts +44 -0
- package/src/router.ts +182 -54
- package/src/rsc/handler-context.ts +3 -2
- package/src/rsc/handler.ts +702 -460
- package/src/rsc/helpers.ts +168 -46
- package/src/rsc/index.ts +2 -25
- package/src/rsc/json-route-result.ts +38 -0
- package/src/rsc/loader-fetch.ts +127 -31
- package/src/rsc/manifest-init.ts +33 -42
- package/src/rsc/origin-guard.ts +39 -25
- package/src/rsc/progressive-enhancement.ts +98 -19
- package/src/rsc/redirect-guard.ts +99 -0
- package/src/rsc/response-cache-serve.ts +238 -0
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +99 -189
- package/src/rsc/rsc-rendering.ts +126 -106
- package/src/rsc/runtime-warnings.ts +23 -10
- package/src/rsc/server-action.ts +269 -114
- package/src/rsc/ssr-setup.ts +144 -0
- package/src/rsc/types.ts +34 -6
- package/src/runtime-env.ts +18 -0
- package/src/search-params.ts +49 -41
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +149 -0
- package/src/segment-system.tsx +281 -129
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +317 -63
- package/src/server/cookie-parse.ts +32 -0
- package/src/server/cookie-store.ts +80 -5
- package/src/server/handle-store.ts +40 -38
- package/src/server/loader-registry.ts +26 -46
- package/src/server/request-context.ts +425 -177
- package/src/server.ts +6 -0
- package/src/ssr/index.tsx +25 -16
- package/src/static-handler.ts +27 -18
- package/src/testing/cache-status.ts +162 -0
- package/src/testing/collect-handle.ts +40 -0
- package/src/testing/dispatch.ts +701 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +128 -0
- package/src/testing/e2e/matchers.ts +35 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +97 -0
- package/src/testing/flight-normalize.ts +11 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +257 -0
- package/src/testing/generated-routes.ts +183 -0
- package/src/testing/index.ts +99 -0
- package/src/testing/internal/context.ts +371 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +54 -0
- package/src/testing/render-handler.ts +343 -0
- package/src/testing/render-route.tsx +581 -0
- package/src/testing/run-loader.ts +385 -0
- package/src/testing/run-middleware.ts +205 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/theme/ThemeProvider.tsx +20 -58
- package/src/theme/ThemeScript.tsx +7 -9
- package/src/theme/constants.ts +52 -13
- package/src/theme/index.ts +3 -19
- package/src/theme/theme-context.ts +1 -5
- package/src/theme/theme-script.ts +22 -21
- package/src/theme/use-theme.ts +0 -3
- package/src/types/boundaries.ts +0 -35
- package/src/types/cache-types.ts +17 -8
- package/src/types/error-types.ts +30 -90
- package/src/types/global-namespace.ts +54 -41
- package/src/types/handler-context.ts +236 -88
- package/src/types/index.ts +1 -10
- package/src/types/loader-types.ts +44 -15
- package/src/types/request-scope.ts +112 -0
- package/src/types/route-config.ts +10 -45
- package/src/types/route-entry.ts +19 -7
- package/src/types/segments.ts +37 -19
- package/src/urls/include-helper.ts +33 -70
- package/src/urls/index.ts +1 -11
- package/src/urls/path-helper-types.ts +58 -11
- package/src/urls/path-helper.ts +57 -111
- package/src/urls/pattern-types.ts +48 -19
- package/src/urls/response-types.ts +25 -22
- package/src/urls/type-extraction.ts +58 -139
- package/src/urls/urls-function.ts +1 -18
- package/src/use-loader.tsx +346 -89
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +64 -91
- package/src/vite/discovery/discover-routers.ts +147 -88
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +247 -145
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +61 -13
- package/src/vite/discovery/virtual-module-codegen.ts +14 -34
- package/src/vite/index.ts +10 -3
- package/src/vite/inject-client-debug.ts +36 -0
- package/src/vite/plugin-types.ts +155 -65
- package/src/vite/plugins/cjs-to-esm.ts +16 -19
- package/src/vite/plugins/client-ref-dedup.ts +120 -0
- package/src/vite/plugins/client-ref-hashing.ts +28 -15
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
- package/src/vite/plugins/expose-action-id.ts +49 -98
- package/src/vite/plugins/expose-id-utils.ts +96 -51
- package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
- package/src/vite/plugins/expose-ids/handler-transform.ts +15 -64
- package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
- package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
- package/src/vite/plugins/expose-internal-ids.ts +553 -317
- package/src/vite/plugins/performance-tracks.ts +89 -0
- package/src/vite/plugins/refresh-cmd.ts +127 -0
- package/src/vite/plugins/use-cache-transform.ts +73 -83
- package/src/vite/plugins/version-injector.ts +21 -25
- package/src/vite/plugins/version-plugin.ts +46 -37
- package/src/vite/plugins/virtual-entries.ts +13 -18
- package/src/vite/rango.ts +241 -287
- package/src/vite/router-discovery.ts +956 -149
- package/src/vite/utils/ast-handler-extract.ts +26 -35
- package/src/vite/utils/banner.ts +4 -4
- package/src/vite/utils/bundle-analysis.ts +10 -15
- package/src/vite/utils/client-chunks.ts +184 -0
- package/src/vite/utils/directive-prologue.ts +40 -0
- package/src/vite/utils/forward-user-plugins.ts +171 -0
- package/src/vite/utils/manifest-utils.ts +4 -59
- package/src/vite/utils/package-resolution.ts +20 -52
- package/src/vite/utils/prerender-utils.ts +141 -34
- package/src/vite/utils/shared-utils.ts +92 -42
- package/CLAUDE.md +0 -5
- package/src/browser/action-response-classifier.ts +0 -99
- package/src/browser/react/use-client-cache.ts +0 -58
- package/src/browser/shallow.ts +0 -40
- package/src/handles/index.ts +0 -6
- package/src/network-error-thrower.tsx +0 -23
- package/src/route-definition/route-function.ts +0 -119
- package/src/router/middleware-cookies.ts +0 -55
package/src/rsc/origin-guard.ts
CHANGED
|
@@ -9,11 +9,31 @@
|
|
|
9
9
|
* navigations, bookmarks, and non-browser clients don't send Origin.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import type { RequestPlan } from "../router/request-classification.js";
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* Request phase that triggered the origin check.
|
|
14
16
|
*/
|
|
15
17
|
export type OriginCheckPhase = "action" | "loader" | "pe-form";
|
|
16
18
|
|
|
19
|
+
// Exhaustive over RequestPlan modes so a new mode must be classified here (the
|
|
20
|
+
// security gate) instead of silently falling through to no origin check.
|
|
21
|
+
export const ORIGIN_CHECK_PHASE_BY_MODE: Record<
|
|
22
|
+
RequestPlan["mode"],
|
|
23
|
+
OriginCheckPhase | null
|
|
24
|
+
> = {
|
|
25
|
+
action: "action",
|
|
26
|
+
loader: "loader",
|
|
27
|
+
"pe-render": "pe-form",
|
|
28
|
+
"full-render": null,
|
|
29
|
+
"partial-render": null,
|
|
30
|
+
response: null,
|
|
31
|
+
redirect: null,
|
|
32
|
+
"version-mismatch": null,
|
|
33
|
+
// Terminal: handled before the origin guard (emits X-RSC-Reload, no execution).
|
|
34
|
+
"app-switch": null,
|
|
35
|
+
};
|
|
36
|
+
|
|
17
37
|
/**
|
|
18
38
|
* Context passed to the originCheck callback.
|
|
19
39
|
*/
|
|
@@ -49,11 +69,8 @@ export type OriginCheckConfig<TEnv = any> =
|
|
|
49
69
|
* Returns true to allow, false to reject.
|
|
50
70
|
*/
|
|
51
71
|
export function defaultOriginCheck(request: Request, url: URL): boolean {
|
|
52
|
-
// 1. Read Origin header (present on all cross-origin requests and
|
|
53
|
-
// same-origin POST/PUT/PATCH/DELETE in modern browsers)
|
|
54
72
|
let requestOrigin = request.headers.get("origin");
|
|
55
73
|
|
|
56
|
-
// 2. Fallback to Referer if Origin is absent (some proxies strip it)
|
|
57
74
|
if (!requestOrigin) {
|
|
58
75
|
const referer = request.headers.get("referer");
|
|
59
76
|
if (referer) {
|
|
@@ -65,23 +82,20 @@ export function defaultOriginCheck(request: Request, url: URL): boolean {
|
|
|
65
82
|
}
|
|
66
83
|
}
|
|
67
84
|
|
|
68
|
-
// 3. No Origin or Referer — allow (can't be browser-initiated CSRF)
|
|
69
85
|
if (!requestOrigin) return true;
|
|
70
86
|
|
|
71
|
-
// "null" origin comes from privacy-sensitive contexts (data: URLs,
|
|
72
|
-
// sandboxed iframes, cross-origin redirects). Reject it.
|
|
73
87
|
if (requestOrigin === "null") return false;
|
|
74
88
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
const
|
|
89
|
+
// An Origin/Referer is present, so this is a browser request worth checking.
|
|
90
|
+
// Establish the expected origin from the Host header only -- browsers always
|
|
91
|
+
// send Host alongside Origin (runtimes synthesize it from the HTTP/2
|
|
92
|
+
// :authority), so a missing Host here is anomalous. Fail closed rather than
|
|
93
|
+
// fall back to url.host (derived from the request line) when the trusted Host
|
|
94
|
+
// cannot be established.
|
|
95
|
+
const expectedHost = request.headers.get("host");
|
|
96
|
+
if (!expectedHost) return false;
|
|
82
97
|
|
|
83
|
-
|
|
84
|
-
const expectedOrigin = `${expectedProtocol}//${expectedHost}`;
|
|
98
|
+
const expectedOrigin = `${url.protocol}//${expectedHost}`;
|
|
85
99
|
|
|
86
100
|
return requestOrigin.toLowerCase() === expectedOrigin.toLowerCase();
|
|
87
101
|
}
|
|
@@ -116,14 +130,15 @@ export async function checkRequestOrigin<TEnv = any>(
|
|
|
116
130
|
// Disabled by explicit opt-out
|
|
117
131
|
if (config === false) return null;
|
|
118
132
|
|
|
119
|
-
// Default
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
// Default (true/undefined) becomes a callback returning boolean, so the
|
|
134
|
+
// Response|true|reject resolution below is written once.
|
|
135
|
+
const check: (
|
|
136
|
+
ctx: OriginCheckContext<TEnv>,
|
|
137
|
+
) => boolean | Response | Promise<boolean | Response> =
|
|
138
|
+
config === true || config === undefined
|
|
139
|
+
? () => defaultOriginCheck(request, url)
|
|
140
|
+
: config;
|
|
125
141
|
|
|
126
|
-
// Custom function — build context and call
|
|
127
142
|
const ctx: OriginCheckContext<TEnv> = {
|
|
128
143
|
request,
|
|
129
144
|
url,
|
|
@@ -133,9 +148,8 @@ export async function checkRequestOrigin<TEnv = any>(
|
|
|
133
148
|
defaultCheck: () => defaultOriginCheck(request, url),
|
|
134
149
|
};
|
|
135
150
|
|
|
136
|
-
const result = await
|
|
151
|
+
const result = await check(ctx);
|
|
137
152
|
|
|
138
153
|
if (result instanceof Response) return result;
|
|
139
|
-
|
|
140
|
-
return createForbiddenResponse(request);
|
|
154
|
+
return result === true ? null : createForbiddenResponse(request);
|
|
141
155
|
}
|
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
getRequestContext,
|
|
11
11
|
setRequestContextParams,
|
|
12
12
|
} from "../server/request-context.js";
|
|
13
|
+
import { getSSRSetup } from "./ssr-setup.js";
|
|
13
14
|
import type { MiddlewareFn } from "../router/middleware.js";
|
|
14
15
|
import { executeMiddleware } from "../router/middleware.js";
|
|
16
|
+
import { observePhase, PHASES } from "../router/instrument.js";
|
|
15
17
|
import type { RscPayload, ReactFormState } from "./types.js";
|
|
16
18
|
import {
|
|
17
19
|
createResponseWithMergedHeaders,
|
|
@@ -43,7 +45,7 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
43
45
|
env: TEnv,
|
|
44
46
|
url: URL,
|
|
45
47
|
isAction: boolean,
|
|
46
|
-
handleStore: ReturnType<typeof
|
|
48
|
+
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
47
49
|
nonce: string | undefined,
|
|
48
50
|
routeMwInfo?: PeRouteMiddlewareInfo,
|
|
49
51
|
): Promise<Response | null> {
|
|
@@ -114,6 +116,13 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
114
116
|
// Execute action and return HTML
|
|
115
117
|
let actionResult: unknown = undefined;
|
|
116
118
|
let reactFormState: ReactFormState | null = null;
|
|
119
|
+
// Status for the fall-through re-render after a boundaryless action error.
|
|
120
|
+
// When an action throws and NO error boundary matches, the PE path re-renders
|
|
121
|
+
// the page (below) the same way it would after a successful action. Without
|
|
122
|
+
// this the re-render serves HTTP 200, diverging from the JS path which serves
|
|
123
|
+
// 500 (server-action.ts sets actionStatus=500 for the same boundaryless error).
|
|
124
|
+
// 500 is carried only into the final HTML response, never the redirect branch.
|
|
125
|
+
let boundarylessErrorStatus: number | undefined;
|
|
117
126
|
|
|
118
127
|
if (isUseActionState) {
|
|
119
128
|
// Decode and extract action identity before execution so error
|
|
@@ -123,7 +132,11 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
123
132
|
const boundAction = await ctx.decodeAction(formData);
|
|
124
133
|
// React's custom .bind() preserves $$id on server references.
|
|
125
134
|
useActionStateId = (boundAction as { $$id?: string }).$$id ?? undefined;
|
|
126
|
-
|
|
135
|
+
// Meter the no-JS form action as the action phase, same as the JS path.
|
|
136
|
+
actionResult = await observePhase(
|
|
137
|
+
PHASES.action(useActionStateId ?? "useActionState"),
|
|
138
|
+
() => boundAction(),
|
|
139
|
+
);
|
|
127
140
|
} catch (error) {
|
|
128
141
|
// Handle thrown redirect (e.g., throw redirect('/path'))
|
|
129
142
|
const redirectResponse = extractRedirectResponse(error);
|
|
@@ -150,10 +163,21 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
150
163
|
handledByBoundary: false,
|
|
151
164
|
});
|
|
152
165
|
console.error("[RSC] Progressive enhancement action error:", error);
|
|
166
|
+
// No boundary matched — the fall-through re-render must carry 500 to match
|
|
167
|
+
// the JS path's boundaryless-error status (server-action.ts).
|
|
168
|
+
boundarylessErrorStatus = 500;
|
|
153
169
|
}
|
|
154
170
|
} else if (isDirectAction && directActionId) {
|
|
155
171
|
const temporaryReferences = ctx.createTemporaryReferenceSet();
|
|
156
172
|
|
|
173
|
+
// INTENTIONAL JS/PE divergence (do NOT "fix" to match the JS reject path).
|
|
174
|
+
// On the JS path React Flight-encodes the action args, so decodeReply
|
|
175
|
+
// succeeds or a failure means a malformed body (rejected). On the no-JS PE
|
|
176
|
+
// path the browser submits a raw <form action={fn}> POST with NO encoded
|
|
177
|
+
// args, so decodeReply throws by design and the raw FormData IS the action
|
|
178
|
+
// argument (the React form-action convention: fn(formData)). Removing this
|
|
179
|
+
// fallback breaks every unbound no-JS form action (verified: it fails the
|
|
180
|
+
// progressive-enhancement dev+prod e2e suite). See #572 (decided: keep).
|
|
157
181
|
let args: unknown[] = [];
|
|
158
182
|
try {
|
|
159
183
|
args = await ctx.decodeReply(formData, { temporaryReferences });
|
|
@@ -163,7 +187,9 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
163
187
|
|
|
164
188
|
try {
|
|
165
189
|
const loadedAction = await ctx.loadServerAction(directActionId);
|
|
166
|
-
actionResult = await
|
|
190
|
+
actionResult = await observePhase(PHASES.action(directActionId), () =>
|
|
191
|
+
loadedAction.apply(null, args),
|
|
192
|
+
);
|
|
167
193
|
} catch (error) {
|
|
168
194
|
// Handle thrown redirect (e.g., throw redirect('/path'))
|
|
169
195
|
const redirectResponse = extractRedirectResponse(error);
|
|
@@ -190,6 +216,9 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
190
216
|
handledByBoundary: false,
|
|
191
217
|
});
|
|
192
218
|
console.error("[RSC] Progressive enhancement action error:", error);
|
|
219
|
+
// No boundary matched — the fall-through re-render must carry 500 to match
|
|
220
|
+
// the JS path's boundaryless-error status (server-action.ts).
|
|
221
|
+
boundarylessErrorStatus = 500;
|
|
193
222
|
}
|
|
194
223
|
}
|
|
195
224
|
|
|
@@ -225,9 +254,19 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
225
254
|
// cookies set by route middleware are available during re-render — matching
|
|
226
255
|
// the behavior of JS-enabled requests.
|
|
227
256
|
const renderPage = async (): Promise<Response> => {
|
|
257
|
+
// Preserve the original POST request's headers (Authorization, Cookie,
|
|
258
|
+
// custom headers) so loaders that read request headers/cookies behave
|
|
259
|
+
// identically under PE and the JS action path. Drop body-framing headers
|
|
260
|
+
// from the bodyless GET and force the HTML accept.
|
|
261
|
+
const headers = new Headers(request.headers);
|
|
262
|
+
headers.delete("content-type");
|
|
263
|
+
headers.delete("content-length");
|
|
264
|
+
headers.delete("content-encoding");
|
|
265
|
+
headers.delete("transfer-encoding");
|
|
266
|
+
headers.set("accept", "text/html");
|
|
228
267
|
const renderRequest = new Request(url.toString(), {
|
|
229
268
|
method: "GET",
|
|
230
|
-
headers
|
|
269
|
+
headers,
|
|
231
270
|
});
|
|
232
271
|
|
|
233
272
|
const match = await ctx.router.match(renderRequest, { env });
|
|
@@ -242,25 +281,42 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
242
281
|
const payload: RscPayload = {
|
|
243
282
|
metadata: {
|
|
244
283
|
pathname: url.pathname,
|
|
284
|
+
routerId: ctx.router.id,
|
|
285
|
+
basename: ctx.router.basename,
|
|
245
286
|
segments: match.segments,
|
|
246
287
|
matched: match.matched,
|
|
247
288
|
diff: match.diff,
|
|
289
|
+
resolvedIds: match.resolvedIds,
|
|
290
|
+
params: match.params,
|
|
248
291
|
isPartial: false,
|
|
249
292
|
rootLayout: ctx.router.rootLayout,
|
|
250
293
|
handles: handleStore.stream(),
|
|
251
294
|
version: ctx.version,
|
|
295
|
+
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
252
296
|
themeConfig: ctx.router.themeConfig,
|
|
253
297
|
warmupEnabled: ctx.router.warmupEnabled,
|
|
254
|
-
|
|
298
|
+
strictMode: ctx.router.strictMode,
|
|
299
|
+
initialTheme: getRequestContext().theme,
|
|
255
300
|
},
|
|
256
|
-
formState: actionResult,
|
|
257
301
|
};
|
|
258
302
|
|
|
259
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
303
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
304
|
+
onError: (error: unknown) => {
|
|
305
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
// metricsStore=undefined is safe: the handler already stashed the early
|
|
309
|
+
// SSR setup promise on request variables, so getSSRSetup returns it
|
|
310
|
+
// without falling back to a fresh startSSRSetup.
|
|
311
|
+
const [ssrModule, streamMode] = await getSSRSetup(
|
|
312
|
+
ctx,
|
|
313
|
+
request,
|
|
314
|
+
env,
|
|
315
|
+
url,
|
|
316
|
+
undefined,
|
|
317
|
+
);
|
|
318
|
+
// reactFormState carries the useActionState payload via the SSR-option path
|
|
319
|
+
// (renderToReadableStream({ formState })); it does NOT travel on RscPayload.
|
|
264
320
|
const htmlStream = await ssrModule.renderHTML(rscStream, {
|
|
265
321
|
formState: reactFormState,
|
|
266
322
|
nonce,
|
|
@@ -268,6 +324,13 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
268
324
|
});
|
|
269
325
|
|
|
270
326
|
return createResponseWithMergedHeaders(htmlStream, {
|
|
327
|
+
// boundarylessErrorStatus is set only when the action threw and no error
|
|
328
|
+
// boundary matched; it makes the re-render carry 500 like the JS path.
|
|
329
|
+
// The redirect branch above returns before this, so a redirect re-render
|
|
330
|
+
// keeps its 308 and is never overridden.
|
|
331
|
+
...(boundarylessErrorStatus !== undefined
|
|
332
|
+
? { status: boundarylessErrorStatus }
|
|
333
|
+
: {}),
|
|
271
334
|
headers: { "content-type": "text/html;charset=utf-8" },
|
|
272
335
|
});
|
|
273
336
|
};
|
|
@@ -302,7 +365,7 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
302
365
|
env: TEnv,
|
|
303
366
|
url: URL,
|
|
304
367
|
error: unknown,
|
|
305
|
-
handleStore: ReturnType<typeof
|
|
368
|
+
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
306
369
|
nonce: string | undefined,
|
|
307
370
|
actionId?: string | null,
|
|
308
371
|
): Promise<Response | null> {
|
|
@@ -335,25 +398,41 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
335
398
|
const payload: RscPayload = {
|
|
336
399
|
metadata: {
|
|
337
400
|
pathname: url.pathname,
|
|
401
|
+
routerId: ctx.router.id,
|
|
402
|
+
basename: ctx.router.basename,
|
|
338
403
|
segments: errorResult.segments,
|
|
339
404
|
matched: errorResult.matched,
|
|
340
405
|
diff: errorResult.diff,
|
|
406
|
+
resolvedIds: errorResult.resolvedIds,
|
|
407
|
+
params: errorResult.params,
|
|
341
408
|
isPartial: false,
|
|
342
409
|
isError: true,
|
|
343
410
|
rootLayout: ctx.router.rootLayout,
|
|
344
411
|
handles: handleStore.stream(),
|
|
345
412
|
version: ctx.version,
|
|
413
|
+
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
346
414
|
themeConfig: ctx.router.themeConfig,
|
|
347
415
|
warmupEnabled: ctx.router.warmupEnabled,
|
|
348
|
-
|
|
416
|
+
strictMode: ctx.router.strictMode,
|
|
417
|
+
initialTheme: getRequestContext().theme,
|
|
349
418
|
},
|
|
350
419
|
};
|
|
351
420
|
|
|
352
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
421
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
422
|
+
onError: (error: unknown) => {
|
|
423
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
// metricsStore=undefined is safe: the handler already stashed the early
|
|
427
|
+
// SSR setup promise on request variables, so getSSRSetup returns it
|
|
428
|
+
// without falling back to a fresh startSSRSetup.
|
|
429
|
+
const [ssrModule, streamMode] = await getSSRSetup(
|
|
430
|
+
ctx,
|
|
431
|
+
request,
|
|
432
|
+
env,
|
|
433
|
+
url,
|
|
434
|
+
undefined,
|
|
435
|
+
);
|
|
357
436
|
const htmlStream = await ssrModule.renderHTML(rscStream, {
|
|
358
437
|
nonce,
|
|
359
438
|
streamMode,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side open-redirect guard.
|
|
3
|
+
*
|
|
4
|
+
* Applied to the FINAL handler response (the single top-level return in
|
|
5
|
+
* `handler.ts`) so every browser-followed redirect honors the same same-origin
|
|
6
|
+
* rule the client enforces (`browser/validate-redirect-origin.ts`), via the one
|
|
7
|
+
* shared resolver in `redirect-origin.ts`. This is the server half of the
|
|
8
|
+
* client's existing guard: the client can only validate redirects its own JS
|
|
9
|
+
* navigates to (the SPA/fetch channel), so document-native redirects -- a no-JS
|
|
10
|
+
* PE form POST, a full-page GET `match.redirect`, a middleware `redirect()`
|
|
11
|
+
* short-circuit, a response-route 3xx -- reach the browser with no client in the
|
|
12
|
+
* loop. They all funnel through one handler return, so guarding there covers
|
|
13
|
+
* every one and any future redirect exit.
|
|
14
|
+
*
|
|
15
|
+
* Soft (SPA/Flight) redirects are 200/204 responses (`X-RSC-Redirect` header or
|
|
16
|
+
* `metadata.redirect` payload) and are NOT redirect Responses, so they never
|
|
17
|
+
* reach this guard -- they stay validated client-side.
|
|
18
|
+
*
|
|
19
|
+
* Behavior on a `Location` header:
|
|
20
|
+
* - same-origin / relative -> passes through unchanged
|
|
21
|
+
* - `redirect(url, { external: true })` (out-of-band brand present) and an
|
|
22
|
+
* http(s) target -> allowed (explicit, auditable, unforgeable opt-in)
|
|
23
|
+
* - branded but a non-http(s) target (e.g. `javascript:`) -> neutralized: the
|
|
24
|
+
* opt-in waives the same-origin rule, NOT scheme safety
|
|
25
|
+
* - cross-origin without the brand -> Location rewritten to the basename root
|
|
26
|
+
* (a safe same-origin landing, the document analog of the client's "stay put");
|
|
27
|
+
* dev logs the blocked target and points to `{ external: true }`.
|
|
28
|
+
*
|
|
29
|
+
* The opt-in is an out-of-band brand on the Response object (isExternalRedirect),
|
|
30
|
+
* never a wire header: a header is forgeable by an attacker-controlled upstream
|
|
31
|
+
* response a proxy-style response route copies through, which would defeat the
|
|
32
|
+
* guard without app code ever opting in. The reserved header name is stripped
|
|
33
|
+
* defensively so a forged value can never reach the browser.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { isRedirectResponse } from "../response-utils.js";
|
|
37
|
+
import {
|
|
38
|
+
resolveSameOriginRedirect,
|
|
39
|
+
resolveExternalRedirect,
|
|
40
|
+
isExternalRedirect,
|
|
41
|
+
EXTERNAL_REDIRECT_MARKER,
|
|
42
|
+
} from "../redirect-origin.js";
|
|
43
|
+
import { carryOverRedirectHeaders } from "./helpers.js";
|
|
44
|
+
|
|
45
|
+
export function guardOutgoingRedirect(
|
|
46
|
+
response: Response,
|
|
47
|
+
requestOrigin: string,
|
|
48
|
+
basename: string | undefined,
|
|
49
|
+
): Response {
|
|
50
|
+
// Only 3xx + Location responses (document-native redirects) are guarded.
|
|
51
|
+
if (!isRedirectResponse(response)) {
|
|
52
|
+
return response;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The reserved marker is never a trust signal. Strip any value -- forged by a
|
|
56
|
+
// proxied upstream or otherwise -- so it can never reach the browser. Trust
|
|
57
|
+
// comes solely from the out-of-band brand below.
|
|
58
|
+
try {
|
|
59
|
+
response.headers.delete(EXTERNAL_REDIRECT_MARKER);
|
|
60
|
+
} catch {
|
|
61
|
+
// Some platform responses carry immutable headers; the header is inert on
|
|
62
|
+
// the browser, so a failed strip is harmless.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// isRedirectResponse guarantees a truthy Location.
|
|
66
|
+
const location = response.headers.get("Location")!;
|
|
67
|
+
|
|
68
|
+
// Explicit opt-in via redirect(url, { external: true }): allow an off-host
|
|
69
|
+
// target, but only an http(s) one. external waives the same-origin rule, not
|
|
70
|
+
// scheme safety -- a branded javascript:/data: target falls through to be
|
|
71
|
+
// neutralized so it can never become a scriptable navigation downstream.
|
|
72
|
+
if (isExternalRedirect(response)) {
|
|
73
|
+
if (resolveExternalRedirect(location, requestOrigin) !== null) {
|
|
74
|
+
return response;
|
|
75
|
+
}
|
|
76
|
+
} else if (resolveSameOriginRedirect(location, requestOrigin) !== null) {
|
|
77
|
+
return response;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Cross-origin (or unsafe-scheme external): neutralize to a safe same-origin
|
|
81
|
+
// landing.
|
|
82
|
+
const safeTarget = basename && basename !== "/" ? basename : "/";
|
|
83
|
+
if (process.env.NODE_ENV !== "production") {
|
|
84
|
+
console.error(
|
|
85
|
+
`[rango] Blocked cross-origin redirect to "${location}"; sent to ` +
|
|
86
|
+
`"${safeTarget}" instead. To redirect off-host on purpose, use ` +
|
|
87
|
+
`redirect(url, { external: true }).`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const blocked = new Response(null, {
|
|
92
|
+
status: response.status,
|
|
93
|
+
headers: { Location: safeTarget },
|
|
94
|
+
});
|
|
95
|
+
// Preserve cookies and any other headers (Set-Cookie, Server-Timing, ...);
|
|
96
|
+
// carryOverRedirectHeaders intentionally skips Location.
|
|
97
|
+
carryOverRedirectHeaders(response, blocked);
|
|
98
|
+
return blocked;
|
|
99
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared response-route cache serve.
|
|
3
|
+
*
|
|
4
|
+
* Owns the single response-cache contract — cache-scope resolution from the
|
|
5
|
+
* matched entry tree, condition eval, key resolution (route key() > store
|
|
6
|
+
* keyGenerator > default), tag resolution, pre-handler-callback timing, and the
|
|
7
|
+
* fresh-hit / SWR-revalidate / miss-write branches — for BOTH the production
|
|
8
|
+
* response-route handler (rsc/response-route-handler.ts) and the dispatch testing
|
|
9
|
+
* primitive (testing/dispatch.ts), so the two can never drift.
|
|
10
|
+
*
|
|
11
|
+
* Plugin-rsc hazard: cache-scope.ts pulls @vitejs/plugin-rsc (via segment-codec),
|
|
12
|
+
* which the non-Vite unit-test runner cannot resolve, and this module is on the
|
|
13
|
+
* testing barrel's EAGER graph (dispatch imports it). So `createCacheScope` and
|
|
14
|
+
* `resolveCacheTags` are NOT imported here at runtime — they are INJECTED by the
|
|
15
|
+
* caller (production imports them statically; dispatch lazy-imports them only once
|
|
16
|
+
* a response route matches). The only runtime imports here are plugin-rsc-free
|
|
17
|
+
* (helpers' isCacheableStatus/finalizeResponse, traverseBack); cache-scope is a
|
|
18
|
+
* type-only import (erased at build).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { CacheScope } from "../cache/cache-scope.js";
|
|
22
|
+
import type { PartialCacheOptions } from "../types.js";
|
|
23
|
+
import type { RequestContext } from "../server/request-context.js";
|
|
24
|
+
import type { SegmentCacheStore } from "../cache/types.js";
|
|
25
|
+
import type { EntryCacheConfig, EntryData } from "../server/context.js";
|
|
26
|
+
import { traverseBack } from "../router/pattern-matching.js";
|
|
27
|
+
import { isCacheableStatus, finalizeResponse } from "./helpers.js";
|
|
28
|
+
import { reportCacheError } from "../cache/cache-error.js";
|
|
29
|
+
|
|
30
|
+
/** Injected cache-scope builders (kept off this module's runtime import graph). */
|
|
31
|
+
export interface CacheScopeDeps {
|
|
32
|
+
createCacheScope: (
|
|
33
|
+
config: EntryCacheConfig | undefined,
|
|
34
|
+
parent?: CacheScope | null,
|
|
35
|
+
) => CacheScope | null;
|
|
36
|
+
resolveCacheTags: (
|
|
37
|
+
config: PartialCacheOptions | false,
|
|
38
|
+
ctx: RequestContext | undefined,
|
|
39
|
+
) => string[] | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ServeResponseRouteWithCacheArgs {
|
|
43
|
+
reqCtx: RequestContext;
|
|
44
|
+
manifestEntry: EntryData;
|
|
45
|
+
responseType: string;
|
|
46
|
+
url: URL;
|
|
47
|
+
/** callHandler wrapped by route-level middleware — the unit the cache wraps. */
|
|
48
|
+
executeHandler: () => Promise<Response>;
|
|
49
|
+
deps: CacheScopeDeps;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Serve a response route through its cache, or return `undefined` when no cache
|
|
54
|
+
* applies (no scope, disabled, condition false, or store lacks get/putResponse)
|
|
55
|
+
* so the caller falls through to a plain `executeHandler()` run.
|
|
56
|
+
*
|
|
57
|
+
* Must run inside runWithRequestContext (reads the ambient request context via
|
|
58
|
+
* the helpers and reqCtx.waitUntil for background writes).
|
|
59
|
+
*/
|
|
60
|
+
export async function serveResponseRouteWithCache(
|
|
61
|
+
args: ServeResponseRouteWithCacheArgs,
|
|
62
|
+
): Promise<Response | undefined> {
|
|
63
|
+
const { reqCtx, manifestEntry, responseType, url, executeHandler, deps } =
|
|
64
|
+
args;
|
|
65
|
+
|
|
66
|
+
let cacheScope: CacheScope | null = null;
|
|
67
|
+
for (const entry of traverseBack(manifestEntry)) {
|
|
68
|
+
if (entry.cache) {
|
|
69
|
+
cacheScope = deps.createCacheScope(entry.cache, cacheScope);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!cacheScope?.enabled) return undefined;
|
|
74
|
+
|
|
75
|
+
// Evaluate condition — skip the response cache when condition returns false.
|
|
76
|
+
let conditionPassed = true;
|
|
77
|
+
if (cacheScope.config !== false && cacheScope.config.condition) {
|
|
78
|
+
try {
|
|
79
|
+
conditionPassed = !!cacheScope.config.condition(reqCtx);
|
|
80
|
+
} catch {
|
|
81
|
+
conditionPassed = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const store = cacheScope.getStore() ?? reqCtx._cacheStore;
|
|
86
|
+
if (!conditionPassed || !store?.getResponse || !store?.putResponse) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build cache key with the response:{type}: prefix (avoids collision with
|
|
91
|
+
// segment keys); include host + url.search so query-driven and multi-host
|
|
92
|
+
// responses cache separately.
|
|
93
|
+
let cacheKey = `response:${responseType}:${url.host}${url.pathname}${url.search}`;
|
|
94
|
+
|
|
95
|
+
// Priority 1: route-level key() (full override). Priority 2: store-level
|
|
96
|
+
// keyGenerator (modifies the default key).
|
|
97
|
+
//
|
|
98
|
+
// A CONFIGURED key()/keyGenerator that THROWS must DEGRADE TO A MISS, not fall
|
|
99
|
+
// back to the broad default key. The default key
|
|
100
|
+
// `response:${type}:${host}${path}${search}` is intentionally broad; if the
|
|
101
|
+
// configured key encodes tenant/user/auth state, falling back to the broad key
|
|
102
|
+
// would cache PERSONALIZED output under it and serve it cross-user (cache
|
|
103
|
+
// poisoning). Mirrors the segment-cache behavior (cache-scope.ts lookupRoute):
|
|
104
|
+
// a throwing key degrades to a cache miss, never a collision onto the default
|
|
105
|
+
// slot. The no-key default path is left untouched (the broad key is correct
|
|
106
|
+
// when no key is configured).
|
|
107
|
+
let keyResolutionFailed = false;
|
|
108
|
+
if (cacheScope.config !== false && cacheScope.config.key) {
|
|
109
|
+
try {
|
|
110
|
+
const customKey = await cacheScope.config.key(reqCtx);
|
|
111
|
+
cacheKey = `response:${customKey}`;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
keyResolutionFailed = true;
|
|
114
|
+
reportCacheError(
|
|
115
|
+
error,
|
|
116
|
+
"cache-read",
|
|
117
|
+
"[ResponseCache] Key resolution failed",
|
|
118
|
+
reqCtx,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
} else if (store.keyGenerator) {
|
|
122
|
+
try {
|
|
123
|
+
cacheKey = await store.keyGenerator(reqCtx, cacheKey);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
keyResolutionFailed = true;
|
|
126
|
+
reportCacheError(
|
|
127
|
+
error,
|
|
128
|
+
"cache-read",
|
|
129
|
+
"[ResponseCache] keyGenerator failed",
|
|
130
|
+
reqCtx,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Degrade to a MISS: return undefined so the caller runs the route UNCACHED.
|
|
136
|
+
// This early-returns BEFORE _onResponseCallbacks is saved/cleared below, so the
|
|
137
|
+
// pre-handler onResponse callbacks are still intact for the uncached run.
|
|
138
|
+
if (keyResolutionFailed) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Resolve cache tags for this document entry (static or dynamic) while the
|
|
143
|
+
// request context is available, so the stored entry is tag-invalidatable.
|
|
144
|
+
const responseTags = deps.resolveCacheTags(cacheScope.config, reqCtx);
|
|
145
|
+
|
|
146
|
+
// Pre-handler callbacks (registered by app-level middleware before the cache
|
|
147
|
+
// block) are saved and the live array is cleared:
|
|
148
|
+
// createResponseWithMergedHeaders inside the handler eagerly drains whatever is
|
|
149
|
+
// in _onResponseCallbacks, so handler-registered callbacks bake into the cached
|
|
150
|
+
// artifact, while these pre-handler callbacks are applied once per serve on
|
|
151
|
+
// every path (hit + miss).
|
|
152
|
+
const savedCallbacks = reqCtx._onResponseCallbacks;
|
|
153
|
+
reqCtx._onResponseCallbacks = [];
|
|
154
|
+
const applyPreHandlerCallbacks = (response: Response): Response => {
|
|
155
|
+
let result = response;
|
|
156
|
+
for (const callback of savedCallbacks) {
|
|
157
|
+
result = callback(result) ?? result;
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const putFresh = (
|
|
163
|
+
store2: SegmentCacheStore,
|
|
164
|
+
fresh: Response,
|
|
165
|
+
): Promise<void> =>
|
|
166
|
+
store2.putResponse!(
|
|
167
|
+
cacheKey,
|
|
168
|
+
fresh.clone(),
|
|
169
|
+
cacheScope!.ttl,
|
|
170
|
+
cacheScope!.swr,
|
|
171
|
+
responseTags,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const cached = await store.getResponse(cacheKey);
|
|
176
|
+
if (cached && isCacheableStatus(cached.response.status)) {
|
|
177
|
+
if (!cached.shouldRevalidate) {
|
|
178
|
+
return applyPreHandlerCallbacks(cached.response);
|
|
179
|
+
}
|
|
180
|
+
// Stale hit (SWR): return cached, revalidate in background.
|
|
181
|
+
reqCtx.waitUntil(async () => {
|
|
182
|
+
try {
|
|
183
|
+
const fresh = finalizeResponse(await executeHandler());
|
|
184
|
+
if (isCacheableStatus(fresh.status)) await putFresh(store, fresh);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
reportCacheError(
|
|
187
|
+
error,
|
|
188
|
+
"stale-revalidation",
|
|
189
|
+
"[ResponseCache] background revalidation",
|
|
190
|
+
reqCtx,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return applyPreHandlerCallbacks(cached.response);
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
reportCacheError(
|
|
198
|
+
error,
|
|
199
|
+
"cache-read",
|
|
200
|
+
"[ResponseCache] Cache lookup failed",
|
|
201
|
+
reqCtx,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Cache miss: execute the handler and cache the result.
|
|
206
|
+
const response = finalizeResponse(await executeHandler());
|
|
207
|
+
if (isCacheableStatus(response.status)) {
|
|
208
|
+
// Clone SYNCHRONOUSLY here, before returning. The original `response` is
|
|
209
|
+
// handed back to the middleware chain, where mergeResponse rebuilds it as
|
|
210
|
+
// `new Response(response.body, ...)`. Deferring the clone into the waitUntil
|
|
211
|
+
// callback (putFresh(response), which clones inside the async body) raced
|
|
212
|
+
// that rebuild: the background clone() and the foreground body read could
|
|
213
|
+
// interleave and throw "Response body object should not be disturbed or
|
|
214
|
+
// locked" (a flaky 500). Teeing now keeps the returned body independent of
|
|
215
|
+
// the cache write. The SWR path above is unaffected (its `fresh` is created
|
|
216
|
+
// inside the background callback and never returned to the caller).
|
|
217
|
+
const toCache = response.clone();
|
|
218
|
+
reqCtx.waitUntil(async () => {
|
|
219
|
+
try {
|
|
220
|
+
await store.putResponse!(
|
|
221
|
+
cacheKey,
|
|
222
|
+
toCache,
|
|
223
|
+
cacheScope!.ttl,
|
|
224
|
+
cacheScope!.swr,
|
|
225
|
+
responseTags,
|
|
226
|
+
);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
reportCacheError(
|
|
229
|
+
error,
|
|
230
|
+
"cache-write",
|
|
231
|
+
"[ResponseCache] Cache write failed",
|
|
232
|
+
reqCtx,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return applyPreHandlerCallbacks(response);
|
|
238
|
+
}
|