@timber-js/app 0.2.0-alpha.185 → 0.2.0-alpha.187
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{actions-O_LsyCE4.js → actions-35jnMdeJ.js} +4 -3
- package/dist/_chunks/{actions-O_LsyCE4.js.map → actions-35jnMdeJ.js.map} +1 -1
- package/dist/_chunks/als-registry-C6kcfprT.js +41 -0
- package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -0
- package/dist/_chunks/als-slots-BEEIPKYm.js +26 -0
- package/dist/_chunks/als-slots-BEEIPKYm.js.map +1 -0
- package/dist/_chunks/base-path-DaQrzbez.js +80 -0
- package/dist/_chunks/base-path-DaQrzbez.js.map +1 -0
- package/dist/_chunks/build-manifest-DWppEdLB.js +86 -0
- package/dist/_chunks/build-manifest-DWppEdLB.js.map +1 -0
- package/dist/_chunks/build-output-helper-C3DrfzZR.js +369 -0
- package/dist/_chunks/build-output-helper-C3DrfzZR.js.map +1 -0
- package/dist/_chunks/{cache-api-B-lhk9p4.js → cache-api-DjNrIWRR.js} +8 -14
- package/dist/_chunks/cache-api-DjNrIWRR.js.map +1 -0
- package/dist/_chunks/cli-check-CpmN7Nh-.js +256 -0
- package/dist/_chunks/cli-check-CpmN7Nh-.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-CGMp_Psg.js +298 -0
- package/dist/_chunks/cli-schema-sync-CGMp_Psg.js.map +1 -0
- package/dist/_chunks/{cloudflare-CnT5Lr7U.js → cloudflare-CGP6BZKO.js} +92 -14
- package/dist/_chunks/{cloudflare-CnT5Lr7U.js.map → cloudflare-CGP6BZKO.js.map} +1 -1
- package/dist/_chunks/convention-lint-kXsgc_-7.js +784 -0
- package/dist/_chunks/convention-lint-kXsgc_-7.js.map +1 -0
- package/dist/_chunks/error-boundary-D-ODYX41.js +353 -0
- package/dist/_chunks/error-boundary-D-ODYX41.js.map +1 -0
- package/dist/_chunks/{fast-hash-D6hIVt1Y.js → fast-hash-C-tVYrLb.js} +6 -1
- package/dist/_chunks/fast-hash-C-tVYrLb.js.map +1 -0
- package/dist/_chunks/file-cache-DmX7OqZP.js +454 -0
- package/dist/_chunks/file-cache-DmX7OqZP.js.map +1 -0
- package/dist/_chunks/fs-identity-D7vb6GdI.js +208 -0
- package/dist/_chunks/fs-identity-D7vb6GdI.js.map +1 -0
- package/dist/_chunks/{logger-AWfuX-KJ.js → logger-D8xJZXIN.js} +34 -51
- package/dist/_chunks/logger-D8xJZXIN.js.map +1 -0
- package/dist/_chunks/mdx-file-C005ay-P.js +25 -0
- package/dist/_chunks/mdx-file-C005ay-P.js.map +1 -0
- package/dist/_chunks/{plugin-context---kTF5v8.js → plugin-context-rCinWLiE.js} +8 -2
- package/dist/_chunks/plugin-context-rCinWLiE.js.map +1 -0
- package/dist/_chunks/purge-store-Byr8XOjU.js +14 -0
- package/dist/_chunks/purge-store-Byr8XOjU.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Dz3fcFUo.js → resolve-schema-5ma5pp1b.js} +2 -2
- package/dist/_chunks/{resolve-schema-Dz3fcFUo.js.map → resolve-schema-5ma5pp1b.js.map} +1 -1
- package/dist/_chunks/{router-ref-CtmF-aPv.js → router-ref-DuYuV_0Q.js} +2 -2
- package/dist/_chunks/router-ref-DuYuV_0Q.js.map +1 -0
- package/dist/_chunks/rsc-cache-key-DD0fl_-s.js +243 -0
- package/dist/_chunks/rsc-cache-key-DD0fl_-s.js.map +1 -0
- package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js +40 -0
- package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js.map +1 -0
- package/dist/_chunks/rsc-media-type-DRqE_lD_.js +46 -0
- package/dist/_chunks/rsc-media-type-DRqE_lD_.js.map +1 -0
- package/dist/_chunks/rsc-payload-path-B_LBodc2.js +48 -0
- package/dist/_chunks/rsc-payload-path-B_LBodc2.js.map +1 -0
- package/dist/_chunks/scanner-C8b0Gcw3.js +1564 -0
- package/dist/_chunks/scanner-C8b0Gcw3.js.map +1 -0
- package/dist/_chunks/schema-bridge-Cc2Gngu1.js +199 -0
- package/dist/_chunks/schema-bridge-Cc2Gngu1.js.map +1 -0
- package/dist/_chunks/{segment-classify-Byy425ng.js → segment-classify-C539Pa2O.js} +23 -2
- package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -0
- package/dist/_chunks/segment-context-CjOlyB8Y.js +97 -0
- package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -0
- package/dist/_chunks/{canonicalize-DQHyFClh.js → segment-keys-BawYuNFO.js} +61 -2
- package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -0
- package/dist/_chunks/slot-params-BCTmZkQB.js +76 -0
- package/dist/_chunks/slot-params-BCTmZkQB.js.map +1 -0
- package/dist/_chunks/{ssr-data-BOWsq18U.js → ssr-data-14MXm7Pj.js} +17 -2
- package/dist/_chunks/ssr-data-14MXm7Pj.js.map +1 -0
- package/dist/_chunks/{use-query-states-DFvWd-EA.js → use-query-states-BbU5Ge1V.js} +74 -26
- package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +1 -0
- package/dist/_chunks/use-segment-params-C4r4BD9T.js +398 -0
- package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -0
- package/dist/_chunks/walkers-RzN6AFjr.js +141 -0
- package/dist/_chunks/walkers-RzN6AFjr.js.map +1 -0
- package/dist/adapters/build-output-helper.d.ts +37 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/compress-module.d.ts +12 -0
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/fs-identity.d.ts +64 -0
- package/dist/adapters/fs-identity.d.ts.map +1 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +56 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/adapters/shared.d.ts +57 -1
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/adapters/types.d.ts +13 -1
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cache/fast-hash.d.ts +5 -0
- package/dist/cache/fast-hash.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cdn/cloudflare-purge.js +30 -0
- package/dist/cdn/cloudflare-purge.js.map +1 -0
- package/dist/cdn/fastly-purge.js +33 -0
- package/dist/cdn/fastly-purge.js.map +1 -0
- package/dist/cdn/index.js +94 -0
- package/dist/cdn/index.js.map +1 -0
- package/dist/cdn/workers-cache-purge.js +35 -0
- package/dist/cdn/workers-cache-purge.js.map +1 -0
- package/dist/cli-check.d.ts +153 -0
- package/dist/cli-check.d.ts.map +1 -0
- package/dist/cli.d.ts +34 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +46 -21
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/hydrate.d.ts +20 -9
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +1 -1
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts +4 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +3 -0
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/browser-entry/rsc-stream.d.ts +8 -0
- package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts +7 -0
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -162
- package/dist/client/history.d.ts +13 -6
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +42 -41
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.d.ts +4 -3
- package/dist/client/internal.d.ts.map +1 -1
- package/dist/client/internal.js +877 -311
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +1 -7
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts +2 -2
- package/dist/client/navigation-commit.d.ts +87 -0
- package/dist/client/navigation-commit.d.ts.map +1 -0
- package/dist/client/navigation-context.d.ts +37 -20
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +63 -23
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/params-context.d.ts +81 -0
- package/dist/client/params-context.d.ts.map +1 -0
- package/dist/client/router-effects.d.ts +129 -0
- package/dist/client/router-effects.d.ts.map +1 -0
- package/dist/client/router-lifecycle.d.ts +55 -0
- package/dist/client/router-lifecycle.d.ts.map +1 -0
- package/dist/client/router-pipeline.d.ts +84 -0
- package/dist/client/router-pipeline.d.ts.map +1 -0
- package/dist/client/router-ref.d.ts +1 -1
- package/dist/client/router-ref.d.ts.map +1 -1
- package/dist/client/router-skew.d.ts +21 -0
- package/dist/client/router-skew.d.ts.map +1 -0
- package/dist/client/router-types.d.ts +207 -0
- package/dist/client/router-types.d.ts.map +1 -0
- package/dist/client/router.d.ts +1 -182
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +47 -26
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +87 -34
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/ssr-data.d.ts +6 -0
- package/dist/client/ssr-data.d.ts.map +1 -1
- package/dist/client/stale-client.d.ts +69 -0
- package/dist/client/stale-client.d.ts.map +1 -0
- package/dist/client/state.d.ts +10 -1
- package/dist/client/state.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +2 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/unload-guard.d.ts +5 -0
- package/dist/client/unload-guard.d.ts.map +1 -1
- package/dist/client/use-cookie.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +9 -3
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/client/use-router.d.ts +1 -1
- package/dist/client/use-segment-params.d.ts +18 -8
- package/dist/client/use-segment-params.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +5 -5
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1009 -759
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/plugin-context.d.ts +46 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/request-dep/analysis.d.ts +106 -0
- package/dist/plugins/request-dep/analysis.d.ts.map +1 -0
- package/dist/plugins/request-dep/index.d.ts +75 -0
- package/dist/plugins/request-dep/index.d.ts.map +1 -0
- package/dist/plugins/request-dep/origin.d.ts +61 -0
- package/dist/plugins/request-dep/origin.d.ts.map +1 -0
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +160 -8
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +3 -44
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +28 -31
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/codegen-write.d.ts +51 -0
- package/dist/routing/codegen-write.d.ts.map +1 -0
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/collision-probe.d.ts +104 -0
- package/dist/routing/collision-probe.d.ts.map +1 -0
- package/dist/routing/collision-spaces.d.ts +26 -0
- package/dist/routing/collision-spaces.d.ts.map +1 -0
- package/dist/routing/convention-lint.d.ts +18 -4
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +16 -0
- package/dist/routing/export-detect.d.ts.map +1 -1
- package/dist/routing/index.js +4 -3
- package/dist/routing/interception.d.ts +235 -6
- package/dist/routing/interception.d.ts.map +1 -1
- package/dist/routing/link-codegen.d.ts +19 -4
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts +1 -7
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/segment-classify.d.ts +33 -0
- package/dist/routing/segment-classify.d.ts.map +1 -1
- package/dist/routing/segment-keys.d.ts +95 -0
- package/dist/routing/segment-keys.d.ts.map +1 -0
- package/dist/routing/slot-placement.d.ts +48 -0
- package/dist/routing/slot-placement.d.ts.map +1 -0
- package/dist/routing/types.d.ts +0 -6
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/routing/walkers.d.ts +6 -0
- package/dist/routing/walkers.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +60 -9
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts +62 -14
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.d.ts +0 -1
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +221 -10
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/parse-total.d.ts +70 -0
- package/dist/search-params/parse-total.d.ts.map +1 -0
- package/dist/search-params/wrappers.d.ts +26 -3
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/server/access-gate.d.ts +19 -8
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +6 -3
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +18 -1
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +13 -1
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/chain-url-parts.d.ts +82 -0
- package/dist/server/chain-url-parts.d.ts.map +1 -0
- package/dist/server/children-interception.d.ts +49 -0
- package/dist/server/children-interception.d.ts.map +1 -0
- package/dist/server/compress.d.ts.map +1 -1
- package/dist/server/default-status-page.d.ts +45 -0
- package/dist/server/default-status-page.d.ts.map +1 -0
- package/dist/server/deny-boundary.d.ts +148 -15
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +15 -4
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +85 -15
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/head-response.d.ts +35 -0
- package/dist/server/head-response.d.ts.map +1 -0
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.d.ts +3 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +712 -418
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-collector.d.ts +52 -0
- package/dist/server/metadata-collector.d.ts.map +1 -0
- package/dist/server/param-coercion.d.ts +22 -6
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +0 -18
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline-interception.d.ts +59 -9
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline-outcome.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +11 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +23 -0
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/publish-params.d.ts +33 -0
- package/dist/server/publish-params.d.ts.map +1 -0
- package/dist/server/request-context.d.ts +48 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +22 -3
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-handler.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +13 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-cache-key-guard.d.ts +78 -0
- package/dist/server/rsc-cache-key-guard.d.ts.map +1 -0
- package/dist/server/rsc-entry/api-handler.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts +15 -3
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +2 -17
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +0 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +1 -2
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +15 -2
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/skippable-prefix.d.ts +23 -0
- package/dist/server/skippable-prefix.d.ts.map +1 -0
- package/dist/server/slot-interception.d.ts +80 -0
- package/dist/server/slot-interception.d.ts.map +1 -0
- package/dist/server/slot-resolver.d.ts +70 -8
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +17 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts +0 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts +24 -13
- package/dist/server/ssr-wrappers.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +6 -27
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/dist/server/static-generator.d.ts +6 -0
- package/dist/server/static-generator.d.ts.map +1 -1
- package/dist/server/static-not-found.d.ts +52 -0
- package/dist/server/static-not-found.d.ts.map +1 -0
- package/dist/server/status-code-resolver.d.ts +8 -1
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +28 -36
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +12 -7
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/utils/element-type.d.ts +40 -0
- package/dist/server/utils/element-type.d.ts.map +1 -0
- package/dist/shared/als-slots.d.ts +41 -0
- package/dist/shared/als-slots.d.ts.map +1 -0
- package/dist/shared/base-path.d.ts +56 -0
- package/dist/shared/base-path.d.ts.map +1 -0
- package/dist/shared/param-value.d.ts +80 -0
- package/dist/shared/param-value.d.ts.map +1 -0
- package/dist/shared/payload-root.d.ts +108 -0
- package/dist/shared/payload-root.d.ts.map +1 -0
- package/dist/shared/rsc-cache-key.d.ts +163 -0
- package/dist/shared/rsc-cache-key.d.ts.map +1 -0
- package/dist/shared/rsc-error-envelope.d.ts +25 -0
- package/dist/shared/rsc-error-envelope.d.ts.map +1 -0
- package/dist/shared/rsc-manifest.d.ts +28 -0
- package/dist/shared/rsc-manifest.d.ts.map +1 -0
- package/dist/shared/rsc-media-type.d.ts +40 -0
- package/dist/shared/rsc-media-type.d.ts.map +1 -0
- package/dist/shared/rsc-payload-path.d.ts +42 -0
- package/dist/shared/rsc-payload-path.d.ts.map +1 -0
- package/dist/shared/segment-info.d.ts +59 -0
- package/dist/shared/segment-info.d.ts.map +1 -0
- package/dist/shared/slot-params.d.ts +51 -0
- package/dist/shared/slot-params.d.ts.map +1 -0
- package/dist/shared/static-platform-files.d.ts +71 -0
- package/dist/shared/static-platform-files.d.ts.map +1 -0
- package/docs/api/30-api-server.mdx +1 -1
- package/docs/api/31-api-client.mdx +4 -2
- package/docs/api/33-api-search-params.mdx +38 -16
- package/docs/api/35-api-typescript.mdx +3 -3
- package/docs/api/36-cli.mdx +34 -7
- package/docs/learn/00-introduction.mdx +1 -1
- package/docs/learn/02-pages-and-layouts.mdx +2 -2
- package/docs/learn/05-typed-params.mdx +8 -8
- package/docs/learn/07-typed-routes.mdx +12 -7
- package/docs/learn/11-error-handling.mdx +18 -0
- package/docs/learn/12-client-navigation.mdx +2 -2
- package/docs/learn/14-deploying.mdx +23 -0
- package/docs/more/01-advanced-routing.mdx +173 -3
- package/docs/more/03-coming-from-nextjs.mdx +2 -2
- package/docs/more/04-metadata-and-fonts.mdx +1 -1
- package/docs/more/50-ai-agent-instructions.mdx +6 -4
- package/package.json +9 -6
- package/src/adapters/build-output-helper.ts +116 -3
- package/src/adapters/cloudflare.ts +90 -12
- package/src/adapters/compress-module.ts +79 -1
- package/src/adapters/fs-identity.ts +156 -0
- package/src/adapters/nitro.ts +6 -6
- package/src/adapters/shared.ts +196 -21
- package/src/adapters/types.ts +13 -1
- package/src/cache/fast-hash.ts +5 -0
- package/src/cli-check.ts +458 -0
- package/src/cli.ts +59 -24
- package/src/client/browser-entry/action-dispatch.ts +11 -5
- package/src/client/browser-entry/hydrate.ts +40 -29
- package/src/client/browser-entry/index.ts +15 -6
- package/src/client/browser-entry/post-hydration.ts +10 -9
- package/src/client/browser-entry/router-init.ts +104 -48
- package/src/client/browser-entry/rsc-stream.ts +11 -2
- package/src/client/error-boundary.tsx +85 -16
- package/src/client/history.ts +13 -6
- package/src/client/index.ts +14 -3
- package/src/client/internal.ts +4 -3
- package/src/client/link.tsx +120 -72
- package/src/client/navigation-api.ts +3 -3
- package/src/client/navigation-commit.ts +197 -0
- package/src/client/navigation-context.ts +40 -67
- package/src/client/navigation-root.tsx +326 -101
- package/src/client/params-context.ts +192 -0
- package/src/client/router-effects.ts +231 -0
- package/src/client/router-lifecycle.ts +288 -0
- package/src/client/router-pipeline.ts +393 -0
- package/src/client/router-ref.ts +1 -1
- package/src/client/router-skew.ts +30 -0
- package/src/client/router-types.ts +230 -0
- package/src/client/router.ts +243 -707
- package/src/client/rsc-fetch.ts +183 -106
- package/src/client/segment-cache.ts +129 -44
- package/src/client/ssr-data.ts +6 -0
- package/src/client/stale-client.ts +191 -0
- package/src/client/state.ts +14 -1
- package/src/client/top-loader.tsx +2 -1
- package/src/client/unload-guard.ts +34 -2
- package/src/client/use-cookie.ts +9 -2
- package/src/client/use-query-states.ts +102 -39
- package/src/client/use-router.ts +1 -1
- package/src/client/use-segment-params.ts +41 -18
- package/src/cookies/define-cookie.ts +6 -1
- package/src/index.ts +25 -4
- package/src/plugin-context.ts +58 -0
- package/src/plugins/adapter-build.ts +4 -0
- package/src/plugins/build-manifest.ts +34 -13
- package/src/plugins/entries.ts +4 -0
- package/src/plugins/fonts.ts +11 -8
- package/src/plugins/request-dep/analysis.ts +447 -0
- package/src/plugins/request-dep/index.ts +376 -0
- package/src/plugins/request-dep/origin.ts +310 -0
- package/src/plugins/routing.ts +97 -102
- package/src/plugins/shims.ts +0 -1
- package/src/plugins/static-build.ts +391 -95
- package/src/routing/codegen-shared.ts +3 -79
- package/src/routing/codegen-types.ts +28 -31
- package/src/routing/codegen-write.ts +139 -0
- package/src/routing/codegen.ts +86 -172
- package/src/routing/collision-probe.ts +438 -0
- package/src/routing/collision-spaces.ts +281 -0
- package/src/routing/convention-lint.ts +139 -40
- package/src/routing/export-detect.ts +151 -7
- package/src/routing/interception.ts +896 -50
- package/src/routing/link-codegen.ts +32 -65
- package/src/routing/manifest-codegen.ts +6 -62
- package/src/routing/scanner.ts +117 -92
- package/src/routing/segment-classify.ts +57 -0
- package/src/routing/segment-keys.ts +146 -0
- package/src/routing/slot-placement.ts +97 -0
- package/src/routing/types.ts +0 -6
- package/src/routing/walkers.ts +8 -0
- package/src/schema-bridge.ts +180 -58
- package/src/search-params/define.ts +108 -66
- package/src/search-params/index.ts +0 -1
- package/src/search-params/parse-total.ts +78 -0
- package/src/search-params/wrappers.ts +60 -11
- package/src/server/access-gate.tsx +60 -40
- package/src/server/action-handler.ts +4 -5
- package/src/server/actions.ts +6 -3
- package/src/server/als-registry.ts +18 -1
- package/src/server/build-manifest.ts +14 -1
- package/src/server/chain-url-parts.ts +163 -0
- package/src/server/children-interception.ts +115 -0
- package/src/server/compress.ts +9 -1
- package/src/server/default-status-page.ts +127 -0
- package/src/server/deny-boundary.ts +269 -41
- package/src/server/deny-renderer.ts +111 -39
- package/src/server/error-boundary-wrapper.ts +166 -79
- package/src/server/head-response.ts +54 -0
- package/src/server/index.ts +1 -3
- package/src/server/internal.ts +2 -2
- package/src/server/metadata-collector.ts +115 -0
- package/src/server/param-coercion.ts +51 -69
- package/src/server/pipeline-helpers.ts +2 -47
- package/src/server/pipeline-interception.ts +81 -32
- package/src/server/pipeline-outcome.ts +4 -5
- package/src/server/pipeline-phases.ts +77 -25
- package/src/server/pipeline.ts +57 -10
- package/src/server/prebuilt-runtime.ts +4 -4
- package/src/server/primitives.ts +25 -0
- package/src/server/publish-params.ts +42 -0
- package/src/server/request-context.ts +97 -18
- package/src/server/route-element-builder.ts +241 -253
- package/src/server/route-handler.ts +8 -8
- package/src/server/route-matcher.ts +21 -9
- package/src/server/rsc-cache-key-guard.ts +164 -0
- package/src/server/rsc-entry/api-handler.ts +6 -8
- package/src/server/rsc-entry/deny-fallback.ts +3 -2
- package/src/server/rsc-entry/error-renderer.ts +94 -35
- package/src/server/rsc-entry/helpers.ts +33 -28
- package/src/server/rsc-entry/index.ts +36 -33
- package/src/server/rsc-entry/render-route.ts +18 -10
- package/src/server/rsc-entry/revalidate-renderer.ts +3 -2
- package/src/server/rsc-entry/rsc-payload.ts +113 -40
- package/src/server/rsc-entry/rsc-stream.ts +29 -5
- package/src/server/rsc-entry/ssr-renderer.ts +38 -22
- package/src/server/skippable-prefix.ts +92 -0
- package/src/server/slot-interception.ts +168 -0
- package/src/server/slot-resolver.ts +263 -158
- package/src/server/ssr-bridge-types.ts +18 -0
- package/src/server/ssr-entry.ts +20 -10
- package/src/server/ssr-wrappers.tsx +33 -32
- package/src/server/state-tree-diff.ts +14 -55
- package/src/server/static-generator.ts +68 -29
- package/src/server/static-not-found.ts +134 -0
- package/src/server/status-code-resolver.ts +28 -11
- package/src/server/tree-builder.ts +35 -218
- package/src/server/types.ts +12 -7
- package/src/server/utils/element-type.ts +72 -0
- package/src/shared/als-slots.ts +72 -0
- package/src/shared/base-path.ts +80 -0
- package/src/shared/param-value.ts +244 -0
- package/src/shared/payload-root.ts +151 -0
- package/src/shared/rsc-cache-key.ts +269 -0
- package/src/shared/rsc-error-envelope.ts +31 -0
- package/src/shared/rsc-manifest.ts +29 -0
- package/src/shared/rsc-media-type.ts +43 -0
- package/src/shared/rsc-payload-path.ts +45 -0
- package/src/shared/segment-info.ts +60 -0
- package/src/shared/slot-params.ts +111 -0
- package/src/shared/static-platform-files.ts +75 -0
- package/dist/_chunks/build-output-helper-DOGYFb_X.js +0 -196
- package/dist/_chunks/build-output-helper-DOGYFb_X.js.map +0 -1
- package/dist/_chunks/cache-api-B-lhk9p4.js.map +0 -1
- package/dist/_chunks/canonicalize-DQHyFClh.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-BTWEKJXo.js +0 -668
- package/dist/_chunks/cli-schema-sync-BTWEKJXo.js.map +0 -1
- package/dist/_chunks/define-COtkxMRT.js +0 -191
- package/dist/_chunks/define-COtkxMRT.js.map +0 -1
- package/dist/_chunks/define-c4au4I9R.js +0 -82
- package/dist/_chunks/define-c4au4I9R.js.map +0 -1
- package/dist/_chunks/fast-hash-D6hIVt1Y.js.map +0 -1
- package/dist/_chunks/logger-AWfuX-KJ.js.map +0 -1
- package/dist/_chunks/navigation-root-bUDZLw74.js +0 -124
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +0 -1
- package/dist/_chunks/plugin-context---kTF5v8.js.map +0 -1
- package/dist/_chunks/registry-DbJPKoBp.js +0 -20
- package/dist/_chunks/registry-DbJPKoBp.js.map +0 -1
- package/dist/_chunks/router-ref-CtmF-aPv.js.map +0 -1
- package/dist/_chunks/schema-bridge-DT_Tn0Xf.js +0 -119
- package/dist/_chunks/schema-bridge-DT_Tn0Xf.js.map +0 -1
- package/dist/_chunks/segment-classify-Byy425ng.js.map +0 -1
- package/dist/_chunks/segment-context-ZDnXDkbz.js +0 -34
- package/dist/_chunks/segment-context-ZDnXDkbz.js.map +0 -1
- package/dist/_chunks/ssr-data-BOWsq18U.js.map +0 -1
- package/dist/_chunks/use-query-states-DFvWd-EA.js.map +0 -1
- package/dist/_chunks/use-segment-params-D1CzlgBo.js +0 -151
- package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +0 -1
- package/dist/_chunks/walkers-_6zKFlch.js +0 -1002
- package/dist/_chunks/walkers-_6zKFlch.js.map +0 -1
- package/dist/client/error-boundary.js.map +0 -1
- package/dist/plugins/request-dep.d.ts +0 -32
- package/dist/plugins/request-dep.d.ts.map +0 -1
- package/dist/search-params/registry.d.ts +0 -20
- package/dist/search-params/registry.d.ts.map +0 -1
- package/dist/segment-params/define.d.ts +0 -89
- package/dist/segment-params/define.d.ts.map +0 -1
- package/dist/segment-params/index.d.ts +0 -3
- package/dist/segment-params/index.d.ts.map +0 -1
- package/dist/segment-params/index.js +0 -2
- package/src/plugins/request-dep.ts +0 -499
- package/src/search-params/registry.ts +0 -31
- package/src/segment-params/define.ts +0 -249
- package/src/segment-params/index.ts +0 -9
package/dist/server/internal.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import "../
|
|
7
|
-
import {
|
|
8
|
-
import "../_chunks/
|
|
1
|
+
import { n as isRscRequest } from "../_chunks/rsc-media-type-DRqE_lD_.js";
|
|
2
|
+
import { a as canonicalize, c as getMetadataRouteAutoLink, l as getMetadataRouteServePath, o as METADATA_ROUTE_CONVENTIONS, s as classifyMetadataRoute, t as computeSegmentTreePaths } from "../_chunks/segment-keys-BawYuNFO.js";
|
|
3
|
+
import { n as classifyUrlSegment, r as effectiveUrlSegment } from "../_chunks/segment-classify-C539Pa2O.js";
|
|
4
|
+
import { a as timingAls, r as requestContextAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-C6kcfprT.js";
|
|
5
|
+
import { A as setMatchedSegmentPath, C as applyRequestHeaderOverlay, D as getSegmentParams, E as getSearchParams, F as getSetCookieHeaders, G as replaceTraceId, H as getOtelTraceId, I as getWaitUntil, J as withSpan, K as runWithTraceId, L as isDebug, M as setSegmentParams, N as getCookie, O as markResponseFlushed, V as generateTraceId, W as getTraceId, _ as RedirectSignal, a as logProxyError, c as logRequestReceived, d as logSwrRefetchFailed, f as logWaitUntilRejected, g as DenySignal, h as swallow, i as logMiddlewareShortCircuit, j as setMutableCookieContext, k as runWithRequestContext, l as logRouteError, m as setLogger, n as logCacheMiss, o as logRenderError, p as logWaitUntilUnsupported, q as setSpanAttribute, r as logMiddlewareError, s as logRequestCompleted, t as getLogger, u as logSlowRequest, v as RenderError, w as getHeader } from "../_chunks/logger-D8xJZXIN.js";
|
|
6
|
+
import { i as isRscCacheKeyShape, n as RSC_KEY_PARAM, s as rscCacheKey, t as RSC_KEY_HEADERS } from "../_chunks/rsc-cache-key-DD0fl_-s.js";
|
|
7
|
+
import { t as isMdxFilePath } from "../_chunks/mdx-file-C005ay-P.js";
|
|
8
|
+
import { i as collectRouteModulepreloads, n as collectRouteCss, r as collectRouteFonts } from "../_chunks/build-manifest-DWppEdLB.js";
|
|
9
|
+
import { i as coerce, t as executeAction } from "../_chunks/actions-35jnMdeJ.js";
|
|
10
|
+
import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPayload, d as checkVersionSkew, i as storeOverlayEntry, l as setPrebuiltPayloadSource, n as lookupOverlay, o as hasPrebuiltPayloadSource, r as overlayKey, s as isParamIndependent, u as applyReloadHeaders, v as currentInvalidationEpoch, x as createSingleflight, y as lastInvalidationEpochFor } from "../_chunks/cache-api-DjNrIWRR.js";
|
|
11
|
+
import "../_chunks/error-boundary-D-ODYX41.js";
|
|
12
|
+
import { n as toBracketKey } from "../_chunks/resolve-schema-5ma5pp1b.js";
|
|
13
|
+
import { r as normalizeParamValue } from "../_chunks/segment-context-CjOlyB8Y.js";
|
|
14
|
+
import { n as rscErrorEnvelope } from "../_chunks/rsc-error-envelope-tT5PJs4q.js";
|
|
9
15
|
import { createHash } from "node:crypto";
|
|
10
16
|
import { readFile } from "node:fs/promises";
|
|
11
17
|
import React, { createElement, useContext } from "react";
|
|
@@ -209,38 +215,6 @@ function hasOnRequestError() {
|
|
|
209
215
|
//#endregion
|
|
210
216
|
//#region src/server/pipeline-helpers.ts
|
|
211
217
|
/**
|
|
212
|
-
* Only __proto__ needs stripping — it has a language-level setter that
|
|
213
|
-
* changes the prototype chain of spread copies. constructor and prototype
|
|
214
|
-
* are harmless own properties on null-prototype objects.
|
|
215
|
-
*/
|
|
216
|
-
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__"]);
|
|
217
|
-
/**
|
|
218
|
-
* Deep-walk a value returned by a segment param codec, producing a
|
|
219
|
-
* sanitized copy where every plain object is null-prototype and
|
|
220
|
-
* dangerous keys (__proto__, constructor, prototype) are stripped at
|
|
221
|
-
* every depth.
|
|
222
|
-
*
|
|
223
|
-
* Non-plain objects (Date, Map, class instances, etc.) are returned
|
|
224
|
-
* as-is — they cannot be poisoned by `{...x}` spread and may carry
|
|
225
|
-
* author-intended prototype methods.
|
|
226
|
-
*
|
|
227
|
-
* Arrays are walked element-wise.
|
|
228
|
-
*
|
|
229
|
-
* Performance: URL params are bounded by URL length (~8 KB). Realistic
|
|
230
|
-
* trees are <1 KB. The recursive walk is sub-microsecond.
|
|
231
|
-
*
|
|
232
|
-
* See TIM-655, TIM-855, TIM-873, design/13-security.md
|
|
233
|
-
*/
|
|
234
|
-
function sanitizeParamValue(value) {
|
|
235
|
-
if (value === null || typeof value !== "object") return value;
|
|
236
|
-
if (Array.isArray(value)) return value.map(sanitizeParamValue);
|
|
237
|
-
const proto = Object.getPrototypeOf(value);
|
|
238
|
-
if (proto !== Object.prototype && proto !== null) return value;
|
|
239
|
-
const out = Object.create(null);
|
|
240
|
-
for (const key of Object.keys(value)) if (!DANGEROUS_KEYS.has(key)) out[key] = sanitizeParamValue(value[key]);
|
|
241
|
-
return out;
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
218
|
* Build a proxy resolver closure from the declared source. Called exactly
|
|
245
219
|
* once at `createPipeline` setup time, so the hot path sees only the branch
|
|
246
220
|
* that corresponds to this pipeline's configured variant.
|
|
@@ -315,7 +289,7 @@ function cloneWithMutableHeaders(response) {
|
|
|
315
289
|
* createFromFetch. See design/19-client-navigation.md.
|
|
316
290
|
*/
|
|
317
291
|
function buildRedirectResponse(signal, req, headers) {
|
|
318
|
-
if ((req
|
|
292
|
+
if (isRscRequest(req)) {
|
|
319
293
|
headers.set("X-Timber-Redirect", signal.location);
|
|
320
294
|
return new Response(null, {
|
|
321
295
|
status: 204,
|
|
@@ -1164,6 +1138,42 @@ async function loadModule(loader) {
|
|
|
1164
1138
|
}
|
|
1165
1139
|
}
|
|
1166
1140
|
//#endregion
|
|
1141
|
+
//#region src/server/utils/element-type.ts
|
|
1142
|
+
var REACT_COMPONENT_TYPE_MARKERS = /* @__PURE__ */ new Set([
|
|
1143
|
+
Symbol.for("react.forward_ref"),
|
|
1144
|
+
Symbol.for("react.memo"),
|
|
1145
|
+
Symbol.for("react.lazy"),
|
|
1146
|
+
Symbol.for("react.provider"),
|
|
1147
|
+
Symbol.for("react.context"),
|
|
1148
|
+
Symbol.for("react.suspense"),
|
|
1149
|
+
Symbol.for("react.suspense_list"),
|
|
1150
|
+
Symbol.for("react.client.reference")
|
|
1151
|
+
]);
|
|
1152
|
+
/**
|
|
1153
|
+
* Validate that a loaded module's `default` export is something React
|
|
1154
|
+
* accepts as the first argument to `createElement` — i.e. a valid component
|
|
1155
|
+
* type. React doesn't export `isValidElementType` (only `isValidElement`,
|
|
1156
|
+
* which checks for *elements*, not *component types*), so this mirrors
|
|
1157
|
+
* React's internal check:
|
|
1158
|
+
*
|
|
1159
|
+
* - functions → function or class components
|
|
1160
|
+
* - objects with a `$$typeof` matching one of React's known component
|
|
1161
|
+
* markers → exotic components (`memo`, `forwardRef`, `lazy`, context,
|
|
1162
|
+
* suspense, client references via `@vitejs/plugin-rsc`)
|
|
1163
|
+
*
|
|
1164
|
+
* Strings (HTML tag names) are valid for `createElement` but never appear
|
|
1165
|
+
* as a route module's default export, so they're not recognized here.
|
|
1166
|
+
*
|
|
1167
|
+
* Anything else (numbers, plain config objects, JSON, etc.) is rejected so
|
|
1168
|
+
* the boundary wrapper is skipped rather than crashing inside React.
|
|
1169
|
+
*/
|
|
1170
|
+
function isValidElementType(value) {
|
|
1171
|
+
if (typeof value === "function") return true;
|
|
1172
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1173
|
+
const marker = value.$$typeof;
|
|
1174
|
+
return typeof marker === "symbol" && REACT_COMPONENT_TYPE_MARKERS.has(marker);
|
|
1175
|
+
}
|
|
1176
|
+
//#endregion
|
|
1167
1177
|
//#region src/server/deny-boundary.ts
|
|
1168
1178
|
/**
|
|
1169
1179
|
* Deny boundary subsystem — the in-tree DenySignal flow.
|
|
@@ -1175,10 +1185,14 @@ async function loadModule(loader) {
|
|
|
1175
1185
|
* `DenyPageEntry` records ordered by specificity (specific status →
|
|
1176
1186
|
* category catch-all → `error.tsx`).
|
|
1177
1187
|
*
|
|
1178
|
-
* 2. **Runtime matching** (`
|
|
1179
|
-
* chain entry whose status filter
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1188
|
+
* 2. **Runtime matching and placement** (`matchDenyEntry`, `placeDenyPage`,
|
|
1189
|
+
* `handleCaughtDeny`) — picks the first chain entry whose status filter
|
|
1190
|
+
* matches the thrown DenySignal, then decides whether the catch site may
|
|
1191
|
+
* render it where it stands. A chain spans the catcher outward to root,
|
|
1192
|
+
* so a match may belong to an ancestor segment; rendering it in place
|
|
1193
|
+
* would wrap it in every layout in between. Ancestor-owned matches are
|
|
1194
|
+
* re-thrown with `ownerKey` stamped on the signal so the error boundary
|
|
1195
|
+
* at the owning segment renders it. See TIM-1356.
|
|
1182
1196
|
*
|
|
1183
1197
|
* 3. **The page boundary itself** (`PageDenyBoundary`) — the async server
|
|
1184
1198
|
* component that wraps a server-component page, calls it, and catches
|
|
@@ -1196,19 +1210,86 @@ async function loadModule(loader) {
|
|
|
1196
1210
|
* See design/04-authorization.md, design/10-error-handling.md, TIM-666.
|
|
1197
1211
|
*/
|
|
1198
1212
|
/**
|
|
1199
|
-
* Find the first deny page in the chain
|
|
1200
|
-
* Returns
|
|
1213
|
+
* Find the first deny page in the chain whose status filter matches.
|
|
1214
|
+
* Returns null when nothing in the chain accepts this status.
|
|
1215
|
+
*
|
|
1216
|
+
* For 4xx statuses, the chain's stored order is correct: every status file
|
|
1217
|
+
* across all segments (inner→outer) before any error.tsx — so a root
|
|
1218
|
+
* `404.tsx` beats a leaf `error.tsx`. This matches `resolve4xx`.
|
|
1219
|
+
*
|
|
1220
|
+
* For 5xx statuses, the match walks per-segment (inner→outer): at each
|
|
1221
|
+
* segment, status files (`503.tsx` → `5xx.tsx`) then `error.tsx`, before
|
|
1222
|
+
* moving to the next segment outward. A leaf `error.tsx` therefore beats a
|
|
1223
|
+
* root `5xx.tsx` — the intentional inverse of the 4xx chain, matching
|
|
1224
|
+
* `resolve5xx`. See TIM-1360.
|
|
1201
1225
|
*/
|
|
1202
|
-
function
|
|
1226
|
+
function matchDenyEntry(chain, status) {
|
|
1227
|
+
if (status >= 500) return matchDenyEntry5xx(chain, status);
|
|
1203
1228
|
for (const entry of chain) {
|
|
1204
|
-
if (entry.status === status) return
|
|
1205
|
-
if (entry.status === 400 && status >= 400 && status <= 499) return
|
|
1206
|
-
if (entry.status ===
|
|
1207
|
-
if (entry.status === null) return renderDenyEntry(entry, status, data);
|
|
1229
|
+
if (entry.status === status) return entry;
|
|
1230
|
+
if (entry.status === 400 && status >= 400 && status <= 499) return entry;
|
|
1231
|
+
if (entry.status === null) return entry;
|
|
1208
1232
|
}
|
|
1209
1233
|
return null;
|
|
1210
1234
|
}
|
|
1211
1235
|
/**
|
|
1236
|
+
* 5xx per-segment walk: at each segment (inner→outer), check status files
|
|
1237
|
+
* then error.tsx before moving outward. Mirrors `resolve5xx`.
|
|
1238
|
+
*/
|
|
1239
|
+
function matchDenyEntry5xx(chain, status) {
|
|
1240
|
+
const segmentIndices = [];
|
|
1241
|
+
for (const entry of chain) if (!segmentIndices.includes(entry.segmentIndex)) segmentIndices.push(entry.segmentIndex);
|
|
1242
|
+
segmentIndices.sort((a, b) => b - a);
|
|
1243
|
+
for (const seg of segmentIndices) {
|
|
1244
|
+
for (const entry of chain) {
|
|
1245
|
+
if (entry.segmentIndex !== seg || entry.kind === "error") continue;
|
|
1246
|
+
if (entry.status === status) return entry;
|
|
1247
|
+
if (entry.status === 500 && status >= 500 && status <= 599) return entry;
|
|
1248
|
+
}
|
|
1249
|
+
for (const entry of chain) {
|
|
1250
|
+
if (entry.segmentIndex !== seg || entry.kind !== "error") continue;
|
|
1251
|
+
return entry;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Decide where the deny page for `status` renders, from the perspective of
|
|
1258
|
+
* the catch site at `selfKey`.
|
|
1259
|
+
*
|
|
1260
|
+
* The match itself is unchanged — first entry in fallback order wins, so
|
|
1261
|
+
* every path picks the same file. What this adds is the position: a chain
|
|
1262
|
+
* built at segment i spans i outward to root, so a matched entry is owned
|
|
1263
|
+
* either by the catcher or by one of its ancestors.
|
|
1264
|
+
*
|
|
1265
|
+
* - Owned by the catcher → `render`. The catcher already sits at the
|
|
1266
|
+
* owning segment's position.
|
|
1267
|
+
* - Owned by an ancestor → `hoist`. Rendering here would wrap the deny page
|
|
1268
|
+
* in every layout between the owner and the catcher. The caller re-throws
|
|
1269
|
+
* with `ownerKey` stamped on the signal so the boundary at that segment
|
|
1270
|
+
* renders it instead — inside the owner's layout, outside the rest.
|
|
1271
|
+
*
|
|
1272
|
+
* Returns null when no entry matches; the caller re-throws unannotated and
|
|
1273
|
+
* the re-render fallback resolves it.
|
|
1274
|
+
*
|
|
1275
|
+
* See design/04-authorization.md §"Where a Deny Page Renders", TIM-1356.
|
|
1276
|
+
*/
|
|
1277
|
+
function placeDenyPage(chain, status, data, selfKey) {
|
|
1278
|
+
const entry = matchDenyEntry(chain, status);
|
|
1279
|
+
if (!entry) return null;
|
|
1280
|
+
if (entry.ownerKey !== selfKey) {
|
|
1281
|
+
if (entry.isMdx) return null;
|
|
1282
|
+
return {
|
|
1283
|
+
kind: "hoist",
|
|
1284
|
+
ownerKey: entry.ownerKey
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
return {
|
|
1288
|
+
kind: "render",
|
|
1289
|
+
element: renderDenyEntry(entry, status, data)
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1212
1293
|
* Build the element for a matched deny chain entry with the props contract
|
|
1213
1294
|
* the file's convention documents:
|
|
1214
1295
|
*
|
|
@@ -1239,6 +1320,37 @@ function renderDenyEntry(entry, status, data) {
|
|
|
1239
1320
|
});
|
|
1240
1321
|
}
|
|
1241
1322
|
/**
|
|
1323
|
+
* The whole of what an in-tree catch site does with a `DenySignal`, shared
|
|
1324
|
+
* by all three of them (`PageDenyBoundary`, `TracedLayout`, `AccessGate`).
|
|
1325
|
+
*
|
|
1326
|
+
* Returns the deny page element when this catch site owns the matched file.
|
|
1327
|
+
* Otherwise it throws — annotated with `ownerKey` when an ancestor owns the
|
|
1328
|
+
* file, bare when nothing matched at all.
|
|
1329
|
+
*
|
|
1330
|
+
* `setDenyStatus` runs on the hoist path too, and must run *before* the
|
|
1331
|
+
* throw: on the RSC payload path no boundary executes server-side, so the
|
|
1332
|
+
* ALS status is the only thing standing between the response and a 200
|
|
1333
|
+
* (`rsc-payload.ts` reads it after the stream settles).
|
|
1334
|
+
*
|
|
1335
|
+
* Either branch leaves part of the matched chain unmounted — hoisting sends
|
|
1336
|
+
* the render to a boundary that discards everything below it, and a layout
|
|
1337
|
+
* that denies returns the deny page instead of its own child outlet. What the
|
|
1338
|
+
* response then says about the tree is decided from the deny status alone in
|
|
1339
|
+
* `rsc-payload.ts`, not tracked here; see the note there for why.
|
|
1340
|
+
*/
|
|
1341
|
+
function handleCaughtDeny(chain, error, selfKey) {
|
|
1342
|
+
const placement = chain ? placeDenyPage(chain, error.status, error.data, selfKey) : null;
|
|
1343
|
+
if (placement?.kind === "render") {
|
|
1344
|
+
setDenyStatus(error.status);
|
|
1345
|
+
return placement.element;
|
|
1346
|
+
}
|
|
1347
|
+
if (placement?.kind === "hoist") {
|
|
1348
|
+
setDenyStatus(error.status);
|
|
1349
|
+
error.ownerKey = placement.ownerKey;
|
|
1350
|
+
}
|
|
1351
|
+
throw error;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1242
1354
|
* Set the deny status in the request context ALS.
|
|
1243
1355
|
* Called from AccessGate / PageDenyBoundary when a DenySignal is caught.
|
|
1244
1356
|
* The pipeline reads this after render to set the HTTP status code.
|
|
@@ -1250,6 +1362,17 @@ function setDenyStatus(status) {
|
|
|
1250
1362
|
store.onDenyStatus?.();
|
|
1251
1363
|
}
|
|
1252
1364
|
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Record the first denied segment index in the request context ALS.
|
|
1367
|
+
* Called from AccessGate when a DenySignal is caught during render.
|
|
1368
|
+
* Only the first (shallowest) denial is recorded — deeper denials are
|
|
1369
|
+
* unreachable because the gate short-circuits the subtree.
|
|
1370
|
+
* See TIM-1363.
|
|
1371
|
+
*/
|
|
1372
|
+
function setFirstDeniedSegmentIndex(index) {
|
|
1373
|
+
const store = requestContextAls.getStore();
|
|
1374
|
+
if (store && store._firstDeniedSegmentIndex === void 0) store._firstDeniedSegmentIndex = index;
|
|
1375
|
+
}
|
|
1253
1376
|
//#endregion
|
|
1254
1377
|
//#region src/server/access-gate.tsx
|
|
1255
1378
|
/**
|
|
@@ -1269,37 +1392,37 @@ function setDenyStatus(status) {
|
|
|
1269
1392
|
/**
|
|
1270
1393
|
* Framework-injected access gate for segments.
|
|
1271
1394
|
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
* When no verdict is provided (backward compat with tree-builder.ts),
|
|
1278
|
-
* AccessGate calls accessFn directly with OTEL instrumentation.
|
|
1395
|
+
* Calls accessFn during render — inside `renderToReadableStream`, sharing
|
|
1396
|
+
* the same `React.cache` scope as layouts and pages. A `requireUser()`
|
|
1397
|
+
* call in access.ts populates the cache; the same call in any layout is
|
|
1398
|
+
* a free hit. Access executes exactly once per request. See TIM-1363.
|
|
1279
1399
|
*
|
|
1280
1400
|
* access.ts is a pure gate — return values are discarded. The layout below
|
|
1281
1401
|
* gets the same data by calling the same cached functions (React.cache dedup).
|
|
1402
|
+
*
|
|
1403
|
+
* On denial the gate renders the matching deny page only when this segment
|
|
1404
|
+
* owns it. Because the gate is wrapped OUTSIDE its own layout, that render
|
|
1405
|
+
* omits the denying segment's layout — deliberate, and the one place the
|
|
1406
|
+
* in-tree path diverges from `renderDenyPage`'s inclusive slice: a layout
|
|
1407
|
+
* whose access.ts denied must never run and serialize protected data into
|
|
1408
|
+
* the Flight stream (TIM-1074). An ancestor-owned page is hoisted instead,
|
|
1409
|
+
* because the gate also sits inside every layout above it and rendering
|
|
1410
|
+
* here would wrap the deny page in all of them (TIM-1356).
|
|
1411
|
+
*
|
|
1412
|
+
* For skipped segments (skippedSegment=true), deny pages are NOT rendered
|
|
1413
|
+
* in-tree — the client's cached layout chrome would wrap them incorrectly.
|
|
1414
|
+
* Instead, the deny is recorded in ALS and rethrown so the response path
|
|
1415
|
+
* can return rscErrorEnvelope for a client hard-navigate.
|
|
1282
1416
|
*/
|
|
1283
1417
|
function AccessGate(props) {
|
|
1284
|
-
const { accessFn, segmentName,
|
|
1285
|
-
|
|
1286
|
-
if (verdict === "pass") return children;
|
|
1287
|
-
if (verdict instanceof DenySignal && denyPages) {
|
|
1288
|
-
const denyElement = renderMatchingDenyPage(denyPages, verdict.status, verdict.data);
|
|
1289
|
-
if (denyElement) {
|
|
1290
|
-
setDenyStatus(verdict.status);
|
|
1291
|
-
return denyElement;
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
throw verdict;
|
|
1295
|
-
}
|
|
1296
|
-
return accessGateFallback(accessFn, segmentName, denyPages, children);
|
|
1418
|
+
const { accessFn, segmentName, segmentKey, segmentIndex, denyPages, skippedSegment, onSettled, children } = props;
|
|
1419
|
+
return accessGateFallback(accessFn, segmentName, segmentKey, segmentIndex, skippedSegment ? void 0 : denyPages, skippedSegment, onSettled, children);
|
|
1297
1420
|
}
|
|
1298
1421
|
/**
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1422
|
+
* Calls accessFn during render with OTEL instrumentation.
|
|
1423
|
+
* Records the first denied segment index in ALS for post-render consumers.
|
|
1301
1424
|
*/
|
|
1302
|
-
async function accessGateFallback(accessFn, segmentName, denyPages, children) {
|
|
1425
|
+
async function accessGateFallback(accessFn, segmentName, segmentKey, segmentIndex, denyPages, skippedSegment, onSettled, children) {
|
|
1303
1426
|
try {
|
|
1304
1427
|
await withSpan("timber.access", { "timber.segment": segmentName ?? "unknown" }, async () => {
|
|
1305
1428
|
try {
|
|
@@ -1315,14 +1438,17 @@ async function accessGateFallback(accessFn, segmentName, denyPages, children) {
|
|
|
1315
1438
|
}
|
|
1316
1439
|
});
|
|
1317
1440
|
} catch (error) {
|
|
1318
|
-
if (error instanceof DenySignal
|
|
1319
|
-
|
|
1320
|
-
if (
|
|
1321
|
-
|
|
1322
|
-
|
|
1441
|
+
if (error instanceof DenySignal) {
|
|
1442
|
+
if (segmentIndex !== void 0) setFirstDeniedSegmentIndex(segmentIndex);
|
|
1443
|
+
if (skippedSegment) {
|
|
1444
|
+
error._hardNavigate = true;
|
|
1445
|
+
throw error;
|
|
1323
1446
|
}
|
|
1447
|
+
if (denyPages) return handleCaughtDeny(denyPages, error, segmentKey);
|
|
1324
1448
|
}
|
|
1325
1449
|
throw error;
|
|
1450
|
+
} finally {
|
|
1451
|
+
onSettled?.();
|
|
1326
1452
|
}
|
|
1327
1453
|
return children;
|
|
1328
1454
|
}
|
|
@@ -1744,69 +1870,248 @@ function wrapInSlotsProvider(shell, slotValues) {
|
|
|
1744
1870
|
});
|
|
1745
1871
|
}
|
|
1746
1872
|
//#endregion
|
|
1873
|
+
//#region src/server/error-boundary-wrapper.ts
|
|
1874
|
+
/**
|
|
1875
|
+
* Load a status/error file's component, or `null` if the boundary should be skipped.
|
|
1876
|
+
*
|
|
1877
|
+
* Two ways to get `null`, both deliberate:
|
|
1878
|
+
* - the module fails to load (syntax error in the error page) — building an
|
|
1879
|
+
* error boundary must not itself throw, so the boundary is skipped (TIM-584);
|
|
1880
|
+
* - the default export is not a valid React component type, which would
|
|
1881
|
+
* otherwise crash inside `createElement`.
|
|
1882
|
+
*/
|
|
1883
|
+
async function loadBoundaryComponent(file, loadModule) {
|
|
1884
|
+
let mod;
|
|
1885
|
+
try {
|
|
1886
|
+
mod = await loadModule(file);
|
|
1887
|
+
} catch {
|
|
1888
|
+
return null;
|
|
1889
|
+
}
|
|
1890
|
+
return isValidElementType(mod?.default) ? mod.default : null;
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Wrap `element` in one error boundary rendering `Component` on failure.
|
|
1894
|
+
*
|
|
1895
|
+
* MDX status files are server components and cannot be passed as function
|
|
1896
|
+
* props to `TimberErrorBoundary` (a 'use client' component). They are
|
|
1897
|
+
* pre-rendered as elements and passed as `fallbackElement` instead. See TIM-503.
|
|
1898
|
+
*
|
|
1899
|
+
* @param status boundary status filter — omitted entirely for `error.tsx`
|
|
1900
|
+
* @param fallbackProps props handed to an MDX fallback when it is pre-rendered
|
|
1901
|
+
*/
|
|
1902
|
+
function wrapInBoundary(element, Component, file, status, fallbackProps, config) {
|
|
1903
|
+
const props = isMdxFilePath(file.filePath) ? {
|
|
1904
|
+
fallbackElement: config.createElement(Component, fallbackProps),
|
|
1905
|
+
children: element
|
|
1906
|
+
} : {
|
|
1907
|
+
fallbackComponent: Component,
|
|
1908
|
+
children: element
|
|
1909
|
+
};
|
|
1910
|
+
if (status !== void 0) props.status = status;
|
|
1911
|
+
if (config.segmentKey !== void 0) props.segmentKey = config.segmentKey;
|
|
1912
|
+
return config.createElement(config.errorBoundaryComponent, props);
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Wrap an element in the error boundaries declared by a route segment.
|
|
1916
|
+
*
|
|
1917
|
+
* Wrapping is applied inside-out — the last wrap produces the outermost
|
|
1918
|
+
* boundary — so the processing order below is innermost to outermost:
|
|
1919
|
+
*
|
|
1920
|
+
* 1. Specific status files (e.g. `404.tsx`, `503.tsx`) — highest priority at runtime
|
|
1921
|
+
* 2. Category catch-alls (`4xx.tsx`, `5xx.tsx`)
|
|
1922
|
+
* 3. `error.tsx` — catches anything not matched by a status file
|
|
1923
|
+
*
|
|
1924
|
+
* The order is load-bearing: it decides which boundary catches a given throw.
|
|
1925
|
+
*
|
|
1926
|
+
* See design/10-error-handling.md §"Format-Aware Fallback Chains".
|
|
1927
|
+
*/
|
|
1928
|
+
async function wrapSegmentWithErrorBoundaries(segment, element, config) {
|
|
1929
|
+
const { loadModule, skipDenyBoundaries = false } = config;
|
|
1930
|
+
if (segment.statusFiles) {
|
|
1931
|
+
for (const [key, file] of Object.entries(segment.statusFiles)) {
|
|
1932
|
+
if (key === "4xx" || key === "5xx") continue;
|
|
1933
|
+
const status = parseInt(key, 10);
|
|
1934
|
+
if (isNaN(status)) continue;
|
|
1935
|
+
if (skipDenyBoundaries && status >= 400 && status <= 499) continue;
|
|
1936
|
+
const Component = await loadBoundaryComponent(file, loadModule);
|
|
1937
|
+
if (Component) element = wrapInBoundary(element, Component, file, status, { status }, config);
|
|
1938
|
+
}
|
|
1939
|
+
for (const [key, file] of Object.entries(segment.statusFiles)) {
|
|
1940
|
+
if (key !== "4xx" && key !== "5xx") continue;
|
|
1941
|
+
if (skipDenyBoundaries && key === "4xx") continue;
|
|
1942
|
+
const Component = await loadBoundaryComponent(file, loadModule);
|
|
1943
|
+
if (Component) element = wrapInBoundary(element, Component, file, key === "4xx" ? 400 : 500, {}, config);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
if (segment.error) {
|
|
1947
|
+
const Component = await loadBoundaryComponent(segment.error, loadModule);
|
|
1948
|
+
if (Component) element = wrapInBoundary(element, Component, segment.error, void 0, {}, config);
|
|
1949
|
+
}
|
|
1950
|
+
return element;
|
|
1951
|
+
}
|
|
1952
|
+
//#endregion
|
|
1747
1953
|
//#region src/server/param-coercion.ts
|
|
1748
1954
|
/** Global schema codecs, set once at startup from virtual:timber-schema. */
|
|
1749
1955
|
var _globalCodecs = null;
|
|
1750
1956
|
/**
|
|
1751
1957
|
* Run segment param coercion on the matched route's segments.
|
|
1752
1958
|
*
|
|
1753
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
1959
|
+
* Codecs come from `app/schema.ts` and nowhere else (TIM-1342 completed
|
|
1960
|
+
* design/41-global-params.md migration step 9). A route with no schema, or
|
|
1961
|
+
* a param the schema does not declare, keeps its raw string — which is the
|
|
1962
|
+
* honest type for an uninterpreted URL segment.
|
|
1756
1963
|
*
|
|
1757
1964
|
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
1758
1965
|
*
|
|
1759
1966
|
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
1760
1967
|
* See design/07-routing.md §"Where Coercion Runs"
|
|
1761
1968
|
* See design/41-global-params.md §"Pipeline Integration"
|
|
1969
|
+
*
|
|
1970
|
+
* Stays `async` with nothing to await: it is injected as a
|
|
1971
|
+
* `CoerceSegmentParamsFn` into the revalidation renderer and the action
|
|
1972
|
+
* middleware runner, both of which await it. Narrowing the return type to
|
|
1973
|
+
* `void` would break those contracts and remove a microtask boundary the
|
|
1974
|
+
* pipeline's ordering has always had, for no gain.
|
|
1762
1975
|
*/
|
|
1763
1976
|
async function coerceSegmentParams(match) {
|
|
1764
1977
|
const globalCodecs = _globalCodecs;
|
|
1765
1978
|
const mergeTarget = Object.create(null);
|
|
1766
1979
|
for (const key of Object.keys(match.segmentParams)) if (key !== "__proto__") mergeTarget[key] = match.segmentParams[key];
|
|
1767
1980
|
match.segmentParams = mergeTarget;
|
|
1768
|
-
if (globalCodecs) {
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
mergeTarget[key] = sanitizeParamValue(codec.parse(mergeTarget[key]));
|
|
1778
|
-
} catch (err) {
|
|
1779
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1780
|
-
if (isDebug()) console.warn(`[timber] Global schema codec rejected value for param "${key}"\n Error: ${message}\n Raw value: ${JSON.stringify(mergeTarget[key])}\n Hint: check the codec in app/schema.ts for key '${bracketKey}'.`);
|
|
1781
|
-
throw new ParamCoercionError(message);
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
return;
|
|
1785
|
-
}
|
|
1786
|
-
for (const segment of match.segments) {
|
|
1787
|
-
if (!segment.params) continue;
|
|
1788
|
-
let mod;
|
|
1981
|
+
if (globalCodecs) for (const node of match.segments) {
|
|
1982
|
+
const segment = effectiveUrlSegment(node);
|
|
1983
|
+
if (!segment.paramName) continue;
|
|
1984
|
+
const bracketKey = toBracketKey(segment.segmentType, segment.paramName);
|
|
1985
|
+
const codec = globalCodecs[bracketKey];
|
|
1986
|
+
if (!codec) continue;
|
|
1987
|
+
const key = segment.paramName;
|
|
1988
|
+
if (!(key in mergeTarget)) continue;
|
|
1989
|
+
let parsed;
|
|
1789
1990
|
try {
|
|
1790
|
-
|
|
1991
|
+
parsed = codec.parse(mergeTarget[key]);
|
|
1791
1992
|
} catch (err) {
|
|
1792
|
-
const message =
|
|
1793
|
-
if (isDebug()) console.warn(`[timber]
|
|
1993
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1994
|
+
if (isDebug()) console.warn(`[timber] Global schema codec rejected value for param "${key}"\n Error: ${message}\n Raw value: ${JSON.stringify(mergeTarget[key])}\n Hint: check the codec in app/schema.ts for key '${bracketKey}'.`);
|
|
1794
1995
|
throw new ParamCoercionError(message);
|
|
1795
1996
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1997
|
+
mergeTarget[key] = normalizeParamValue(parsed, key);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
//#endregion
|
|
2001
|
+
//#region src/server/pipeline-interception.ts
|
|
2002
|
+
/**
|
|
2003
|
+
* Interception route matching for the request pipeline.
|
|
2004
|
+
*
|
|
2005
|
+
* Matches target URLs against interception rewrites to support the
|
|
2006
|
+
* modal route pattern (soft navigation intercepts).
|
|
2007
|
+
*
|
|
2008
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
2009
|
+
*
|
|
2010
|
+
* See design/07-routing.md §"Intercepting Routes"
|
|
2011
|
+
*/
|
|
2012
|
+
/**
|
|
2013
|
+
* Check if an intercepting route applies for this soft navigation.
|
|
2014
|
+
*
|
|
2015
|
+
* Two conditions must hold:
|
|
2016
|
+
*
|
|
2017
|
+
* 1. The **target** pathname matches the rewrite's intercepted pattern.
|
|
2018
|
+
* 2. The **source** route — the page the user is navigating FROM, per the
|
|
2019
|
+
* validated X-Timber-URL header — renders the segment that owns the slot
|
|
2020
|
+
* holding the intercepting page (`interceptingScope`). Only then does that
|
|
2021
|
+
* slot exist in the rendered tree, so only then can the intercepting page
|
|
2022
|
+
* render.
|
|
2023
|
+
*
|
|
2024
|
+
* Condition 2 replaced a URL-prefix check (TIM-1279). A prefix cannot
|
|
2025
|
+
* distinguish a route-group-scoped interception from a genuinely root-level
|
|
2026
|
+
* one — route groups are invisible in `urlPath`, so both collapse to "/" and
|
|
2027
|
+
* the guard degenerates to always-true. Tree paths carry group identity.
|
|
2028
|
+
* The scope check also subsumes the old segment-boundary rule: a source route
|
|
2029
|
+
* under /feed-private has no "/feed" in its chain.
|
|
2030
|
+
*
|
|
2031
|
+
* A third condition lives with the caller, which owns route matching, and it
|
|
2032
|
+
* applies to the `'slot'` shape only: the route produced by re-matching
|
|
2033
|
+
* `interceptingPrefix` must pass through the scope too. Conditions 1–2 are
|
|
2034
|
+
* about the route the user is leaving; the slot has to exist in the tree that
|
|
2035
|
+
* actually renders. The `'children'` shape resolves its owner from the scope
|
|
2036
|
+
* directly rather than from a pathname, so it satisfies that by construction.
|
|
2037
|
+
*
|
|
2038
|
+
* `resolveSourceScopes` is a thunk so its inputs are only resolved when some
|
|
2039
|
+
* rewrite's pattern actually matches the target — the common case is no match
|
|
2040
|
+
* at all. It returns the two client-supplied answers to "what is on screen?"
|
|
2041
|
+
* as **separate sets, most authoritative first**: the tree paths the client
|
|
2042
|
+
* says it has mounted, then those of the route the departing URL matches. It
|
|
2043
|
+
* returns null only when neither yields anything, which is not a soft
|
|
2044
|
+
* navigation this can reason about.
|
|
2045
|
+
*
|
|
2046
|
+
* They are two sets rather than one union because ranking needs a chain
|
|
2047
|
+
* (TIM-1318). Each channel describes a single rendered chain, so within one the
|
|
2048
|
+
* scopes are strictly nested and the deepest is the innermost owner. Unioned
|
|
2049
|
+
* they are not: once a modal is open the address bar holds the *intercepted*
|
|
2050
|
+
* URL, so the second channel describes the canonical branch — possibly an
|
|
2051
|
+
* exclusive route group the client never mounted — and a deeper scope from
|
|
2052
|
+
* there would outrank the owner actually on screen. Trying the mounted channel
|
|
2053
|
+
* to exhaustion first keeps the fallback the union was there to provide while
|
|
2054
|
+
* ranking only within one chain.
|
|
2055
|
+
*
|
|
2056
|
+
* Returns the winning build-time rewrite, or null. Only build-time values ever
|
|
2057
|
+
* reach the renderer: the client-supplied header selects a rewrite but never
|
|
2058
|
+
* becomes a rendered route or pathname.
|
|
2059
|
+
*
|
|
2060
|
+
* The **first** matching rewrite wins, and the caller does not fall back to the
|
|
2061
|
+
* next one when the winner fails to resolve. That is deliberate and pre-dates
|
|
2062
|
+
* the two shapes: a rewrite that matched the target and the scope described the
|
|
2063
|
+
* author's intent, and quietly rendering some other author's interception
|
|
2064
|
+
* instead would be harder to explain than rendering the canonical route.
|
|
2065
|
+
*
|
|
2066
|
+
* "First" is meaningful because the list arrives **deepest scope first** —
|
|
2067
|
+
* `collectInterceptionRewrites` sorts it at build time, so the deepest owner a
|
|
2068
|
+
* single chain passes through wins and nothing here has to score (TIM-1318).
|
|
2069
|
+
* Tree-walk order used to decide it, which meant renaming a route group flipped
|
|
2070
|
+
* which modal opened. The precondition is a property of the manifest this reads
|
|
2071
|
+
* rather than something it can re-derive, so it is asserted at its source:
|
|
2072
|
+
* `tests/interception.test.ts` §"rewrite order is decided by scope depth".
|
|
2073
|
+
*/
|
|
2074
|
+
function findInterceptionMatch(targetPathname, resolveSourceScopes, rewrites) {
|
|
2075
|
+
const candidates = rewrites.filter((rewrite) => pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern));
|
|
2076
|
+
if (candidates.length === 0) return null;
|
|
2077
|
+
const scopes = resolveSourceScopes();
|
|
2078
|
+
if (!scopes) return null;
|
|
2079
|
+
for (const scope of scopes) for (const rewrite of candidates) if (scope.has(rewrite.interceptingScope)) return rewrite;
|
|
2080
|
+
return null;
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
2084
|
+
*
|
|
2085
|
+
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
2086
|
+
* Static segments must match exactly.
|
|
2087
|
+
*/
|
|
2088
|
+
function pathnameMatchesPattern(pathname, pattern) {
|
|
2089
|
+
const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
|
|
2090
|
+
const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
2091
|
+
let pi = 0;
|
|
2092
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
2093
|
+
const seg = classifyUrlSegment(patternParts[i]);
|
|
2094
|
+
switch (seg.kind) {
|
|
2095
|
+
case "catch-all": return pi < pathParts.length;
|
|
2096
|
+
case "optional-catch-all": return true;
|
|
2097
|
+
case "dynamic": {
|
|
2098
|
+
if (pi >= pathParts.length) return false;
|
|
2099
|
+
const prefix = seg.prefix ?? "";
|
|
2100
|
+
const suffix = seg.suffix ?? "";
|
|
2101
|
+
const pathPart = pathParts[pi];
|
|
2102
|
+
if (prefix || suffix) {
|
|
2103
|
+
if (pathPart.length <= prefix.length + suffix.length || prefix && !pathPart.startsWith(prefix) || suffix && !pathPart.endsWith(suffix)) return false;
|
|
2104
|
+
}
|
|
2105
|
+
pi++;
|
|
2106
|
+
continue;
|
|
1806
2107
|
}
|
|
1807
|
-
|
|
2108
|
+
case "static":
|
|
2109
|
+
if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
|
|
2110
|
+
pi++;
|
|
2111
|
+
continue;
|
|
1808
2112
|
}
|
|
1809
2113
|
}
|
|
2114
|
+
return pi === pathParts.length;
|
|
1810
2115
|
}
|
|
1811
2116
|
//#endregion
|
|
1812
2117
|
//#region src/client/segment-update-context.ts
|
|
@@ -1865,9 +2170,54 @@ getOrCreateContext();
|
|
|
1865
2170
|
* See design/13-security.md §"State tree manipulation".
|
|
1866
2171
|
*/
|
|
1867
2172
|
//#endregion
|
|
2173
|
+
//#region src/server/state-tree-diff.ts
|
|
2174
|
+
/**
|
|
2175
|
+
* State Tree Diffing — Server-side parsing and diffing of X-Timber-State-Tree.
|
|
2176
|
+
*
|
|
2177
|
+
* The client sends X-Timber-State-Tree on navigation requests, listing
|
|
2178
|
+
* the sync segments it has cached. The server diffs this against the
|
|
2179
|
+
* target route's segments to skip re-rendering unchanged sync layouts.
|
|
2180
|
+
*
|
|
2181
|
+
* This is a performance optimization only — NOT a security boundary.
|
|
2182
|
+
* All access.ts files run regardless of the state tree content.
|
|
2183
|
+
* A fabricated state tree can only cause extra rendering work or stale
|
|
2184
|
+
* layouts — never auth bypass.
|
|
2185
|
+
*
|
|
2186
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
2187
|
+
* See design/13-security.md §"State tree manipulation"
|
|
2188
|
+
*/
|
|
2189
|
+
/**
|
|
2190
|
+
* Parse the X-Timber-State-Tree header from a request.
|
|
2191
|
+
*
|
|
2192
|
+
* Returns a Set of segment paths the client has cached (with an
|
|
2193
|
+
* additional `slots` property for cached slot paths), or null if
|
|
2194
|
+
* the header is missing, malformed, or empty. Parsing happens before
|
|
2195
|
+
* renderToReadableStream — not inside the React render pass.
|
|
2196
|
+
*
|
|
2197
|
+
* @returns ClientStateTree with segments and slots, or null if no valid state tree
|
|
2198
|
+
*/
|
|
2199
|
+
function parseClientStateTree(req) {
|
|
2200
|
+
const header = req.headers.get("X-Timber-State-Tree");
|
|
2201
|
+
if (!header) return null;
|
|
2202
|
+
try {
|
|
2203
|
+
const parsed = JSON.parse(header);
|
|
2204
|
+
const hasSegments = Array.isArray(parsed.segments) && parsed.segments.length > 0;
|
|
2205
|
+
const hasSlots = Array.isArray(parsed.slots) && parsed.slots.length > 0;
|
|
2206
|
+
const hasTreePaths = Array.isArray(parsed.treePaths) && parsed.treePaths.length > 0;
|
|
2207
|
+
if (!hasSegments && !hasSlots && !hasTreePaths) return null;
|
|
2208
|
+
const result = new Set(hasSegments ? parsed.segments : []);
|
|
2209
|
+
result.slots = hasSlots ? new Set(parsed.slots) : null;
|
|
2210
|
+
result.treePaths = hasTreePaths ? new Set(parsed.treePaths) : null;
|
|
2211
|
+
return result;
|
|
2212
|
+
} catch (err) {
|
|
2213
|
+
swallow(err, "malformed X-Timber-State-Tree header");
|
|
2214
|
+
return null;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
//#endregion
|
|
1868
2218
|
//#region src/server/route-element-builder.ts
|
|
1869
2219
|
/**
|
|
1870
|
-
* Thrown when
|
|
2220
|
+
* Thrown when an app/schema.ts segment-param codec's parse() fails.
|
|
1871
2221
|
* The pipeline catches this and responds with 404.
|
|
1872
2222
|
*/
|
|
1873
2223
|
var ParamCoercionError = class extends Error {
|
|
@@ -1942,78 +2292,6 @@ function escapeXml(str) {
|
|
|
1942
2292
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1943
2293
|
}
|
|
1944
2294
|
//#endregion
|
|
1945
|
-
//#region src/server/pipeline-interception.ts
|
|
1946
|
-
/**
|
|
1947
|
-
* Interception route matching for the request pipeline.
|
|
1948
|
-
*
|
|
1949
|
-
* Matches target URLs against interception rewrites to support the
|
|
1950
|
-
* modal route pattern (soft navigation intercepts).
|
|
1951
|
-
*
|
|
1952
|
-
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
1953
|
-
*
|
|
1954
|
-
* See design/07-routing.md §"Intercepting Routes"
|
|
1955
|
-
*/
|
|
1956
|
-
/**
|
|
1957
|
-
* Check if a pathname starts with a prefix on a segment boundary.
|
|
1958
|
-
*
|
|
1959
|
-
* Prevents /feed from matching /feed-private — the prefix must be
|
|
1960
|
-
* followed by '/' or be an exact match.
|
|
1961
|
-
*/
|
|
1962
|
-
function hasSegmentPrefix(pathname, prefix) {
|
|
1963
|
-
if (prefix === "/") return true;
|
|
1964
|
-
if (!pathname.startsWith(prefix)) return false;
|
|
1965
|
-
return pathname.length === prefix.length || pathname[prefix.length] === "/";
|
|
1966
|
-
}
|
|
1967
|
-
/**
|
|
1968
|
-
* Check if an intercepting route applies for this soft navigation.
|
|
1969
|
-
*
|
|
1970
|
-
* Matches the target pathname against interception rewrites, constrained
|
|
1971
|
-
* by the source URL (X-Timber-URL header — where the user navigates FROM).
|
|
1972
|
-
*
|
|
1973
|
-
* Returns the source pathname to re-match if interception applies, or null.
|
|
1974
|
-
*/
|
|
1975
|
-
function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
|
|
1976
|
-
for (const rewrite of rewrites) {
|
|
1977
|
-
if (!hasSegmentPrefix(sourceUrl, rewrite.interceptingPrefix)) continue;
|
|
1978
|
-
if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
|
|
1979
|
-
}
|
|
1980
|
-
return null;
|
|
1981
|
-
}
|
|
1982
|
-
/**
|
|
1983
|
-
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
1984
|
-
*
|
|
1985
|
-
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
1986
|
-
* Static segments must match exactly.
|
|
1987
|
-
*/
|
|
1988
|
-
function pathnameMatchesPattern(pathname, pattern) {
|
|
1989
|
-
const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
|
|
1990
|
-
const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
1991
|
-
let pi = 0;
|
|
1992
|
-
for (let i = 0; i < patternParts.length; i++) {
|
|
1993
|
-
const seg = classifyUrlSegment(patternParts[i]);
|
|
1994
|
-
switch (seg.kind) {
|
|
1995
|
-
case "catch-all": return pi < pathParts.length;
|
|
1996
|
-
case "optional-catch-all": return true;
|
|
1997
|
-
case "dynamic": {
|
|
1998
|
-
if (pi >= pathParts.length) return false;
|
|
1999
|
-
const prefix = seg.prefix ?? "";
|
|
2000
|
-
const suffix = seg.suffix ?? "";
|
|
2001
|
-
const pathPart = pathParts[pi];
|
|
2002
|
-
if (prefix || suffix) {
|
|
2003
|
-
if (pathPart.length <= prefix.length + suffix.length || prefix && !pathPart.startsWith(prefix) || suffix && !pathPart.endsWith(suffix)) return false;
|
|
2004
|
-
}
|
|
2005
|
-
pi++;
|
|
2006
|
-
continue;
|
|
2007
|
-
}
|
|
2008
|
-
case "static":
|
|
2009
|
-
if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
|
|
2010
|
-
pi++;
|
|
2011
|
-
continue;
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
return pi === pathParts.length;
|
|
2015
|
-
}
|
|
2016
|
-
//#endregion
|
|
2017
2295
|
//#region src/server/pipeline-outcome.ts
|
|
2018
2296
|
/**
|
|
2019
2297
|
* Pipeline outcome translator — converts a `PhaseOutcome` (the value
|
|
@@ -2028,16 +2306,7 @@ function pathnameMatchesPattern(pathname, pattern) {
|
|
|
2028
2306
|
*/
|
|
2029
2307
|
function rscErrorResponse(isRsc, status, headers) {
|
|
2030
2308
|
if (!isRsc) return new Response(null, { status });
|
|
2031
|
-
|
|
2032
|
-
h.set("X-Timber-Error", "1");
|
|
2033
|
-
h.set("content-type", "application/json; charset=utf-8");
|
|
2034
|
-
return new Response(JSON.stringify({
|
|
2035
|
-
error: true,
|
|
2036
|
-
status
|
|
2037
|
-
}), {
|
|
2038
|
-
status,
|
|
2039
|
-
headers: h
|
|
2040
|
-
});
|
|
2309
|
+
return rscErrorEnvelope(status, headers);
|
|
2041
2310
|
}
|
|
2042
2311
|
/**
|
|
2043
2312
|
* Terminal outcome handler — converts a `PhaseOutcome` into a final
|
|
@@ -2091,7 +2360,7 @@ async function outcomeToResponse(config, outcome, ctx) {
|
|
|
2091
2360
|
});
|
|
2092
2361
|
}
|
|
2093
2362
|
case "error": {
|
|
2094
|
-
const isRsc = (ctx.req
|
|
2363
|
+
const isRsc = isRscRequest(ctx.req);
|
|
2095
2364
|
if (outcome.phase === "proxy") {
|
|
2096
2365
|
logProxyError({ error: outcome.error });
|
|
2097
2366
|
await fireOnRequestError(outcome.error, ctx.req, "proxy");
|
|
@@ -2334,15 +2603,6 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2334
2603
|
}
|
|
2335
2604
|
const handlerResult = await mod.default();
|
|
2336
2605
|
if (handlerResult instanceof Response) {
|
|
2337
|
-
if (method === "HEAD") {
|
|
2338
|
-
const headHeaders = new Headers(handlerResult.headers);
|
|
2339
|
-
if (handlerResult.ok && !headHeaders.has("Cache-Control")) headHeaders.set("Cache-Control", "public, max-age=14400, must-revalidate");
|
|
2340
|
-
return new Response(null, {
|
|
2341
|
-
status: handlerResult.status,
|
|
2342
|
-
statusText: handlerResult.statusText,
|
|
2343
|
-
headers: headHeaders
|
|
2344
|
-
});
|
|
2345
|
-
}
|
|
2346
2606
|
const res = cloneWithMutableHeaders(handlerResult);
|
|
2347
2607
|
if (res.ok && !res.headers.has("Cache-Control")) res.headers.set("Cache-Control", "public, max-age=14400, must-revalidate");
|
|
2348
2608
|
return res;
|
|
@@ -2387,8 +2647,8 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2387
2647
|
if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "auto-sitemap");
|
|
2388
2648
|
return new Response(null, { status: 500 });
|
|
2389
2649
|
}
|
|
2390
|
-
const
|
|
2391
|
-
if (
|
|
2650
|
+
const isRsc = isRscRequest(req);
|
|
2651
|
+
if (isRsc) {
|
|
2392
2652
|
if (!checkVersionSkew(req).ok) {
|
|
2393
2653
|
const reloadHeaders = new Headers();
|
|
2394
2654
|
applyReloadHeaders(reloadHeaders);
|
|
@@ -2400,14 +2660,23 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2400
2660
|
}
|
|
2401
2661
|
let match = config.matchRoute(canonicalPathname);
|
|
2402
2662
|
let interception;
|
|
2403
|
-
if (
|
|
2663
|
+
if (isRsc && config.interceptionRewrites?.length) {
|
|
2404
2664
|
const rawSourceUrl = req.headers.get("X-Timber-URL");
|
|
2405
2665
|
const validatedSourceUrl = rawSourceUrl ? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash) : null;
|
|
2406
2666
|
if (validatedSourceUrl) {
|
|
2407
|
-
const
|
|
2408
|
-
|
|
2409
|
-
const
|
|
2410
|
-
|
|
2667
|
+
const resolveSourceScopes = () => {
|
|
2668
|
+
const mounted = new Set(parseClientStateTree(req)?.treePaths ?? []);
|
|
2669
|
+
const sourceRoute = config.matchRoute(validatedSourceUrl);
|
|
2670
|
+
const scopes = [mounted, new Set(sourceRoute ? computeSegmentTreePaths(sourceRoute.segments) : [])].filter((scope) => scope.size > 0);
|
|
2671
|
+
return scopes.length > 0 ? scopes : null;
|
|
2672
|
+
};
|
|
2673
|
+
const rewrite = findInterceptionMatch(canonicalPathname, resolveSourceScopes, config.interceptionRewrites);
|
|
2674
|
+
if (rewrite?.mode === "children") {
|
|
2675
|
+
const interceptedMatch = config.matchInterceptedRoute?.(canonicalPathname, rewrite.interceptingScope);
|
|
2676
|
+
if (interceptedMatch) match = interceptedMatch;
|
|
2677
|
+
} else if (rewrite) {
|
|
2678
|
+
const sourceMatch = config.matchRoute(rewrite.interceptingPrefix);
|
|
2679
|
+
if (sourceMatch !== null && computeSegmentTreePaths(sourceMatch.segments).includes(rewrite.interceptingScope)) {
|
|
2411
2680
|
match = sourceMatch;
|
|
2412
2681
|
interception = { targetPathname: canonicalPathname };
|
|
2413
2682
|
}
|
|
@@ -2427,7 +2696,7 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2427
2696
|
responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
|
|
2428
2697
|
const leafSegment = match.segments[match.segments.length - 1];
|
|
2429
2698
|
const isApiRoute = leafSegment?.route && !leafSegment?.page;
|
|
2430
|
-
if (config.earlyHints && !
|
|
2699
|
+
if (config.earlyHints && !isRsc && !isApiRoute) try {
|
|
2431
2700
|
await config.earlyHints(match, req, responseHeaders);
|
|
2432
2701
|
} catch (err) {
|
|
2433
2702
|
swallow(err, "early hints hook threw");
|
|
@@ -2439,7 +2708,7 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2439
2708
|
if (error instanceof ParamCoercionError) {
|
|
2440
2709
|
if (isDebug()) {
|
|
2441
2710
|
const segmentChain = match.segments.map((s) => s.segmentName || "/").join(" → ");
|
|
2442
|
-
console.warn(`[timber] Param coercion failed for ${method} ${canonicalPathname} — responding with 404\n Matched segments: ${segmentChain}\n Error: ${error.message}\n This usually means a
|
|
2711
|
+
console.warn(`[timber] Param coercion failed for ${method} ${canonicalPathname} — responding with 404\n Matched segments: ${segmentChain}\n Error: ${error.message}\n This usually means a codec in app/schema.ts rejected the URL value.\n Check the codec for the bracket key of the segment named above.`);
|
|
2443
2712
|
}
|
|
2444
2713
|
const leafSegment = match.segments[match.segments.length - 1];
|
|
2445
2714
|
if (leafSegment.route && !leafSegment.page) return new Response(null, { status: 404 });
|
|
@@ -2466,6 +2735,188 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2466
2735
|
});
|
|
2467
2736
|
}
|
|
2468
2737
|
//#endregion
|
|
2738
|
+
//#region src/server/rsc-cache-key-guard.ts
|
|
2739
|
+
/**
|
|
2740
|
+
* Origin-side binding of the `_rsc` cache key to the request that produced
|
|
2741
|
+
* the response (TIM-1268).
|
|
2742
|
+
*
|
|
2743
|
+
* The key is deterministic, and deliberately so — that is what makes RSC
|
|
2744
|
+
* payloads cacheable. But deterministic also means *predictable*: anyone can
|
|
2745
|
+
* compute the key another client will request. Without this guard, a caller
|
|
2746
|
+
* could request `/about?_rsc=<victim's key>` while sending its own
|
|
2747
|
+
* `X-Timber-State-Tree`, and a shared cache that keys by URL — the exact
|
|
2748
|
+
* environment this feature targets — would store that variant under the
|
|
2749
|
+
* victim's key. Every subsequent legitimate navigation would then be served
|
|
2750
|
+
* a payload rendered for someone else's segment tree or deployment.
|
|
2751
|
+
*
|
|
2752
|
+
* The guard is a *storability* rule, not a rejection: if the claimed key
|
|
2753
|
+
* does not match the key derived from the received headers, the response
|
|
2754
|
+
* loses its shared-cache directives. Mismatched requests are still served
|
|
2755
|
+
* normally, they just cannot be stored for anyone else.
|
|
2756
|
+
*
|
|
2757
|
+
* Why not Next.js's 307 self-correction: a redirect is cacheable in its own
|
|
2758
|
+
* right, costs a round trip on every legitimate miss, and would make the
|
|
2759
|
+
* server's behaviour depend on a parameter it otherwise never reads. This
|
|
2760
|
+
* rule closes the same hole by making the poisoned entry unstorable at the
|
|
2761
|
+
* source, in the response the developer already has to opt into sharing.
|
|
2762
|
+
*
|
|
2763
|
+
* See design/19-client-navigation.md §"The `_rsc` cache key".
|
|
2764
|
+
*/
|
|
2765
|
+
/**
|
|
2766
|
+
* Legacy shared-cache control fields that predate RFC 9213 and so do not
|
|
2767
|
+
* follow the `<target>-Cache-Control` naming pattern — the one part of this
|
|
2768
|
+
* rule that cannot be matched by shape. `X-Accel-Expires` is nginx
|
|
2769
|
+
* `proxy_cache`, which gives it precedence over `Cache-Control`.
|
|
2770
|
+
*
|
|
2771
|
+
* A closed historical set, unlike the vendor `*-Cache-Control` names, which
|
|
2772
|
+
* keep being minted and are handled by pattern below.
|
|
2773
|
+
*/
|
|
2774
|
+
var LEGACY_CDN_CACHE_HEADERS = [
|
|
2775
|
+
"Surrogate-Control",
|
|
2776
|
+
"Edge-Control",
|
|
2777
|
+
"X-Accel-Expires"
|
|
2778
|
+
];
|
|
2779
|
+
/**
|
|
2780
|
+
* Whether a response header overrides `Cache-Control` at a shared cache.
|
|
2781
|
+
*
|
|
2782
|
+
* Matched by *shape*, not by vendor name. RFC 9213 defines targeted fields
|
|
2783
|
+
* as `<target>-Cache-Control`, and every CDN spells its own: `CDN-`,
|
|
2784
|
+
* `Cloudflare-CDN-`, `Netlify-CDN-`, `Vercel-CDN-`, and whichever ships
|
|
2785
|
+
* next. A vendor list here is a denylist with an unbounded tail — it was
|
|
2786
|
+
* extended twice during review, each time by one name, which is the signal
|
|
2787
|
+
* that enumeration is the wrong shape. The suffix rule is total.
|
|
2788
|
+
*
|
|
2789
|
+
* `Cache-Control` itself does not match (no `-` prefix) and is replaced
|
|
2790
|
+
* separately.
|
|
2791
|
+
*/
|
|
2792
|
+
function isSharedCacheOverride(name) {
|
|
2793
|
+
const lowered = name.toLowerCase();
|
|
2794
|
+
return lowered.endsWith("-cache-control") || LEGACY_CDN_CACHE_HEADERS.some((legacy) => legacy.toLowerCase() === lowered);
|
|
2795
|
+
}
|
|
2796
|
+
/**
|
|
2797
|
+
* Whether the request's `_rsc` parameter matches the key derived from its
|
|
2798
|
+
* own headers.
|
|
2799
|
+
*
|
|
2800
|
+
* Requests without the parameter are not claiming a key at all, so they are
|
|
2801
|
+
* left alone — the bare document URL is the developer's declared territory
|
|
2802
|
+
* (see design/32-response-caching.md, TIM-1264). Neither is a parameter
|
|
2803
|
+
* whose value could not be one of our digests: `_rsc` is not reserved, and
|
|
2804
|
+
* an application that already uses it for its own search state
|
|
2805
|
+
* (`/search?_rsc=foo`) must not have its cache policy silently revoked by a
|
|
2806
|
+
* guard that mistook that for a forgery.
|
|
2807
|
+
*/
|
|
2808
|
+
async function hasValidRscCacheKey(req) {
|
|
2809
|
+
if (!req.url.includes("?")) return true;
|
|
2810
|
+
let values;
|
|
2811
|
+
try {
|
|
2812
|
+
values = new URL(req.url).searchParams.getAll(RSC_KEY_PARAM);
|
|
2813
|
+
} catch {
|
|
2814
|
+
return false;
|
|
2815
|
+
}
|
|
2816
|
+
const claims = values.filter(isRscCacheKeyShape);
|
|
2817
|
+
if (claims.length === 0) return true;
|
|
2818
|
+
const expected = await rscCacheKey((name) => req.headers.get(name));
|
|
2819
|
+
if (expected === null) return false;
|
|
2820
|
+
return claims.every((claim) => claim === expected);
|
|
2821
|
+
}
|
|
2822
|
+
/**
|
|
2823
|
+
* Ensure a Flight payload can never be interpreted as a document.
|
|
2824
|
+
*
|
|
2825
|
+
* Applied here, at the pipeline's single exit, rather than where each
|
|
2826
|
+
* payload response is built. Six places build one — the success path, three
|
|
2827
|
+
* in `deny-renderer.ts`, the action paths — and hardening them one at a time
|
|
2828
|
+
* is how you end up hardening five of six. The response is Flight if it says
|
|
2829
|
+
* it is; that is the only test that covers every producer, including ones
|
|
2830
|
+
* added later.
|
|
2831
|
+
*
|
|
2832
|
+
* Matters because the payload URL is observable and, on a shared-cache hit,
|
|
2833
|
+
* the origin is never consulted — so a top-level navigation can be handed
|
|
2834
|
+
* these bytes with no request-time check available. See design/13-security.md
|
|
2835
|
+
* #36c.
|
|
2836
|
+
*/
|
|
2837
|
+
function hardenRscPayloadResponse(response) {
|
|
2838
|
+
const contentType = response.headers.get("content-type");
|
|
2839
|
+
if (!contentType) return;
|
|
2840
|
+
if (contentType.split(";")[0].trim().toLowerCase() !== "text/x-component") return;
|
|
2841
|
+
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Make a response unstorable by any shared cache when its request claimed an
|
|
2845
|
+
* `_rsc` key that does not match its own headers.
|
|
2846
|
+
*
|
|
2847
|
+
* The end state is unconditional and therefore statable: **a mismatched
|
|
2848
|
+
* response is never stored by any cache.** Every targeted cache-control
|
|
2849
|
+
* field is removed and `Cache-Control` is replaced outright, rather than
|
|
2850
|
+
* downgrading only when a shared directive is spotted. Conditional variants
|
|
2851
|
+
* of this were tried and were wrong twice over — they missed the CDN-
|
|
2852
|
+
* specific headers, and leaving `Cache-Control` alone when it looked
|
|
2853
|
+
* harmless lets a CDN apply heuristic caching to a response that has none.
|
|
2854
|
+
*
|
|
2855
|
+
* The cost is that a mismatched request loses developer-chosen *browser*
|
|
2856
|
+
* caching too. That is a request no timber client ever makes.
|
|
2857
|
+
*
|
|
2858
|
+
* Purge tags (`Surrogate-Key`, `Cache-Tag`) are deliberately left alone:
|
|
2859
|
+
* they address stored entries, and this response is not stored.
|
|
2860
|
+
*
|
|
2861
|
+
* Mutates `response.headers` in place; the pipeline guarantees mutable
|
|
2862
|
+
* headers at this point (see TIM-866).
|
|
2863
|
+
*/
|
|
2864
|
+
async function enforceRscCacheKey(req, response) {
|
|
2865
|
+
if (await hasValidRscCacheKey(req)) return;
|
|
2866
|
+
const overrides = [];
|
|
2867
|
+
for (const name of response.headers.keys()) if (isSharedCacheOverride(name)) overrides.push(name);
|
|
2868
|
+
for (const name of overrides) response.headers.delete(name);
|
|
2869
|
+
response.headers.set("Cache-Control", "private, no-store");
|
|
2870
|
+
}
|
|
2871
|
+
//#endregion
|
|
2872
|
+
//#region src/server/head-response.ts
|
|
2873
|
+
/**
|
|
2874
|
+
* HEAD body stripping — RFC 9110 §9.3.2.
|
|
2875
|
+
*
|
|
2876
|
+
* "The HEAD method is identical to GET except that the server MUST NOT send
|
|
2877
|
+
* content in the response." The headers, however, must be the ones the
|
|
2878
|
+
* corresponding GET would have produced — which is why timber renders the
|
|
2879
|
+
* route in full and discards the body here rather than short-circuiting the
|
|
2880
|
+
* render. Content-Type, Content-Length, Set-Cookie, cache directives and
|
|
2881
|
+
* everything else ride through untouched.
|
|
2882
|
+
*
|
|
2883
|
+
* Node's `ServerResponse` happens to discard writes for HEAD, so the wire is
|
|
2884
|
+
* usually correct even without this. That is a property of one adapter, not of
|
|
2885
|
+
* the `Response` object: static generation, the Cloudflare and Nitro adapters,
|
|
2886
|
+
* tests, and anything calling the pipeline handler as a function all observe
|
|
2887
|
+
* the `Response` directly and saw the full body before TIM-1324.
|
|
2888
|
+
*
|
|
2889
|
+
* The pipeline applies this at its single exit so every response shape is
|
|
2890
|
+
* covered — page render, API route, metadata route, deny page, error page,
|
|
2891
|
+
* proxy short-circuit. The two unit-level producers that are also reachable as
|
|
2892
|
+
* standalone functions (`handleRouteRequest`, `handleApiRoute`) call it too, so
|
|
2893
|
+
* each honours HEAD on its own; they share this implementation rather than
|
|
2894
|
+
* repeating it, and applying it twice is a no-op.
|
|
2895
|
+
*/
|
|
2896
|
+
/**
|
|
2897
|
+
* Return a bodyless copy of `response` when `method` is HEAD.
|
|
2898
|
+
*
|
|
2899
|
+
* A no-op for every other method and for responses that already carry no body
|
|
2900
|
+
* (204, 304, redirects), so callers do not need to pre-check either.
|
|
2901
|
+
*
|
|
2902
|
+
* The discarded stream is cancelled: an SSR render that reached this point has
|
|
2903
|
+
* a live producer attached, and dropping the reference without cancelling
|
|
2904
|
+
* leaks it for the lifetime of the render (design/13-security.md A4).
|
|
2905
|
+
*/
|
|
2906
|
+
function stripHeadBody(response, method) {
|
|
2907
|
+
if (method.toUpperCase() !== "HEAD") return response;
|
|
2908
|
+
const body = response.body;
|
|
2909
|
+
if (body === null) return response;
|
|
2910
|
+
try {
|
|
2911
|
+
body.cancel().catch(() => {});
|
|
2912
|
+
} catch {}
|
|
2913
|
+
return new Response(null, {
|
|
2914
|
+
status: response.status,
|
|
2915
|
+
statusText: response.statusText,
|
|
2916
|
+
headers: response.headers
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
//#endregion
|
|
2469
2920
|
//#region src/server/pipeline.ts
|
|
2470
2921
|
/**
|
|
2471
2922
|
* Create the request handler from a pipeline configuration.
|
|
@@ -2529,12 +2980,14 @@ function createPipeline(config) {
|
|
|
2529
2980
|
});
|
|
2530
2981
|
else result = await innerHandler();
|
|
2531
2982
|
await setSpanAttribute("http.response.status_code", result.status);
|
|
2532
|
-
const varyTokens = [
|
|
2533
|
-
if (config.interceptionRewrites?.length) varyTokens.push("X-Timber-URL");
|
|
2983
|
+
const varyTokens = [...RSC_KEY_HEADERS];
|
|
2534
2984
|
const existingVary = result.headers.get("Vary");
|
|
2535
2985
|
const existingTokens = existingVary ? existingVary.toLowerCase().split(",").map((t) => t.trim()) : [];
|
|
2536
2986
|
const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
|
|
2537
2987
|
if (newTokens.length > 0) result.headers.set("Vary", existingVary ? `${existingVary}, ${newTokens.join(", ")}` : newTokens.join(", "));
|
|
2988
|
+
await enforceRscCacheKey(canonicalReq, result);
|
|
2989
|
+
hardenRscPayloadResponse(result);
|
|
2990
|
+
result = stripHeadBody(result, method);
|
|
2538
2991
|
if (serverTiming === "detailed") {
|
|
2539
2992
|
const timingHeader = getServerTimingHeader();
|
|
2540
2993
|
if (timingHeader) result.headers.set("Server-Timing", timingHeader);
|
|
@@ -2576,68 +3029,6 @@ function createPipeline(config) {
|
|
|
2576
3029
|
return pipelineFn;
|
|
2577
3030
|
}
|
|
2578
3031
|
//#endregion
|
|
2579
|
-
//#region src/server/build-manifest.ts
|
|
2580
|
-
/**
|
|
2581
|
-
* Collect all CSS files needed for a matched route's segment chain.
|
|
2582
|
-
*
|
|
2583
|
-
* Walks segments root → leaf, collecting CSS for each layout and page.
|
|
2584
|
-
* Deduplicates while preserving order (root layout CSS first).
|
|
2585
|
-
*/
|
|
2586
|
-
function collectRouteCss(segments, manifest) {
|
|
2587
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2588
|
-
const result = [];
|
|
2589
|
-
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2590
|
-
if (!file) continue;
|
|
2591
|
-
const cssFiles = manifest.css[file.filePath];
|
|
2592
|
-
if (!cssFiles) continue;
|
|
2593
|
-
for (const url of cssFiles) if (!seen.has(url)) {
|
|
2594
|
-
seen.add(url);
|
|
2595
|
-
result.push(url);
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
return result;
|
|
2599
|
-
}
|
|
2600
|
-
/**
|
|
2601
|
-
* Collect all font entries needed for a matched route's segment chain.
|
|
2602
|
-
*
|
|
2603
|
-
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
2604
|
-
* Deduplicates by href while preserving order.
|
|
2605
|
-
*/
|
|
2606
|
-
function collectRouteFonts(segments, manifest) {
|
|
2607
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2608
|
-
const result = [];
|
|
2609
|
-
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2610
|
-
if (!file) continue;
|
|
2611
|
-
const fonts = manifest.fonts[file.filePath];
|
|
2612
|
-
if (!fonts) continue;
|
|
2613
|
-
for (const entry of fonts) if (!seen.has(entry.href)) {
|
|
2614
|
-
seen.add(entry.href);
|
|
2615
|
-
result.push(entry);
|
|
2616
|
-
}
|
|
2617
|
-
}
|
|
2618
|
-
return result;
|
|
2619
|
-
}
|
|
2620
|
-
/**
|
|
2621
|
-
* Collect modulepreload URLs for a matched route's segment chain.
|
|
2622
|
-
*
|
|
2623
|
-
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
2624
|
-
* for each layout and page. Deduplicates across segments.
|
|
2625
|
-
*/
|
|
2626
|
-
function collectRouteModulepreloads(segments, manifest) {
|
|
2627
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2628
|
-
const result = [];
|
|
2629
|
-
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2630
|
-
if (!file) continue;
|
|
2631
|
-
const preloads = manifest.modulepreload[file.filePath];
|
|
2632
|
-
if (!preloads) continue;
|
|
2633
|
-
for (const url of preloads) if (!seen.has(url)) {
|
|
2634
|
-
seen.add(url);
|
|
2635
|
-
result.push(url);
|
|
2636
|
-
}
|
|
2637
|
-
}
|
|
2638
|
-
return result;
|
|
2639
|
-
}
|
|
2640
|
-
//#endregion
|
|
2641
3032
|
//#region src/server/early-hints.ts
|
|
2642
3033
|
/**
|
|
2643
3034
|
* 103 Early Hints utilities.
|
|
@@ -2771,40 +3162,6 @@ function sendEarlyHints103(links) {
|
|
|
2771
3162
|
}
|
|
2772
3163
|
//#endregion
|
|
2773
3164
|
//#region src/server/tree-builder.ts
|
|
2774
|
-
var REACT_COMPONENT_TYPE_MARKERS = /* @__PURE__ */ new Set([
|
|
2775
|
-
Symbol.for("react.forward_ref"),
|
|
2776
|
-
Symbol.for("react.memo"),
|
|
2777
|
-
Symbol.for("react.lazy"),
|
|
2778
|
-
Symbol.for("react.provider"),
|
|
2779
|
-
Symbol.for("react.context"),
|
|
2780
|
-
Symbol.for("react.suspense"),
|
|
2781
|
-
Symbol.for("react.suspense_list"),
|
|
2782
|
-
Symbol.for("react.client.reference")
|
|
2783
|
-
]);
|
|
2784
|
-
/**
|
|
2785
|
-
* Validate that a loaded module's `default` export is something React
|
|
2786
|
-
* accepts as the first argument to `createElement` — i.e. a valid component
|
|
2787
|
-
* type. React doesn't export `isValidElementType` (only `isValidElement`,
|
|
2788
|
-
* which checks for *elements*, not *component types*), so this mirrors
|
|
2789
|
-
* React's internal check:
|
|
2790
|
-
*
|
|
2791
|
-
* - functions → function or class components
|
|
2792
|
-
* - objects with a `$$typeof` matching one of React's known component
|
|
2793
|
-
* markers → exotic components (`memo`, `forwardRef`, `lazy`, context,
|
|
2794
|
-
* suspense, client references via `@vitejs/plugin-rsc`)
|
|
2795
|
-
*
|
|
2796
|
-
* Strings (HTML tag names) are valid for `createElement` but never appear
|
|
2797
|
-
* as a route module's default export, so they're not recognized here.
|
|
2798
|
-
*
|
|
2799
|
-
* Anything else (numbers, plain config objects, JSON, etc.) is rejected so
|
|
2800
|
-
* the boundary wrapper is skipped rather than crashing inside React.
|
|
2801
|
-
*/
|
|
2802
|
-
function isValidElementType(value) {
|
|
2803
|
-
if (typeof value === "function") return true;
|
|
2804
|
-
if (typeof value !== "object" || value === null) return false;
|
|
2805
|
-
const marker = value.$$typeof;
|
|
2806
|
-
return typeof marker === "symbol" && REACT_COMPONENT_TYPE_MARKERS.has(marker);
|
|
2807
|
-
}
|
|
2808
3165
|
/**
|
|
2809
3166
|
* Build the unified element tree from a matched segment chain.
|
|
2810
3167
|
*
|
|
@@ -2830,7 +3187,11 @@ async function buildElementTree(config) {
|
|
|
2830
3187
|
let element = createElement(PageComponent, {});
|
|
2831
3188
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2832
3189
|
const segment = segments[i];
|
|
2833
|
-
element = await
|
|
3190
|
+
element = await wrapSegmentWithErrorBoundaries(segment, element, {
|
|
3191
|
+
loadModule,
|
|
3192
|
+
createElement,
|
|
3193
|
+
errorBoundaryComponent
|
|
3194
|
+
});
|
|
2834
3195
|
if (segment.access) {
|
|
2835
3196
|
const accessFn = (await loadModule(segment.access)).default;
|
|
2836
3197
|
element = createElement("timber:access-gate", {
|
|
@@ -2871,7 +3232,11 @@ async function buildSlotElement(slotNode, loadModule, createElement, errorBounda
|
|
|
2871
3232
|
const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
|
|
2872
3233
|
if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {}) : null;
|
|
2873
3234
|
let element = createElement(PageComponent, {});
|
|
2874
|
-
element = await
|
|
3235
|
+
element = await wrapSegmentWithErrorBoundaries(slotNode, element, {
|
|
3236
|
+
loadModule,
|
|
3237
|
+
createElement,
|
|
3238
|
+
errorBoundaryComponent
|
|
3239
|
+
});
|
|
2875
3240
|
if (slotNode.access) {
|
|
2876
3241
|
const accessFn = (await loadModule(slotNode.access)).default;
|
|
2877
3242
|
const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default ?? null;
|
|
@@ -2887,81 +3252,6 @@ async function buildSlotElement(slotNode, loadModule, createElement, errorBounda
|
|
|
2887
3252
|
}
|
|
2888
3253
|
return element;
|
|
2889
3254
|
}
|
|
2890
|
-
/** MDX/markdown extensions — these are server components that cannot be passed as function props. */
|
|
2891
|
-
var MDX_EXTENSIONS = /* @__PURE__ */ new Set(["mdx", "md"]);
|
|
2892
|
-
/**
|
|
2893
|
-
* Check if a route file is an MDX/markdown file based on its extension.
|
|
2894
|
-
* MDX components are server components by default and cannot cross the
|
|
2895
|
-
* RSC→client boundary as function props. They must be pre-rendered as
|
|
2896
|
-
* elements and passed as fallbackElement instead of fallbackComponent.
|
|
2897
|
-
*/
|
|
2898
|
-
function isMdxFile(file) {
|
|
2899
|
-
return MDX_EXTENSIONS.has(file.extension);
|
|
2900
|
-
}
|
|
2901
|
-
/**
|
|
2902
|
-
* Wrap an element with error boundaries from a segment's status-code files.
|
|
2903
|
-
*
|
|
2904
|
-
* Wrapping order (innermost to outermost):
|
|
2905
|
-
* 1. Specific status files (503.tsx, 429.tsx, etc.)
|
|
2906
|
-
* 2. Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
2907
|
-
* 3. error.tsx (general error boundary)
|
|
2908
|
-
*
|
|
2909
|
-
* This creates the fallback chain described in design/10-error-handling.md.
|
|
2910
|
-
*
|
|
2911
|
-
* MDX status files are server components and cannot be passed as function
|
|
2912
|
-
* props to TimberErrorBoundary (a 'use client' component). Instead, they
|
|
2913
|
-
* are pre-rendered as elements and passed as fallbackElement. The error
|
|
2914
|
-
* boundary renders the element directly when an error is caught.
|
|
2915
|
-
* See TIM-503.
|
|
2916
|
-
*/
|
|
2917
|
-
async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
|
|
2918
|
-
if (segment.statusFiles) {
|
|
2919
|
-
for (const [key, file] of Object.entries(segment.statusFiles)) if (key !== "4xx" && key !== "5xx") {
|
|
2920
|
-
const status = parseInt(key, 10);
|
|
2921
|
-
if (!isNaN(status)) {
|
|
2922
|
-
const mod = await loadModule(file);
|
|
2923
|
-
const Component = isValidElementType(mod.default) ? mod.default : null;
|
|
2924
|
-
if (Component) element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
|
|
2925
|
-
fallbackElement: createElement(Component, { status }),
|
|
2926
|
-
status,
|
|
2927
|
-
children: element
|
|
2928
|
-
} : {
|
|
2929
|
-
fallbackComponent: Component,
|
|
2930
|
-
status,
|
|
2931
|
-
children: element
|
|
2932
|
-
});
|
|
2933
|
-
}
|
|
2934
|
-
}
|
|
2935
|
-
for (const [key, file] of Object.entries(segment.statusFiles)) if (key === "4xx" || key === "5xx") {
|
|
2936
|
-
const mod = await loadModule(file);
|
|
2937
|
-
const Component = isValidElementType(mod.default) ? mod.default : null;
|
|
2938
|
-
if (Component) {
|
|
2939
|
-
const categoryStatus = key === "4xx" ? 400 : 500;
|
|
2940
|
-
element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
|
|
2941
|
-
fallbackElement: createElement(Component, {}),
|
|
2942
|
-
status: categoryStatus,
|
|
2943
|
-
children: element
|
|
2944
|
-
} : {
|
|
2945
|
-
fallbackComponent: Component,
|
|
2946
|
-
status: categoryStatus,
|
|
2947
|
-
children: element
|
|
2948
|
-
});
|
|
2949
|
-
}
|
|
2950
|
-
}
|
|
2951
|
-
}
|
|
2952
|
-
if (segment.error) {
|
|
2953
|
-
const errorModule = await loadModule(segment.error);
|
|
2954
|
-
const ErrorComponent = isValidElementType(errorModule.default) ? errorModule.default : null;
|
|
2955
|
-
if (ErrorComponent) element = createElement(errorBoundaryComponent, isMdxFile(segment.error) ? {
|
|
2956
|
-
fallbackElement: createElement(ErrorComponent, {}),
|
|
2957
|
-
children: element
|
|
2958
|
-
} : {
|
|
2959
|
-
fallbackComponent: ErrorComponent,
|
|
2960
|
-
children: element
|
|
2961
|
-
});
|
|
2962
|
-
}
|
|
2963
|
-
return element;
|
|
2964
|
-
}
|
|
2965
3255
|
//#endregion
|
|
2966
3256
|
//#region src/server/status-code-resolver.ts
|
|
2967
3257
|
/**
|
|
@@ -2999,12 +3289,19 @@ function lookupInGroup(group, statusStr, categoryKey, segmentIndex, status) {
|
|
|
2999
3289
|
* @param status - The HTTP status code (4xx or 5xx).
|
|
3000
3290
|
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
3001
3291
|
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
3002
|
-
|
|
3003
|
-
|
|
3292
|
+
* @param maxIndex - Optional upper bound on the segment index to search.
|
|
3293
|
+
* When set, the resolver only considers segments `0..maxIndex` (inclusive),
|
|
3294
|
+
* skipping any segments deeper in the tree. Used by the deny renderers to
|
|
3295
|
+
* prevent resolving a status file below a denied segment — matching
|
|
3296
|
+
* `buildDenyPageChain`'s start-at-i semantics. The error renderers omit
|
|
3297
|
+
* this parameter, so the full chain is searched — a 500 from a leaf page
|
|
3298
|
+
* should use the leaf's own `5xx.tsx`. See TIM-1358.
|
|
3299
|
+
*/
|
|
3300
|
+
function resolveStatusFile(status, segments, format = "component", maxIndex) {
|
|
3004
3301
|
if (status < 400 || status > 599) return null;
|
|
3005
|
-
if (format === "json") return resolveJson(status, segments);
|
|
3006
|
-
if (status <= 499) return resolve4xx(status, segments);
|
|
3007
|
-
return resolve5xx(status, segments);
|
|
3302
|
+
if (format === "json") return resolveJson(status, segments, maxIndex);
|
|
3303
|
+
if (status <= 499) return resolve4xx(status, segments, maxIndex);
|
|
3304
|
+
return resolve5xx(status, segments, maxIndex);
|
|
3008
3305
|
}
|
|
3009
3306
|
/**
|
|
3010
3307
|
* 4xx component fallback chain — two separate full passes leaf→root.
|
|
@@ -3018,13 +3315,14 @@ function resolveStatusFile(status, segments, format = "component") {
|
|
|
3018
3315
|
* Pass 1 — {status}.tsx → 4xx.tsx (statusFiles)
|
|
3019
3316
|
* Pass 2 — error.tsx (error)
|
|
3020
3317
|
*/
|
|
3021
|
-
function resolve4xx(status, segments) {
|
|
3318
|
+
function resolve4xx(status, segments, maxIndex) {
|
|
3022
3319
|
const statusStr = String(status);
|
|
3023
|
-
|
|
3320
|
+
const start = maxIndex !== void 0 ? Math.min(segments.length - 1, maxIndex) : segments.length - 1;
|
|
3321
|
+
for (let i = start; i >= 0; i--) {
|
|
3024
3322
|
const r = lookupInGroup(segments[i].statusFiles, statusStr, "4xx", i, status);
|
|
3025
3323
|
if (r) return r;
|
|
3026
3324
|
}
|
|
3027
|
-
for (let i =
|
|
3325
|
+
for (let i = start; i >= 0; i--) {
|
|
3028
3326
|
const errorFile = segments[i].error;
|
|
3029
3327
|
if (errorFile) return {
|
|
3030
3328
|
file: errorFile,
|
|
@@ -3042,9 +3340,10 @@ function resolve4xx(status, segments) {
|
|
|
3042
3340
|
* `error.tsx` therefore beats a root's `5xx.tsx`, which is the
|
|
3043
3341
|
* intentional inverse of the 4xx chain.
|
|
3044
3342
|
*/
|
|
3045
|
-
function resolve5xx(status, segments) {
|
|
3343
|
+
function resolve5xx(status, segments, maxIndex) {
|
|
3046
3344
|
const statusStr = String(status);
|
|
3047
|
-
|
|
3345
|
+
const start = maxIndex !== void 0 ? Math.min(segments.length - 1, maxIndex) : segments.length - 1;
|
|
3346
|
+
for (let i = start; i >= 0; i--) {
|
|
3048
3347
|
const segment = segments[i];
|
|
3049
3348
|
const r = lookupInGroup(segment.statusFiles, statusStr, "5xx", i, status);
|
|
3050
3349
|
if (r) return r;
|
|
@@ -3064,10 +3363,11 @@ function resolve5xx(status, segments) {
|
|
|
3064
3363
|
* no error.tsx — the JSON chain terminates at the category catch-all
|
|
3065
3364
|
* and the caller falls back to a bare-JSON framework default.
|
|
3066
3365
|
*/
|
|
3067
|
-
function resolveJson(status, segments) {
|
|
3366
|
+
function resolveJson(status, segments, maxIndex) {
|
|
3068
3367
|
const statusStr = String(status);
|
|
3069
3368
|
const categoryKey = status >= 500 ? "5xx" : "4xx";
|
|
3070
|
-
|
|
3369
|
+
const start = maxIndex !== void 0 ? Math.min(segments.length - 1, maxIndex) : segments.length - 1;
|
|
3370
|
+
for (let i = start; i >= 0; i--) {
|
|
3071
3371
|
const r = lookupInGroup(segments[i].jsonStatusFiles, statusStr, categoryKey, i, status);
|
|
3072
3372
|
if (r) return r;
|
|
3073
3373
|
}
|
|
@@ -3277,14 +3577,8 @@ async function handleRouteRequest(mod, ctx) {
|
|
|
3277
3577
|
});
|
|
3278
3578
|
}
|
|
3279
3579
|
if (method === "HEAD") {
|
|
3280
|
-
if (mod.HEAD) return runHandler(mod.HEAD, ctx);
|
|
3281
|
-
if (mod.GET)
|
|
3282
|
-
const res = await runHandler(mod.GET, ctx);
|
|
3283
|
-
return new Response(null, {
|
|
3284
|
-
status: res.status,
|
|
3285
|
-
headers: res.headers
|
|
3286
|
-
});
|
|
3287
|
-
}
|
|
3580
|
+
if (mod.HEAD) return stripHeadBody(await runHandler(mod.HEAD, ctx), method);
|
|
3581
|
+
if (mod.GET) return stripHeadBody(await runHandler(mod.GET, ctx), method);
|
|
3288
3582
|
}
|
|
3289
3583
|
const handler = mod[method];
|
|
3290
3584
|
if (!handler) return new Response(null, {
|
|
@@ -3511,11 +3805,11 @@ var registry = /* @__PURE__ */ new Map();
|
|
|
3511
3805
|
*/
|
|
3512
3806
|
function resolveKeyParams(store) {
|
|
3513
3807
|
const main = store.segmentParams ?? {};
|
|
3514
|
-
const
|
|
3515
|
-
if (!
|
|
3808
|
+
const slots = store.slotParams;
|
|
3809
|
+
if (!slots || Object.keys(slots).length === 0) return main;
|
|
3516
3810
|
const mainCanonical = tryCanonicalize({ ...main });
|
|
3517
3811
|
if (mainCanonical === null) return null;
|
|
3518
|
-
for (const slotParams of
|
|
3812
|
+
for (const slotParams of Object.values(slots)) {
|
|
3519
3813
|
const merged = tryCanonicalize({
|
|
3520
3814
|
...main,
|
|
3521
3815
|
...slotParams
|