@timber-js/app 0.2.0-alpha.184 → 0.2.0-alpha.186
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-C-Rw9vPc.js} +3 -3
- package/dist/_chunks/{actions-O_LsyCE4.js.map → actions-C-Rw9vPc.js.map} +1 -1
- package/dist/_chunks/als-slots-mFweg276.js +27 -0
- package/dist/_chunks/als-slots-mFweg276.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-Cd0VZ_Pd.js} +3 -3
- package/dist/_chunks/{cache-api-B-lhk9p4.js.map → cache-api-Cd0VZ_Pd.js.map} +1 -1
- package/dist/_chunks/cli-schema-sync-CKgHC2MB.js +1857 -0
- package/dist/_chunks/cli-schema-sync-CKgHC2MB.js.map +1 -0
- package/dist/_chunks/{cloudflare-CnT5Lr7U.js → cloudflare-Cs0uZXea.js} +89 -12
- package/dist/_chunks/{cloudflare-CnT5Lr7U.js.map → cloudflare-Cs0uZXea.js.map} +1 -1
- package/dist/_chunks/error-boundary-DpYRI_I1.js +330 -0
- package/dist/_chunks/error-boundary-DpYRI_I1.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/fs-identity-D7vb6GdI.js +208 -0
- package/dist/_chunks/fs-identity-D7vb6GdI.js.map +1 -0
- package/dist/_chunks/{logger-AWfuX-KJ.js → logger-N7e5auP0.js} +9 -13
- package/dist/_chunks/logger-N7e5auP0.js.map +1 -0
- package/dist/_chunks/navigation-root-B29qg0_T.js +274 -0
- package/dist/_chunks/navigation-root-B29qg0_T.js.map +1 -0
- package/dist/_chunks/param-value-C8TNYchQ.js +66 -0
- package/dist/_chunks/param-value-C8TNYchQ.js.map +1 -0
- package/dist/_chunks/{plugin-context---kTF5v8.js → plugin-context-DEGLSJs3.js} +2 -1
- package/dist/_chunks/plugin-context-DEGLSJs3.js.map +1 -0
- 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-payload-path-B_LBodc2.js +48 -0
- package/dist/_chunks/rsc-payload-path-B_LBodc2.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/{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-segment-params-ClyUNq4d.js +128 -0
- package/dist/_chunks/use-segment-params-ClyUNq4d.js.map +1 -0
- package/dist/_chunks/{walkers-BtTlKviE.js → walkers-BhhwI9TD.js} +21 -87
- package/dist/_chunks/walkers-BhhwI9TD.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/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 +2 -3
- 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/cli.js +2 -2
- 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/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 +1 -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.js +18 -11
- 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 +872 -305
- package/dist/client/internal.js.map +1 -1
- 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 +71 -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 +39 -16
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +79 -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-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/cookies/index.js +3 -3
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +915 -320
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +33 -1
- 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 +9 -0
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +149 -8
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +18 -0
- package/dist/routing/codegen-types.d.ts.map +1 -1
- 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/index.js +3 -3
- package/dist/routing/interception.d.ts +235 -6
- package/dist/routing/interception.d.ts.map +1 -1
- 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 +1 -3
- 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/search-params/define.d.ts +0 -6
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.js +176 -2
- package/dist/search-params/index.js.map +1 -1
- package/dist/segment-params/define.d.ts +0 -6
- package/dist/segment-params/define.d.ts.map +1 -1
- package/dist/segment-params/index.js +69 -1
- package/dist/segment-params/index.js.map +1 -0
- package/dist/server/actions.d.ts +6 -3
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +2 -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/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 +4 -7
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +14 -3
- package/dist/server/deny-renderer.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.js +2 -2
- package/dist/server/internal.js +528 -373
- package/dist/server/internal.js.map +1 -1
- package/dist/server/param-coercion.d.ts +10 -1
- 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/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +0 -12
- 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 +32 -2
- 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/error-renderer.d.ts +14 -2
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +2 -10
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -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/rsc-payload.d.ts +3 -2
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +3 -2
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +2 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/sitemap-generator.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 +6 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -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 +3 -10
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- 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-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/dist/shims/navigation-client.d.ts +8 -19
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +0 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/docs/api/31-api-client.mdx +4 -2
- package/docs/learn/02-pages-and-layouts.mdx +1 -1
- package/docs/learn/11-error-handling.mdx +4 -3
- package/docs/learn/12-client-navigation.mdx +2 -2
- package/docs/learn/14-deploying.mdx +23 -0
- package/docs/more/01-advanced-routing.mdx +172 -2
- package/docs/more/04-metadata-and-fonts.mdx +1 -1
- package/package.json +2 -2
- package/src/adapters/build-output-helper.ts +116 -3
- package/src/adapters/cloudflare.ts +86 -11
- 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/client/browser-entry/action-dispatch.ts +9 -4
- package/src/client/browser-entry/hydrate.ts +40 -29
- package/src/client/browser-entry/index.ts +15 -1
- 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 +20 -15
- package/src/client/history.ts +13 -6
- package/src/client/internal.ts +4 -3
- package/src/client/link.tsx +55 -8
- package/src/client/navigation-api.ts +3 -3
- package/src/client/navigation-commit.ts +174 -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 +389 -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 +242 -690
- package/src/client/rsc-fetch.ts +180 -122
- package/src/client/segment-cache.ts +121 -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-router.ts +1 -1
- package/src/client/use-segment-params.ts +41 -18
- package/src/index.ts +5 -1
- package/src/plugin-context.ts +34 -1
- 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 +60 -3
- package/src/plugins/static-build.ts +333 -63
- package/src/routing/codegen-types.ts +18 -0
- package/src/routing/codegen.ts +50 -10
- package/src/routing/collision-probe.ts +438 -0
- package/src/routing/collision-spaces.ts +281 -0
- package/src/routing/interception.ts +896 -50
- package/src/routing/manifest-codegen.ts +6 -9
- package/src/routing/scanner.ts +114 -113
- 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 +1 -3
- package/src/routing/walkers.ts +8 -0
- package/src/search-params/define.ts +6 -29
- package/src/segment-params/define.ts +4 -27
- package/src/server/action-handler.ts +3 -1
- package/src/server/actions.ts +6 -3
- package/src/server/als-registry.ts +2 -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/default-status-page.ts +127 -0
- package/src/server/deny-boundary.ts +5 -31
- package/src/server/deny-renderer.ts +83 -22
- package/src/server/head-response.ts +54 -0
- package/src/server/param-coercion.ts +48 -18
- package/src/server/pipeline-helpers.ts +0 -45
- package/src/server/pipeline-interception.ts +81 -32
- package/src/server/pipeline-outcome.ts +3 -5
- package/src/server/pipeline-phases.ts +68 -16
- package/src/server/pipeline.ts +57 -10
- package/src/server/prebuilt-builder.ts +1 -7
- package/src/server/prebuilt-runtime.ts +4 -4
- package/src/server/primitives.ts +0 -18
- package/src/server/publish-params.ts +42 -0
- package/src/server/request-context.ts +97 -18
- package/src/server/route-element-builder.ts +89 -98
- package/src/server/route-handler.ts +8 -8
- package/src/server/route-matcher.ts +21 -9
- package/src/server/rsc-cache-key-guard.ts +170 -0
- package/src/server/rsc-entry/api-handler.ts +6 -8
- package/src/server/rsc-entry/error-renderer.ts +64 -25
- package/src/server/rsc-entry/helpers.ts +31 -16
- package/src/server/rsc-entry/index.ts +36 -28
- package/src/server/rsc-entry/render-route.ts +12 -7
- package/src/server/rsc-entry/revalidate-renderer.ts +1 -1
- package/src/server/rsc-entry/rsc-payload.ts +14 -25
- package/src/server/rsc-entry/rsc-stream.ts +4 -3
- package/src/server/rsc-entry/ssr-renderer.ts +13 -9
- package/src/server/sitemap-generator.ts +1 -7
- package/src/server/skippable-prefix.ts +92 -0
- package/src/server/slot-interception.ts +168 -0
- package/src/server/slot-resolver.ts +253 -156
- package/src/server/ssr-bridge-types.ts +6 -0
- package/src/server/ssr-entry.ts +12 -4
- package/src/server/ssr-wrappers.tsx +33 -32
- package/src/server/state-tree-diff.ts +14 -55
- package/src/server/static-generator.ts +67 -45
- package/src/server/static-not-found.ts +134 -0
- package/src/server/status-code-resolver.ts +4 -47
- 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-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/src/shims/navigation-client.ts +8 -26
- package/src/shims/navigation.ts +0 -4
- 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/canonicalize-DQHyFClh.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-D3naS3eH.js +0 -688
- package/dist/_chunks/cli-schema-sync-D3naS3eH.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/router-ref-CtmF-aPv.js.map +0 -1
- package/dist/_chunks/segment-classify-Byy425ng.js.map +0 -1
- package/dist/_chunks/ssr-data-BOWsq18U.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-BtTlKviE.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/src/plugins/request-dep.ts +0 -499
package/dist/server/internal.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { n as classifyUrlSegment } from "../_chunks/segment-classify-
|
|
3
|
-
import { $ as timingAls, 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, Y as earlyHintsSenderAls, Z as requestContextAls, _ 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-
|
|
4
|
-
import { i as
|
|
5
|
-
import {
|
|
6
|
-
import "../
|
|
1
|
+
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";
|
|
2
|
+
import { n as classifyUrlSegment, r as effectiveUrlSegment } from "../_chunks/segment-classify-C539Pa2O.js";
|
|
3
|
+
import { $ as timingAls, 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, Y as earlyHintsSenderAls, Z as requestContextAls, _ 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-N7e5auP0.js";
|
|
4
|
+
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";
|
|
5
|
+
import { i as collectRouteModulepreloads, n as collectRouteCss, r as collectRouteFonts } from "../_chunks/build-manifest-DWppEdLB.js";
|
|
6
|
+
import { i as coerce, t as executeAction } from "../_chunks/actions-C-Rw9vPc.js";
|
|
7
|
+
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-Cd0VZ_Pd.js";
|
|
8
|
+
import "../_chunks/error-boundary-DpYRI_I1.js";
|
|
7
9
|
import { n as toBracketKey } from "../_chunks/resolve-schema-Dz3fcFUo.js";
|
|
10
|
+
import { t as normalizeParamValue } from "../_chunks/param-value-C8TNYchQ.js";
|
|
8
11
|
import "../_chunks/segment-context-ZDnXDkbz.js";
|
|
12
|
+
import { n as rscErrorEnvelope } from "../_chunks/rsc-error-envelope-tT5PJs4q.js";
|
|
9
13
|
import { createHash } from "node:crypto";
|
|
10
14
|
import { readFile } from "node:fs/promises";
|
|
11
15
|
import React, { createElement, useContext } from "react";
|
|
@@ -209,38 +213,6 @@ function hasOnRequestError() {
|
|
|
209
213
|
//#endregion
|
|
210
214
|
//#region src/server/pipeline-helpers.ts
|
|
211
215
|
/**
|
|
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
216
|
* Build a proxy resolver closure from the declared source. Called exactly
|
|
245
217
|
* once at `createPipeline` setup time, so the hot path sees only the branch
|
|
246
218
|
* that corresponds to this pipeline's configured variant.
|
|
@@ -1163,165 +1135,6 @@ async function loadModule(loader) {
|
|
|
1163
1135
|
throw new ModuleLoadError(loader.filePath, error);
|
|
1164
1136
|
}
|
|
1165
1137
|
}
|
|
1166
|
-
/** Reverse index: status code → legacy file name. Built once at module load. */
|
|
1167
|
-
var STATUS_TO_LEGACY_FILE = Object.fromEntries(Object.entries({
|
|
1168
|
-
"not-found": 404,
|
|
1169
|
-
"forbidden": 403,
|
|
1170
|
-
"unauthorized": 401
|
|
1171
|
-
}).map(([name, status]) => [status, name]));
|
|
1172
|
-
/**
|
|
1173
|
-
* Look up `{statusStr}` then `{categoryKey}` (e.g. "4xx" / "5xx") in a
|
|
1174
|
-
* status-file group on a single segment. Shared by all three fallback
|
|
1175
|
-
* chains — the only structural difference between component 4xx,
|
|
1176
|
-
* component 5xx, and JSON resolution is *which* group is searched and
|
|
1177
|
-
* how the per-segment loop is layered around it.
|
|
1178
|
-
*/
|
|
1179
|
-
function lookupInGroup(group, statusStr, categoryKey, segmentIndex, status) {
|
|
1180
|
-
if (!group) return null;
|
|
1181
|
-
const exact = group[statusStr];
|
|
1182
|
-
if (exact) return {
|
|
1183
|
-
file: exact,
|
|
1184
|
-
status,
|
|
1185
|
-
kind: "exact",
|
|
1186
|
-
segmentIndex
|
|
1187
|
-
};
|
|
1188
|
-
const category = group[categoryKey];
|
|
1189
|
-
if (category) return {
|
|
1190
|
-
file: category,
|
|
1191
|
-
status,
|
|
1192
|
-
kind: "category",
|
|
1193
|
-
segmentIndex
|
|
1194
|
-
};
|
|
1195
|
-
return null;
|
|
1196
|
-
}
|
|
1197
|
-
/**
|
|
1198
|
-
* Look up the legacy convention file (`not-found.tsx` / `forbidden.tsx` /
|
|
1199
|
-
* `unauthorized.tsx`) for `status` on a single segment. Returns null if
|
|
1200
|
-
* `status` has no legacy mapping or the file isn't present.
|
|
1201
|
-
*/
|
|
1202
|
-
function lookupLegacy(group, status, segmentIndex) {
|
|
1203
|
-
if (!group) return null;
|
|
1204
|
-
const name = STATUS_TO_LEGACY_FILE[status];
|
|
1205
|
-
if (!name) return null;
|
|
1206
|
-
const file = group[name];
|
|
1207
|
-
return file ? {
|
|
1208
|
-
file,
|
|
1209
|
-
status,
|
|
1210
|
-
kind: "legacy",
|
|
1211
|
-
segmentIndex
|
|
1212
|
-
} : null;
|
|
1213
|
-
}
|
|
1214
|
-
/**
|
|
1215
|
-
* Resolve the status-code file to render for a given HTTP status code.
|
|
1216
|
-
*
|
|
1217
|
-
* Walks the segment chain from leaf to root following the fallback chain
|
|
1218
|
-
* defined in design/10-error-handling.md. Returns null if no file is found
|
|
1219
|
-
* (caller should render the framework default).
|
|
1220
|
-
*
|
|
1221
|
-
* @param status - The HTTP status code (4xx or 5xx).
|
|
1222
|
-
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
1223
|
-
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
1224
|
-
*/
|
|
1225
|
-
function resolveStatusFile(status, segments, format = "component") {
|
|
1226
|
-
if (status < 400 || status > 599) return null;
|
|
1227
|
-
if (format === "json") return resolveJson(status, segments);
|
|
1228
|
-
if (status <= 499) return resolve4xx(status, segments);
|
|
1229
|
-
return resolve5xx(status, segments);
|
|
1230
|
-
}
|
|
1231
|
-
/**
|
|
1232
|
-
* 4xx component fallback chain — three separate full passes leaf→root.
|
|
1233
|
-
*
|
|
1234
|
-
* The passes must be separate (not interleaved per-segment) so that a
|
|
1235
|
-
* root-level `404.tsx` beats a leaf-level `error.tsx`. The 5xx chain
|
|
1236
|
-
* inverts this and is per-segment: a leaf's `error.tsx` beats a root's
|
|
1237
|
-
* `5xx.tsx`. This asymmetry is the only reason these two functions exist
|
|
1238
|
-
* separately.
|
|
1239
|
-
*
|
|
1240
|
-
* Pass 1 — {status}.tsx → 4xx.tsx (statusFiles)
|
|
1241
|
-
* Pass 2 — not-found / forbidden / unauthorized (legacyStatusFiles)
|
|
1242
|
-
* Pass 3 — error.tsx (error)
|
|
1243
|
-
*/
|
|
1244
|
-
function resolve4xx(status, segments) {
|
|
1245
|
-
const statusStr = String(status);
|
|
1246
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1247
|
-
const r = lookupInGroup(segments[i].statusFiles, statusStr, "4xx", i, status);
|
|
1248
|
-
if (r) return r;
|
|
1249
|
-
}
|
|
1250
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1251
|
-
const r = lookupLegacy(segments[i].legacyStatusFiles, status, i);
|
|
1252
|
-
if (r) return r;
|
|
1253
|
-
}
|
|
1254
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1255
|
-
const errorFile = segments[i].error;
|
|
1256
|
-
if (errorFile) return {
|
|
1257
|
-
file: errorFile,
|
|
1258
|
-
status,
|
|
1259
|
-
kind: "error",
|
|
1260
|
-
segmentIndex: i
|
|
1261
|
-
};
|
|
1262
|
-
}
|
|
1263
|
-
return null;
|
|
1264
|
-
}
|
|
1265
|
-
/**
|
|
1266
|
-
* 5xx component fallback chain — single pass, per-segment leaf→root.
|
|
1267
|
-
*
|
|
1268
|
-
* At each segment: {status}.tsx → 5xx.tsx → error.tsx. A leaf's
|
|
1269
|
-
* `error.tsx` therefore beats a root's `5xx.tsx`, which is the
|
|
1270
|
-
* intentional inverse of the 4xx chain.
|
|
1271
|
-
*/
|
|
1272
|
-
function resolve5xx(status, segments) {
|
|
1273
|
-
const statusStr = String(status);
|
|
1274
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1275
|
-
const segment = segments[i];
|
|
1276
|
-
const r = lookupInGroup(segment.statusFiles, statusStr, "5xx", i, status);
|
|
1277
|
-
if (r) return r;
|
|
1278
|
-
if (segment.error) return {
|
|
1279
|
-
file: segment.error,
|
|
1280
|
-
status,
|
|
1281
|
-
kind: "error",
|
|
1282
|
-
segmentIndex: i
|
|
1283
|
-
};
|
|
1284
|
-
}
|
|
1285
|
-
return null;
|
|
1286
|
-
}
|
|
1287
|
-
/**
|
|
1288
|
-
* JSON fallback chain (for both 4xx and 5xx) — single pass leaf→root.
|
|
1289
|
-
*
|
|
1290
|
-
* At each segment: {status}.json → {category}.json. No legacy compat,
|
|
1291
|
-
* no error.tsx — the JSON chain terminates at the category catch-all
|
|
1292
|
-
* and the caller falls back to a bare-JSON framework default.
|
|
1293
|
-
*/
|
|
1294
|
-
function resolveJson(status, segments) {
|
|
1295
|
-
const statusStr = String(status);
|
|
1296
|
-
const categoryKey = status >= 500 ? "5xx" : "4xx";
|
|
1297
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1298
|
-
const r = lookupInGroup(segments[i].jsonStatusFiles, statusStr, categoryKey, i, status);
|
|
1299
|
-
if (r) return r;
|
|
1300
|
-
}
|
|
1301
|
-
return null;
|
|
1302
|
-
}
|
|
1303
|
-
/**
|
|
1304
|
-
* Resolve the denial file for a parallel route slot.
|
|
1305
|
-
*
|
|
1306
|
-
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
1307
|
-
* Fallback chain: denied.tsx → default.tsx → null.
|
|
1308
|
-
*
|
|
1309
|
-
* @param slotNode - The segment node for the slot (segmentType === 'slot').
|
|
1310
|
-
*/
|
|
1311
|
-
function resolveSlotDenied(slotNode) {
|
|
1312
|
-
const slotName = slotNode.segmentName.replace(/^@/, "");
|
|
1313
|
-
if (slotNode.denied) return {
|
|
1314
|
-
file: slotNode.denied,
|
|
1315
|
-
slotName,
|
|
1316
|
-
kind: "denied"
|
|
1317
|
-
};
|
|
1318
|
-
if (slotNode.default) return {
|
|
1319
|
-
file: slotNode.default,
|
|
1320
|
-
slotName,
|
|
1321
|
-
kind: "default"
|
|
1322
|
-
};
|
|
1323
|
-
return null;
|
|
1324
|
-
}
|
|
1325
1138
|
//#endregion
|
|
1326
1139
|
//#region src/server/deny-boundary.ts
|
|
1327
1140
|
/**
|
|
@@ -1371,7 +1184,7 @@ function renderMatchingDenyPage(chain, status, data) {
|
|
|
1371
1184
|
* Build the element for a matched deny chain entry with the props contract
|
|
1372
1185
|
* the file's convention documents:
|
|
1373
1186
|
*
|
|
1374
|
-
* - Status-code
|
|
1187
|
+
* - Status-code files: { status, dangerouslyPassData }
|
|
1375
1188
|
* - error.tsx (TSX): { error, digest, reset } via ErrorReconstituter, plus
|
|
1376
1189
|
* { status, dangerouslyPassData } for dual-shape implementations. Without
|
|
1377
1190
|
* the Error prop, any error.tsx written per the docs (reading
|
|
@@ -1925,20 +1738,23 @@ async function coerceSegmentParams(match) {
|
|
|
1925
1738
|
for (const key of Object.keys(match.segmentParams)) if (key !== "__proto__") mergeTarget[key] = match.segmentParams[key];
|
|
1926
1739
|
match.segmentParams = mergeTarget;
|
|
1927
1740
|
if (globalCodecs) {
|
|
1928
|
-
for (const
|
|
1741
|
+
for (const node of match.segments) {
|
|
1742
|
+
const segment = effectiveUrlSegment(node);
|
|
1929
1743
|
if (!segment.paramName) continue;
|
|
1930
1744
|
const bracketKey = toBracketKey(segment.segmentType, segment.paramName);
|
|
1931
1745
|
const codec = globalCodecs[bracketKey];
|
|
1932
1746
|
if (!codec) continue;
|
|
1933
1747
|
const key = segment.paramName;
|
|
1934
1748
|
if (!(key in mergeTarget)) continue;
|
|
1749
|
+
let parsed;
|
|
1935
1750
|
try {
|
|
1936
|
-
|
|
1751
|
+
parsed = codec.parse(mergeTarget[key]);
|
|
1937
1752
|
} catch (err) {
|
|
1938
1753
|
const message = err instanceof Error ? err.message : String(err);
|
|
1939
1754
|
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}'.`);
|
|
1940
1755
|
throw new ParamCoercionError(message);
|
|
1941
1756
|
}
|
|
1757
|
+
mergeTarget[key] = normalizeParamValue(parsed, key);
|
|
1942
1758
|
}
|
|
1943
1759
|
return;
|
|
1944
1760
|
}
|
|
@@ -1954,9 +1770,9 @@ async function coerceSegmentParams(match) {
|
|
|
1954
1770
|
}
|
|
1955
1771
|
const segmentParamsDef = mod.segmentParams;
|
|
1956
1772
|
if (!segmentParamsDef || typeof segmentParamsDef.parse !== "function") continue;
|
|
1773
|
+
let coerced;
|
|
1957
1774
|
try {
|
|
1958
|
-
|
|
1959
|
-
for (const key of Object.keys(coerced)) if (key !== "__proto__") mergeTarget[key] = sanitizeParamValue(coerced[key]);
|
|
1775
|
+
coerced = segmentParamsDef.parse(match.segmentParams);
|
|
1960
1776
|
} catch (err) {
|
|
1961
1777
|
const message = err instanceof Error ? err.message : String(err);
|
|
1962
1778
|
if (isDebug()) {
|
|
@@ -1965,7 +1781,127 @@ async function coerceSegmentParams(match) {
|
|
|
1965
1781
|
}
|
|
1966
1782
|
throw new ParamCoercionError(message);
|
|
1967
1783
|
}
|
|
1784
|
+
for (const key of Object.keys(coerced)) {
|
|
1785
|
+
if (key === "__proto__") continue;
|
|
1786
|
+
mergeTarget[key] = normalizeParamValue(coerced[key], key);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
//#endregion
|
|
1791
|
+
//#region src/server/pipeline-interception.ts
|
|
1792
|
+
/**
|
|
1793
|
+
* Interception route matching for the request pipeline.
|
|
1794
|
+
*
|
|
1795
|
+
* Matches target URLs against interception rewrites to support the
|
|
1796
|
+
* modal route pattern (soft navigation intercepts).
|
|
1797
|
+
*
|
|
1798
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
1799
|
+
*
|
|
1800
|
+
* See design/07-routing.md §"Intercepting Routes"
|
|
1801
|
+
*/
|
|
1802
|
+
/**
|
|
1803
|
+
* Check if an intercepting route applies for this soft navigation.
|
|
1804
|
+
*
|
|
1805
|
+
* Two conditions must hold:
|
|
1806
|
+
*
|
|
1807
|
+
* 1. The **target** pathname matches the rewrite's intercepted pattern.
|
|
1808
|
+
* 2. The **source** route — the page the user is navigating FROM, per the
|
|
1809
|
+
* validated X-Timber-URL header — renders the segment that owns the slot
|
|
1810
|
+
* holding the intercepting page (`interceptingScope`). Only then does that
|
|
1811
|
+
* slot exist in the rendered tree, so only then can the intercepting page
|
|
1812
|
+
* render.
|
|
1813
|
+
*
|
|
1814
|
+
* Condition 2 replaced a URL-prefix check (TIM-1279). A prefix cannot
|
|
1815
|
+
* distinguish a route-group-scoped interception from a genuinely root-level
|
|
1816
|
+
* one — route groups are invisible in `urlPath`, so both collapse to "/" and
|
|
1817
|
+
* the guard degenerates to always-true. Tree paths carry group identity.
|
|
1818
|
+
* The scope check also subsumes the old segment-boundary rule: a source route
|
|
1819
|
+
* under /feed-private has no "/feed" in its chain.
|
|
1820
|
+
*
|
|
1821
|
+
* A third condition lives with the caller, which owns route matching, and it
|
|
1822
|
+
* applies to the `'slot'` shape only: the route produced by re-matching
|
|
1823
|
+
* `interceptingPrefix` must pass through the scope too. Conditions 1–2 are
|
|
1824
|
+
* about the route the user is leaving; the slot has to exist in the tree that
|
|
1825
|
+
* actually renders. The `'children'` shape resolves its owner from the scope
|
|
1826
|
+
* directly rather than from a pathname, so it satisfies that by construction.
|
|
1827
|
+
*
|
|
1828
|
+
* `resolveSourceScopes` is a thunk so its inputs are only resolved when some
|
|
1829
|
+
* rewrite's pattern actually matches the target — the common case is no match
|
|
1830
|
+
* at all. It returns the two client-supplied answers to "what is on screen?"
|
|
1831
|
+
* as **separate sets, most authoritative first**: the tree paths the client
|
|
1832
|
+
* says it has mounted, then those of the route the departing URL matches. It
|
|
1833
|
+
* returns null only when neither yields anything, which is not a soft
|
|
1834
|
+
* navigation this can reason about.
|
|
1835
|
+
*
|
|
1836
|
+
* They are two sets rather than one union because ranking needs a chain
|
|
1837
|
+
* (TIM-1318). Each channel describes a single rendered chain, so within one the
|
|
1838
|
+
* scopes are strictly nested and the deepest is the innermost owner. Unioned
|
|
1839
|
+
* they are not: once a modal is open the address bar holds the *intercepted*
|
|
1840
|
+
* URL, so the second channel describes the canonical branch — possibly an
|
|
1841
|
+
* exclusive route group the client never mounted — and a deeper scope from
|
|
1842
|
+
* there would outrank the owner actually on screen. Trying the mounted channel
|
|
1843
|
+
* to exhaustion first keeps the fallback the union was there to provide while
|
|
1844
|
+
* ranking only within one chain.
|
|
1845
|
+
*
|
|
1846
|
+
* Returns the winning build-time rewrite, or null. Only build-time values ever
|
|
1847
|
+
* reach the renderer: the client-supplied header selects a rewrite but never
|
|
1848
|
+
* becomes a rendered route or pathname.
|
|
1849
|
+
*
|
|
1850
|
+
* The **first** matching rewrite wins, and the caller does not fall back to the
|
|
1851
|
+
* next one when the winner fails to resolve. That is deliberate and pre-dates
|
|
1852
|
+
* the two shapes: a rewrite that matched the target and the scope described the
|
|
1853
|
+
* author's intent, and quietly rendering some other author's interception
|
|
1854
|
+
* instead would be harder to explain than rendering the canonical route.
|
|
1855
|
+
*
|
|
1856
|
+
* "First" is meaningful because the list arrives **deepest scope first** —
|
|
1857
|
+
* `collectInterceptionRewrites` sorts it at build time, so the deepest owner a
|
|
1858
|
+
* single chain passes through wins and nothing here has to score (TIM-1318).
|
|
1859
|
+
* Tree-walk order used to decide it, which meant renaming a route group flipped
|
|
1860
|
+
* which modal opened. The precondition is a property of the manifest this reads
|
|
1861
|
+
* rather than something it can re-derive, so it is asserted at its source:
|
|
1862
|
+
* `tests/interception.test.ts` §"rewrite order is decided by scope depth".
|
|
1863
|
+
*/
|
|
1864
|
+
function findInterceptionMatch(targetPathname, resolveSourceScopes, rewrites) {
|
|
1865
|
+
const candidates = rewrites.filter((rewrite) => pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern));
|
|
1866
|
+
if (candidates.length === 0) return null;
|
|
1867
|
+
const scopes = resolveSourceScopes();
|
|
1868
|
+
if (!scopes) return null;
|
|
1869
|
+
for (const scope of scopes) for (const rewrite of candidates) if (scope.has(rewrite.interceptingScope)) return rewrite;
|
|
1870
|
+
return null;
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
1874
|
+
*
|
|
1875
|
+
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
1876
|
+
* Static segments must match exactly.
|
|
1877
|
+
*/
|
|
1878
|
+
function pathnameMatchesPattern(pathname, pattern) {
|
|
1879
|
+
const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
|
|
1880
|
+
const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
1881
|
+
let pi = 0;
|
|
1882
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
1883
|
+
const seg = classifyUrlSegment(patternParts[i]);
|
|
1884
|
+
switch (seg.kind) {
|
|
1885
|
+
case "catch-all": return pi < pathParts.length;
|
|
1886
|
+
case "optional-catch-all": return true;
|
|
1887
|
+
case "dynamic": {
|
|
1888
|
+
if (pi >= pathParts.length) return false;
|
|
1889
|
+
const prefix = seg.prefix ?? "";
|
|
1890
|
+
const suffix = seg.suffix ?? "";
|
|
1891
|
+
const pathPart = pathParts[pi];
|
|
1892
|
+
if (prefix || suffix) {
|
|
1893
|
+
if (pathPart.length <= prefix.length + suffix.length || prefix && !pathPart.startsWith(prefix) || suffix && !pathPart.endsWith(suffix)) return false;
|
|
1894
|
+
}
|
|
1895
|
+
pi++;
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
case "static":
|
|
1899
|
+
if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
|
|
1900
|
+
pi++;
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1968
1903
|
}
|
|
1904
|
+
return pi === pathParts.length;
|
|
1969
1905
|
}
|
|
1970
1906
|
//#endregion
|
|
1971
1907
|
//#region src/client/segment-update-context.ts
|
|
@@ -2024,6 +1960,51 @@ getOrCreateContext();
|
|
|
2024
1960
|
* See design/13-security.md §"State tree manipulation".
|
|
2025
1961
|
*/
|
|
2026
1962
|
//#endregion
|
|
1963
|
+
//#region src/server/state-tree-diff.ts
|
|
1964
|
+
/**
|
|
1965
|
+
* State Tree Diffing — Server-side parsing and diffing of X-Timber-State-Tree.
|
|
1966
|
+
*
|
|
1967
|
+
* The client sends X-Timber-State-Tree on navigation requests, listing
|
|
1968
|
+
* the sync segments it has cached. The server diffs this against the
|
|
1969
|
+
* target route's segments to skip re-rendering unchanged sync layouts.
|
|
1970
|
+
*
|
|
1971
|
+
* This is a performance optimization only — NOT a security boundary.
|
|
1972
|
+
* All access.ts files run regardless of the state tree content.
|
|
1973
|
+
* A fabricated state tree can only cause extra rendering work or stale
|
|
1974
|
+
* layouts — never auth bypass.
|
|
1975
|
+
*
|
|
1976
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
1977
|
+
* See design/13-security.md §"State tree manipulation"
|
|
1978
|
+
*/
|
|
1979
|
+
/**
|
|
1980
|
+
* Parse the X-Timber-State-Tree header from a request.
|
|
1981
|
+
*
|
|
1982
|
+
* Returns a Set of segment paths the client has cached (with an
|
|
1983
|
+
* additional `slots` property for cached slot paths), or null if
|
|
1984
|
+
* the header is missing, malformed, or empty. Parsing happens before
|
|
1985
|
+
* renderToReadableStream — not inside the React render pass.
|
|
1986
|
+
*
|
|
1987
|
+
* @returns ClientStateTree with segments and slots, or null if no valid state tree
|
|
1988
|
+
*/
|
|
1989
|
+
function parseClientStateTree(req) {
|
|
1990
|
+
const header = req.headers.get("X-Timber-State-Tree");
|
|
1991
|
+
if (!header) return null;
|
|
1992
|
+
try {
|
|
1993
|
+
const parsed = JSON.parse(header);
|
|
1994
|
+
const hasSegments = Array.isArray(parsed.segments) && parsed.segments.length > 0;
|
|
1995
|
+
const hasSlots = Array.isArray(parsed.slots) && parsed.slots.length > 0;
|
|
1996
|
+
const hasTreePaths = Array.isArray(parsed.treePaths) && parsed.treePaths.length > 0;
|
|
1997
|
+
if (!hasSegments && !hasSlots && !hasTreePaths) return null;
|
|
1998
|
+
const result = new Set(hasSegments ? parsed.segments : []);
|
|
1999
|
+
result.slots = hasSlots ? new Set(parsed.slots) : null;
|
|
2000
|
+
result.treePaths = hasTreePaths ? new Set(parsed.treePaths) : null;
|
|
2001
|
+
return result;
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
swallow(err, "malformed X-Timber-State-Tree header");
|
|
2004
|
+
return null;
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
//#endregion
|
|
2027
2008
|
//#region src/server/route-element-builder.ts
|
|
2028
2009
|
/**
|
|
2029
2010
|
* Thrown when a defineSegmentParams codec's parse() fails.
|
|
@@ -2101,78 +2082,6 @@ function escapeXml(str) {
|
|
|
2101
2082
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2102
2083
|
}
|
|
2103
2084
|
//#endregion
|
|
2104
|
-
//#region src/server/pipeline-interception.ts
|
|
2105
|
-
/**
|
|
2106
|
-
* Interception route matching for the request pipeline.
|
|
2107
|
-
*
|
|
2108
|
-
* Matches target URLs against interception rewrites to support the
|
|
2109
|
-
* modal route pattern (soft navigation intercepts).
|
|
2110
|
-
*
|
|
2111
|
-
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
2112
|
-
*
|
|
2113
|
-
* See design/07-routing.md §"Intercepting Routes"
|
|
2114
|
-
*/
|
|
2115
|
-
/**
|
|
2116
|
-
* Check if a pathname starts with a prefix on a segment boundary.
|
|
2117
|
-
*
|
|
2118
|
-
* Prevents /feed from matching /feed-private — the prefix must be
|
|
2119
|
-
* followed by '/' or be an exact match.
|
|
2120
|
-
*/
|
|
2121
|
-
function hasSegmentPrefix(pathname, prefix) {
|
|
2122
|
-
if (prefix === "/") return true;
|
|
2123
|
-
if (!pathname.startsWith(prefix)) return false;
|
|
2124
|
-
return pathname.length === prefix.length || pathname[prefix.length] === "/";
|
|
2125
|
-
}
|
|
2126
|
-
/**
|
|
2127
|
-
* Check if an intercepting route applies for this soft navigation.
|
|
2128
|
-
*
|
|
2129
|
-
* Matches the target pathname against interception rewrites, constrained
|
|
2130
|
-
* by the source URL (X-Timber-URL header — where the user navigates FROM).
|
|
2131
|
-
*
|
|
2132
|
-
* Returns the source pathname to re-match if interception applies, or null.
|
|
2133
|
-
*/
|
|
2134
|
-
function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
|
|
2135
|
-
for (const rewrite of rewrites) {
|
|
2136
|
-
if (!hasSegmentPrefix(sourceUrl, rewrite.interceptingPrefix)) continue;
|
|
2137
|
-
if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
|
|
2138
|
-
}
|
|
2139
|
-
return null;
|
|
2140
|
-
}
|
|
2141
|
-
/**
|
|
2142
|
-
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
2143
|
-
*
|
|
2144
|
-
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
2145
|
-
* Static segments must match exactly.
|
|
2146
|
-
*/
|
|
2147
|
-
function pathnameMatchesPattern(pathname, pattern) {
|
|
2148
|
-
const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
|
|
2149
|
-
const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
2150
|
-
let pi = 0;
|
|
2151
|
-
for (let i = 0; i < patternParts.length; i++) {
|
|
2152
|
-
const seg = classifyUrlSegment(patternParts[i]);
|
|
2153
|
-
switch (seg.kind) {
|
|
2154
|
-
case "catch-all": return pi < pathParts.length;
|
|
2155
|
-
case "optional-catch-all": return true;
|
|
2156
|
-
case "dynamic": {
|
|
2157
|
-
if (pi >= pathParts.length) return false;
|
|
2158
|
-
const prefix = seg.prefix ?? "";
|
|
2159
|
-
const suffix = seg.suffix ?? "";
|
|
2160
|
-
const pathPart = pathParts[pi];
|
|
2161
|
-
if (prefix || suffix) {
|
|
2162
|
-
if (pathPart.length <= prefix.length + suffix.length || prefix && !pathPart.startsWith(prefix) || suffix && !pathPart.endsWith(suffix)) return false;
|
|
2163
|
-
}
|
|
2164
|
-
pi++;
|
|
2165
|
-
continue;
|
|
2166
|
-
}
|
|
2167
|
-
case "static":
|
|
2168
|
-
if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
|
|
2169
|
-
pi++;
|
|
2170
|
-
continue;
|
|
2171
|
-
}
|
|
2172
|
-
}
|
|
2173
|
-
return pi === pathParts.length;
|
|
2174
|
-
}
|
|
2175
|
-
//#endregion
|
|
2176
2085
|
//#region src/server/pipeline-outcome.ts
|
|
2177
2086
|
/**
|
|
2178
2087
|
* Pipeline outcome translator — converts a `PhaseOutcome` (the value
|
|
@@ -2187,16 +2096,7 @@ function pathnameMatchesPattern(pathname, pattern) {
|
|
|
2187
2096
|
*/
|
|
2188
2097
|
function rscErrorResponse(isRsc, status, headers) {
|
|
2189
2098
|
if (!isRsc) return new Response(null, { status });
|
|
2190
|
-
|
|
2191
|
-
h.set("X-Timber-Error", "1");
|
|
2192
|
-
h.set("content-type", "application/json; charset=utf-8");
|
|
2193
|
-
return new Response(JSON.stringify({
|
|
2194
|
-
error: true,
|
|
2195
|
-
status
|
|
2196
|
-
}), {
|
|
2197
|
-
status,
|
|
2198
|
-
headers: h
|
|
2199
|
-
});
|
|
2099
|
+
return rscErrorEnvelope(status, headers);
|
|
2200
2100
|
}
|
|
2201
2101
|
/**
|
|
2202
2102
|
* Terminal outcome handler — converts a `PhaseOutcome` into a final
|
|
@@ -2243,7 +2143,7 @@ async function outcomeToResponse(config, outcome, ctx) {
|
|
|
2243
2143
|
await fireOnRequestError(denyRenderError, ctx.req, "render");
|
|
2244
2144
|
if (config.onPipelineError && denyRenderError instanceof Error) config.onPipelineError(denyRenderError, "render");
|
|
2245
2145
|
}
|
|
2246
|
-
if (isDebug()) console.warn(`[timber] DenySignal(${outcome.signal.status}) from ${outcome.phase} phase — no renderDenyFallback configured, returning bare ${outcome.signal.status} response\n Request: ${ctx.method} ${ctx.path}\n Add a
|
|
2146
|
+
if (isDebug()) console.warn(`[timber] DenySignal(${outcome.signal.status}) from ${outcome.phase} phase — no renderDenyFallback configured, returning bare ${outcome.signal.status} response\n Request: ${ctx.method} ${ctx.path}\n Add a status-code file (e.g. 404.tsx) or error.tsx to render a custom deny page.`);
|
|
2247
2147
|
return new Response(null, {
|
|
2248
2148
|
status: outcome.signal.status,
|
|
2249
2149
|
headers
|
|
@@ -2493,15 +2393,6 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2493
2393
|
}
|
|
2494
2394
|
const handlerResult = await mod.default();
|
|
2495
2395
|
if (handlerResult instanceof Response) {
|
|
2496
|
-
if (method === "HEAD") {
|
|
2497
|
-
const headHeaders = new Headers(handlerResult.headers);
|
|
2498
|
-
if (handlerResult.ok && !headHeaders.has("Cache-Control")) headHeaders.set("Cache-Control", "public, max-age=14400, must-revalidate");
|
|
2499
|
-
return new Response(null, {
|
|
2500
|
-
status: handlerResult.status,
|
|
2501
|
-
statusText: handlerResult.statusText,
|
|
2502
|
-
headers: headHeaders
|
|
2503
|
-
});
|
|
2504
|
-
}
|
|
2505
2396
|
const res = cloneWithMutableHeaders(handlerResult);
|
|
2506
2397
|
if (res.ok && !res.headers.has("Cache-Control")) res.headers.set("Cache-Control", "public, max-age=14400, must-revalidate");
|
|
2507
2398
|
return res;
|
|
@@ -2563,10 +2454,19 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2563
2454
|
const rawSourceUrl = req.headers.get("X-Timber-URL");
|
|
2564
2455
|
const validatedSourceUrl = rawSourceUrl ? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash) : null;
|
|
2565
2456
|
if (validatedSourceUrl) {
|
|
2566
|
-
const
|
|
2567
|
-
|
|
2568
|
-
const
|
|
2569
|
-
|
|
2457
|
+
const resolveSourceScopes = () => {
|
|
2458
|
+
const mounted = new Set(parseClientStateTree(req)?.treePaths ?? []);
|
|
2459
|
+
const sourceRoute = config.matchRoute(validatedSourceUrl);
|
|
2460
|
+
const scopes = [mounted, new Set(sourceRoute ? computeSegmentTreePaths(sourceRoute.segments) : [])].filter((scope) => scope.size > 0);
|
|
2461
|
+
return scopes.length > 0 ? scopes : null;
|
|
2462
|
+
};
|
|
2463
|
+
const rewrite = findInterceptionMatch(canonicalPathname, resolveSourceScopes, config.interceptionRewrites);
|
|
2464
|
+
if (rewrite?.mode === "children") {
|
|
2465
|
+
const interceptedMatch = config.matchInterceptedRoute?.(canonicalPathname, rewrite.interceptingScope);
|
|
2466
|
+
if (interceptedMatch) match = interceptedMatch;
|
|
2467
|
+
} else if (rewrite) {
|
|
2468
|
+
const sourceMatch = config.matchRoute(rewrite.interceptingPrefix);
|
|
2469
|
+
if (sourceMatch !== null && computeSegmentTreePaths(sourceMatch.segments).includes(rewrite.interceptingScope)) {
|
|
2570
2470
|
match = sourceMatch;
|
|
2571
2471
|
interception = { targetPathname: canonicalPathname };
|
|
2572
2472
|
}
|
|
@@ -2625,6 +2525,194 @@ async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
|
2625
2525
|
});
|
|
2626
2526
|
}
|
|
2627
2527
|
//#endregion
|
|
2528
|
+
//#region src/server/rsc-cache-key-guard.ts
|
|
2529
|
+
/**
|
|
2530
|
+
* Origin-side binding of the `_rsc` cache key to the request that produced
|
|
2531
|
+
* the response (TIM-1268).
|
|
2532
|
+
*
|
|
2533
|
+
* The key is deterministic, and deliberately so — that is what makes RSC
|
|
2534
|
+
* payloads cacheable. But deterministic also means *predictable*: anyone can
|
|
2535
|
+
* compute the key another client will request. Without this guard, a caller
|
|
2536
|
+
* could request `/about?_rsc=<victim's key>` while sending its own
|
|
2537
|
+
* `X-Timber-State-Tree`, and a shared cache that keys by URL — the exact
|
|
2538
|
+
* environment this feature targets — would store that variant under the
|
|
2539
|
+
* victim's key. Every subsequent legitimate navigation would then be served
|
|
2540
|
+
* a payload rendered for someone else's segment tree or deployment.
|
|
2541
|
+
*
|
|
2542
|
+
* The guard is a *storability* rule, not a rejection: if the claimed key
|
|
2543
|
+
* does not match the key derived from the received headers, the response
|
|
2544
|
+
* loses its shared-cache directives. Mismatched requests are still served
|
|
2545
|
+
* normally, they just cannot be stored for anyone else.
|
|
2546
|
+
*
|
|
2547
|
+
* Why not Next.js's 307 self-correction: a redirect is cacheable in its own
|
|
2548
|
+
* right, costs a round trip on every legitimate miss, and would make the
|
|
2549
|
+
* server's behaviour depend on a parameter it otherwise never reads. This
|
|
2550
|
+
* rule closes the same hole by making the poisoned entry unstorable at the
|
|
2551
|
+
* source, in the response the developer already has to opt into sharing.
|
|
2552
|
+
*
|
|
2553
|
+
* See design/19-client-navigation.md §"The `_rsc` cache key".
|
|
2554
|
+
*/
|
|
2555
|
+
/**
|
|
2556
|
+
* Legacy shared-cache control fields that predate RFC 9213 and so do not
|
|
2557
|
+
* follow the `<target>-Cache-Control` naming pattern — the one part of this
|
|
2558
|
+
* rule that cannot be matched by shape. `X-Accel-Expires` is nginx
|
|
2559
|
+
* `proxy_cache`, which gives it precedence over `Cache-Control`.
|
|
2560
|
+
*
|
|
2561
|
+
* A closed historical set, unlike the vendor `*-Cache-Control` names, which
|
|
2562
|
+
* keep being minted and are handled by pattern below.
|
|
2563
|
+
*/
|
|
2564
|
+
var LEGACY_CDN_CACHE_HEADERS = [
|
|
2565
|
+
"Surrogate-Control",
|
|
2566
|
+
"Edge-Control",
|
|
2567
|
+
"X-Accel-Expires"
|
|
2568
|
+
];
|
|
2569
|
+
/**
|
|
2570
|
+
* Whether a response header overrides `Cache-Control` at a shared cache.
|
|
2571
|
+
*
|
|
2572
|
+
* Matched by *shape*, not by vendor name. RFC 9213 defines targeted fields
|
|
2573
|
+
* as `<target>-Cache-Control`, and every CDN spells its own: `CDN-`,
|
|
2574
|
+
* `Cloudflare-CDN-`, `Netlify-CDN-`, `Vercel-CDN-`, and whichever ships
|
|
2575
|
+
* next. A vendor list here is a denylist with an unbounded tail — it was
|
|
2576
|
+
* extended twice during review, each time by one name, which is the signal
|
|
2577
|
+
* that enumeration is the wrong shape. The suffix rule is total.
|
|
2578
|
+
*
|
|
2579
|
+
* `Cache-Control` itself does not match (no `-` prefix) and is replaced
|
|
2580
|
+
* separately.
|
|
2581
|
+
*/
|
|
2582
|
+
function isSharedCacheOverride(name) {
|
|
2583
|
+
const lowered = name.toLowerCase();
|
|
2584
|
+
return lowered.endsWith("-cache-control") || LEGACY_CDN_CACHE_HEADERS.some((legacy) => legacy.toLowerCase() === lowered);
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Whether the request's `_rsc` parameter matches the key derived from its
|
|
2588
|
+
* own headers.
|
|
2589
|
+
*
|
|
2590
|
+
* Requests without the parameter are not claiming a key at all, so they are
|
|
2591
|
+
* left alone — the bare document URL is the developer's declared territory
|
|
2592
|
+
* (see design/32-response-caching.md, TIM-1264). Neither is a parameter
|
|
2593
|
+
* whose value could not be one of our digests: `_rsc` is not reserved, and
|
|
2594
|
+
* an application that already uses it for its own search state
|
|
2595
|
+
* (`/search?_rsc=foo`) must not have its cache policy silently revoked by a
|
|
2596
|
+
* guard that mistook that for a forgery.
|
|
2597
|
+
*/
|
|
2598
|
+
async function hasValidRscCacheKey(req) {
|
|
2599
|
+
if (!req.url.includes("?")) return true;
|
|
2600
|
+
let values;
|
|
2601
|
+
try {
|
|
2602
|
+
values = new URL(req.url).searchParams.getAll(RSC_KEY_PARAM);
|
|
2603
|
+
} catch {
|
|
2604
|
+
return false;
|
|
2605
|
+
}
|
|
2606
|
+
const claims = values.filter(isRscCacheKeyShape);
|
|
2607
|
+
if (claims.length === 0) return true;
|
|
2608
|
+
const expected = await rscCacheKey((name) => req.headers.get(name));
|
|
2609
|
+
if (expected === null) return false;
|
|
2610
|
+
return claims.every((claim) => claim === expected);
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Media type of a Flight payload. Also spelled in `rsc-entry/helpers.ts`,
|
|
2614
|
+
* `deny-renderer.ts`, `action-handler.ts`, and `client/rsc-fetch.ts` — that
|
|
2615
|
+
* duplication predates this module and is tracked separately.
|
|
2616
|
+
*/
|
|
2617
|
+
var RSC_MEDIA_TYPE = "text/x-component";
|
|
2618
|
+
/**
|
|
2619
|
+
* Ensure a Flight payload can never be interpreted as a document.
|
|
2620
|
+
*
|
|
2621
|
+
* Applied here, at the pipeline's single exit, rather than where each
|
|
2622
|
+
* payload response is built. Six places build one — the success path, three
|
|
2623
|
+
* in `deny-renderer.ts`, the action paths — and hardening them one at a time
|
|
2624
|
+
* is how you end up hardening five of six. The response is Flight if it says
|
|
2625
|
+
* it is; that is the only test that covers every producer, including ones
|
|
2626
|
+
* added later.
|
|
2627
|
+
*
|
|
2628
|
+
* Matters because the payload URL is observable and, on a shared-cache hit,
|
|
2629
|
+
* the origin is never consulted — so a top-level navigation can be handed
|
|
2630
|
+
* these bytes with no request-time check available. See design/13-security.md
|
|
2631
|
+
* #36c.
|
|
2632
|
+
*/
|
|
2633
|
+
function hardenRscPayloadResponse(response) {
|
|
2634
|
+
const contentType = response.headers.get("content-type");
|
|
2635
|
+
if (!contentType) return;
|
|
2636
|
+
if (contentType.split(";")[0].trim().toLowerCase() !== RSC_MEDIA_TYPE) return;
|
|
2637
|
+
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Make a response unstorable by any shared cache when its request claimed an
|
|
2641
|
+
* `_rsc` key that does not match its own headers.
|
|
2642
|
+
*
|
|
2643
|
+
* The end state is unconditional and therefore statable: **a mismatched
|
|
2644
|
+
* response is never stored by any cache.** Every targeted cache-control
|
|
2645
|
+
* field is removed and `Cache-Control` is replaced outright, rather than
|
|
2646
|
+
* downgrading only when a shared directive is spotted. Conditional variants
|
|
2647
|
+
* of this were tried and were wrong twice over — they missed the CDN-
|
|
2648
|
+
* specific headers, and leaving `Cache-Control` alone when it looked
|
|
2649
|
+
* harmless lets a CDN apply heuristic caching to a response that has none.
|
|
2650
|
+
*
|
|
2651
|
+
* The cost is that a mismatched request loses developer-chosen *browser*
|
|
2652
|
+
* caching too. That is a request no timber client ever makes.
|
|
2653
|
+
*
|
|
2654
|
+
* Purge tags (`Surrogate-Key`, `Cache-Tag`) are deliberately left alone:
|
|
2655
|
+
* they address stored entries, and this response is not stored.
|
|
2656
|
+
*
|
|
2657
|
+
* Mutates `response.headers` in place; the pipeline guarantees mutable
|
|
2658
|
+
* headers at this point (see TIM-866).
|
|
2659
|
+
*/
|
|
2660
|
+
async function enforceRscCacheKey(req, response) {
|
|
2661
|
+
if (await hasValidRscCacheKey(req)) return;
|
|
2662
|
+
const overrides = [];
|
|
2663
|
+
for (const name of response.headers.keys()) if (isSharedCacheOverride(name)) overrides.push(name);
|
|
2664
|
+
for (const name of overrides) response.headers.delete(name);
|
|
2665
|
+
response.headers.set("Cache-Control", "private, no-store");
|
|
2666
|
+
}
|
|
2667
|
+
//#endregion
|
|
2668
|
+
//#region src/server/head-response.ts
|
|
2669
|
+
/**
|
|
2670
|
+
* HEAD body stripping — RFC 9110 §9.3.2.
|
|
2671
|
+
*
|
|
2672
|
+
* "The HEAD method is identical to GET except that the server MUST NOT send
|
|
2673
|
+
* content in the response." The headers, however, must be the ones the
|
|
2674
|
+
* corresponding GET would have produced — which is why timber renders the
|
|
2675
|
+
* route in full and discards the body here rather than short-circuiting the
|
|
2676
|
+
* render. Content-Type, Content-Length, Set-Cookie, cache directives and
|
|
2677
|
+
* everything else ride through untouched.
|
|
2678
|
+
*
|
|
2679
|
+
* Node's `ServerResponse` happens to discard writes for HEAD, so the wire is
|
|
2680
|
+
* usually correct even without this. That is a property of one adapter, not of
|
|
2681
|
+
* the `Response` object: static generation, the Cloudflare and Nitro adapters,
|
|
2682
|
+
* tests, and anything calling the pipeline handler as a function all observe
|
|
2683
|
+
* the `Response` directly and saw the full body before TIM-1324.
|
|
2684
|
+
*
|
|
2685
|
+
* The pipeline applies this at its single exit so every response shape is
|
|
2686
|
+
* covered — page render, API route, metadata route, deny page, error page,
|
|
2687
|
+
* proxy short-circuit. The two unit-level producers that are also reachable as
|
|
2688
|
+
* standalone functions (`handleRouteRequest`, `handleApiRoute`) call it too, so
|
|
2689
|
+
* each honours HEAD on its own; they share this implementation rather than
|
|
2690
|
+
* repeating it, and applying it twice is a no-op.
|
|
2691
|
+
*/
|
|
2692
|
+
/**
|
|
2693
|
+
* Return a bodyless copy of `response` when `method` is HEAD.
|
|
2694
|
+
*
|
|
2695
|
+
* A no-op for every other method and for responses that already carry no body
|
|
2696
|
+
* (204, 304, redirects), so callers do not need to pre-check either.
|
|
2697
|
+
*
|
|
2698
|
+
* The discarded stream is cancelled: an SSR render that reached this point has
|
|
2699
|
+
* a live producer attached, and dropping the reference without cancelling
|
|
2700
|
+
* leaks it for the lifetime of the render (design/13-security.md A4).
|
|
2701
|
+
*/
|
|
2702
|
+
function stripHeadBody(response, method) {
|
|
2703
|
+
if (method.toUpperCase() !== "HEAD") return response;
|
|
2704
|
+
const body = response.body;
|
|
2705
|
+
if (body === null) return response;
|
|
2706
|
+
try {
|
|
2707
|
+
body.cancel().catch(() => {});
|
|
2708
|
+
} catch {}
|
|
2709
|
+
return new Response(null, {
|
|
2710
|
+
status: response.status,
|
|
2711
|
+
statusText: response.statusText,
|
|
2712
|
+
headers: response.headers
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
//#endregion
|
|
2628
2716
|
//#region src/server/pipeline.ts
|
|
2629
2717
|
/**
|
|
2630
2718
|
* Create the request handler from a pipeline configuration.
|
|
@@ -2688,12 +2776,14 @@ function createPipeline(config) {
|
|
|
2688
2776
|
});
|
|
2689
2777
|
else result = await innerHandler();
|
|
2690
2778
|
await setSpanAttribute("http.response.status_code", result.status);
|
|
2691
|
-
const varyTokens = [
|
|
2692
|
-
if (config.interceptionRewrites?.length) varyTokens.push("X-Timber-URL");
|
|
2779
|
+
const varyTokens = [...RSC_KEY_HEADERS];
|
|
2693
2780
|
const existingVary = result.headers.get("Vary");
|
|
2694
2781
|
const existingTokens = existingVary ? existingVary.toLowerCase().split(",").map((t) => t.trim()) : [];
|
|
2695
2782
|
const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
|
|
2696
2783
|
if (newTokens.length > 0) result.headers.set("Vary", existingVary ? `${existingVary}, ${newTokens.join(", ")}` : newTokens.join(", "));
|
|
2784
|
+
await enforceRscCacheKey(canonicalReq, result);
|
|
2785
|
+
hardenRscPayloadResponse(result);
|
|
2786
|
+
result = stripHeadBody(result, method);
|
|
2697
2787
|
if (serverTiming === "detailed") {
|
|
2698
2788
|
const timingHeader = getServerTimingHeader();
|
|
2699
2789
|
if (timingHeader) result.headers.set("Server-Timing", timingHeader);
|
|
@@ -2735,68 +2825,6 @@ function createPipeline(config) {
|
|
|
2735
2825
|
return pipelineFn;
|
|
2736
2826
|
}
|
|
2737
2827
|
//#endregion
|
|
2738
|
-
//#region src/server/build-manifest.ts
|
|
2739
|
-
/**
|
|
2740
|
-
* Collect all CSS files needed for a matched route's segment chain.
|
|
2741
|
-
*
|
|
2742
|
-
* Walks segments root → leaf, collecting CSS for each layout and page.
|
|
2743
|
-
* Deduplicates while preserving order (root layout CSS first).
|
|
2744
|
-
*/
|
|
2745
|
-
function collectRouteCss(segments, manifest) {
|
|
2746
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2747
|
-
const result = [];
|
|
2748
|
-
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2749
|
-
if (!file) continue;
|
|
2750
|
-
const cssFiles = manifest.css[file.filePath];
|
|
2751
|
-
if (!cssFiles) continue;
|
|
2752
|
-
for (const url of cssFiles) if (!seen.has(url)) {
|
|
2753
|
-
seen.add(url);
|
|
2754
|
-
result.push(url);
|
|
2755
|
-
}
|
|
2756
|
-
}
|
|
2757
|
-
return result;
|
|
2758
|
-
}
|
|
2759
|
-
/**
|
|
2760
|
-
* Collect all font entries needed for a matched route's segment chain.
|
|
2761
|
-
*
|
|
2762
|
-
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
2763
|
-
* Deduplicates by href while preserving order.
|
|
2764
|
-
*/
|
|
2765
|
-
function collectRouteFonts(segments, manifest) {
|
|
2766
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2767
|
-
const result = [];
|
|
2768
|
-
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2769
|
-
if (!file) continue;
|
|
2770
|
-
const fonts = manifest.fonts[file.filePath];
|
|
2771
|
-
if (!fonts) continue;
|
|
2772
|
-
for (const entry of fonts) if (!seen.has(entry.href)) {
|
|
2773
|
-
seen.add(entry.href);
|
|
2774
|
-
result.push(entry);
|
|
2775
|
-
}
|
|
2776
|
-
}
|
|
2777
|
-
return result;
|
|
2778
|
-
}
|
|
2779
|
-
/**
|
|
2780
|
-
* Collect modulepreload URLs for a matched route's segment chain.
|
|
2781
|
-
*
|
|
2782
|
-
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
2783
|
-
* for each layout and page. Deduplicates across segments.
|
|
2784
|
-
*/
|
|
2785
|
-
function collectRouteModulepreloads(segments, manifest) {
|
|
2786
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2787
|
-
const result = [];
|
|
2788
|
-
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2789
|
-
if (!file) continue;
|
|
2790
|
-
const preloads = manifest.modulepreload[file.filePath];
|
|
2791
|
-
if (!preloads) continue;
|
|
2792
|
-
for (const url of preloads) if (!seen.has(url)) {
|
|
2793
|
-
seen.add(url);
|
|
2794
|
-
result.push(url);
|
|
2795
|
-
}
|
|
2796
|
-
}
|
|
2797
|
-
return result;
|
|
2798
|
-
}
|
|
2799
|
-
//#endregion
|
|
2800
2828
|
//#region src/server/early-hints.ts
|
|
2801
2829
|
/**
|
|
2802
2830
|
* 103 Early Hints utilities.
|
|
@@ -3122,6 +3150,139 @@ async function wrapWithErrorBoundaries(segment, element, loadModule, createEleme
|
|
|
3122
3150
|
return element;
|
|
3123
3151
|
}
|
|
3124
3152
|
//#endregion
|
|
3153
|
+
//#region src/server/status-code-resolver.ts
|
|
3154
|
+
/**
|
|
3155
|
+
* Look up `{statusStr}` then `{categoryKey}` (e.g. "4xx" / "5xx") in a
|
|
3156
|
+
* status-file group on a single segment. Shared by all three fallback
|
|
3157
|
+
* chains — the only structural difference between component 4xx,
|
|
3158
|
+
* component 5xx, and JSON resolution is *which* group is searched and
|
|
3159
|
+
* how the per-segment loop is layered around it.
|
|
3160
|
+
*/
|
|
3161
|
+
function lookupInGroup(group, statusStr, categoryKey, segmentIndex, status) {
|
|
3162
|
+
if (!group) return null;
|
|
3163
|
+
const exact = group[statusStr];
|
|
3164
|
+
if (exact) return {
|
|
3165
|
+
file: exact,
|
|
3166
|
+
status,
|
|
3167
|
+
kind: "exact",
|
|
3168
|
+
segmentIndex
|
|
3169
|
+
};
|
|
3170
|
+
const category = group[categoryKey];
|
|
3171
|
+
if (category) return {
|
|
3172
|
+
file: category,
|
|
3173
|
+
status,
|
|
3174
|
+
kind: "category",
|
|
3175
|
+
segmentIndex
|
|
3176
|
+
};
|
|
3177
|
+
return null;
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Resolve the status-code file to render for a given HTTP status code.
|
|
3181
|
+
*
|
|
3182
|
+
* Walks the segment chain from leaf to root following the fallback chain
|
|
3183
|
+
* defined in design/10-error-handling.md. Returns null if no file is found
|
|
3184
|
+
* (caller should render the framework default).
|
|
3185
|
+
*
|
|
3186
|
+
* @param status - The HTTP status code (4xx or 5xx).
|
|
3187
|
+
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
3188
|
+
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
3189
|
+
*/
|
|
3190
|
+
function resolveStatusFile(status, segments, format = "component") {
|
|
3191
|
+
if (status < 400 || status > 599) return null;
|
|
3192
|
+
if (format === "json") return resolveJson(status, segments);
|
|
3193
|
+
if (status <= 499) return resolve4xx(status, segments);
|
|
3194
|
+
return resolve5xx(status, segments);
|
|
3195
|
+
}
|
|
3196
|
+
/**
|
|
3197
|
+
* 4xx component fallback chain — two separate full passes leaf→root.
|
|
3198
|
+
*
|
|
3199
|
+
* The passes must be separate (not interleaved per-segment) so that a
|
|
3200
|
+
* root-level `404.tsx` beats a leaf-level `error.tsx`. The 5xx chain
|
|
3201
|
+
* inverts this and is per-segment: a leaf's `error.tsx` beats a root's
|
|
3202
|
+
* `5xx.tsx`. This asymmetry is the only reason these two functions exist
|
|
3203
|
+
* separately.
|
|
3204
|
+
*
|
|
3205
|
+
* Pass 1 — {status}.tsx → 4xx.tsx (statusFiles)
|
|
3206
|
+
* Pass 2 — error.tsx (error)
|
|
3207
|
+
*/
|
|
3208
|
+
function resolve4xx(status, segments) {
|
|
3209
|
+
const statusStr = String(status);
|
|
3210
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
3211
|
+
const r = lookupInGroup(segments[i].statusFiles, statusStr, "4xx", i, status);
|
|
3212
|
+
if (r) return r;
|
|
3213
|
+
}
|
|
3214
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
3215
|
+
const errorFile = segments[i].error;
|
|
3216
|
+
if (errorFile) return {
|
|
3217
|
+
file: errorFile,
|
|
3218
|
+
status,
|
|
3219
|
+
kind: "error",
|
|
3220
|
+
segmentIndex: i
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
return null;
|
|
3224
|
+
}
|
|
3225
|
+
/**
|
|
3226
|
+
* 5xx component fallback chain — single pass, per-segment leaf→root.
|
|
3227
|
+
*
|
|
3228
|
+
* At each segment: {status}.tsx → 5xx.tsx → error.tsx. A leaf's
|
|
3229
|
+
* `error.tsx` therefore beats a root's `5xx.tsx`, which is the
|
|
3230
|
+
* intentional inverse of the 4xx chain.
|
|
3231
|
+
*/
|
|
3232
|
+
function resolve5xx(status, segments) {
|
|
3233
|
+
const statusStr = String(status);
|
|
3234
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
3235
|
+
const segment = segments[i];
|
|
3236
|
+
const r = lookupInGroup(segment.statusFiles, statusStr, "5xx", i, status);
|
|
3237
|
+
if (r) return r;
|
|
3238
|
+
if (segment.error) return {
|
|
3239
|
+
file: segment.error,
|
|
3240
|
+
status,
|
|
3241
|
+
kind: "error",
|
|
3242
|
+
segmentIndex: i
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
return null;
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* JSON fallback chain (for both 4xx and 5xx) — single pass leaf→root.
|
|
3249
|
+
*
|
|
3250
|
+
* At each segment: {status}.json → {category}.json. No legacy compat,
|
|
3251
|
+
* no error.tsx — the JSON chain terminates at the category catch-all
|
|
3252
|
+
* and the caller falls back to a bare-JSON framework default.
|
|
3253
|
+
*/
|
|
3254
|
+
function resolveJson(status, segments) {
|
|
3255
|
+
const statusStr = String(status);
|
|
3256
|
+
const categoryKey = status >= 500 ? "5xx" : "4xx";
|
|
3257
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
3258
|
+
const r = lookupInGroup(segments[i].jsonStatusFiles, statusStr, categoryKey, i, status);
|
|
3259
|
+
if (r) return r;
|
|
3260
|
+
}
|
|
3261
|
+
return null;
|
|
3262
|
+
}
|
|
3263
|
+
/**
|
|
3264
|
+
* Resolve the denial file for a parallel route slot.
|
|
3265
|
+
*
|
|
3266
|
+
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
3267
|
+
* Fallback chain: denied.tsx → default.tsx → null.
|
|
3268
|
+
*
|
|
3269
|
+
* @param slotNode - The segment node for the slot (segmentType === 'slot').
|
|
3270
|
+
*/
|
|
3271
|
+
function resolveSlotDenied(slotNode) {
|
|
3272
|
+
const slotName = slotNode.segmentName.replace(/^@/, "");
|
|
3273
|
+
if (slotNode.denied) return {
|
|
3274
|
+
file: slotNode.denied,
|
|
3275
|
+
slotName,
|
|
3276
|
+
kind: "denied"
|
|
3277
|
+
};
|
|
3278
|
+
if (slotNode.default) return {
|
|
3279
|
+
file: slotNode.default,
|
|
3280
|
+
slotName,
|
|
3281
|
+
kind: "default"
|
|
3282
|
+
};
|
|
3283
|
+
return null;
|
|
3284
|
+
}
|
|
3285
|
+
//#endregion
|
|
3125
3286
|
//#region src/server/csrf.ts
|
|
3126
3287
|
/** HTTP methods that are considered safe (no mutation). */
|
|
3127
3288
|
var SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
@@ -3303,14 +3464,8 @@ async function handleRouteRequest(mod, ctx) {
|
|
|
3303
3464
|
});
|
|
3304
3465
|
}
|
|
3305
3466
|
if (method === "HEAD") {
|
|
3306
|
-
if (mod.HEAD) return runHandler(mod.HEAD, ctx);
|
|
3307
|
-
if (mod.GET)
|
|
3308
|
-
const res = await runHandler(mod.GET, ctx);
|
|
3309
|
-
return new Response(null, {
|
|
3310
|
-
status: res.status,
|
|
3311
|
-
headers: res.headers
|
|
3312
|
-
});
|
|
3313
|
-
}
|
|
3467
|
+
if (mod.HEAD) return stripHeadBody(await runHandler(mod.HEAD, ctx), method);
|
|
3468
|
+
if (mod.GET) return stripHeadBody(await runHandler(mod.GET, ctx), method);
|
|
3314
3469
|
}
|
|
3315
3470
|
const handler = mod[method];
|
|
3316
3471
|
if (!handler) return new Response(null, {
|
|
@@ -3537,11 +3692,11 @@ var registry = /* @__PURE__ */ new Map();
|
|
|
3537
3692
|
*/
|
|
3538
3693
|
function resolveKeyParams(store) {
|
|
3539
3694
|
const main = store.segmentParams ?? {};
|
|
3540
|
-
const
|
|
3541
|
-
if (!
|
|
3695
|
+
const slots = store.slotParams;
|
|
3696
|
+
if (!slots || Object.keys(slots).length === 0) return main;
|
|
3542
3697
|
const mainCanonical = tryCanonicalize({ ...main });
|
|
3543
3698
|
if (mainCanonical === null) return null;
|
|
3544
|
-
for (const slotParams of
|
|
3699
|
+
for (const slotParams of Object.values(slots)) {
|
|
3545
3700
|
const merged = tryCanonicalize({
|
|
3546
3701
|
...main,
|
|
3547
3702
|
...slotParams
|