@timber-js/app 0.2.0-alpha.20 → 0.2.0-alpha.200
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/LICENSE +8 -0
- package/agent-skill.md +346 -0
- package/dist/_chunks/actions-d1hCqnU3.js +345 -0
- package/dist/_chunks/actions-d1hCqnU3.js.map +1 -0
- 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-DTmSGLRz.js +135 -0
- package/dist/_chunks/build-manifest-DTmSGLRz.js.map +1 -0
- package/dist/_chunks/build-output-helper-9dqCv1pZ.js +370 -0
- package/dist/_chunks/build-output-helper-9dqCv1pZ.js.map +1 -0
- package/dist/_chunks/cache-api-ByagcC-J.js +1149 -0
- package/dist/_chunks/cache-api-ByagcC-J.js.map +1 -0
- package/dist/_chunks/canonicalize-CgHoscYO.js +66 -0
- package/dist/_chunks/canonicalize-CgHoscYO.js.map +1 -0
- package/dist/_chunks/chains-Bpb0W4ax.js +412 -0
- package/dist/_chunks/chains-Bpb0W4ax.js.map +1 -0
- package/dist/_chunks/cli-check-D6VolrDV.js +293 -0
- package/dist/_chunks/cli-check-D6VolrDV.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-D6rO-VcS.js +299 -0
- package/dist/_chunks/cli-schema-sync-D6rO-VcS.js.map +1 -0
- package/dist/_chunks/cloudflare-BFb__LYG.js +1290 -0
- package/dist/_chunks/cloudflare-BFb__LYG.js.map +1 -0
- package/dist/_chunks/codec-registry-oOUxugz3.js +21 -0
- package/dist/_chunks/codec-registry-oOUxugz3.js.map +1 -0
- package/dist/_chunks/convention-lint-fRkwVwEH.js +893 -0
- package/dist/_chunks/convention-lint-fRkwVwEH.js.map +1 -0
- package/dist/_chunks/dist-DtrJyBbw.js +386 -0
- package/dist/_chunks/dist-DtrJyBbw.js.map +1 -0
- package/dist/_chunks/error-boundary-BfPHZjm0.js +1050 -0
- package/dist/_chunks/error-boundary-BfPHZjm0.js.map +1 -0
- package/dist/_chunks/fast-hash-C-tVYrLb.js +41 -0
- package/dist/_chunks/fast-hash-C-tVYrLb.js.map +1 -0
- package/dist/_chunks/file-cache-Dw6BJPG7.js +458 -0
- package/dist/_chunks/file-cache-Dw6BJPG7.js.map +1 -0
- package/dist/_chunks/format-BKclgVbk.js +14 -0
- package/dist/_chunks/format-BKclgVbk.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/graph-cache-CP4GEmf9.js +304 -0
- package/dist/_chunks/graph-cache-CP4GEmf9.js.map +1 -0
- package/dist/_chunks/href-validation-BIrxavIy.js +213 -0
- package/dist/_chunks/href-validation-BIrxavIy.js.map +1 -0
- package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
- package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
- package/dist/_chunks/live-graph-D_2D32Ad.js +150 -0
- package/dist/_chunks/live-graph-D_2D32Ad.js.map +1 -0
- package/dist/_chunks/logger-uLBuGKDI.js +1484 -0
- package/dist/_chunks/logger-uLBuGKDI.js.map +1 -0
- package/dist/_chunks/navigation-context-D0TU0Jog.js +135 -0
- package/dist/_chunks/navigation-context-D0TU0Jog.js.map +1 -0
- package/dist/_chunks/navigation-root-mHSK9psY.js +126 -0
- package/dist/_chunks/navigation-root-mHSK9psY.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-8idfR3UZ.js +321 -0
- package/dist/_chunks/plugin-context-8idfR3UZ.js.map +1 -0
- package/dist/_chunks/poison-rules-DoEhbqaY.js +110 -0
- package/dist/_chunks/poison-rules-DoEhbqaY.js.map +1 -0
- package/dist/_chunks/poison-scan-Bm9Yyqk9.js +101 -0
- package/dist/_chunks/poison-scan-Bm9Yyqk9.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-CBR6Lm4i.js +50 -0
- package/dist/_chunks/resolve-schema-CBR6Lm4i.js.map +1 -0
- package/dist/_chunks/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/_chunks/rsc-cache-key-ClUiXQnK.js +243 -0
- package/dist/_chunks/rsc-cache-key-ClUiXQnK.js.map +1 -0
- package/dist/_chunks/rsc-media-type-DDc7duTD.js +61 -0
- package/dist/_chunks/rsc-media-type-DDc7duTD.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/scan-BoP17W3w.js +140 -0
- package/dist/_chunks/scan-BoP17W3w.js.map +1 -0
- package/dist/_chunks/scanner-AiazgH_f.js +1771 -0
- package/dist/_chunks/scanner-AiazgH_f.js.map +1 -0
- package/dist/_chunks/schema-bridge-C83xa9lT.js +199 -0
- package/dist/_chunks/schema-bridge-C83xa9lT.js.map +1 -0
- package/dist/_chunks/segment-classify-C539Pa2O.js +197 -0
- package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -0
- package/dist/_chunks/segment-context-D9_89u34.js +34 -0
- package/dist/_chunks/segment-context-D9_89u34.js.map +1 -0
- package/dist/_chunks/segment-keys-lqtdookO.js +256 -0
- package/dist/_chunks/segment-keys-lqtdookO.js.map +1 -0
- package/dist/_chunks/singleflight-2lUWfcAk.js +54 -0
- package/dist/_chunks/singleflight-2lUWfcAk.js.map +1 -0
- package/dist/_chunks/sizeof-lXffQ1Vr.js +35 -0
- package/dist/_chunks/sizeof-lXffQ1Vr.js.map +1 -0
- package/dist/_chunks/{ssr-data-MjmprTmO.js → ssr-data-D6T6Y3ef.js} +3 -31
- package/dist/_chunks/ssr-data-D6T6Y3ef.js.map +1 -0
- package/dist/_chunks/state-FippDgxN.js +52 -0
- package/dist/_chunks/state-FippDgxN.js.map +1 -0
- package/dist/_chunks/status-page-marker-DwQBrLBz.js +496 -0
- package/dist/_chunks/status-page-marker-DwQBrLBz.js.map +1 -0
- package/dist/_chunks/use-query-states-I3JMng6J.js +226 -0
- package/dist/_chunks/use-query-states-I3JMng6J.js.map +1 -0
- package/dist/_chunks/walkers-B6XUtmqK.js +144 -0
- package/dist/_chunks/walkers-B6XUtmqK.js.map +1 -0
- package/dist/adapters/build-output-helper.d.ts +72 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -0
- package/dist/adapters/cloudflare-dev.d.ts +109 -0
- package/dist/adapters/cloudflare-dev.d.ts.map +1 -0
- package/dist/adapters/cloudflare-dev.js +74 -0
- package/dist/adapters/cloudflare-dev.js.map +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts +65 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -0
- package/dist/adapters/cloudflare-kv-cache.js +123 -0
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +200 -22
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +2 -188
- package/dist/adapters/compress-module.d.ts +17 -4
- 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-presets.d.ts +37 -0
- package/dist/adapters/nitro-presets.d.ts.map +1 -0
- package/dist/adapters/nitro-preview.d.ts +32 -0
- package/dist/adapters/nitro-preview.d.ts.map +1 -0
- package/dist/adapters/nitro.d.ts +66 -37
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +457 -300
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/adapters/shared.d.ts +62 -0
- package/dist/adapters/shared.d.ts.map +1 -0
- package/dist/adapters/types.d.ts +28 -6
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/analyze/chains.d.ts +124 -0
- package/dist/analyze/chains.d.ts.map +1 -0
- package/dist/analyze/classify.d.ts +150 -0
- package/dist/analyze/classify.d.ts.map +1 -0
- package/dist/analyze/crawl-entry.d.ts +17 -0
- package/dist/analyze/crawl-entry.d.ts.map +1 -0
- package/dist/analyze/crawl-entry.js +313 -0
- package/dist/analyze/crawl-entry.js.map +1 -0
- package/dist/analyze/crawl.d.ts +134 -0
- package/dist/analyze/crawl.d.ts.map +1 -0
- package/dist/analyze/graph-cache.d.ts +104 -0
- package/dist/analyze/graph-cache.d.ts.map +1 -0
- package/dist/analyze/graph-command.d.ts +43 -0
- package/dist/analyze/graph-command.d.ts.map +1 -0
- package/dist/analyze/graph-command.js +472 -0
- package/dist/analyze/graph-command.js.map +1 -0
- package/dist/analyze/live-graph.d.ts +81 -0
- package/dist/analyze/live-graph.d.ts.map +1 -0
- package/dist/analyze/poison-rules.d.ts +72 -0
- package/dist/analyze/poison-rules.d.ts.map +1 -0
- package/dist/analyze/poison-scan.d.ts +48 -0
- package/dist/analyze/poison-scan.d.ts.map +1 -0
- package/dist/analyze/scan.d.ts +73 -0
- package/dist/analyze/scan.d.ts.map +1 -0
- package/dist/cache/cache-api.d.ts +48 -0
- package/dist/cache/cache-api.d.ts.map +1 -0
- package/dist/cache/fast-hash.d.ts +5 -0
- package/dist/cache/fast-hash.d.ts.map +1 -1
- package/dist/cache/handler-store.d.ts +33 -0
- package/dist/cache/handler-store.d.ts.map +1 -0
- package/dist/cache/index.d.ts +32 -9
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +3 -326
- package/dist/cache/invalidation-epoch.d.ts +43 -0
- package/dist/cache/invalidation-epoch.d.ts.map +1 -0
- package/dist/cache/json-lossy-check.d.ts +11 -0
- package/dist/cache/json-lossy-check.d.ts.map +1 -0
- package/dist/cache/redis-handler.d.ts +141 -9
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/singleflight.d.ts +21 -2
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/sizeof.d.ts +22 -0
- package/dist/cache/sizeof.d.ts.map +1 -0
- package/dist/cache/stable-stringify.d.ts +9 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -1
- package/dist/cache/store.d.ts +53 -0
- package/dist/cache/store.d.ts.map +1 -0
- package/dist/cache/stores/cloudflare-kv.d.ts +38 -0
- package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -0
- package/dist/cache/stores/cloudflare-kv.js +29 -0
- package/dist/cache/stores/cloudflare-kv.js.map +1 -0
- package/dist/cache/stores/memory.d.ts +42 -0
- package/dist/cache/stores/memory.d.ts.map +1 -0
- package/dist/cache/stores/memory.js +90 -0
- package/dist/cache/stores/memory.js.map +1 -0
- package/dist/cache/stores/redis.d.ts +69 -0
- package/dist/cache/stores/redis.d.ts.map +1 -0
- package/dist/cache/stores/redis.js +35 -0
- package/dist/cache/stores/redis.js.map +1 -0
- package/dist/cache/stores/vercel.d.ts +36 -0
- package/dist/cache/stores/vercel.d.ts.map +1 -0
- package/dist/cache/stores/vercel.js +33 -0
- package/dist/cache/stores/vercel.js.map +1 -0
- package/dist/cache/tag-aware-handler.d.ts +52 -0
- package/dist/cache/tag-aware-handler.d.ts.map +1 -0
- package/dist/cache/timber-cache.d.ts +21 -11
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cdn/cloudflare-purge.d.ts +23 -0
- package/dist/cdn/cloudflare-purge.d.ts.map +1 -0
- package/dist/cdn/cloudflare-purge.js +30 -0
- package/dist/cdn/cloudflare-purge.js.map +1 -0
- package/dist/cdn/fastly-purge.d.ts +25 -0
- package/dist/cdn/fastly-purge.d.ts.map +1 -0
- package/dist/cdn/fastly-purge.js +33 -0
- package/dist/cdn/fastly-purge.js.map +1 -0
- package/dist/cdn/headers.d.ts +97 -0
- package/dist/cdn/headers.d.ts.map +1 -0
- package/dist/cdn/index.d.ts +5 -0
- package/dist/cdn/index.d.ts.map +1 -0
- package/dist/cdn/index.js +94 -0
- package/dist/cdn/index.js.map +1 -0
- package/dist/cdn/purge-store.d.ts +23 -0
- package/dist/cdn/purge-store.d.ts.map +1 -0
- package/dist/cdn/workers-cache-purge.d.ts +20 -0
- package/dist/cdn/workers-cache-purge.d.ts.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 +163 -0
- package/dist/cli-check.d.ts.map +1 -0
- package/dist/cli-schema-sync.d.ts +59 -0
- package/dist/cli-schema-sync.d.ts.map +1 -0
- package/dist/cli.d.ts +75 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +175 -47
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-dev.d.ts +37 -1
- package/dist/client/browser-dev.d.ts.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts +19 -0
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -0
- package/dist/client/browser-entry/action-queue.d.ts +44 -0
- package/dist/client/browser-entry/action-queue.d.ts.map +1 -0
- package/dist/client/browser-entry/hmr.d.ts +21 -0
- package/dist/client/browser-entry/hmr.d.ts.map +1 -0
- package/dist/client/browser-entry/hydrate.d.ts +63 -0
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -0
- package/dist/client/browser-entry/index.d.ts +30 -0
- package/dist/client/browser-entry/index.d.ts.map +1 -0
- package/dist/client/browser-entry/post-hydration.d.ts +29 -0
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -0
- package/dist/client/browser-entry/router-init.d.ts +46 -0
- package/dist/client/browser-entry/router-init.d.ts.map +1 -0
- package/dist/client/browser-entry/rsc-stream.d.ts +32 -0
- package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -0
- package/dist/client/browser-entry/scroll.d.ts +28 -0
- package/dist/client/browser-entry/scroll.d.ts.map +1 -0
- package/dist/client/deny-last-resort.d.ts +29 -0
- package/dist/client/deny-last-resort.d.ts.map +1 -0
- package/dist/client/error-boundary.d.ts +66 -6
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +2 -126
- package/dist/client/error-reconstituter.d.ts +61 -0
- package/dist/client/error-reconstituter.d.ts.map +1 -0
- package/dist/client/form.d.ts +16 -27
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/global-context.d.ts +15 -0
- package/dist/client/global-context.d.ts.map +1 -0
- package/dist/client/history.d.ts +50 -9
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.d.ts +20 -33
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +323 -1148
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.d.ts +20 -0
- package/dist/client/internal.d.ts.map +1 -0
- package/dist/client/internal.js +818 -0
- package/dist/client/internal.js.map +1 -0
- package/dist/client/link.d.ts +63 -60
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/location-search.d.ts +12 -0
- package/dist/client/location-search.d.ts.map +1 -0
- package/dist/client/navigation-api.d.ts +121 -0
- package/dist/client/navigation-api.d.ts.map +1 -0
- package/dist/client/navigation-commit.d.ts +98 -0
- package/dist/client/navigation-commit.d.ts.map +1 -0
- package/dist/client/navigation-context.d.ts +51 -31
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +98 -0
- package/dist/client/navigation-root.d.ts.map +1 -0
- package/dist/client/navigation-transition.d.ts +187 -0
- package/dist/client/navigation-transition.d.ts.map +1 -0
- package/dist/client/nuqs-adapter.d.ts +1 -1
- package/dist/client/nuqs-adapter.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/react-root.d.ts +44 -0
- package/dist/client/react-root.d.ts.map +1 -0
- package/dist/client/router-effects.d.ts +130 -0
- package/dist/client/router-effects.d.ts.map +1 -0
- package/dist/client/router-lifecycle.d.ts +98 -0
- package/dist/client/router-lifecycle.d.ts.map +1 -0
- package/dist/client/router-pipeline.d.ts +87 -0
- package/dist/client/router-pipeline.d.ts.map +1 -0
- package/dist/client/router-ref.d.ts +2 -2
- 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 +272 -0
- package/dist/client/router-types.d.ts.map +1 -0
- package/dist/client/router.d.ts +1 -97
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +104 -26
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +161 -25
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +68 -0
- package/dist/client/segment-outlet.d.ts.map +1 -0
- package/dist/client/segment-update-context.d.ts +15 -0
- package/dist/client/segment-update-context.d.ts.map +1 -0
- package/dist/client/shallow-url.d.ts +10 -0
- package/dist/client/shallow-url.d.ts.map +1 -0
- package/dist/client/slot-content-cache-context.d.ts +35 -0
- package/dist/client/slot-content-cache-context.d.ts.map +1 -0
- package/dist/client/slot-context.d.ts +31 -0
- package/dist/client/slot-context.d.ts.map +1 -0
- package/dist/client/slot-outlet.d.ts +16 -0
- package/dist/client/slot-outlet.d.ts.map +1 -0
- package/dist/client/slot-provider.d.ts +25 -0
- package/dist/client/slot-provider.d.ts.map +1 -0
- package/dist/client/ssr-data.d.ts +29 -7
- 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 +19 -8
- package/dist/client/state.d.ts.map +1 -1
- package/dist/client/status-page-marker.d.ts +25 -0
- package/dist/client/status-page-marker.d.ts.map +1 -0
- package/dist/client/top-loader.d.ts +7 -6
- 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 +12 -0
- package/dist/client/use-cookie.d.ts.map +1 -1
- package/dist/client/use-link-status.d.ts +5 -5
- package/dist/client/use-link-status.d.ts.map +1 -1
- package/dist/client/use-pathname.d.ts +13 -11
- package/dist/client/use-pathname.d.ts.map +1 -1
- package/dist/client/use-pending-navigation.d.ts +23 -0
- package/dist/client/use-pending-navigation.d.ts.map +1 -0
- package/dist/client/use-query-states.d.ts +10 -4
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/client/use-router.d.ts +2 -2
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +15 -8
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/client/use-segment-params.d.ts +55 -0
- package/dist/client/use-segment-params.d.ts.map +1 -0
- package/dist/codec.d.ts +69 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +76 -0
- package/dist/codec.js.map +1 -0
- package/dist/config-types.d.ts +322 -0
- package/dist/config-types.d.ts.map +1 -0
- package/dist/config-validation.d.ts +51 -0
- package/dist/config-validation.d.ts.map +1 -0
- package/dist/cookies/define-cookie.d.ts +58 -25
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.d.ts +3 -2
- package/dist/cookies/index.d.ts.map +1 -1
- package/dist/cookies/index.js +256 -36
- package/dist/cookies/index.js.map +1 -1
- package/dist/cookies/json-cookie.d.ts +64 -0
- package/dist/cookies/json-cookie.d.ts.map +1 -0
- package/dist/cookies/validation.d.ts +34 -0
- package/dist/cookies/validation.d.ts.map +1 -0
- package/dist/dev-tools/404-page.d.ts +50 -0
- package/dist/dev-tools/404-page.d.ts.map +1 -0
- package/dist/dev-tools/browser-logs.d.ts +40 -0
- package/dist/dev-tools/browser-logs.d.ts.map +1 -0
- package/dist/dev-tools/dev-page-shell.d.ts +78 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
- package/dist/dev-tools/error-page.d.ts +32 -0
- package/dist/dev-tools/error-page.d.ts.map +1 -0
- package/dist/dev-tools/holding-server.d.ts +46 -0
- package/dist/dev-tools/holding-server.d.ts.map +1 -0
- package/dist/dev-tools/hydration-overlay.d.ts +11 -0
- package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
- package/dist/dev-tools/index.d.ts +31 -0
- package/dist/dev-tools/index.d.ts.map +1 -0
- package/dist/dev-tools/instrumentation.d.ts +59 -0
- package/dist/dev-tools/instrumentation.d.ts.map +1 -0
- package/dist/{server/dev-logger.d.ts → dev-tools/logger.d.ts} +1 -1
- package/dist/dev-tools/logger.d.ts.map +1 -0
- package/dist/{plugins/dev-logs.d.ts → dev-tools/logs.d.ts} +17 -3
- package/dist/dev-tools/logs.d.ts.map +1 -0
- package/dist/dev-tools/overlay.d.ts +91 -0
- package/dist/dev-tools/overlay.d.ts.map +1 -0
- package/dist/dev-tools/stack-classifier.d.ts +34 -0
- package/dist/dev-tools/stack-classifier.d.ts.map +1 -0
- package/dist/dev-tools/terminal.d.ts +28 -0
- package/dist/dev-tools/terminal.d.ts.map +1 -0
- package/dist/fonts/ast-walk.d.ts +68 -0
- package/dist/fonts/ast-walk.d.ts.map +1 -0
- package/dist/fonts/ast.d.ts +112 -8
- package/dist/fonts/ast.d.ts.map +1 -1
- package/dist/fonts/bundle.d.ts +50 -0
- package/dist/fonts/bundle.d.ts.map +1 -0
- package/dist/fonts/css.d.ts +1 -1
- package/dist/fonts/dev-middleware.d.ts +22 -0
- package/dist/fonts/dev-middleware.d.ts.map +1 -0
- package/dist/fonts/fallbacks.d.ts +0 -7
- package/dist/fonts/fallbacks.d.ts.map +1 -1
- package/dist/fonts/google.d.ts +5 -3
- package/dist/fonts/google.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +5 -5
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/fonts/paths.d.ts +43 -0
- package/dist/fonts/paths.d.ts.map +1 -0
- package/dist/fonts/pipeline.d.ts +177 -0
- package/dist/fonts/pipeline.d.ts.map +1 -0
- package/dist/fonts/transform.d.ts +82 -0
- package/dist/fonts/transform.d.ts.map +1 -0
- package/dist/fonts/types.d.ts +47 -1
- package/dist/fonts/types.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +77 -0
- package/dist/fonts/virtual-modules.d.ts.map +1 -0
- package/dist/index.d.ts +35 -181
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10461 -14176
- package/dist/index.js.map +1 -1
- package/dist/params/codec-registry.d.ts +26 -0
- package/dist/params/codec-registry.d.ts.map +1 -0
- package/dist/params/define-schema.d.ts +81 -0
- package/dist/params/define-schema.d.ts.map +1 -0
- package/dist/params/index.d.ts +5 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +34 -0
- package/dist/params/index.js.map +1 -0
- package/dist/params/resolve-schema.d.ts +34 -0
- package/dist/params/resolve-schema.d.ts.map +1 -0
- package/dist/plugin-context.d.ts +276 -0
- package/dist/plugin-context.d.ts.map +1 -0
- package/dist/plugins/adapter-build.d.ts +21 -1
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts +7 -3
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/build-report.d.ts +47 -10
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +64 -0
- package/dist/plugins/cache.d.ts.map +1 -0
- package/dist/plugins/callsite-ast.d.ts +117 -0
- package/dist/plugins/callsite-ast.d.ts.map +1 -0
- package/dist/plugins/callsite-jsx.d.ts +35 -0
- package/dist/plugins/callsite-jsx.d.ts.map +1 -0
- package/dist/plugins/chunks.d.ts +11 -4
- package/dist/plugins/chunks.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts +40 -0
- package/dist/plugins/client-chunks.d.ts.map +1 -0
- package/dist/plugins/cloudflare-wasm.d.ts +21 -0
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -0
- package/dist/plugins/content.d.ts +1 -1
- package/dist/plugins/content.d.ts.map +1 -1
- package/dist/plugins/dev-discovery.d.ts +123 -0
- package/dist/plugins/dev-discovery.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts +17 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +28 -4
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +16 -73
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/graph-endpoint.d.ts +112 -0
- package/dist/plugins/graph-endpoint.d.ts.map +1 -0
- package/dist/plugins/mdx.d.ts +18 -5
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/plugins/prebuilt-capture.d.ts +60 -0
- package/dist/plugins/prebuilt-capture.d.ts.map +1 -0
- package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
- package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
- package/dist/plugins/prebuilt.d.ts +82 -0
- package/dist/plugins/prebuilt.d.ts.map +1 -0
- package/dist/plugins/prerender-sugar.d.ts +39 -0
- package/dist/plugins/prerender-sugar.d.ts.map +1 -0
- 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 +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts +29 -5
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +218 -21
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +26 -0
- package/dist/routing/codegen-shared.d.ts.map +1 -0
- package/dist/routing/codegen-types.d.ts +61 -0
- package/dist/routing/codegen-types.d.ts.map +1 -0
- 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 +8 -3
- 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 +55 -0
- package/dist/routing/convention-lint.d.ts.map +1 -0
- package/dist/routing/export-detect.d.ts +72 -0
- package/dist/routing/export-detect.d.ts.map +1 -0
- package/dist/routing/file-cache.d.ts +8 -0
- package/dist/routing/file-cache.d.ts.map +1 -0
- package/dist/routing/index.d.ts +13 -7
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +5 -2
- package/dist/routing/interception-overlap.d.ts +35 -0
- package/dist/routing/interception-overlap.d.ts.map +1 -0
- package/dist/routing/interception.d.ts +234 -10
- package/dist/routing/interception.d.ts.map +1 -1
- package/dist/routing/link-codegen.d.ts +105 -0
- package/dist/routing/link-codegen.d.ts.map +1 -0
- package/dist/routing/manifest-codegen.d.ts +26 -0
- package/dist/routing/manifest-codegen.d.ts.map +1 -0
- package/dist/routing/scanner.d.ts +14 -10
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/schema-validation.d.ts +35 -0
- package/dist/routing/schema-validation.d.ts.map +1 -0
- package/dist/routing/segment-classify.d.ts +114 -0
- package/dist/routing/segment-classify.d.ts.map +1 -0
- package/dist/routing/segment-codegen.d.ts +38 -0
- package/dist/routing/segment-codegen.d.ts.map +1 -0
- package/dist/routing/segment-keys.d.ts +118 -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 +74 -26
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/routing/walkers.d.ts +57 -0
- package/dist/routing/walkers.d.ts.map +1 -0
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/rsc-runtime/ssr.d.ts +14 -0
- package/dist/rsc-runtime/ssr.d.ts.map +1 -1
- package/dist/rsc-runtime/transforms.d.ts +13 -0
- package/dist/rsc-runtime/transforms.d.ts.map +1 -0
- package/dist/schema-bridge.d.ts +145 -0
- package/dist/schema-bridge.d.ts.map +1 -0
- package/dist/search-params/define.d.ts +262 -0
- package/dist/search-params/define.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +3 -7
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +179 -360
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/parse-total.d.ts +81 -0
- package/dist/search-params/parse-total.d.ts.map +1 -0
- package/dist/search-params/serialize-equal.d.ts +9 -0
- package/dist/search-params/serialize-equal.d.ts.map +1 -0
- package/dist/search-params/wrappers.d.ts +76 -0
- package/dist/search-params/wrappers.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +98 -10
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-client.d.ts +42 -7
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/action-encryption.d.ts +76 -0
- package/dist/server/action-encryption.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts +39 -5
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +29 -35
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +117 -13
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/asset-headers.d.ts +8 -18
- package/dist/server/asset-headers.d.ts.map +1 -1
- package/dist/server/body-limits.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +25 -30
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/canonicalize.d.ts +10 -0
- package/dist/server/canonicalize.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/coalesce-tick.d.ts +40 -0
- package/dist/server/coalesce-tick.d.ts.map +1 -0
- package/dist/server/compress.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts +170 -0
- package/dist/server/cookie-context.d.ts.map +1 -0
- package/dist/server/cookie-parsing.d.ts +65 -0
- package/dist/server/cookie-parsing.d.ts.map +1 -0
- package/dist/server/csp-nonce.d.ts +45 -0
- package/dist/server/csp-nonce.d.ts.map +1 -0
- package/dist/server/csrf.d.ts +3 -2
- package/dist/server/csrf.d.ts.map +1 -1
- package/dist/server/default-logger.d.ts +22 -0
- package/dist/server/default-logger.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 +253 -0
- package/dist/server/deny-boundary.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts +20 -9
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/dev-source-map.d.ts +22 -0
- package/dist/server/dev-source-map.d.ts.map +1 -0
- package/dist/server/early-hints-sender.d.ts.map +1 -1
- package/dist/server/early-hints.d.ts +14 -12
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +101 -10
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/error-response-headers.d.ts +3 -0
- package/dist/server/error-response-headers.d.ts.map +1 -0
- package/dist/server/fallback-error.d.ts +12 -7
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/flight-injection-state.d.ts +66 -0
- package/dist/server/flight-injection-state.d.ts.map +1 -0
- package/dist/server/flight-scripts.d.ts +45 -0
- package/dist/server/flight-scripts.d.ts.map +1 -0
- package/dist/server/form-data.d.ts +41 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/form-flash.d.ts +1 -1
- package/dist/server/head-response.d.ts +35 -0
- package/dist/server/head-response.d.ts.map +1 -0
- package/dist/server/html-injector-core.d.ts +257 -0
- package/dist/server/html-injector-core.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts +84 -42
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +18 -54
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +195 -2922
- package/dist/server/index.js.map +1 -1
- package/dist/server/instrumentation.d.ts +1 -1
- package/dist/server/internal.d.ts +50 -0
- package/dist/server/internal.d.ts.map +1 -0
- package/dist/server/internal.js +4034 -0
- package/dist/server/internal.js.map +1 -0
- package/dist/server/logger.d.ts +39 -7
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/metadata-collector.d.ts +71 -0
- package/dist/server/metadata-collector.d.ts.map +1 -0
- package/dist/server/metadata-platform.d.ts +2 -2
- package/dist/server/metadata-render.d.ts +2 -2
- package/dist/server/metadata-render.d.ts.map +1 -1
- package/dist/server/metadata-routes.d.ts +60 -11
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/metadata-social.d.ts +2 -2
- package/dist/server/metadata.d.ts +10 -2
- package/dist/server/metadata.d.ts.map +1 -1
- package/dist/server/middleware-runner.d.ts +20 -5
- package/dist/server/middleware-runner.d.ts.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +105 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -0
- package/dist/server/nuqs-ssr-provider.d.ts +7 -3
- package/dist/server/nuqs-ssr-provider.d.ts.map +1 -1
- package/dist/server/param-coercion.d.ts +78 -0
- package/dist/server/param-coercion.d.ts.map +1 -0
- package/dist/server/pipeline-helpers.d.ts +77 -0
- package/dist/server/pipeline-helpers.d.ts.map +1 -0
- package/dist/server/pipeline-interception.d.ts +59 -9
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline-metadata.d.ts +7 -1
- package/dist/server/pipeline-metadata.d.ts.map +1 -1
- package/dist/server/pipeline-outcome.d.ts +49 -0
- package/dist/server/pipeline-outcome.d.ts.map +1 -0
- package/dist/server/pipeline-phases.d.ts +70 -0
- package/dist/server/pipeline-phases.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +153 -20
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/port-resolution.d.ts +31 -0
- package/dist/server/port-resolution.d.ts.map +1 -0
- package/dist/server/prebuilt/cache-key.d.ts +63 -0
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -0
- package/dist/server/prebuilt/capture-state.d.ts +57 -0
- package/dist/server/prebuilt/capture-state.d.ts.map +1 -0
- package/dist/server/prebuilt/key-discipline.d.ts +52 -0
- package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
- package/dist/server/prebuilt/manifest-tag-index.d.ts +27 -0
- package/dist/server/prebuilt/manifest-tag-index.d.ts.map +1 -0
- package/dist/server/prebuilt/overlay.d.ts +100 -0
- package/dist/server/prebuilt/overlay.d.ts.map +1 -0
- package/dist/server/prebuilt/payload-source.d.ts +79 -0
- package/dist/server/prebuilt/payload-source.d.ts.map +1 -0
- package/dist/server/prebuilt/slots.d.ts +99 -0
- package/dist/server/prebuilt/slots.d.ts.map +1 -0
- package/dist/server/prebuilt/synthetic-store.d.ts +67 -0
- package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -0
- package/dist/server/prebuilt-builder.d.ts +121 -0
- package/dist/server/prebuilt-builder.d.ts.map +1 -0
- package/dist/server/prebuilt-runtime.d.ts +111 -0
- package/dist/server/prebuilt-runtime.d.ts.map +1 -0
- package/dist/server/primitives.d.ts +129 -45
- 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/render-timeout.d.ts +51 -0
- package/dist/server/render-timeout.d.ts.map +1 -0
- package/dist/server/render-utils.d.ts +26 -0
- package/dist/server/render-utils.d.ts.map +1 -0
- package/dist/server/request-context.d.ts +111 -107
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-dep.d.ts +15 -0
- package/dist/server/request-dep.d.ts.map +1 -0
- package/dist/server/response-cache-policy.d.ts +3 -0
- package/dist/server/response-cache-policy.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +57 -30
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-element-helpers.d.ts +12 -0
- package/dist/server/route-element-helpers.d.ts.map +1 -0
- package/dist/server/route-handler.d.ts +1 -1
- package/dist/server/route-handler.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +40 -38
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/route-module-loader.d.ts +37 -0
- package/dist/server/route-module-loader.d.ts.map +1 -0
- 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/action-middleware-runner.d.ts +66 -0
- package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -0
- package/dist/server/rsc-entry/api-handler.d.ts +12 -3
- package/dist/server/rsc-entry/api-handler.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
- package/dist/server/rsc-entry/error-renderer.d.ts +38 -13
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +59 -42
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +40 -6
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +51 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -0
- package/dist/server/rsc-entry/revalidate-renderer.d.ts +108 -0
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -0
- package/dist/server/rsc-entry/rsc-payload.d.ts +26 -6
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +33 -3
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-bridge.d.ts +5 -1
- package/dist/server/rsc-entry/ssr-bridge.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +37 -5
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +89 -0
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
- package/dist/server/rsc-error-envelope.d.ts +11 -0
- package/dist/server/rsc-error-envelope.d.ts.map +1 -0
- package/dist/server/safe-load.d.ts +46 -0
- package/dist/server/safe-load.d.ts.map +1 -0
- package/dist/server/sensitive-fields.d.ts +74 -0
- package/dist/server/sensitive-fields.d.ts.map +1 -0
- package/dist/server/sitemap-generator.d.ts +130 -0
- package/dist/server/sitemap-generator.d.ts.map +1 -0
- package/dist/server/sitemap-handler.d.ts +22 -0
- package/dist/server/sitemap-handler.d.ts.map +1 -0
- package/dist/server/skippable-prefix.d.ts +29 -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 +137 -7
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/slot-subtree-contain.d.ts +54 -0
- package/dist/server/slot-subtree-contain.d.ts.map +1 -0
- package/dist/server/ssr-bridge-types.d.ts +160 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -0
- package/dist/server/ssr-entry.d.ts +1 -43
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +69 -22
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts +93 -0
- package/dist/server/ssr-wrappers.d.ts.map +1 -0
- package/dist/server/state-tree-diff.d.ts +44 -9
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/dist/server/static-generator.d.ts +112 -0
- package/dist/server/static-generator.d.ts.map +1 -0
- 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 +26 -15
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/stream-utils.d.ts +47 -0
- package/dist/server/stream-utils.d.ts.map +1 -0
- package/dist/server/tracing.d.ts +46 -11
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/tree-match.d.ts +49 -0
- package/dist/server/tree-match.d.ts.map +1 -0
- package/dist/server/types.d.ts +88 -7
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/utils/element-type.d.ts +50 -0
- package/dist/server/utils/element-type.d.ts.map +1 -0
- package/dist/server/utils/escape-html.d.ts +14 -0
- package/dist/server/utils/escape-html.d.ts.map +1 -0
- package/dist/server/utils/mdx-file.d.ts +17 -0
- package/dist/server/utils/mdx-file.d.ts.map +1 -0
- package/dist/server/version-skew.d.ts +61 -0
- package/dist/server/version-skew.d.ts.map +1 -0
- package/dist/server/workers-cache-bridge.d.ts +26 -0
- package/dist/server/workers-cache-bridge.d.ts.map +1 -0
- package/dist/shared/als-slots.d.ts +42 -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/href-validation.d.ts +49 -0
- package/dist/shared/href-validation.d.ts.map +1 -0
- package/dist/shared/merge-search-params.d.ts +22 -0
- package/dist/shared/merge-search-params.d.ts.map +1 -0
- package/dist/shared/param-value.d.ts +104 -0
- package/dist/shared/param-value.d.ts.map +1 -0
- package/dist/shared/payload-root.d.ts +109 -0
- package/dist/shared/payload-root.d.ts.map +1 -0
- package/dist/shared/redirect-type.d.ts +17 -0
- package/dist/shared/redirect-type.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 +16 -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 +52 -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 +66 -0
- package/dist/shared/segment-info.d.ts.map +1 -0
- package/dist/shared/slot-params.d.ts +52 -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/shared/status-reason-phrase.d.ts +26 -0
- package/dist/shared/status-reason-phrase.d.ts.map +1 -0
- package/dist/shims/font-google.d.ts +1 -1
- package/dist/shims/font-google.d.ts.map +1 -1
- package/dist/shims/font-google.js +42 -0
- package/dist/shims/font-google.js.map +1 -0
- package/dist/shims/font-local.d.ts +26 -0
- package/dist/shims/font-local.d.ts.map +1 -0
- package/dist/shims/font-local.js +20 -0
- package/dist/shims/font-local.js.map +1 -0
- package/dist/shims/headers.d.ts +53 -1
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/image.d.ts +120 -120
- package/dist/shims/image.d.ts.map +1 -1
- package/dist/shims/navigation-client.d.ts +25 -12
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation-rsc.d.ts +21 -0
- package/dist/shims/navigation-rsc.d.ts.map +1 -0
- package/dist/shims/navigation.d.ts +10 -5
- package/dist/shims/navigation.d.ts.map +1 -1
- package/dist/utils/state-machine.d.ts +80 -0
- package/dist/utils/state-machine.d.ts.map +1 -0
- package/docs/api/30-api-server.mdx +236 -0
- package/docs/api/31-api-client.mdx +167 -0
- package/docs/api/32-api-cache.mdx +311 -0
- package/docs/api/33-api-search-params.mdx +176 -0
- package/docs/api/34-api-config.mdx +329 -0
- package/docs/api/35-api-typescript.mdx +91 -0
- package/docs/api/36-cli.mdx +192 -0
- package/docs/learn/00-introduction.mdx +109 -0
- package/docs/learn/01-quick-start.mdx +94 -0
- package/docs/learn/02-pages-and-layouts.mdx +147 -0
- package/docs/learn/03-fetching-data.mdx +211 -0
- package/docs/learn/04-access-control.mdx +115 -0
- package/docs/learn/05-the-flush-point.mdx +175 -0
- package/docs/learn/06-typed-params.mdx +275 -0
- package/docs/learn/07-typed-routes.mdx +108 -0
- package/docs/learn/08-forms-and-actions.mdx +146 -0
- package/docs/learn/09-streaming.mdx +140 -0
- package/docs/learn/10-caching.mdx +252 -0
- package/docs/learn/11-middleware.mdx +158 -0
- package/docs/learn/12-error-handling.mdx +188 -0
- package/docs/learn/13-deploying.mdx +287 -0
- package/llms.txt +55 -0
- package/package.json +117 -30
- package/src/adapters/build-output-helper.ts +351 -0
- package/src/adapters/cloudflare-dev.ts +183 -0
- package/src/adapters/cloudflare-kv-cache.ts +207 -0
- package/src/adapters/cloudflare.ts +747 -130
- package/src/adapters/compress-module.ts +101 -88
- package/src/adapters/fs-identity.ts +156 -0
- package/src/adapters/nitro-presets.ts +139 -0
- package/src/adapters/nitro-preview.ts +340 -0
- package/src/adapters/nitro.ts +249 -426
- package/src/adapters/raw-imports.d.ts +5 -0
- package/src/adapters/shared.ts +274 -0
- package/src/adapters/types.ts +28 -7
- package/src/adapters/wrangler.d.ts +7 -0
- package/src/analyze/chains.ts +411 -0
- package/src/analyze/classify.ts +258 -0
- package/src/analyze/crawl-entry.ts +68 -0
- package/src/analyze/crawl.ts +500 -0
- package/src/analyze/graph-cache.ts +332 -0
- package/src/analyze/graph-command.ts +762 -0
- package/src/analyze/live-graph.ts +232 -0
- package/src/analyze/poison-rules.ts +165 -0
- package/src/analyze/poison-scan.ts +162 -0
- package/src/analyze/scan.ts +149 -0
- package/src/cache/cache-api.ts +107 -0
- package/src/cache/fast-hash.ts +5 -0
- package/src/cache/handler-store.ts +76 -0
- package/src/cache/index.ts +115 -22
- package/src/cache/invalidation-epoch.ts +93 -0
- package/src/cache/json-lossy-check.ts +75 -0
- package/src/cache/redis-handler.ts +198 -20
- package/src/cache/singleflight.ts +69 -6
- package/src/cache/sizeof.ts +33 -0
- package/src/cache/stable-stringify.ts +65 -2
- package/src/cache/store.ts +71 -0
- package/src/cache/stores/cloudflare-kv.ts +52 -0
- package/src/cache/stores/memory.ts +125 -0
- package/src/cache/stores/redis.ts +97 -0
- package/src/cache/stores/vercel.ts +59 -0
- package/src/cache/tag-aware-handler.ts +237 -0
- package/src/cache/timber-cache.ts +165 -40
- package/src/cdn/cloudflare-purge.ts +50 -0
- package/src/cdn/fastly-purge.ts +63 -0
- package/src/cdn/headers.ts +153 -0
- package/src/cdn/index.ts +23 -0
- package/src/cdn/purge-store.ts +34 -0
- package/src/cdn/workers-cache-purge.ts +34 -0
- package/src/cli-check.ts +507 -0
- package/src/cli-schema-sync.ts +401 -0
- package/src/cli.ts +262 -55
- package/src/client/browser-dev.ts +157 -2
- package/src/client/browser-entry/action-dispatch.ts +197 -0
- package/src/client/browser-entry/action-queue.ts +90 -0
- package/src/client/browser-entry/hmr.ts +70 -0
- package/src/client/browser-entry/hydrate.ts +148 -0
- package/src/client/browser-entry/index.ts +131 -0
- package/src/client/browser-entry/post-hydration.ts +123 -0
- package/src/client/browser-entry/router-init.ts +418 -0
- package/src/client/browser-entry/rsc-stream.ts +166 -0
- package/src/client/browser-entry/scroll.ts +50 -0
- package/src/client/deny-last-resort.tsx +54 -0
- package/src/client/error-boundary.tsx +316 -40
- package/src/client/error-reconstituter.tsx +79 -0
- package/src/client/form.tsx +36 -32
- package/src/client/global-context.ts +31 -0
- package/src/client/history.ts +81 -10
- package/src/client/index.ts +89 -53
- package/src/client/internal.ts +59 -0
- package/src/client/link.tsx +439 -151
- package/src/client/location-search.ts +15 -0
- package/src/client/navigation-api.ts +347 -0
- package/src/client/navigation-commit.ts +295 -0
- package/src/client/navigation-context.ts +67 -98
- package/src/client/navigation-root.tsx +224 -0
- package/src/client/navigation-transition.ts +327 -0
- package/src/client/nuqs-adapter.tsx +21 -9
- package/src/client/params-context.ts +187 -0
- package/src/client/react-root.ts +72 -0
- package/src/client/router-effects.ts +228 -0
- package/src/client/router-lifecycle.ts +354 -0
- package/src/client/router-pipeline.ts +490 -0
- package/src/client/router-ref.ts +3 -3
- package/src/client/router-skew.ts +30 -0
- package/src/client/router-types.ts +294 -0
- package/src/client/router.ts +435 -421
- package/src/client/rsc-fetch.ts +512 -73
- package/src/client/segment-cache.ts +327 -40
- package/src/client/segment-context.ts +7 -4
- package/src/client/segment-outlet.tsx +96 -0
- package/src/client/segment-update-context.ts +23 -0
- package/src/client/shallow-url.ts +22 -0
- package/src/client/slot-content-cache-context.ts +43 -0
- package/src/client/slot-context.ts +50 -0
- package/src/client/slot-outlet.tsx +22 -0
- package/src/client/slot-provider.tsx +32 -0
- package/src/client/ssr-data.ts +30 -9
- package/src/client/stale-client.ts +191 -0
- package/src/client/state.ts +29 -15
- package/src/client/status-page-marker.tsx +32 -0
- package/src/client/top-loader.tsx +13 -10
- package/src/client/unload-guard.ts +35 -3
- package/src/client/use-cookie.ts +70 -18
- package/src/client/use-link-status.ts +7 -7
- package/src/client/use-pathname.ts +22 -31
- package/src/client/use-pending-navigation.ts +40 -0
- package/src/client/use-query-states.ts +206 -36
- package/src/client/use-router.ts +6 -3
- package/src/client/use-search-params.ts +38 -49
- package/src/client/use-segment-params.ts +69 -0
- package/src/client/use-selected-layout-segment.ts +2 -2
- package/src/codec.ts +149 -0
- package/src/config-types.ts +322 -0
- package/src/config-validation.ts +347 -0
- package/src/cookies/define-cookie.ts +222 -53
- package/src/cookies/index.ts +13 -2
- package/src/cookies/json-cookie.ts +105 -0
- package/src/cookies/validation.ts +192 -0
- package/src/dev-tools/404-page.ts +286 -0
- package/src/dev-tools/browser-logs.ts +55 -0
- package/src/dev-tools/dev-page-shell.ts +260 -0
- package/src/dev-tools/error-page.ts +331 -0
- package/src/dev-tools/holding-server.ts +185 -0
- package/src/dev-tools/hydration-overlay.ts +158 -0
- package/src/dev-tools/index.ts +83 -0
- package/src/dev-tools/instrumentation.ts +213 -0
- package/src/{server/dev-logger.ts → dev-tools/logger.ts} +33 -51
- package/src/{plugins/dev-logs.ts → dev-tools/logs.ts} +122 -26
- package/src/dev-tools/overlay.ts +469 -0
- package/src/dev-tools/stack-classifier.ts +75 -0
- package/src/dev-tools/terminal.ts +182 -0
- package/src/fonts/ast-walk.ts +235 -0
- package/src/fonts/ast.ts +479 -128
- package/src/fonts/bundle.ts +138 -0
- package/src/fonts/css.ts +1 -1
- package/src/fonts/dev-middleware.ts +85 -0
- package/src/fonts/fallbacks.ts +0 -23
- package/src/fonts/google.ts +101 -19
- package/src/fonts/local.ts +12 -11
- package/src/fonts/paths.ts +58 -0
- package/src/fonts/pipeline.ts +348 -0
- package/src/fonts/transform.ts +311 -0
- package/src/fonts/types.ts +52 -1
- package/src/fonts/virtual-modules.ts +179 -0
- package/src/index.ts +486 -377
- package/src/params/codec-registry.ts +34 -0
- package/src/params/define-schema.ts +115 -0
- package/src/params/index.ts +17 -0
- package/src/params/resolve-schema.ts +84 -0
- package/src/plugin-context.ts +503 -0
- package/src/plugins/adapter-build.ts +229 -7
- package/src/plugins/build-manifest.ts +201 -26
- package/src/plugins/build-report.ts +259 -47
- package/src/plugins/cache.ts +243 -0
- package/src/plugins/callsite-ast.ts +472 -0
- package/src/plugins/callsite-jsx.ts +243 -0
- package/src/plugins/chunks.ts +74 -5
- package/src/plugins/client-chunks.ts +185 -0
- package/src/plugins/cloudflare-wasm.ts +141 -0
- package/src/plugins/content.ts +5 -1
- package/src/plugins/dev-discovery.ts +218 -0
- package/src/plugins/dev-server.ts +369 -96
- package/src/plugins/entries.ts +141 -16
- package/src/plugins/fonts.ts +234 -659
- package/src/plugins/graph-endpoint.ts +532 -0
- package/src/plugins/mdx.ts +107 -35
- package/src/plugins/prebuilt-capture.ts +217 -0
- package/src/plugins/prebuilt-options-analysis.ts +175 -0
- package/src/plugins/prebuilt.ts +655 -0
- package/src/plugins/prerender-sugar.ts +320 -0
- 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 +273 -277
- package/src/plugins/server-bundle.ts +99 -14
- package/src/plugins/shims.ts +259 -97
- package/src/plugins/static-build.ts +708 -66
- package/src/react-canary.d.ts +2 -0
- package/src/routing/codegen-shared.ts +39 -0
- package/src/routing/codegen-types.ts +62 -0
- package/src/routing/codegen-write.ts +141 -0
- package/src/routing/codegen.ts +349 -232
- package/src/routing/collision-probe.ts +438 -0
- package/src/routing/collision-spaces.ts +281 -0
- package/src/routing/convention-lint.ts +623 -0
- package/src/routing/export-detect.ts +448 -0
- package/src/routing/file-cache.ts +23 -0
- package/src/routing/index.ts +16 -7
- package/src/routing/interception-overlap.ts +141 -0
- package/src/routing/interception.ts +1044 -67
- package/src/routing/link-codegen.ts +280 -0
- package/src/routing/manifest-codegen.ts +226 -0
- package/src/routing/scanner.ts +393 -219
- package/src/routing/schema-validation.ts +155 -0
- package/src/routing/segment-classify.ts +280 -0
- package/src/routing/segment-codegen.ts +98 -0
- package/src/routing/segment-keys.ts +186 -0
- package/src/routing/slot-placement.ts +97 -0
- package/src/routing/types.ts +75 -26
- package/src/routing/walkers.ts +98 -0
- package/src/rsc-runtime/rsc.ts +9 -0
- package/src/rsc-runtime/ssr.ts +51 -0
- package/src/rsc-runtime/transforms.ts +13 -0
- package/src/rsc-runtime/vendor-types.d.ts +19 -0
- package/src/schema-bridge.ts +380 -0
- package/src/search-params/define.ts +677 -0
- package/src/search-params/index.ts +14 -20
- package/src/search-params/parse-total.ts +91 -0
- package/src/search-params/serialize-equal.ts +14 -0
- package/src/search-params/wrappers.ts +150 -0
- package/src/server/access-gate.tsx +194 -56
- package/src/server/action-client.ts +122 -31
- package/src/server/action-encryption.ts +144 -0
- package/src/server/action-handler.ts +256 -56
- package/src/server/actions.ts +112 -68
- package/src/server/als-registry.ts +160 -21
- package/src/server/asset-headers.ts +15 -37
- package/src/server/body-limits.ts +10 -2
- package/src/server/build-manifest.ts +33 -57
- package/src/server/canonicalize.ts +6 -3
- package/src/server/chain-url-parts.ts +163 -0
- package/src/server/children-interception.ts +115 -0
- package/src/server/coalesce-tick.ts +75 -0
- package/src/server/compress.ts +43 -8
- package/src/server/cookie-context.ts +484 -0
- package/src/server/cookie-parsing.ts +163 -0
- package/src/server/csp-nonce.ts +70 -0
- package/src/server/csrf.ts +39 -6
- package/src/server/default-logger.ts +99 -0
- package/src/server/default-status-page.ts +88 -0
- package/src/server/deny-boundary.ts +533 -0
- package/src/server/deny-renderer.ts +213 -112
- package/src/server/dev-source-map.ts +31 -0
- package/src/server/early-hints-sender.ts +4 -3
- package/src/server/early-hints.ts +39 -29
- package/src/server/error-boundary-wrapper.ts +181 -44
- package/src/server/error-response-headers.ts +18 -0
- package/src/server/fallback-error.ts +79 -102
- package/src/server/flight-injection-state.ts +113 -0
- package/src/server/flight-scripts.ts +67 -0
- package/src/server/form-data.ts +124 -12
- package/src/server/form-flash.ts +2 -2
- package/src/server/head-response.ts +54 -0
- package/src/server/html-injector-core.ts +512 -0
- package/src/server/html-injectors.ts +272 -255
- package/src/server/index.ts +40 -192
- package/src/server/instrumentation.ts +1 -1
- package/src/server/internal.ts +173 -0
- package/src/server/logger.ts +73 -37
- package/src/server/metadata-collector.ts +174 -0
- package/src/server/metadata-platform.ts +2 -2
- package/src/server/metadata-render.ts +30 -8
- package/src/server/metadata-routes.ts +205 -24
- package/src/server/metadata-social.ts +2 -2
- package/src/server/metadata.ts +23 -2
- package/src/server/middleware-runner.ts +32 -5
- package/src/server/node-stream-transforms.ts +323 -0
- package/src/server/nuqs-ssr-provider.tsx +8 -7
- package/src/server/param-coercion.ts +198 -0
- package/src/server/pipeline-helpers.ts +159 -0
- package/src/server/pipeline-interception.ts +109 -33
- package/src/server/pipeline-metadata.ts +15 -1
- package/src/server/pipeline-outcome.ts +218 -0
- package/src/server/pipeline-phases.ts +606 -0
- package/src/server/pipeline.ts +324 -384
- package/src/server/port-resolution.ts +53 -0
- package/src/server/prebuilt/cache-key.ts +299 -0
- package/src/server/prebuilt/capture-state.ts +89 -0
- package/src/server/prebuilt/key-discipline.ts +122 -0
- package/src/server/prebuilt/manifest-tag-index.ts +46 -0
- package/src/server/prebuilt/overlay.ts +256 -0
- package/src/server/prebuilt/payload-source.ts +202 -0
- package/src/server/prebuilt/slots.ts +190 -0
- package/src/server/prebuilt/synthetic-store.ts +122 -0
- package/src/server/prebuilt-builder.ts +804 -0
- package/src/server/prebuilt-runtime.ts +817 -0
- package/src/server/primitives.ts +245 -97
- package/src/server/publish-params.ts +42 -0
- package/src/server/render-timeout.ts +108 -0
- package/src/server/render-utils.ts +35 -0
- package/src/server/request-context.ts +244 -334
- package/src/server/request-dep.ts +37 -0
- package/src/server/response-cache-policy.ts +45 -0
- package/src/server/route-element-builder.ts +508 -355
- package/src/server/route-element-helpers.ts +37 -0
- package/src/server/route-handler.ts +40 -13
- package/src/server/route-matcher.ts +165 -272
- package/src/server/route-module-loader.ts +161 -0
- package/src/server/rsc-cache-key-guard.ts +122 -0
- package/src/server/rsc-entry/action-middleware-runner.ts +174 -0
- package/src/server/rsc-entry/api-handler.ts +78 -34
- package/src/server/rsc-entry/deny-fallback.ts +93 -0
- package/src/server/rsc-entry/error-renderer.ts +373 -95
- package/src/server/rsc-entry/helpers.ts +226 -95
- package/src/server/rsc-entry/index.ts +370 -331
- package/src/server/rsc-entry/render-route.ts +304 -0
- package/src/server/rsc-entry/revalidate-renderer.ts +350 -0
- package/src/server/rsc-entry/rsc-payload.ts +209 -55
- package/src/server/rsc-entry/rsc-stream.ts +151 -26
- package/src/server/rsc-entry/ssr-bridge.ts +18 -5
- package/src/server/rsc-entry/ssr-renderer.ts +311 -60
- package/src/server/rsc-entry/wrap-action-dispatch.ts +186 -0
- package/src/server/rsc-error-envelope.ts +18 -0
- package/src/server/rsc-prop-warnings.ts +1 -1
- package/src/server/safe-load.ts +60 -0
- package/src/server/sensitive-fields.ts +230 -0
- package/src/server/server-only-guard-noop.js +3 -0
- package/src/server/server-timing.ts +1 -1
- package/src/server/sitemap-generator.ts +337 -0
- package/src/server/sitemap-handler.ts +157 -0
- package/src/server/skippable-prefix.ts +172 -0
- package/src/server/slot-interception.ts +168 -0
- package/src/server/slot-resolver.ts +707 -300
- package/src/server/slot-subtree-contain.ts +255 -0
- package/src/server/ssr-bridge-types.ts +165 -0
- package/src/server/ssr-entry.ts +230 -85
- package/src/server/ssr-render.ts +357 -85
- package/src/server/ssr-wrappers.tsx +162 -0
- package/src/server/state-tree-diff.ts +85 -30
- package/src/server/static-generator.ts +715 -0
- package/src/server/static-not-found.ts +134 -0
- package/src/server/status-code-resolver.ts +107 -148
- package/src/server/stream-utils.ts +292 -0
- package/src/server/tracing.ts +128 -16
- package/src/server/tree-match.ts +262 -0
- package/src/server/types.ts +95 -10
- package/src/server/utils/element-type.ts +88 -0
- package/src/server/utils/escape-html.ts +20 -0
- package/src/server/utils/mdx-file.ts +22 -0
- package/src/server/version-skew.ts +104 -0
- package/src/server/waituntil-bridge.ts +1 -1
- package/src/server/workers-cache-bridge.ts +33 -0
- package/src/shared/als-slots.ts +74 -0
- package/src/shared/base-path.ts +80 -0
- package/src/shared/href-validation.ts +159 -0
- package/src/shared/merge-search-params.ts +55 -0
- package/src/shared/param-value.ts +281 -0
- package/src/shared/payload-root.ts +152 -0
- package/src/shared/redirect-type.ts +17 -0
- package/src/shared/rsc-cache-key.ts +269 -0
- package/src/shared/rsc-error-envelope.ts +16 -0
- package/src/shared/rsc-manifest.ts +29 -0
- package/src/shared/rsc-media-type.ts +59 -0
- package/src/shared/rsc-payload-path.ts +45 -0
- package/src/shared/segment-info.ts +67 -0
- package/src/shared/slot-params.ts +109 -0
- package/src/shared/static-platform-files.ts +75 -0
- package/src/shared/status-reason-phrase.ts +61 -0
- package/src/shims/font-google.ts +1 -1
- package/src/shims/font-local.ts +34 -0
- package/src/shims/headers.ts +122 -1
- package/src/shims/navigation-client.ts +36 -32
- package/src/shims/navigation-rsc.ts +47 -0
- package/src/shims/navigation.ts +10 -7
- package/src/utils/state-machine.ts +111 -0
- package/dist/_chunks/als-registry-B7DbZ2hS.js +0 -41
- package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
- package/dist/_chunks/debug-B3Gypr3D.js +0 -108
- package/dist/_chunks/debug-B3Gypr3D.js.map +0 -1
- package/dist/_chunks/format-RyoGQL74.js +0 -164
- package/dist/_chunks/format-RyoGQL74.js.map +0 -1
- package/dist/_chunks/interception-BOoWmLUA.js +0 -676
- package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
- package/dist/_chunks/metadata-routes-Cjmvi3rQ.js +0 -153
- package/dist/_chunks/metadata-routes-Cjmvi3rQ.js.map +0 -1
- package/dist/_chunks/request-context-BQUC8PHn.js +0 -330
- package/dist/_chunks/request-context-BQUC8PHn.js.map +0 -1
- package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
- package/dist/_chunks/tracing-CemImE6h.js +0 -187
- package/dist/_chunks/tracing-CemImE6h.js.map +0 -1
- package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
- package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
- package/dist/_chunks/use-query-states-D5KaffOK.js +0 -109
- package/dist/_chunks/use-query-states-D5KaffOK.js.map +0 -1
- package/dist/adapters/cloudflare.js.map +0 -1
- package/dist/cache/index.js.map +0 -1
- package/dist/cache/register-cached-function.d.ts +0 -17
- package/dist/cache/register-cached-function.d.ts.map +0 -1
- package/dist/client/browser-entry.d.ts +0 -21
- package/dist/client/browser-entry.d.ts.map +0 -1
- package/dist/client/error-boundary.js.map +0 -1
- package/dist/client/head.d.ts +0 -16
- package/dist/client/head.d.ts.map +0 -1
- package/dist/client/link-status-provider.d.ts +0 -11
- package/dist/client/link-status-provider.d.ts.map +0 -1
- package/dist/client/segment-merger.d.ts +0 -119
- package/dist/client/segment-merger.d.ts.map +0 -1
- package/dist/client/stale-reload.d.ts +0 -44
- package/dist/client/stale-reload.d.ts.map +0 -1
- package/dist/client/transition-root.d.ts +0 -74
- package/dist/client/transition-root.d.ts.map +0 -1
- package/dist/client/use-navigation-pending.d.ts +0 -26
- package/dist/client/use-navigation-pending.d.ts.map +0 -1
- package/dist/client/use-params.d.ts +0 -92
- package/dist/client/use-params.d.ts.map +0 -1
- package/dist/content/index.d.ts +0 -11
- package/dist/content/index.d.ts.map +0 -1
- package/dist/content/index.js +0 -2
- package/dist/plugins/cache-transform.d.ts +0 -36
- package/dist/plugins/cache-transform.d.ts.map +0 -1
- package/dist/plugins/dev-error-overlay.d.ts +0 -60
- package/dist/plugins/dev-error-overlay.d.ts.map +0 -1
- package/dist/plugins/dev-logs.d.ts.map +0 -1
- package/dist/plugins/dynamic-transform.d.ts +0 -72
- package/dist/plugins/dynamic-transform.d.ts.map +0 -1
- package/dist/plugins/server-action-exports.d.ts +0 -26
- package/dist/plugins/server-action-exports.d.ts.map +0 -1
- package/dist/routing/status-file-lint.d.ts +0 -33
- package/dist/routing/status-file-lint.d.ts.map +0 -1
- package/dist/search-params/analyze.d.ts +0 -54
- package/dist/search-params/analyze.d.ts.map +0 -1
- package/dist/search-params/builtin-codecs.d.ts +0 -105
- package/dist/search-params/builtin-codecs.d.ts.map +0 -1
- package/dist/search-params/codecs.d.ts +0 -53
- package/dist/search-params/codecs.d.ts.map +0 -1
- package/dist/search-params/create.d.ts +0 -106
- package/dist/search-params/create.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/server/client-module-map.d.ts +0 -47
- package/dist/server/client-module-map.d.ts.map +0 -1
- package/dist/server/dev-fetch-instrumentation.d.ts +0 -22
- package/dist/server/dev-fetch-instrumentation.d.ts.map +0 -1
- package/dist/server/dev-logger.d.ts.map +0 -1
- package/dist/server/dev-span-processor.d.ts +0 -29
- package/dist/server/dev-span-processor.d.ts.map +0 -1
- package/dist/server/dev-warnings.d.ts +0 -129
- package/dist/server/dev-warnings.d.ts.map +0 -1
- package/dist/server/flush.d.ts +0 -74
- package/dist/server/flush.d.ts.map +0 -1
- package/dist/server/manifest-status-resolver.d.ts +0 -58
- package/dist/server/manifest-status-resolver.d.ts.map +0 -1
- package/dist/server/prerender.d.ts +0 -77
- package/dist/server/prerender.d.ts.map +0 -1
- package/dist/server/tree-builder.d.ts +0 -126
- package/dist/server/tree-builder.d.ts.map +0 -1
- package/dist/utils/directive-parser.d.ts +0 -70
- package/dist/utils/directive-parser.d.ts.map +0 -1
- package/src/cache/register-cached-function.ts +0 -103
- package/src/client/browser-entry.ts +0 -678
- package/src/client/head.ts +0 -61
- package/src/client/link-status-provider.tsx +0 -30
- package/src/client/segment-merger.ts +0 -339
- package/src/client/stale-reload.ts +0 -87
- package/src/client/transition-root.tsx +0 -166
- package/src/client/use-navigation-pending.ts +0 -38
- package/src/client/use-params.ts +0 -141
- package/src/content/index.ts +0 -13
- package/src/plugins/cache-transform.ts +0 -199
- package/src/plugins/dev-error-overlay.ts +0 -230
- package/src/plugins/dynamic-transform.ts +0 -161
- package/src/plugins/server-action-exports.ts +0 -220
- package/src/routing/status-file-lint.ts +0 -112
- package/src/search-params/analyze.ts +0 -192
- package/src/search-params/builtin-codecs.ts +0 -228
- package/src/search-params/codecs.ts +0 -153
- package/src/search-params/create.ts +0 -321
- package/src/search-params/registry.ts +0 -31
- package/src/server/client-module-map.ts +0 -58
- package/src/server/dev-fetch-instrumentation.ts +0 -96
- package/src/server/dev-span-processor.ts +0 -78
- package/src/server/dev-warnings.ts +0 -283
- package/src/server/flush.ts +0 -182
- package/src/server/manifest-status-resolver.ts +0 -215
- package/src/server/prerender.ts +0 -139
- package/src/server/tree-builder.ts +0 -382
- package/src/shims/server-only-noop.js +0 -6
- package/src/utils/directive-parser.ts +0 -529
|
@@ -0,0 +1,4034 @@
|
|
|
1
|
+
import { n as isRscContentType, r as isRscRequest } from "../_chunks/rsc-media-type-DDc7duTD.js";
|
|
2
|
+
import { a as timingAls, r as requestContextAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-C6kcfprT.js";
|
|
3
|
+
import { A as isDenySignal, B as getSegmentParams, C as runWithTraceId, D as RedirectSignal, E as DenySignal, G as setSegmentParams, H as runWithRequestContext, I as applyRequestHeaderOverlay, J as getSetCookieHeaders, K as getCookie, L as getHeader, O as RenderError, S as replaceTraceId, T as withSpan, U as setMatchedSegmentPath, V as markResponseFlushed, W as setMutableCookieContext, Y as isDebug, Z as getWaitUntil, a as logProxyError, c as logRequestReceived, d as logSwrRefetchFailed, f as logWaitUntilRejected, h as swallow, i as logMiddlewareShortCircuit, j as isRedirectSignal, l as logRouteError, m as setLogger, n as logCacheMiss, o as logRenderError, p as logWaitUntilUnsupported, r as logMiddlewareError, s as logRequestCompleted, t as getLogger, u as logSlowRequest, v as generateTraceId, w as setSpanAttribute, x as getTraceId, y as getOtelTraceId, z as getSearchParams } from "../_chunks/logger-uLBuGKDI.js";
|
|
4
|
+
import { i as isRscCacheKeyShape, n as RSC_KEY_PARAM, s as rscCacheKey, t as RSC_KEY_HEADERS } from "../_chunks/rsc-cache-key-ClUiXQnK.js";
|
|
5
|
+
import { a as getMetadataRouteAutoLink, i as classifyMetadataRoute, o as getMetadataRouteServePath, r as METADATA_ROUTE_CONVENTIONS, t as computeSegmentTreePaths } from "../_chunks/segment-keys-lqtdookO.js";
|
|
6
|
+
import { r as canonicalize } from "../_chunks/canonicalize-CgHoscYO.js";
|
|
7
|
+
import { n as classifyUrlSegment, r as effectiveUrlSegment } from "../_chunks/segment-classify-C539Pa2O.js";
|
|
8
|
+
import { a as requestCspNonce, i as collectRouteModulepreloads, n as collectRouteCss, r as collectRouteFonts } from "../_chunks/build-manifest-DTmSGLRz.js";
|
|
9
|
+
import { i as coerce, t as executeAction } from "../_chunks/actions-d1hCqnU3.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, y as lastInvalidationEpochFor } from "../_chunks/cache-api-ByagcC-J.js";
|
|
11
|
+
import { n as createSingleflight } from "../_chunks/singleflight-2lUWfcAk.js";
|
|
12
|
+
import { n as toBracketKey } from "../_chunks/resolve-schema-CBR6Lm4i.js";
|
|
13
|
+
import { t as normalizeParamValue } from "../_chunks/param-value-C8TNYchQ.js";
|
|
14
|
+
import { s as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-DwQBrLBz.js";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import React, { createElement, useContext } from "react";
|
|
18
|
+
//#region src/server/server-timing.ts
|
|
19
|
+
/**
|
|
20
|
+
* Server-Timing header — dev-mode timing breakdowns for Chrome DevTools.
|
|
21
|
+
*
|
|
22
|
+
* Collects timing entries per request using ALS. Each pipeline phase
|
|
23
|
+
* (proxy, middleware, render, SSR, access, fetch) records an entry.
|
|
24
|
+
* Before response flush, entries are formatted into a Server-Timing header.
|
|
25
|
+
*
|
|
26
|
+
* Only active in dev mode — zero overhead in production.
|
|
27
|
+
*
|
|
28
|
+
* See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing
|
|
29
|
+
* Task: LOCAL-290
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Run a callback with a per-request timing collector.
|
|
33
|
+
* Must be called at the top of the request pipeline (wraps the full request).
|
|
34
|
+
*/
|
|
35
|
+
function runWithTimingCollector(fn) {
|
|
36
|
+
return timingAls.run({ entries: [] }, fn);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run a function and automatically record its duration as a timing entry.
|
|
40
|
+
* Returns the function's result. No-ops the recording if outside a collector.
|
|
41
|
+
*/
|
|
42
|
+
async function withTiming(name, desc, fn) {
|
|
43
|
+
const store = timingAls.getStore();
|
|
44
|
+
if (!store) return fn();
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
try {
|
|
47
|
+
return await fn();
|
|
48
|
+
} finally {
|
|
49
|
+
const dur = Math.round(performance.now() - start);
|
|
50
|
+
store.entries.push({
|
|
51
|
+
name,
|
|
52
|
+
dur,
|
|
53
|
+
desc
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get the Server-Timing header value for the current request.
|
|
59
|
+
* Returns null if no entries exist or outside a collector.
|
|
60
|
+
*
|
|
61
|
+
* Format: `name;dur=123;desc="description", name2;dur=456`
|
|
62
|
+
* See RFC 6797 / Server-Timing spec for format details.
|
|
63
|
+
*/
|
|
64
|
+
function getServerTimingHeader() {
|
|
65
|
+
const store = timingAls.getStore();
|
|
66
|
+
if (!store || store.entries.length === 0) return null;
|
|
67
|
+
const nameCounts = /* @__PURE__ */ new Map();
|
|
68
|
+
const parts = store.entries.map((entry) => {
|
|
69
|
+
const count = nameCounts.get(entry.name) ?? 0;
|
|
70
|
+
nameCounts.set(entry.name, count + 1);
|
|
71
|
+
const uniqueName = count > 0 ? `${entry.name}-${count}` : entry.name;
|
|
72
|
+
return {
|
|
73
|
+
...entry,
|
|
74
|
+
name: uniqueName
|
|
75
|
+
};
|
|
76
|
+
}).map((entry) => {
|
|
77
|
+
let part = `${entry.name};dur=${entry.dur}`;
|
|
78
|
+
if (entry.desc) {
|
|
79
|
+
const safeDesc = entry.desc.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
80
|
+
part += `;desc="${safeDesc}"`;
|
|
81
|
+
}
|
|
82
|
+
return part;
|
|
83
|
+
});
|
|
84
|
+
const MAX_HEADER_SIZE = 4096;
|
|
85
|
+
let result = "";
|
|
86
|
+
for (let i = 0; i < parts.length; i++) {
|
|
87
|
+
const candidate = result ? `${result}, ${parts[i]}` : parts[i];
|
|
88
|
+
if (candidate.length > MAX_HEADER_SIZE) break;
|
|
89
|
+
result = candidate;
|
|
90
|
+
}
|
|
91
|
+
return result || null;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/server/stream-utils.ts
|
|
95
|
+
/**
|
|
96
|
+
* Stream utilities for the RSC rendering pipeline.
|
|
97
|
+
*
|
|
98
|
+
* teeWithErrorPropagation — wraps native ReadableStream.tee() with:
|
|
99
|
+
* 1. Cross-branch error propagation (error on one branch cancels the other)
|
|
100
|
+
* 2. Buffer divergence monitoring (warns + cancels when threshold exceeded)
|
|
101
|
+
*
|
|
102
|
+
* This prevents two classes of bugs:
|
|
103
|
+
* - Silent stalls when one tee branch errors and the other hangs indefinitely
|
|
104
|
+
* - Unbounded memory growth when one consumer (SSR) is much slower than the other
|
|
105
|
+
*
|
|
106
|
+
* Design doc: 02-rendering-pipeline.md §"RSC → SSR → Client Hydration"
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* Wrap a response body with stream-lifecycle timing instrumentation.
|
|
110
|
+
*
|
|
111
|
+
* Logs firstChunk, lastChunk, close, maxGap (largest inter-chunk gap),
|
|
112
|
+
* chunk count, and byte count on stream termination (clean close OR
|
|
113
|
+
* cancellation/disconnect). Gated behind `serverTiming: 'detailed'`.
|
|
114
|
+
*
|
|
115
|
+
* Uses a manual reader pump to guarantee logging on all termination
|
|
116
|
+
* paths — TransformStream.flush() is not called on cancellation.
|
|
117
|
+
*/
|
|
118
|
+
function wrapResponseWithStreamTiming(body, startTime) {
|
|
119
|
+
let firstChunkMs = 0;
|
|
120
|
+
let lastChunkMs = 0;
|
|
121
|
+
let maxGapMs = 0;
|
|
122
|
+
let prevChunkTime = 0;
|
|
123
|
+
let chunks = 0;
|
|
124
|
+
let bytes = 0;
|
|
125
|
+
let cancelled = false;
|
|
126
|
+
function emitLog() {
|
|
127
|
+
const closeMs = Math.round(performance.now() - startTime);
|
|
128
|
+
getLogger().debug("stream-lifecycle", {
|
|
129
|
+
firstChunkMs,
|
|
130
|
+
lastChunkMs,
|
|
131
|
+
closeMs,
|
|
132
|
+
maxGapMs,
|
|
133
|
+
chunks,
|
|
134
|
+
bytes,
|
|
135
|
+
...cancelled ? { cancelled: true } : {}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const reader = body.getReader();
|
|
139
|
+
return new ReadableStream({
|
|
140
|
+
async pull(controller) {
|
|
141
|
+
try {
|
|
142
|
+
const { done, value } = await reader.read();
|
|
143
|
+
if (done) {
|
|
144
|
+
emitLog();
|
|
145
|
+
controller.close();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const now = performance.now();
|
|
149
|
+
const relativeMs = Math.round(now - startTime);
|
|
150
|
+
if (chunks === 0) firstChunkMs = relativeMs;
|
|
151
|
+
else {
|
|
152
|
+
const gap = Math.round(now - prevChunkTime);
|
|
153
|
+
if (gap > maxGapMs) maxGapMs = gap;
|
|
154
|
+
}
|
|
155
|
+
prevChunkTime = now;
|
|
156
|
+
lastChunkMs = relativeMs;
|
|
157
|
+
chunks++;
|
|
158
|
+
bytes += value.byteLength;
|
|
159
|
+
controller.enqueue(value);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
emitLog();
|
|
162
|
+
controller.error(err);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
cancel(reason) {
|
|
166
|
+
cancelled = true;
|
|
167
|
+
emitLog();
|
|
168
|
+
reader.cancel(reason).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/server/instrumentation.ts
|
|
174
|
+
/**
|
|
175
|
+
* Instrumentation — loads and runs the user's instrumentation.ts file.
|
|
176
|
+
*
|
|
177
|
+
* instrumentation.ts is a file convention at the project root that exports:
|
|
178
|
+
* - register() — called once at server startup, before the first request
|
|
179
|
+
* - onRequestError() — called for every unhandled server error
|
|
180
|
+
* - logger — any object with info/warn/error/debug methods
|
|
181
|
+
*
|
|
182
|
+
* See design/17-logging.md §"instrumentation.ts — The Entry Point"
|
|
183
|
+
*/
|
|
184
|
+
var _initialized = false;
|
|
185
|
+
var _onRequestError = null;
|
|
186
|
+
/**
|
|
187
|
+
* Load and initialize the user's instrumentation.ts module.
|
|
188
|
+
*
|
|
189
|
+
* - Awaits register() before returning (server blocks on this).
|
|
190
|
+
* - Picks up the logger export and wires it into the framework logger.
|
|
191
|
+
* - Stores onRequestError for later invocation.
|
|
192
|
+
*
|
|
193
|
+
* @param loader - Function that dynamically imports the user's instrumentation module.
|
|
194
|
+
* Returns null if no instrumentation.ts exists.
|
|
195
|
+
*/
|
|
196
|
+
async function loadInstrumentation(loader) {
|
|
197
|
+
if (_initialized) return;
|
|
198
|
+
_initialized = true;
|
|
199
|
+
let mod;
|
|
200
|
+
try {
|
|
201
|
+
mod = await loader();
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error("[timber] Failed to load instrumentation.ts:", error);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!mod) return;
|
|
207
|
+
if (mod.logger && typeof mod.logger.info === "function") setLogger(mod.logger);
|
|
208
|
+
if (typeof mod.onRequestError === "function") _onRequestError = mod.onRequestError;
|
|
209
|
+
if (typeof mod.register === "function") try {
|
|
210
|
+
await mod.register();
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error("[timber] instrumentation.ts register() threw:", error);
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Call the user's onRequestError hook. Catches and logs any errors thrown
|
|
218
|
+
* by the hook itself — it must not affect the response.
|
|
219
|
+
*/
|
|
220
|
+
async function callOnRequestError(error, request, context) {
|
|
221
|
+
if (!_onRequestError) return;
|
|
222
|
+
try {
|
|
223
|
+
await _onRequestError(error, request, context);
|
|
224
|
+
} catch (hookError) {
|
|
225
|
+
console.error("[timber] onRequestError hook threw:", hookError);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Check if onRequestError is registered.
|
|
230
|
+
*/
|
|
231
|
+
function hasOnRequestError() {
|
|
232
|
+
return _onRequestError !== null;
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/server/pipeline-helpers.ts
|
|
236
|
+
/**
|
|
237
|
+
* Build a proxy resolver closure from the declared source. Called exactly
|
|
238
|
+
* once at `createPipeline` setup time, so the hot path sees only the branch
|
|
239
|
+
* that corresponds to this pipeline's configured variant.
|
|
240
|
+
*
|
|
241
|
+
* Returns `null` when the app has no proxy.ts — the hot path short-circuits
|
|
242
|
+
* around `runProxyPhase` entirely in that case.
|
|
243
|
+
*
|
|
244
|
+
* Accepts the sugar form (a bare `ProxyExport` — function or function array)
|
|
245
|
+
* and normalises it to the static variant. Functions and arrays are
|
|
246
|
+
* structurally distinct from the tagged `{ kind: 'lazy', loader }` object,
|
|
247
|
+
* so discrimination is unambiguous.
|
|
248
|
+
*/
|
|
249
|
+
function makeProxyResolver(proxy) {
|
|
250
|
+
if (proxy === void 0) return null;
|
|
251
|
+
if (typeof proxy === "function" || Array.isArray(proxy)) {
|
|
252
|
+
const exp = proxy;
|
|
253
|
+
return () => exp;
|
|
254
|
+
}
|
|
255
|
+
if (proxy.kind === "static") {
|
|
256
|
+
const exp = proxy.export;
|
|
257
|
+
return () => exp;
|
|
258
|
+
}
|
|
259
|
+
const loader = proxy.loader;
|
|
260
|
+
return async () => (await loader()).default;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Apply all Set-Cookie headers from the cookie jar to a Headers object.
|
|
264
|
+
* Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
|
|
265
|
+
*/
|
|
266
|
+
function applyCookieJar(headers) {
|
|
267
|
+
for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Merge framework-managed response headers onto a terminal response without
|
|
271
|
+
* overwriting headers the terminal response already set itself.
|
|
272
|
+
*/
|
|
273
|
+
function mergeMissingHeaders(target, source) {
|
|
274
|
+
const existingKeys = new Set([...target.keys()].map((key) => key.toLowerCase()));
|
|
275
|
+
for (const [key, value] of source.entries()) if (!existingKeys.has(key.toLowerCase())) target.append(key, value);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Clone a Response into a fresh one whose header bag is guaranteed mutable.
|
|
279
|
+
*
|
|
280
|
+
* `Response.redirect()` and some platform-level passthrough responses (notably
|
|
281
|
+
* on Cloudflare Workers) return objects with frozen header bags. Calling
|
|
282
|
+
* `.set()` or `.append()` on them throws `TypeError: immutable`, which the
|
|
283
|
+
* pipeline can hit when it appends Set-Cookie or Server-Timing entries.
|
|
284
|
+
*
|
|
285
|
+
* The pipeline calls this at the producer sites where user-controlled
|
|
286
|
+
* responses enter the framework — `outcomeToResponse` for all phase outcomes,
|
|
287
|
+
* and `handleRequest` for metadata-route and auto-sitemap user handlers — so
|
|
288
|
+
* downstream code can write headers without runtime feature-detection.
|
|
289
|
+
*
|
|
290
|
+
* The clone is unconditional. This is a deliberate trade: we avoid a
|
|
291
|
+
* try/catch + thrown `TypeError` on every request (the previous probe-based
|
|
292
|
+
* approach paid that cost on the hot path) and accept one cheap Response
|
|
293
|
+
* rewrap at the framework boundary instead.
|
|
294
|
+
*/
|
|
295
|
+
function cloneWithMutableHeaders(response) {
|
|
296
|
+
return new Response(response.body, {
|
|
297
|
+
status: response.status,
|
|
298
|
+
statusText: response.statusText,
|
|
299
|
+
headers: new Headers(response.headers)
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Build a redirect Response from a RedirectSignal.
|
|
304
|
+
*
|
|
305
|
+
* For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
|
|
306
|
+
* so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
307
|
+
* turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
308
|
+
* createFromFetch. See design/19-client-navigation.md.
|
|
309
|
+
*/
|
|
310
|
+
function buildRedirectResponse(signal, req, headers) {
|
|
311
|
+
if (isRscRequest(req)) {
|
|
312
|
+
headers.set("X-Timber-Redirect", signal.location);
|
|
313
|
+
return new Response(null, {
|
|
314
|
+
status: 204,
|
|
315
|
+
headers
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
headers.set("Location", signal.location);
|
|
319
|
+
return new Response(null, {
|
|
320
|
+
status: signal.status,
|
|
321
|
+
headers
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Fire the user's onRequestError hook with request context.
|
|
326
|
+
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
327
|
+
*/
|
|
328
|
+
async function fireOnRequestError(error, req, phase) {
|
|
329
|
+
const url = new URL(req.url);
|
|
330
|
+
const headersObj = {};
|
|
331
|
+
req.headers.forEach((v, k) => {
|
|
332
|
+
headersObj[k] = v;
|
|
333
|
+
});
|
|
334
|
+
await callOnRequestError(error, {
|
|
335
|
+
method: req.method,
|
|
336
|
+
path: url.pathname,
|
|
337
|
+
headers: headersObj
|
|
338
|
+
}, {
|
|
339
|
+
phase,
|
|
340
|
+
routePath: url.pathname,
|
|
341
|
+
routeType: "page",
|
|
342
|
+
traceId: getTraceId()
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
//#endregion
|
|
346
|
+
//#region src/server/proxy.ts
|
|
347
|
+
/**
|
|
348
|
+
* Run the proxy pipeline.
|
|
349
|
+
*
|
|
350
|
+
* @param proxyExport - The default export from proxy.ts (function or array)
|
|
351
|
+
* @param req - The incoming request
|
|
352
|
+
* @param next - The continuation that proceeds to route matching and rendering
|
|
353
|
+
* @returns The final response
|
|
354
|
+
*/
|
|
355
|
+
async function runProxy(proxyExport, req, next) {
|
|
356
|
+
const fns = Array.isArray(proxyExport) ? proxyExport : [proxyExport];
|
|
357
|
+
let i = fns.length;
|
|
358
|
+
let composed = next;
|
|
359
|
+
while (i--) {
|
|
360
|
+
const fn = fns[i];
|
|
361
|
+
const downstream = composed;
|
|
362
|
+
composed = () => Promise.resolve(fn(req, downstream));
|
|
363
|
+
}
|
|
364
|
+
return composed();
|
|
365
|
+
}
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/server/middleware-runner.ts
|
|
368
|
+
/**
|
|
369
|
+
* Run a route's middleware function.
|
|
370
|
+
*
|
|
371
|
+
* @param middlewareFn - The default export from the route's middleware.ts
|
|
372
|
+
* @param ctx - The middleware context (req, params, headers, requestHeaders, searchParams)
|
|
373
|
+
* @returns A Response if middleware short-circuited, or undefined to continue
|
|
374
|
+
*/
|
|
375
|
+
async function runMiddleware(middlewareFn, ctx) {
|
|
376
|
+
const result = await middlewareFn(ctx);
|
|
377
|
+
if (result instanceof Response) return result;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Run all middleware functions in the segment chain, root to leaf.
|
|
381
|
+
*
|
|
382
|
+
* Execution is top-down: root middleware runs first, leaf middleware runs last.
|
|
383
|
+
* All middleware share the same MiddlewareContext — a parent that sets
|
|
384
|
+
* ctx.requestHeaders makes it visible to child middleware and downstream components.
|
|
385
|
+
*
|
|
386
|
+
* Short-circuits on the first middleware that returns a Response.
|
|
387
|
+
* Remaining middleware in the chain do not execute.
|
|
388
|
+
*
|
|
389
|
+
* @param chain - Middleware functions ordered root-to-leaf
|
|
390
|
+
* @param ctx - Shared middleware context
|
|
391
|
+
* @returns A Response if any middleware short-circuited, or undefined to continue
|
|
392
|
+
*/
|
|
393
|
+
async function runMiddlewareChain(chain, ctx) {
|
|
394
|
+
for (const fn of chain) {
|
|
395
|
+
const result = await fn(ctx);
|
|
396
|
+
if (result instanceof Response) return result;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/server/param-coercion.ts
|
|
401
|
+
/**
|
|
402
|
+
* Thrown when an app/schema.ts segment-param codec's parse() fails.
|
|
403
|
+
* The pipeline catches this and responds with 404.
|
|
404
|
+
*/
|
|
405
|
+
var ParamCoercionError = class extends Error {
|
|
406
|
+
constructor(message) {
|
|
407
|
+
super(message);
|
|
408
|
+
this.name = "ParamCoercionError";
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
/** Global schema codecs, set once at startup from virtual:timber-schema. */
|
|
412
|
+
var _globalCodecs = null;
|
|
413
|
+
/**
|
|
414
|
+
* Run segment param coercion on the matched route's segments.
|
|
415
|
+
*
|
|
416
|
+
* Codecs come from `app/schema.ts` and nowhere else (TIM-1342 completed
|
|
417
|
+
* design/41-global-params.md migration step 9). A route with no schema, or
|
|
418
|
+
* a param the schema does not declare, keeps its raw string — which is the
|
|
419
|
+
* honest type for an uninterpreted URL segment.
|
|
420
|
+
*
|
|
421
|
+
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
422
|
+
*
|
|
423
|
+
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
424
|
+
* See design/07-routing.md §"Where Coercion Runs"
|
|
425
|
+
* See design/41-global-params.md §"Pipeline Integration"
|
|
426
|
+
*
|
|
427
|
+
* Stays `async` with nothing to await: it is injected as a
|
|
428
|
+
* `CoerceSegmentParamsFn` into the revalidation renderer and the action
|
|
429
|
+
* middleware runner, both of which await it. Narrowing the return type to
|
|
430
|
+
* `void` would break those contracts and remove a microtask boundary the
|
|
431
|
+
* pipeline's ordering has always had, for no gain.
|
|
432
|
+
*/
|
|
433
|
+
async function coerceSegmentParams(match) {
|
|
434
|
+
const globalCodecs = _globalCodecs;
|
|
435
|
+
const mergeTarget = Object.create(null);
|
|
436
|
+
for (const key of Object.keys(match.segmentParams)) if (key !== "__proto__") mergeTarget[key] = match.segmentParams[key];
|
|
437
|
+
match.segmentParams = mergeTarget;
|
|
438
|
+
if (globalCodecs) for (const node of match.segments) {
|
|
439
|
+
const segment = effectiveUrlSegment(node);
|
|
440
|
+
if (!segment.paramName) continue;
|
|
441
|
+
const bracketKey = toBracketKey(segment.segmentType, segment.paramName);
|
|
442
|
+
const codec = globalCodecs[bracketKey];
|
|
443
|
+
if (!codec) continue;
|
|
444
|
+
const key = segment.paramName;
|
|
445
|
+
if (!(key in mergeTarget)) continue;
|
|
446
|
+
let parsed;
|
|
447
|
+
try {
|
|
448
|
+
parsed = codec.parse(mergeTarget[key]);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
451
|
+
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}'.`);
|
|
452
|
+
throw new ParamCoercionError(message);
|
|
453
|
+
}
|
|
454
|
+
mergeTarget[key] = normalizeParamValue(parsed, key);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/server/pipeline-metadata.ts
|
|
459
|
+
/**
|
|
460
|
+
* Metadata route helpers for the request pipeline.
|
|
461
|
+
*
|
|
462
|
+
* Handles serving static metadata files and serializing sitemap responses.
|
|
463
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
464
|
+
*
|
|
465
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
466
|
+
*/
|
|
467
|
+
/**
|
|
468
|
+
* Content types that are text-based and should include charset=utf-8.
|
|
469
|
+
* Binary formats (images) should not include charset.
|
|
470
|
+
*/
|
|
471
|
+
var TEXT_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
472
|
+
"application/xml",
|
|
473
|
+
"text/plain",
|
|
474
|
+
"application/json",
|
|
475
|
+
"application/manifest+json",
|
|
476
|
+
"image/svg+xml"
|
|
477
|
+
]);
|
|
478
|
+
/**
|
|
479
|
+
* Serve a static metadata file by reading it from disk.
|
|
480
|
+
*
|
|
481
|
+
* Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
|
|
482
|
+
* are served as-is with the appropriate Content-Type header.
|
|
483
|
+
* Text files include charset=utf-8; binary files do not.
|
|
484
|
+
*
|
|
485
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
486
|
+
*/
|
|
487
|
+
async function serveStaticMetadataFile(metaMatch) {
|
|
488
|
+
const { contentType, file } = metaMatch;
|
|
489
|
+
const isText = TEXT_CONTENT_TYPES.has(contentType);
|
|
490
|
+
const body = await readFile(file.filePath);
|
|
491
|
+
const headers = {
|
|
492
|
+
"Content-Type": isText ? `${contentType}; charset=utf-8` : contentType,
|
|
493
|
+
"Content-Length": String(body.byteLength),
|
|
494
|
+
"Cache-Control": "public, max-age=14400, must-revalidate"
|
|
495
|
+
};
|
|
496
|
+
return new Response(body, {
|
|
497
|
+
status: 200,
|
|
498
|
+
headers
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Serialize a sitemap array to XML.
|
|
503
|
+
* Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
|
|
504
|
+
*/
|
|
505
|
+
function serializeSitemap(entries) {
|
|
506
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => {
|
|
507
|
+
let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
|
|
508
|
+
if (e.lastModified) {
|
|
509
|
+
const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
|
|
510
|
+
xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
|
|
511
|
+
}
|
|
512
|
+
if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
|
|
513
|
+
if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
|
|
514
|
+
xml += "\n </url>";
|
|
515
|
+
return xml;
|
|
516
|
+
}).join("\n")}\n</urlset>`;
|
|
517
|
+
}
|
|
518
|
+
/** Escape special XML characters. */
|
|
519
|
+
function escapeXml(str) {
|
|
520
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region src/server/safe-load.ts
|
|
524
|
+
/**
|
|
525
|
+
* Custom error class for module load failures.
|
|
526
|
+
*
|
|
527
|
+
* Preserves the original error as `cause` while providing a
|
|
528
|
+
* human-readable message with the file path.
|
|
529
|
+
*/
|
|
530
|
+
var ModuleLoadError = class extends Error {
|
|
531
|
+
/** The file path that failed to load. */
|
|
532
|
+
filePath;
|
|
533
|
+
constructor(filePath, cause) {
|
|
534
|
+
const originalMessage = cause instanceof Error ? cause.message : String(cause);
|
|
535
|
+
super(`[timber] Failed to load module ${filePath}\n ${originalMessage}`, { cause });
|
|
536
|
+
this.name = "ModuleLoadError";
|
|
537
|
+
this.filePath = filePath;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
/**
|
|
541
|
+
* Load a route manifest module with enriched error context.
|
|
542
|
+
*
|
|
543
|
+
* On success: returns the module object (same as `loader.load()`).
|
|
544
|
+
* On failure: throws `ModuleLoadError` with file path and original cause.
|
|
545
|
+
*
|
|
546
|
+
* For error rendering paths that need fallthrough instead of throwing,
|
|
547
|
+
* callers should catch at the call site:
|
|
548
|
+
*
|
|
549
|
+
* ```ts
|
|
550
|
+
* // Throwing (default) — route-element-builder, api-handler, etc.
|
|
551
|
+
* const mod = await loadModule(segment.page);
|
|
552
|
+
*
|
|
553
|
+
* // Fallthrough — error-renderer, error-boundary-wrapper
|
|
554
|
+
* const mod = await loadModule(segment.error).catch(() => null);
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
async function loadModule(loader) {
|
|
558
|
+
try {
|
|
559
|
+
return await loader.load();
|
|
560
|
+
} catch (error) {
|
|
561
|
+
throw new ModuleLoadError(loader.filePath, error);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/server/pipeline-interception.ts
|
|
566
|
+
/**
|
|
567
|
+
* Interception route matching for the request pipeline.
|
|
568
|
+
*
|
|
569
|
+
* Matches target URLs against interception rewrites to support the
|
|
570
|
+
* modal route pattern (soft navigation intercepts).
|
|
571
|
+
*
|
|
572
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
573
|
+
*
|
|
574
|
+
* See design/07-routing.md §"Intercepting Routes"
|
|
575
|
+
*/
|
|
576
|
+
/**
|
|
577
|
+
* Check if an intercepting route applies for this soft navigation.
|
|
578
|
+
*
|
|
579
|
+
* Two conditions must hold:
|
|
580
|
+
*
|
|
581
|
+
* 1. The **target** pathname matches the rewrite's intercepted pattern.
|
|
582
|
+
* 2. The **source** route — the page the user is navigating FROM, per the
|
|
583
|
+
* validated X-Timber-URL header — renders the segment that owns the slot
|
|
584
|
+
* holding the intercepting page (`interceptingScope`). Only then does that
|
|
585
|
+
* slot exist in the rendered tree, so only then can the intercepting page
|
|
586
|
+
* render.
|
|
587
|
+
*
|
|
588
|
+
* Condition 2 replaced a URL-prefix check (TIM-1279). A prefix cannot
|
|
589
|
+
* distinguish a route-group-scoped interception from a genuinely root-level
|
|
590
|
+
* one — route groups are invisible in `urlPath`, so both collapse to "/" and
|
|
591
|
+
* the guard degenerates to always-true. Tree paths carry group identity.
|
|
592
|
+
* The scope check also subsumes the old segment-boundary rule: a source route
|
|
593
|
+
* under /feed-private has no "/feed" in its chain.
|
|
594
|
+
*
|
|
595
|
+
* A third condition lives with the caller, which owns route matching, and it
|
|
596
|
+
* applies to the `'slot'` shape only: the route produced by re-matching
|
|
597
|
+
* `interceptingPrefix` must pass through the scope too. Conditions 1–2 are
|
|
598
|
+
* about the route the user is leaving; the slot has to exist in the tree that
|
|
599
|
+
* actually renders. The `'children'` shape resolves its owner from the scope
|
|
600
|
+
* directly rather than from a pathname, so it satisfies that by construction.
|
|
601
|
+
*
|
|
602
|
+
* `resolveSourceScopes` is a thunk so its inputs are only resolved when some
|
|
603
|
+
* rewrite's pattern actually matches the target — the common case is no match
|
|
604
|
+
* at all. It returns the two client-supplied answers to "what is on screen?"
|
|
605
|
+
* as **separate sets, most authoritative first**: the tree paths the client
|
|
606
|
+
* says it has mounted, then those of the route the departing URL matches. It
|
|
607
|
+
* returns null only when neither yields anything, which is not a soft
|
|
608
|
+
* navigation this can reason about.
|
|
609
|
+
*
|
|
610
|
+
* They are two sets rather than one union because ranking needs a chain
|
|
611
|
+
* (TIM-1318). Each channel describes a single rendered chain, so within one the
|
|
612
|
+
* scopes are strictly nested and the deepest is the innermost owner. Unioned
|
|
613
|
+
* they are not: once a modal is open the address bar holds the *intercepted*
|
|
614
|
+
* URL, so the second channel describes the canonical branch — possibly an
|
|
615
|
+
* exclusive route group the client never mounted — and a deeper scope from
|
|
616
|
+
* there would outrank the owner actually on screen. Trying the mounted channel
|
|
617
|
+
* to exhaustion first keeps the fallback the union was there to provide while
|
|
618
|
+
* ranking only within one chain.
|
|
619
|
+
*
|
|
620
|
+
* Returns the winning build-time rewrite, or null. Only build-time values ever
|
|
621
|
+
* reach the renderer: the client-supplied header selects a rewrite but never
|
|
622
|
+
* becomes a rendered route or pathname.
|
|
623
|
+
*
|
|
624
|
+
* The **first** matching rewrite wins, and the caller does not fall back to the
|
|
625
|
+
* next one when the winner fails to resolve. That is deliberate and pre-dates
|
|
626
|
+
* the two shapes: a rewrite that matched the target and the scope described the
|
|
627
|
+
* author's intent, and quietly rendering some other author's interception
|
|
628
|
+
* instead would be harder to explain than rendering the canonical route.
|
|
629
|
+
*
|
|
630
|
+
* "First" is meaningful because the list arrives **deepest scope first** —
|
|
631
|
+
* `collectInterceptionRewrites` sorts it at build time, so the deepest owner a
|
|
632
|
+
* single chain passes through wins and nothing here has to score (TIM-1318).
|
|
633
|
+
* Tree-walk order used to decide it, which meant renaming a route group flipped
|
|
634
|
+
* which modal opened. The precondition is a property of the manifest this reads
|
|
635
|
+
* rather than something it can re-derive, so it is asserted at its source:
|
|
636
|
+
* `tests/interception.test.ts` §"rewrite order is decided by scope depth".
|
|
637
|
+
*/
|
|
638
|
+
function findInterceptionMatch(targetPathname, resolveSourceScopes, rewrites) {
|
|
639
|
+
const candidates = rewrites.filter((rewrite) => pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern));
|
|
640
|
+
if (candidates.length === 0) return null;
|
|
641
|
+
const scopes = resolveSourceScopes();
|
|
642
|
+
if (!scopes) return null;
|
|
643
|
+
for (const scope of scopes) for (const rewrite of candidates) if (scope.has(rewrite.interceptingScope)) return rewrite;
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
648
|
+
*
|
|
649
|
+
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
650
|
+
* Static segments must match exactly.
|
|
651
|
+
*/
|
|
652
|
+
function pathnameMatchesPattern(pathname, pattern) {
|
|
653
|
+
const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
|
|
654
|
+
const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
655
|
+
let pi = 0;
|
|
656
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
657
|
+
const seg = classifyUrlSegment(patternParts[i]);
|
|
658
|
+
switch (seg.kind) {
|
|
659
|
+
case "catch-all": return pi < pathParts.length;
|
|
660
|
+
case "optional-catch-all": return true;
|
|
661
|
+
case "dynamic": {
|
|
662
|
+
if (pi >= pathParts.length) return false;
|
|
663
|
+
const prefix = seg.prefix ?? "";
|
|
664
|
+
const suffix = seg.suffix ?? "";
|
|
665
|
+
const pathPart = pathParts[pi];
|
|
666
|
+
if (prefix || suffix) {
|
|
667
|
+
if (pathPart.length <= prefix.length + suffix.length || prefix && !pathPart.startsWith(prefix) || suffix && !pathPart.endsWith(suffix)) return false;
|
|
668
|
+
}
|
|
669
|
+
pi++;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
case "static":
|
|
673
|
+
if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
|
|
674
|
+
pi++;
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return pi === pathParts.length;
|
|
679
|
+
}
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region src/server/state-tree-diff.ts
|
|
682
|
+
/**
|
|
683
|
+
* State Tree Diffing — Server-side parsing and diffing of X-Timber-State-Tree.
|
|
684
|
+
*
|
|
685
|
+
* The client sends X-Timber-State-Tree on navigation requests, listing
|
|
686
|
+
* the sync segments it has cached. The server diffs this against the
|
|
687
|
+
* target route's segments to skip re-rendering unchanged sync layouts.
|
|
688
|
+
*
|
|
689
|
+
* This is a performance optimization only — NOT a security boundary.
|
|
690
|
+
* All access.ts files run regardless of the state tree content.
|
|
691
|
+
* A fabricated state tree can only cause extra rendering work or stale
|
|
692
|
+
* layouts — never auth bypass.
|
|
693
|
+
*
|
|
694
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
695
|
+
* See design/13-security.md §"State tree manipulation"
|
|
696
|
+
*/
|
|
697
|
+
/**
|
|
698
|
+
* Parse the X-Timber-State-Tree header from a request.
|
|
699
|
+
*
|
|
700
|
+
* Returns a Set of segment paths the client has cached (with an
|
|
701
|
+
* additional `slots` property for cached slot paths), or null if
|
|
702
|
+
* the header is missing, malformed, or empty. Parsing happens before
|
|
703
|
+
* renderToReadableStream — not inside the React render pass.
|
|
704
|
+
*
|
|
705
|
+
* @returns ClientStateTree with segments and slots, or null if no valid state tree
|
|
706
|
+
*/
|
|
707
|
+
function parseClientStateTree(req) {
|
|
708
|
+
const header = req.headers.get("X-Timber-State-Tree");
|
|
709
|
+
if (!header) return null;
|
|
710
|
+
try {
|
|
711
|
+
const parsed = JSON.parse(header);
|
|
712
|
+
const hasSegments = Array.isArray(parsed.segments) && parsed.segments.length > 0;
|
|
713
|
+
const hasSlots = Array.isArray(parsed.slots) && parsed.slots.length > 0;
|
|
714
|
+
const hasTreePaths = Array.isArray(parsed.treePaths) && parsed.treePaths.length > 0;
|
|
715
|
+
if (!hasSegments && !hasSlots && !hasTreePaths) return null;
|
|
716
|
+
const result = new Set(hasSegments ? parsed.segments : []);
|
|
717
|
+
result.slots = hasSlots ? new Set(parsed.slots) : null;
|
|
718
|
+
result.treePaths = hasTreePaths ? new Set(parsed.treePaths) : null;
|
|
719
|
+
return result;
|
|
720
|
+
} catch (err) {
|
|
721
|
+
swallow(err, "malformed X-Timber-State-Tree header");
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/server/response-cache-policy.ts
|
|
727
|
+
/**
|
|
728
|
+
* Legacy shared-cache control fields that predate RFC 9213 and so do not
|
|
729
|
+
* follow the `<target>-Cache-Control` naming pattern — the one part of this
|
|
730
|
+
* rule that cannot be matched by shape. `X-Accel-Expires` is nginx
|
|
731
|
+
* `proxy_cache`, which gives it precedence over `Cache-Control`.
|
|
732
|
+
*
|
|
733
|
+
* A closed historical set, unlike the vendor `*-Cache-Control` names, which
|
|
734
|
+
* keep being minted and are handled by pattern below.
|
|
735
|
+
*/
|
|
736
|
+
var LEGACY_CDN_CACHE_HEADERS = [
|
|
737
|
+
"Surrogate-Control",
|
|
738
|
+
"Edge-Control",
|
|
739
|
+
"X-Accel-Expires"
|
|
740
|
+
];
|
|
741
|
+
/**
|
|
742
|
+
* Whether a response header overrides `Cache-Control` at a shared cache.
|
|
743
|
+
*
|
|
744
|
+
* Matched by *shape*, not by vendor name. RFC 9213 defines targeted fields
|
|
745
|
+
* as `<target>-Cache-Control`, and every CDN spells its own: `CDN-`,
|
|
746
|
+
* `Cloudflare-CDN-`, `Netlify-CDN-`, `Vercel-CDN-`, and whichever ships
|
|
747
|
+
* next. A vendor list here is a denylist with an unbounded tail — it was
|
|
748
|
+
* extended twice during review, each time by one name, which is the signal
|
|
749
|
+
* that enumeration is the wrong shape. The suffix rule is total.
|
|
750
|
+
*
|
|
751
|
+
* `Cache-Control` itself does not match (no `-` prefix) and is replaced
|
|
752
|
+
* separately.
|
|
753
|
+
*/
|
|
754
|
+
function isSharedCacheOverride(name) {
|
|
755
|
+
const lowered = name.toLowerCase();
|
|
756
|
+
return lowered.endsWith("-cache-control") || LEGACY_CDN_CACHE_HEADERS.some((legacy) => legacy.toLowerCase() === lowered);
|
|
757
|
+
}
|
|
758
|
+
/** Disable caching, including CDN overrides that take precedence over Cache-Control. */
|
|
759
|
+
function preventResponseCaching(headers) {
|
|
760
|
+
const overrides = [];
|
|
761
|
+
for (const name of headers.keys()) if (isSharedCacheOverride(name)) overrides.push(name);
|
|
762
|
+
for (const name of overrides) headers.delete(name);
|
|
763
|
+
headers.set("Cache-Control", "private, no-store");
|
|
764
|
+
}
|
|
765
|
+
//#endregion
|
|
766
|
+
//#region src/server/error-response-headers.ts
|
|
767
|
+
/** Preserve request-wide metadata when replacing a failed response body. */
|
|
768
|
+
function errorResponseHeaders(responseHeaders) {
|
|
769
|
+
const headers = new Headers(responseHeaders);
|
|
770
|
+
headers.delete("Content-Length");
|
|
771
|
+
headers.delete("Content-Encoding");
|
|
772
|
+
headers.delete("Transfer-Encoding");
|
|
773
|
+
preventResponseCaching(headers);
|
|
774
|
+
headers.delete("ETag");
|
|
775
|
+
headers.delete("Last-Modified");
|
|
776
|
+
headers.delete("Expires");
|
|
777
|
+
headers.delete("Age");
|
|
778
|
+
return headers;
|
|
779
|
+
}
|
|
780
|
+
//#endregion
|
|
781
|
+
//#region src/server/rsc-error-envelope.ts
|
|
782
|
+
/**
|
|
783
|
+
* Build the hard-navigate envelope for a status code.
|
|
784
|
+
*
|
|
785
|
+
* Preserve middleware's request-wide headers and cookies, but discard the
|
|
786
|
+
* abandoned body's framing, validators, and cache policy. Every producer,
|
|
787
|
+
* including errors returned normally during render, gets this cleanup here.
|
|
788
|
+
* The input header bag is left intact. The body deliberately carries no message —
|
|
789
|
+
* only the status already on the wire (design/13-security.md principle 4).
|
|
790
|
+
*/
|
|
791
|
+
function rscErrorEnvelope(status, responseHeaders) {
|
|
792
|
+
const headers = errorResponseHeaders(responseHeaders);
|
|
793
|
+
headers.set(TIMBER_ERROR_HEADER, "1");
|
|
794
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
795
|
+
return new Response(JSON.stringify({
|
|
796
|
+
error: true,
|
|
797
|
+
status
|
|
798
|
+
}), {
|
|
799
|
+
status,
|
|
800
|
+
headers
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region src/server/pipeline-outcome.ts
|
|
805
|
+
/**
|
|
806
|
+
* Pipeline outcome translator — converts a `PhaseOutcome` (the value
|
|
807
|
+
* each phase function returns) into a final `Response`.
|
|
808
|
+
*
|
|
809
|
+
* Lifted out of `pipeline-phases.ts` (TIM-853) so the per-phase try /
|
|
810
|
+
* catch logic and the terminal Response-building logic each live in
|
|
811
|
+
* their own file. The phases produce values; this module is the single
|
|
812
|
+
* source of truth for how those values become wire responses.
|
|
813
|
+
*
|
|
814
|
+
* See design/07-routing.md §"Request Lifecycle".
|
|
815
|
+
*/
|
|
816
|
+
function rscErrorResponse(isRsc, status, headers) {
|
|
817
|
+
if (isRsc) return rscErrorEnvelope(status, headers);
|
|
818
|
+
return new Response(null, {
|
|
819
|
+
status,
|
|
820
|
+
headers: errorResponseHeaders(headers)
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Resolve the header bag a signal/error outcome is written onto, with the
|
|
825
|
+
* cookie jar applied exactly once.
|
|
826
|
+
*
|
|
827
|
+
* Ownership rule (design/49 §P0b): the phase that owns `responseHeaders`
|
|
828
|
+
* applies the jar to it once; the translator applies it only for outcomes
|
|
829
|
+
* that arrive without it. Render-phase outcomes already carry the jar —
|
|
830
|
+
* `runRenderPhase()` applies it on entry, with or without a middleware
|
|
831
|
+
* chain — so re-applying here emitted every middleware `Set-Cookie` twice
|
|
832
|
+
* (TIM-1491).
|
|
833
|
+
*
|
|
834
|
+
* The guard is "not render", not "only middleware": proxy-phase signals
|
|
835
|
+
* reach the translator with no `responseHeaders` at all, and for a
|
|
836
|
+
* `proxy.ts` that awaits `next()` and then throws, this is the only place
|
|
837
|
+
* the cookies downstream middleware set can reach the wire
|
|
838
|
+
* (design/29-cookies.md §"proxy.ts Cookie Access"). Middleware-phase
|
|
839
|
+
* signals throw before that phase's own application, so they need it too.
|
|
840
|
+
*/
|
|
841
|
+
function outcomeHeaders(outcome, ctx) {
|
|
842
|
+
const headers = ctx.responseHeaders ?? new Headers();
|
|
843
|
+
if (outcome.phase !== "render") applyCookieJar(headers);
|
|
844
|
+
return headers;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Terminal outcome handler — converts a `PhaseOutcome` into a final
|
|
848
|
+
* `Response`, applying cookies, building redirects, rendering deny pages
|
|
849
|
+
* and fallback error pages, and firing instrumentation hooks.
|
|
850
|
+
*
|
|
851
|
+
* This is the single source of truth for how phase outputs become wire
|
|
852
|
+
* responses; the per-phase try/catch blocks now produce values, not
|
|
853
|
+
* Responses, so the conversion logic lives in exactly one place.
|
|
854
|
+
*/
|
|
855
|
+
async function outcomeToResponse(config, outcome, ctx) {
|
|
856
|
+
switch (outcome.kind) {
|
|
857
|
+
case "response": {
|
|
858
|
+
const finalResponse = cloneWithMutableHeaders(outcome.response);
|
|
859
|
+
if (outcome.phase === "proxy") return finalResponse;
|
|
860
|
+
if (outcome.phase === "middleware" && ctx.responseHeaders) {
|
|
861
|
+
applyCookieJar(finalResponse.headers);
|
|
862
|
+
mergeMissingHeaders(finalResponse.headers, ctx.responseHeaders);
|
|
863
|
+
logMiddlewareShortCircuit({
|
|
864
|
+
method: ctx.method,
|
|
865
|
+
path: ctx.path,
|
|
866
|
+
status: finalResponse.status
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
if (outcome.phase === "render") markResponseFlushed();
|
|
870
|
+
return finalResponse;
|
|
871
|
+
}
|
|
872
|
+
case "redirect": {
|
|
873
|
+
const headers = outcomeHeaders(outcome, ctx);
|
|
874
|
+
return buildRedirectResponse(outcome.signal, ctx.req, headers);
|
|
875
|
+
}
|
|
876
|
+
case "deny": {
|
|
877
|
+
const headers = outcomeHeaders(outcome, ctx);
|
|
878
|
+
if (config.renderDenyFallback) try {
|
|
879
|
+
return cloneWithMutableHeaders(await config.renderDenyFallback(outcome.signal, ctx.req, headers, ctx.match));
|
|
880
|
+
} catch (denyRenderError) {
|
|
881
|
+
logRenderError({
|
|
882
|
+
method: ctx.method,
|
|
883
|
+
path: ctx.path,
|
|
884
|
+
error: denyRenderError
|
|
885
|
+
});
|
|
886
|
+
await fireOnRequestError(denyRenderError, ctx.req, "render");
|
|
887
|
+
if (config.onPipelineError && denyRenderError instanceof Error) config.onPipelineError(denyRenderError, "render");
|
|
888
|
+
}
|
|
889
|
+
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.`);
|
|
890
|
+
return new Response(null, {
|
|
891
|
+
status: outcome.signal.status,
|
|
892
|
+
headers
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
case "error": {
|
|
896
|
+
const isRsc = isRscRequest(ctx.req);
|
|
897
|
+
if (outcome.phase === "proxy") {
|
|
898
|
+
logProxyError({ error: outcome.error });
|
|
899
|
+
await fireOnRequestError(outcome.error, ctx.req, "proxy");
|
|
900
|
+
if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "proxy");
|
|
901
|
+
return rscErrorResponse(isRsc, 500, outcomeHeaders(outcome, ctx));
|
|
902
|
+
}
|
|
903
|
+
if (outcome.phase === "middleware") {
|
|
904
|
+
logMiddlewareError({
|
|
905
|
+
method: ctx.method,
|
|
906
|
+
path: ctx.path,
|
|
907
|
+
error: outcome.error
|
|
908
|
+
});
|
|
909
|
+
await fireOnRequestError(outcome.error, ctx.req, "handler");
|
|
910
|
+
if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "middleware");
|
|
911
|
+
return rscErrorResponse(isRsc, 500, outcomeHeaders(outcome, ctx));
|
|
912
|
+
}
|
|
913
|
+
const headers = outcomeHeaders(outcome, ctx);
|
|
914
|
+
logRenderError({
|
|
915
|
+
method: ctx.method,
|
|
916
|
+
path: ctx.path,
|
|
917
|
+
error: outcome.error
|
|
918
|
+
});
|
|
919
|
+
await fireOnRequestError(outcome.error, ctx.req, "render");
|
|
920
|
+
if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "render");
|
|
921
|
+
if (isRsc) return rscErrorResponse(true, 500, headers);
|
|
922
|
+
if (config.renderFallbackError) try {
|
|
923
|
+
return cloneWithMutableHeaders(await config.renderFallbackError(outcome.error, ctx.req, headers));
|
|
924
|
+
} catch (fallbackRenderError) {
|
|
925
|
+
logRenderError({
|
|
926
|
+
method: ctx.method,
|
|
927
|
+
path: ctx.path,
|
|
928
|
+
error: fallbackRenderError
|
|
929
|
+
});
|
|
930
|
+
await fireOnRequestError(fallbackRenderError, ctx.req, "render");
|
|
931
|
+
if (config.onPipelineError && fallbackRenderError instanceof Error) config.onPipelineError(fallbackRenderError, "render");
|
|
932
|
+
}
|
|
933
|
+
return rscErrorResponse(false, 500, headers);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
//#endregion
|
|
938
|
+
//#region src/server/pipeline-phases.ts
|
|
939
|
+
/**
|
|
940
|
+
* Pipeline phase functions — module-level free functions that take their
|
|
941
|
+
* dependencies as explicit parameters. Each phase returns a `PhaseOutcome`
|
|
942
|
+
* (a discriminated union over response / redirect / deny / error) defined
|
|
943
|
+
* in `pipeline-outcome.ts`. The terminal `outcomeToResponse` (also in
|
|
944
|
+
* `pipeline-outcome.ts`) translates outcomes into Responses.
|
|
945
|
+
*
|
|
946
|
+
* Lifted out of `createPipeline` so each phase can be unit-tested in
|
|
947
|
+
* isolation. The lift is mechanical — these functions used to be closures
|
|
948
|
+
* over `config`; they now take `config` as an explicit parameter.
|
|
949
|
+
*
|
|
950
|
+
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow".
|
|
951
|
+
*/
|
|
952
|
+
/**
|
|
953
|
+
* Validate and canonicalize the X-Timber-URL header value.
|
|
954
|
+
*
|
|
955
|
+
* Returns the canonical pathname if valid, or null if rejected:
|
|
956
|
+
* - Must start with '/' (relative pathname, no scheme/authority)
|
|
957
|
+
* - Must not contain control characters
|
|
958
|
+
* - Must pass canonicalization (no encoded separators, null bytes, etc.)
|
|
959
|
+
*/
|
|
960
|
+
function validateInterceptionHeader(raw, stripTrailingSlash) {
|
|
961
|
+
if (!raw.startsWith("/")) return null;
|
|
962
|
+
if (raw.startsWith("//")) return null;
|
|
963
|
+
for (let i = 0; i < raw.length; i++) {
|
|
964
|
+
const code = raw.charCodeAt(i);
|
|
965
|
+
if (code <= 31 || code === 127) return null;
|
|
966
|
+
}
|
|
967
|
+
const result = canonicalize(raw, stripTrailingSlash);
|
|
968
|
+
if (!result.ok) return null;
|
|
969
|
+
return result.pathname;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Run the proxy.ts phase. Calls user proxy code with a `next()` continuation.
|
|
973
|
+
*
|
|
974
|
+
* When `next` is provided, it replaces the default `handleRequest` as the
|
|
975
|
+
* continuation — this lets the pipeline inject action dispatch between proxy
|
|
976
|
+
* and route matching (TIM-1213). The proxy resolver was picked at pipeline
|
|
977
|
+
* construction time so the hot path sees no per-request branching on the
|
|
978
|
+
* `ProxyConfig` discriminant.
|
|
979
|
+
*/
|
|
980
|
+
async function runProxyPhase(config, getProxy, req, method, path, next) {
|
|
981
|
+
const detailed = config.serverTiming === "detailed";
|
|
982
|
+
try {
|
|
983
|
+
const proxyExport = await getProxy();
|
|
984
|
+
const continuation = next ?? (() => handleRequest(config, req, method, path, true));
|
|
985
|
+
const proxyFn = () => runProxy(proxyExport, req, continuation);
|
|
986
|
+
return {
|
|
987
|
+
kind: "response",
|
|
988
|
+
phase: "proxy",
|
|
989
|
+
response: await withSpan("timber.proxy", {}, () => detailed ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn())
|
|
990
|
+
};
|
|
991
|
+
} catch (error) {
|
|
992
|
+
if (isRedirectSignal(error)) return {
|
|
993
|
+
kind: "redirect",
|
|
994
|
+
phase: "proxy",
|
|
995
|
+
signal: error
|
|
996
|
+
};
|
|
997
|
+
if (isDenySignal(error)) return {
|
|
998
|
+
kind: "deny",
|
|
999
|
+
phase: "proxy",
|
|
1000
|
+
signal: error
|
|
1001
|
+
};
|
|
1002
|
+
return {
|
|
1003
|
+
kind: "error",
|
|
1004
|
+
phase: "proxy",
|
|
1005
|
+
error
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Run the middleware chain phase. If the chain short-circuits with a Response,
|
|
1011
|
+
* returns it as a 'response' outcome. Otherwise applies the request header
|
|
1012
|
+
* overlay and falls through to the render phase.
|
|
1013
|
+
*/
|
|
1014
|
+
async function runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext) {
|
|
1015
|
+
const detailed = config.serverTiming === "detailed";
|
|
1016
|
+
const ctx = {
|
|
1017
|
+
req,
|
|
1018
|
+
requestHeaders: requestHeaderOverlay,
|
|
1019
|
+
headers: responseHeaders,
|
|
1020
|
+
segmentParams: match.segmentParams,
|
|
1021
|
+
get nonce() {
|
|
1022
|
+
return requestCspNonce();
|
|
1023
|
+
},
|
|
1024
|
+
earlyHints: (hints) => {
|
|
1025
|
+
for (const hint of hints) {
|
|
1026
|
+
let value;
|
|
1027
|
+
if (hint.as !== void 0) value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
1028
|
+
else value = `<${hint.href}>; rel=${hint.rel}`;
|
|
1029
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
1030
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
1031
|
+
responseHeaders.append("Link", value);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
try {
|
|
1036
|
+
const chainFn = () => runMiddlewareChain(match.middlewareChain, ctx);
|
|
1037
|
+
const middlewareResponse = await (async () => {
|
|
1038
|
+
setMutableCookieContext(true);
|
|
1039
|
+
try {
|
|
1040
|
+
return await withSpan("timber.middleware", {}, () => detailed ? withTiming("mw", "middleware.ts", chainFn) : chainFn());
|
|
1041
|
+
} finally {
|
|
1042
|
+
setMutableCookieContext(false);
|
|
1043
|
+
}
|
|
1044
|
+
})();
|
|
1045
|
+
if (middlewareResponse) return {
|
|
1046
|
+
kind: "response",
|
|
1047
|
+
phase: "middleware",
|
|
1048
|
+
response: middlewareResponse
|
|
1049
|
+
};
|
|
1050
|
+
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
1051
|
+
return runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext);
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
if (isRedirectSignal(error)) return {
|
|
1054
|
+
kind: "redirect",
|
|
1055
|
+
phase: "middleware",
|
|
1056
|
+
signal: error
|
|
1057
|
+
};
|
|
1058
|
+
if (isDenySignal(error)) return {
|
|
1059
|
+
kind: "deny",
|
|
1060
|
+
phase: "middleware",
|
|
1061
|
+
signal: error
|
|
1062
|
+
};
|
|
1063
|
+
return {
|
|
1064
|
+
kind: "error",
|
|
1065
|
+
phase: "middleware",
|
|
1066
|
+
error
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Run the render phase. Wraps the configured renderer in a span and a
|
|
1072
|
+
* timing scope, and translates thrown signals into outcome variants.
|
|
1073
|
+
*
|
|
1074
|
+
* The render phase owns `responseHeaders` from here on, so it is the one
|
|
1075
|
+
* place the cookie jar is applied to that bag (design/29-cookies.md
|
|
1076
|
+
* §"Applied Exactly Once"). Doing it here rather than in the middleware
|
|
1077
|
+
* phase means every render-phase outcome — a 200, or a deny / redirect /
|
|
1078
|
+
* error thrown from render — carries the jar whether or not a middleware
|
|
1079
|
+
* chain ran, and `outcomeToResponse` can skip render-phase outcomes
|
|
1080
|
+
* unconditionally (TIM-1491). Application precedes `config.render` so
|
|
1081
|
+
* middleware cookie writes are visible to route-handler header merging,
|
|
1082
|
+
* while handler `Set-Cookie` values still come after them and take
|
|
1083
|
+
* precedence.
|
|
1084
|
+
*/
|
|
1085
|
+
async function runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, { canonicalPathname, interception }) {
|
|
1086
|
+
const detailed = config.serverTiming === "detailed";
|
|
1087
|
+
applyCookieJar(responseHeaders);
|
|
1088
|
+
try {
|
|
1089
|
+
const renderFn = () => config.render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
1090
|
+
return {
|
|
1091
|
+
kind: "response",
|
|
1092
|
+
phase: "render",
|
|
1093
|
+
response: await withSpan("timber.render", { "http.route": canonicalPathname }, () => detailed ? withTiming("render", "RSC + SSR render", renderFn) : renderFn())
|
|
1094
|
+
};
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
if (isDenySignal(error)) return {
|
|
1097
|
+
kind: "deny",
|
|
1098
|
+
phase: "render",
|
|
1099
|
+
signal: error
|
|
1100
|
+
};
|
|
1101
|
+
if (isRedirectSignal(error)) return {
|
|
1102
|
+
kind: "redirect",
|
|
1103
|
+
phase: "render",
|
|
1104
|
+
signal: error
|
|
1105
|
+
};
|
|
1106
|
+
return {
|
|
1107
|
+
kind: "error",
|
|
1108
|
+
phase: "render",
|
|
1109
|
+
error
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Process a single request from canonicalization through phase dispatch.
|
|
1115
|
+
*
|
|
1116
|
+
* Stages: canonicalize → metadata routes → auto-sitemap → version skew →
|
|
1117
|
+
* route match → interception → early hints → param coercion → middleware →
|
|
1118
|
+
* render → outcome translation. Pre-routing short-circuits return Responses
|
|
1119
|
+
* directly; post-match dispatch goes through `outcomeToResponse`.
|
|
1120
|
+
*
|
|
1121
|
+
* Used both as the top-level entry (when no proxy.ts is configured) and as
|
|
1122
|
+
* the `next()` continuation passed to `runProxy()`.
|
|
1123
|
+
*
|
|
1124
|
+
* @param pathIsCanonical When true, `path` has already been canonicalized by
|
|
1125
|
+
* `createPipeline` — skip re-canonicalization to prevent double-decode.
|
|
1126
|
+
* When false (default), runs canonicalize as a safety net for direct callers.
|
|
1127
|
+
*/
|
|
1128
|
+
async function handleRequest(config, req, method, path, pathIsCanonical) {
|
|
1129
|
+
const stripTrailingSlash = config.stripTrailingSlash ?? true;
|
|
1130
|
+
let canonicalPathname;
|
|
1131
|
+
if (pathIsCanonical) canonicalPathname = path;
|
|
1132
|
+
else {
|
|
1133
|
+
const result = canonicalize(path, stripTrailingSlash);
|
|
1134
|
+
if (!result.ok) {
|
|
1135
|
+
if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${path} — responding with ${result.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
|
|
1136
|
+
return new Response(null, { status: result.status });
|
|
1137
|
+
}
|
|
1138
|
+
canonicalPathname = result.pathname;
|
|
1139
|
+
}
|
|
1140
|
+
if (config.matchMetadataRoute) {
|
|
1141
|
+
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
1142
|
+
if (metaMatch) try {
|
|
1143
|
+
if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
|
|
1144
|
+
setSegmentParams(metaMatch.segmentParams);
|
|
1145
|
+
const mod = await loadModule(metaMatch.file);
|
|
1146
|
+
if (typeof mod.default !== "function") {
|
|
1147
|
+
if (isDebug()) console.warn(`[timber] Metadata route ${metaMatch.file} does not export a default function — responding with 500\n Metadata routes must export a default function that returns the metadata content.`);
|
|
1148
|
+
return new Response("Metadata route must export a default function", { status: 500 });
|
|
1149
|
+
}
|
|
1150
|
+
const handlerResult = await mod.default();
|
|
1151
|
+
if (handlerResult instanceof Response) return cloneWithMutableHeaders(handlerResult);
|
|
1152
|
+
const contentType = metaMatch.contentType;
|
|
1153
|
+
let body;
|
|
1154
|
+
if (typeof handlerResult === "string") body = handlerResult;
|
|
1155
|
+
else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
|
|
1156
|
+
else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
|
|
1157
|
+
else body = String(handlerResult);
|
|
1158
|
+
return new Response(body, {
|
|
1159
|
+
status: 200,
|
|
1160
|
+
headers: {
|
|
1161
|
+
"Content-Type": `${contentType}; charset=utf-8`,
|
|
1162
|
+
"Cache-Control": "public, max-age=14400, must-revalidate"
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (isRedirectSignal(error)) return new Response(null, {
|
|
1167
|
+
status: error.status,
|
|
1168
|
+
headers: { Location: error.location }
|
|
1169
|
+
});
|
|
1170
|
+
if (isDenySignal(error)) return new Response(null, { status: error.status });
|
|
1171
|
+
logRenderError({
|
|
1172
|
+
method,
|
|
1173
|
+
path,
|
|
1174
|
+
error
|
|
1175
|
+
});
|
|
1176
|
+
if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "metadata-route");
|
|
1177
|
+
return new Response(null, { status: 500 });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (config.autoSitemapHandler) try {
|
|
1181
|
+
const sitemapResponse = await config.autoSitemapHandler(canonicalPathname);
|
|
1182
|
+
if (sitemapResponse) return cloneWithMutableHeaders(sitemapResponse);
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
logRenderError({
|
|
1185
|
+
method,
|
|
1186
|
+
path,
|
|
1187
|
+
error
|
|
1188
|
+
});
|
|
1189
|
+
if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "auto-sitemap");
|
|
1190
|
+
return new Response(null, { status: 500 });
|
|
1191
|
+
}
|
|
1192
|
+
const isRsc = isRscRequest(req);
|
|
1193
|
+
if (isRsc) {
|
|
1194
|
+
if (!checkVersionSkew(req).ok) {
|
|
1195
|
+
const reloadHeaders = new Headers();
|
|
1196
|
+
applyReloadHeaders(reloadHeaders);
|
|
1197
|
+
return new Response(null, {
|
|
1198
|
+
status: 204,
|
|
1199
|
+
headers: reloadHeaders
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
let match = config.matchRoute(canonicalPathname);
|
|
1204
|
+
let interception;
|
|
1205
|
+
if (isRsc && config.interceptionRewrites?.length) {
|
|
1206
|
+
const rawSourceUrl = req.headers.get("X-Timber-URL");
|
|
1207
|
+
const validatedSourceUrl = rawSourceUrl ? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash) : null;
|
|
1208
|
+
if (validatedSourceUrl) {
|
|
1209
|
+
const resolveSourceScopes = () => {
|
|
1210
|
+
const mounted = new Set(parseClientStateTree(req)?.treePaths ?? []);
|
|
1211
|
+
const sourceRoute = config.matchRoute(validatedSourceUrl);
|
|
1212
|
+
const scopes = [mounted, new Set(sourceRoute ? computeSegmentTreePaths(sourceRoute.segments) : [])].filter((scope) => scope.size > 0);
|
|
1213
|
+
return scopes.length > 0 ? scopes : null;
|
|
1214
|
+
};
|
|
1215
|
+
const rewrite = findInterceptionMatch(canonicalPathname, resolveSourceScopes, config.interceptionRewrites);
|
|
1216
|
+
if (rewrite?.mode === "children") {
|
|
1217
|
+
const interceptedMatch = config.matchInterceptedRoute?.(canonicalPathname, rewrite.interceptingScope);
|
|
1218
|
+
if (interceptedMatch) match = interceptedMatch;
|
|
1219
|
+
} else if (rewrite) {
|
|
1220
|
+
const sourceMatch = config.matchRoute(rewrite.interceptingPrefix);
|
|
1221
|
+
if (sourceMatch !== null && computeSegmentTreePaths(sourceMatch.segments).includes(rewrite.interceptingScope)) {
|
|
1222
|
+
match = sourceMatch;
|
|
1223
|
+
interception = { targetPathname: canonicalPathname };
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if (!match) {
|
|
1229
|
+
if (isDebug()) console.warn(`[timber] No route matched for pathname: ${canonicalPathname}\n Input path: ${path}\n Method: ${method}`);
|
|
1230
|
+
if (config.renderNoMatch) {
|
|
1231
|
+
const responseHeaders = new Headers();
|
|
1232
|
+
return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
|
|
1233
|
+
}
|
|
1234
|
+
return new Response(null, { status: 404 });
|
|
1235
|
+
}
|
|
1236
|
+
const responseHeaders = new Headers();
|
|
1237
|
+
const requestHeaderOverlay = new Headers();
|
|
1238
|
+
responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
|
|
1239
|
+
const leafSegment = match.segments[match.segments.length - 1];
|
|
1240
|
+
const isApiRoute = leafSegment?.route && !leafSegment?.page;
|
|
1241
|
+
if (config.earlyHints && !isRsc && !isApiRoute) try {
|
|
1242
|
+
await config.earlyHints(match, req, responseHeaders);
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
swallow(err, "early hints hook threw");
|
|
1245
|
+
}
|
|
1246
|
+
match.rawSegmentParams = { ...match.segmentParams };
|
|
1247
|
+
try {
|
|
1248
|
+
await coerceSegmentParams(match);
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
if (error instanceof ParamCoercionError) {
|
|
1251
|
+
if (isDebug()) {
|
|
1252
|
+
const segmentChain = match.segments.map((s) => s.segmentName || "/").join(" → ");
|
|
1253
|
+
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.`);
|
|
1254
|
+
}
|
|
1255
|
+
const leafSegment = match.segments[match.segments.length - 1];
|
|
1256
|
+
if (leafSegment.route && !leafSegment.page) return new Response(null, { status: 404 });
|
|
1257
|
+
if (config.renderNoMatch) return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
|
|
1258
|
+
return new Response(null, { status: 404 });
|
|
1259
|
+
}
|
|
1260
|
+
throw error;
|
|
1261
|
+
}
|
|
1262
|
+
setSegmentParams(match.segmentParams);
|
|
1263
|
+
const segmentPath = match.segments.map((s) => s.segmentName).filter(Boolean).join("/") || "/";
|
|
1264
|
+
setMatchedSegmentPath(segmentPath.startsWith("/") ? segmentPath : `/${segmentPath}`);
|
|
1265
|
+
return outcomeToResponse(config, match.middlewareChain.length > 0 ? await runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, {
|
|
1266
|
+
canonicalPathname,
|
|
1267
|
+
interception
|
|
1268
|
+
}) : await runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, {
|
|
1269
|
+
canonicalPathname,
|
|
1270
|
+
interception
|
|
1271
|
+
}), {
|
|
1272
|
+
req,
|
|
1273
|
+
method,
|
|
1274
|
+
path,
|
|
1275
|
+
responseHeaders,
|
|
1276
|
+
match
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
//#endregion
|
|
1280
|
+
//#region src/server/rsc-cache-key-guard.ts
|
|
1281
|
+
/**
|
|
1282
|
+
* Origin-side binding of the `_rsc` cache key to the request that produced
|
|
1283
|
+
* the response (TIM-1268).
|
|
1284
|
+
*
|
|
1285
|
+
* The key is deterministic, and deliberately so — that is what makes RSC
|
|
1286
|
+
* payloads cacheable. But deterministic also means *predictable*: anyone can
|
|
1287
|
+
* compute the key another client will request. Without this guard, a caller
|
|
1288
|
+
* could request `/about?_rsc=<victim's key>` while sending its own
|
|
1289
|
+
* `X-Timber-State-Tree`, and a shared cache that keys by URL — the exact
|
|
1290
|
+
* environment this feature targets — would store that variant under the
|
|
1291
|
+
* victim's key. Every subsequent legitimate navigation would then be served
|
|
1292
|
+
* a payload rendered for someone else's segment tree or deployment.
|
|
1293
|
+
*
|
|
1294
|
+
* The guard is a *storability* rule, not a rejection: if the claimed key
|
|
1295
|
+
* does not match the key derived from the received headers, the response
|
|
1296
|
+
* loses its shared-cache directives. Mismatched requests are still served
|
|
1297
|
+
* normally, they just cannot be stored for anyone else.
|
|
1298
|
+
*
|
|
1299
|
+
* Why not Next.js's 307 self-correction: a redirect is cacheable in its own
|
|
1300
|
+
* right, costs a round trip on every legitimate miss, and would make the
|
|
1301
|
+
* server's behaviour depend on a parameter it otherwise never reads. This
|
|
1302
|
+
* rule closes the same hole by making the poisoned entry unstorable at the
|
|
1303
|
+
* source, in the response the developer already has to opt into sharing.
|
|
1304
|
+
*
|
|
1305
|
+
* See design/19-client-navigation.md §"The `_rsc` cache key".
|
|
1306
|
+
*/
|
|
1307
|
+
/**
|
|
1308
|
+
* Whether the request's `_rsc` parameter matches the key derived from its
|
|
1309
|
+
* own headers.
|
|
1310
|
+
*
|
|
1311
|
+
* Requests without the parameter are not claiming a key at all, so they are
|
|
1312
|
+
* left alone — the bare document URL is the developer's declared territory
|
|
1313
|
+
* (see design/32-response-caching.md, TIM-1264). Neither is a parameter
|
|
1314
|
+
* whose value could not be one of our digests: `_rsc` is not reserved, and
|
|
1315
|
+
* an application that already uses it for its own search state
|
|
1316
|
+
* (`/search?_rsc=foo`) must not have its cache policy silently revoked by a
|
|
1317
|
+
* guard that mistook that for a forgery.
|
|
1318
|
+
*/
|
|
1319
|
+
async function hasValidRscCacheKey(req) {
|
|
1320
|
+
if (!req.url.includes("?")) return true;
|
|
1321
|
+
let values;
|
|
1322
|
+
try {
|
|
1323
|
+
values = new URL(req.url).searchParams.getAll(RSC_KEY_PARAM);
|
|
1324
|
+
} catch {
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
const claims = values.filter(isRscCacheKeyShape);
|
|
1328
|
+
if (claims.length === 0) return true;
|
|
1329
|
+
const expected = await rscCacheKey((name) => req.headers.get(name));
|
|
1330
|
+
if (expected === null) return false;
|
|
1331
|
+
return claims.every((claim) => claim === expected);
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Ensure a Flight payload can never be interpreted as a document.
|
|
1335
|
+
*
|
|
1336
|
+
* Applied here, at the pipeline's single exit, rather than where each
|
|
1337
|
+
* payload response is built. Six places build one — the success path, three
|
|
1338
|
+
* in `deny-renderer.ts`, the action paths — and hardening them one at a time
|
|
1339
|
+
* is how you end up hardening five of six. The response is Flight if it says
|
|
1340
|
+
* it is; that is the only test that covers every producer, including ones
|
|
1341
|
+
* added later.
|
|
1342
|
+
*
|
|
1343
|
+
* Matters because the payload URL is observable and, on a shared-cache hit,
|
|
1344
|
+
* the origin is never consulted — so a top-level navigation can be handed
|
|
1345
|
+
* these bytes with no request-time check available. See design/13-security.md
|
|
1346
|
+
* #36c.
|
|
1347
|
+
*/
|
|
1348
|
+
function hardenRscPayloadResponse(response) {
|
|
1349
|
+
if (!isRscContentType(response.headers.get("content-type"))) return;
|
|
1350
|
+
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Make a response unstorable by any shared cache when its request claimed an
|
|
1354
|
+
* `_rsc` key that does not match its own headers.
|
|
1355
|
+
*
|
|
1356
|
+
* The end state is unconditional and therefore statable: **a mismatched
|
|
1357
|
+
* response is never stored by any cache.** Every targeted cache-control
|
|
1358
|
+
* field is removed and `Cache-Control` is replaced outright, rather than
|
|
1359
|
+
* downgrading only when a shared directive is spotted. Conditional variants
|
|
1360
|
+
* of this were tried and were wrong twice over — they missed the CDN-
|
|
1361
|
+
* specific headers, and leaving `Cache-Control` alone when it looked
|
|
1362
|
+
* harmless lets a CDN apply heuristic caching to a response that has none.
|
|
1363
|
+
*
|
|
1364
|
+
* The cost is that a mismatched request loses developer-chosen *browser*
|
|
1365
|
+
* caching too. That is a request no timber client ever makes.
|
|
1366
|
+
*
|
|
1367
|
+
* Purge tags (`Surrogate-Key`, `Cache-Tag`) are deliberately left alone:
|
|
1368
|
+
* they address stored entries, and this response is not stored.
|
|
1369
|
+
*
|
|
1370
|
+
* Mutates `response.headers` in place; the pipeline guarantees mutable
|
|
1371
|
+
* headers at this point (see TIM-866).
|
|
1372
|
+
*/
|
|
1373
|
+
async function enforceRscCacheKey(req, response) {
|
|
1374
|
+
if (await hasValidRscCacheKey(req)) return;
|
|
1375
|
+
preventResponseCaching(response.headers);
|
|
1376
|
+
}
|
|
1377
|
+
//#endregion
|
|
1378
|
+
//#region src/server/head-response.ts
|
|
1379
|
+
/**
|
|
1380
|
+
* HEAD body stripping — RFC 9110 §9.3.2.
|
|
1381
|
+
*
|
|
1382
|
+
* "The HEAD method is identical to GET except that the server MUST NOT send
|
|
1383
|
+
* content in the response." The headers, however, must be the ones the
|
|
1384
|
+
* corresponding GET would have produced — which is why timber renders the
|
|
1385
|
+
* route in full and discards the body here rather than short-circuiting the
|
|
1386
|
+
* render. Content-Type, Content-Length, Set-Cookie, cache directives and
|
|
1387
|
+
* everything else ride through untouched.
|
|
1388
|
+
*
|
|
1389
|
+
* Node's `ServerResponse` happens to discard writes for HEAD, so the wire is
|
|
1390
|
+
* usually correct even without this. That is a property of one adapter, not of
|
|
1391
|
+
* the `Response` object: static generation, the Cloudflare and Nitro adapters,
|
|
1392
|
+
* tests, and anything calling the pipeline handler as a function all observe
|
|
1393
|
+
* the `Response` directly and saw the full body before TIM-1324.
|
|
1394
|
+
*
|
|
1395
|
+
* The pipeline applies this at its single exit so every response shape is
|
|
1396
|
+
* covered — page render, API route, metadata route, deny page, error page,
|
|
1397
|
+
* proxy short-circuit. The two unit-level producers that are also reachable as
|
|
1398
|
+
* standalone functions (`handleRouteRequest`, `handleApiRoute`) call it too, so
|
|
1399
|
+
* each honours HEAD on its own; they share this implementation rather than
|
|
1400
|
+
* repeating it, and applying it twice is a no-op.
|
|
1401
|
+
*/
|
|
1402
|
+
/**
|
|
1403
|
+
* Return a bodyless copy of `response` when `method` is HEAD.
|
|
1404
|
+
*
|
|
1405
|
+
* A no-op for every other method and for responses that already carry no body
|
|
1406
|
+
* (204, 304, redirects), so callers do not need to pre-check either.
|
|
1407
|
+
*
|
|
1408
|
+
* The discarded stream is cancelled: an SSR render that reached this point has
|
|
1409
|
+
* a live producer attached, and dropping the reference without cancelling
|
|
1410
|
+
* leaks it for the lifetime of the render (design/13-security.md A4).
|
|
1411
|
+
*/
|
|
1412
|
+
function stripHeadBody(response, method) {
|
|
1413
|
+
if (method.toUpperCase() !== "HEAD") return response;
|
|
1414
|
+
const body = response.body;
|
|
1415
|
+
if (body === null) return response;
|
|
1416
|
+
try {
|
|
1417
|
+
body.cancel().catch(() => {});
|
|
1418
|
+
} catch {}
|
|
1419
|
+
return new Response(null, {
|
|
1420
|
+
status: response.status,
|
|
1421
|
+
statusText: response.statusText,
|
|
1422
|
+
headers: response.headers
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
//#endregion
|
|
1426
|
+
//#region src/server/pipeline.ts
|
|
1427
|
+
/**
|
|
1428
|
+
* Create the request handler from a pipeline configuration.
|
|
1429
|
+
*
|
|
1430
|
+
* Returns a function that processes an incoming Request through all pipeline
|
|
1431
|
+
* stages and produces a Response. This is the top-level entry point for the
|
|
1432
|
+
* server. The body is intentionally small — phase logic lives in
|
|
1433
|
+
* `pipeline-phases.ts`. This function only owns the per-request setup that
|
|
1434
|
+
* has to wrap the entire dispatch: trace ID, request context ALS, span
|
|
1435
|
+
* scope, Server-Timing header emission, and the active-request counter.
|
|
1436
|
+
*/
|
|
1437
|
+
function createPipeline(config) {
|
|
1438
|
+
const proxyResolver = makeProxyResolver(config.proxy);
|
|
1439
|
+
const slowRequestMs = config.slowRequestMs ?? 3e3;
|
|
1440
|
+
const serverTiming = config.serverTiming ?? "total";
|
|
1441
|
+
let activeRequests = 0;
|
|
1442
|
+
const pipelineFn = async (req) => {
|
|
1443
|
+
const url = new URL(req.url);
|
|
1444
|
+
const method = req.method;
|
|
1445
|
+
const path = url.pathname;
|
|
1446
|
+
const startTime = performance.now();
|
|
1447
|
+
activeRequests++;
|
|
1448
|
+
const traceIdValue = generateTraceId();
|
|
1449
|
+
return runWithTraceId(traceIdValue, async () => {
|
|
1450
|
+
return runWithRequestContext(req, async () => {
|
|
1451
|
+
const runRequest = async () => {
|
|
1452
|
+
logRequestReceived({
|
|
1453
|
+
method,
|
|
1454
|
+
path
|
|
1455
|
+
});
|
|
1456
|
+
const response = await withSpan("http.server.request", {
|
|
1457
|
+
"http.request.method": method,
|
|
1458
|
+
"url.path": path
|
|
1459
|
+
}, async () => {
|
|
1460
|
+
const otelIds = await getOtelTraceId();
|
|
1461
|
+
if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
1462
|
+
const stripTrailingSlash = config.stripTrailingSlash ?? true;
|
|
1463
|
+
const canonResult = canonicalize(url.pathname, stripTrailingSlash);
|
|
1464
|
+
if (!canonResult.ok) {
|
|
1465
|
+
if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${canonResult.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
|
|
1466
|
+
return new Response(null, { status: canonResult.status });
|
|
1467
|
+
}
|
|
1468
|
+
const canonicalPath = canonResult.pathname;
|
|
1469
|
+
let canonicalReq = req;
|
|
1470
|
+
if (url.pathname !== canonicalPath) {
|
|
1471
|
+
const canonicalUrl = new URL(req.url);
|
|
1472
|
+
canonicalUrl.pathname = canonicalPath;
|
|
1473
|
+
canonicalReq = new Request(canonicalUrl.toString(), req);
|
|
1474
|
+
}
|
|
1475
|
+
const innerHandler = async () => {
|
|
1476
|
+
if (config.dispatchAction) {
|
|
1477
|
+
const actionResult = await config.dispatchAction(canonicalReq, canonicalPath, pipelineFn);
|
|
1478
|
+
if (actionResult) return actionResult;
|
|
1479
|
+
}
|
|
1480
|
+
return handleRequest(config, canonicalReq, method, canonicalPath, true);
|
|
1481
|
+
};
|
|
1482
|
+
let result;
|
|
1483
|
+
if (proxyResolver) result = await outcomeToResponse(config, await runProxyPhase(config, proxyResolver, canonicalReq, method, canonicalPath, innerHandler), {
|
|
1484
|
+
req: canonicalReq,
|
|
1485
|
+
method,
|
|
1486
|
+
path: canonicalPath
|
|
1487
|
+
});
|
|
1488
|
+
else result = await innerHandler();
|
|
1489
|
+
await setSpanAttribute("http.response.status_code", result.status);
|
|
1490
|
+
const varyTokens = [...RSC_KEY_HEADERS];
|
|
1491
|
+
const existingVary = result.headers.get("Vary");
|
|
1492
|
+
const existingTokens = existingVary ? existingVary.toLowerCase().split(",").map((t) => t.trim()) : [];
|
|
1493
|
+
const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
|
|
1494
|
+
if (newTokens.length > 0) result.headers.set("Vary", existingVary ? `${existingVary}, ${newTokens.join(", ")}` : newTokens.join(", "));
|
|
1495
|
+
await enforceRscCacheKey(canonicalReq, result);
|
|
1496
|
+
hardenRscPayloadResponse(result);
|
|
1497
|
+
result = stripHeadBody(result, method);
|
|
1498
|
+
if (serverTiming === "detailed") {
|
|
1499
|
+
const timingHeader = getServerTimingHeader();
|
|
1500
|
+
if (timingHeader) result.headers.set("Server-Timing", timingHeader);
|
|
1501
|
+
} else if (serverTiming === "total") {
|
|
1502
|
+
const totalMs = Math.round(performance.now() - startTime);
|
|
1503
|
+
result.headers.set("Server-Timing", `total;dur=${totalMs}`);
|
|
1504
|
+
}
|
|
1505
|
+
if (serverTiming === "detailed" && result.body) result = new Response(wrapResponseWithStreamTiming(result.body, startTime), {
|
|
1506
|
+
status: result.status,
|
|
1507
|
+
statusText: result.statusText,
|
|
1508
|
+
headers: result.headers
|
|
1509
|
+
});
|
|
1510
|
+
return result;
|
|
1511
|
+
});
|
|
1512
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
1513
|
+
const status = response.status;
|
|
1514
|
+
const concurrency = activeRequests;
|
|
1515
|
+
activeRequests--;
|
|
1516
|
+
logRequestCompleted({
|
|
1517
|
+
method,
|
|
1518
|
+
path,
|
|
1519
|
+
status,
|
|
1520
|
+
durationMs,
|
|
1521
|
+
concurrency
|
|
1522
|
+
});
|
|
1523
|
+
if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
|
|
1524
|
+
method,
|
|
1525
|
+
path,
|
|
1526
|
+
durationMs,
|
|
1527
|
+
threshold: slowRequestMs,
|
|
1528
|
+
concurrency
|
|
1529
|
+
});
|
|
1530
|
+
return response;
|
|
1531
|
+
};
|
|
1532
|
+
return serverTiming === "detailed" ? runWithTimingCollector(runRequest) : runRequest();
|
|
1533
|
+
});
|
|
1534
|
+
});
|
|
1535
|
+
};
|
|
1536
|
+
return pipelineFn;
|
|
1537
|
+
}
|
|
1538
|
+
//#endregion
|
|
1539
|
+
//#region src/server/early-hints.ts
|
|
1540
|
+
/**
|
|
1541
|
+
* 103 Early Hints utilities.
|
|
1542
|
+
*
|
|
1543
|
+
* Early Hints are sent before the final response to let the browser
|
|
1544
|
+
* start fetching critical resources (CSS, fonts, JS) while the server
|
|
1545
|
+
* is still rendering.
|
|
1546
|
+
*
|
|
1547
|
+
* The framework collects hints from two sources:
|
|
1548
|
+
* 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
|
|
1549
|
+
* 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
|
|
1550
|
+
*
|
|
1551
|
+
* Both are emitted as Link headers. Cloudflare CDN automatically converts
|
|
1552
|
+
* Link headers into 103 Early Hints responses.
|
|
1553
|
+
*
|
|
1554
|
+
* Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
1555
|
+
*/
|
|
1556
|
+
/**
|
|
1557
|
+
* Format a single EarlyHint as a Link header value.
|
|
1558
|
+
*
|
|
1559
|
+
* Attribute order: `as` before `rel` to match Cloudflare CDN's cached
|
|
1560
|
+
* Early Hints format. Cloudflare caches Link headers from 200 responses
|
|
1561
|
+
* and re-emits them as 103 Early Hints on subsequent requests. If our
|
|
1562
|
+
* attribute order differs from Cloudflare's cached copy, the browser
|
|
1563
|
+
* sees two preload headers for the same URL (different attribute order)
|
|
1564
|
+
* and warns "Preload was ignored." Matching the order ensures the
|
|
1565
|
+
* browser deduplicates them correctly.
|
|
1566
|
+
*
|
|
1567
|
+
* Examples:
|
|
1568
|
+
* `</styles/root.css>; as=style; rel=preload`
|
|
1569
|
+
* `</fonts/inter.woff2>; as=font; rel=preload; crossorigin=anonymous`
|
|
1570
|
+
* `</_timber/client.js>; rel=modulepreload`
|
|
1571
|
+
* `<https://fonts.googleapis.com>; rel=preconnect`
|
|
1572
|
+
*/
|
|
1573
|
+
function formatLinkHeader(hint) {
|
|
1574
|
+
if (hint.as !== void 0) {
|
|
1575
|
+
let value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
1576
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
1577
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
1578
|
+
return value;
|
|
1579
|
+
}
|
|
1580
|
+
let value = `<${hint.href}>; rel=${hint.rel}`;
|
|
1581
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
1582
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
1583
|
+
return value;
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Collect all Link header strings for a matched route's segment chain.
|
|
1587
|
+
*
|
|
1588
|
+
* Walks the build manifest to emit hints for:
|
|
1589
|
+
* - CSS stylesheets (as=style; rel=preload)
|
|
1590
|
+
* - Font assets (as=font; rel=preload; crossorigin)
|
|
1591
|
+
* - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
|
|
1592
|
+
*
|
|
1593
|
+
* Returns formatted Link header strings, deduplicated by URL, root → leaf order.
|
|
1594
|
+
* Returns an empty array in dev mode (manifest is empty).
|
|
1595
|
+
*/
|
|
1596
|
+
function collectEarlyHintHeaders(segments, manifest, options) {
|
|
1597
|
+
const result = [];
|
|
1598
|
+
const seenUrls = /* @__PURE__ */ new Set();
|
|
1599
|
+
const add = (url, header) => {
|
|
1600
|
+
if (!seenUrls.has(url)) {
|
|
1601
|
+
seenUrls.add(url);
|
|
1602
|
+
result.push(header);
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
for (const url of collectRouteCss(segments, manifest)) add(url, formatLinkHeader({
|
|
1606
|
+
href: url,
|
|
1607
|
+
rel: "preload",
|
|
1608
|
+
as: "style"
|
|
1609
|
+
}));
|
|
1610
|
+
for (const font of collectRouteFonts(segments, manifest)) add(font.href, formatLinkHeader({
|
|
1611
|
+
href: font.href,
|
|
1612
|
+
rel: "preload",
|
|
1613
|
+
as: "font",
|
|
1614
|
+
crossOrigin: "anonymous"
|
|
1615
|
+
}));
|
|
1616
|
+
if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(url, formatLinkHeader({
|
|
1617
|
+
href: url,
|
|
1618
|
+
rel: "modulepreload"
|
|
1619
|
+
}));
|
|
1620
|
+
return result;
|
|
1621
|
+
}
|
|
1622
|
+
//#endregion
|
|
1623
|
+
//#region src/server/early-hints-sender.ts
|
|
1624
|
+
/**
|
|
1625
|
+
* Per-request 103 Early Hints sender — ALS bridge for platform adapters.
|
|
1626
|
+
*
|
|
1627
|
+
* The pipeline collects Link headers for CSS, fonts, and JS chunks at
|
|
1628
|
+
* route-match time. On platforms that support it (Node.js v18.11+, Bun),
|
|
1629
|
+
* the adapter can send these as a 103 Early Hints interim response before
|
|
1630
|
+
* the final response is ready.
|
|
1631
|
+
*
|
|
1632
|
+
* This module provides an ALS-based bridge: the generated entry point
|
|
1633
|
+
* (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
|
|
1634
|
+
* binding a per-request sender function. The pipeline calls
|
|
1635
|
+
* `sendEarlyHints103()` to fire the 103 if a sender is available.
|
|
1636
|
+
*
|
|
1637
|
+
* On platforms where 103 is handled at the CDN level (e.g., Cloudflare
|
|
1638
|
+
* converts Link headers into 103 automatically), no sender is installed
|
|
1639
|
+
* and `sendEarlyHints103()` is a no-op.
|
|
1640
|
+
*
|
|
1641
|
+
* Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
1642
|
+
*/
|
|
1643
|
+
/**
|
|
1644
|
+
* Run a function with a per-request early hints sender installed.
|
|
1645
|
+
*
|
|
1646
|
+
* Called by generated entry points (e.g., Nitro node-server/bun) to
|
|
1647
|
+
* bind the platform's writeEarlyHints capability for the request duration.
|
|
1648
|
+
*/
|
|
1649
|
+
function runWithEarlyHintsSender(sender, fn) {
|
|
1650
|
+
return earlyHintsSenderAls.run(sender, fn);
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Send collected Link headers as a 103 Early Hints response.
|
|
1654
|
+
*
|
|
1655
|
+
* No-op if no sender is installed for the current request (e.g., on
|
|
1656
|
+
* Cloudflare where the CDN handles 103 automatically, or in dev mode).
|
|
1657
|
+
*
|
|
1658
|
+
* Non-fatal: errors from the sender are caught and silently ignored.
|
|
1659
|
+
*/
|
|
1660
|
+
function sendEarlyHints103(links) {
|
|
1661
|
+
if (!links.length) return;
|
|
1662
|
+
const sender = earlyHintsSenderAls.getStore();
|
|
1663
|
+
if (!sender) return;
|
|
1664
|
+
try {
|
|
1665
|
+
sender(links);
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
swallow(err, "early hints 103 send failed");
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/client/error-reconstituter.tsx
|
|
1672
|
+
/**
|
|
1673
|
+
* Reconstitutes a SerializableError into a real Error instance before
|
|
1674
|
+
* passing to the user's error component.
|
|
1675
|
+
*
|
|
1676
|
+
* TSX error pages are 'use client' components that receive { error: Error, digest, reset }.
|
|
1677
|
+
* Error objects are not RSC-serializable (React Flight throws "Only plain objects
|
|
1678
|
+
* can be passed to Client Components"). This wrapper receives the error as a plain
|
|
1679
|
+
* SerializableError object, reconstitutes a real Error instance, and passes it
|
|
1680
|
+
* to the user's error component — ensuring error instanceof Error works correctly.
|
|
1681
|
+
*
|
|
1682
|
+
* See design/spike-TIM-565-unify-error-pages.md §"Edge Case B"
|
|
1683
|
+
* See design/10-error-handling.md §"RSC → SSR for Error Pages via SerializableError"
|
|
1684
|
+
*/
|
|
1685
|
+
/**
|
|
1686
|
+
* Reconstitute a SerializableError into a real Error instance and render
|
|
1687
|
+
* the user's error component with the proper props.
|
|
1688
|
+
*/
|
|
1689
|
+
function ErrorReconstituter({ error: serialized, digest, reset, component, status, dangerouslyPassData }) {
|
|
1690
|
+
const error = Object.assign(new Error(serialized.message), {
|
|
1691
|
+
name: serialized.name,
|
|
1692
|
+
...serialized.stack != null ? { stack: serialized.stack } : {}
|
|
1693
|
+
});
|
|
1694
|
+
return createElement(component, {
|
|
1695
|
+
error,
|
|
1696
|
+
digest,
|
|
1697
|
+
reset,
|
|
1698
|
+
...status != null ? {
|
|
1699
|
+
status,
|
|
1700
|
+
dangerouslyPassData
|
|
1701
|
+
} : {}
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
//#endregion
|
|
1705
|
+
//#region src/server/deny-boundary.ts
|
|
1706
|
+
/**
|
|
1707
|
+
* Deny boundary subsystem — the in-tree DenySignal flow.
|
|
1708
|
+
*
|
|
1709
|
+
* Three things live together here because they form a single flow:
|
|
1710
|
+
*
|
|
1711
|
+
* 1. **Chain construction** (`buildDenyPageChain`) — walks the matched
|
|
1712
|
+
* segment chain at element-tree build time and produces a list of
|
|
1713
|
+
* `DenyPageEntry` records ordered by specificity (specific status →
|
|
1714
|
+
* category catch-all → `error.tsx`).
|
|
1715
|
+
*
|
|
1716
|
+
* 2. **Runtime matching and placement** (`matchDenyEntry`, `placeDenyPage`,
|
|
1717
|
+
* `handleCaughtDeny`) — picks the first chain entry whose status filter
|
|
1718
|
+
* matches the thrown DenySignal, then decides whether the catch site may
|
|
1719
|
+
* render it where it stands. A chain spans the catcher outward to root,
|
|
1720
|
+
* so a match may belong to an ancestor segment; rendering it in place
|
|
1721
|
+
* would wrap it in every layout in between. Ancestor-owned matches are
|
|
1722
|
+
* re-thrown with `ownerKey` stamped on the signal so the error boundary
|
|
1723
|
+
* at the owning segment renders it. See TIM-1356.
|
|
1724
|
+
*
|
|
1725
|
+
* 3. **The page boundary itself** (`PageDenyBoundary`) — the async server
|
|
1726
|
+
* component that wraps a server-component page, calls it, and catches
|
|
1727
|
+
* `DenySignal` so the deny page renders in-tree (no throw reaches
|
|
1728
|
+
* React Flight, single render pass).
|
|
1729
|
+
*
|
|
1730
|
+
* Plus the ALS helpers (`setDenyStatus` / `getDenyStatus`) the boundary
|
|
1731
|
+
* uses to thread the matched status code back to the pipeline so the
|
|
1732
|
+
* HTTP status reflects the deny.
|
|
1733
|
+
*
|
|
1734
|
+
* Folded into one module from the former `deny-page-resolver.ts` and
|
|
1735
|
+
* `page-deny-boundary.tsx` (TIM-853) — the names were misleading and the
|
|
1736
|
+
* three pieces only made sense together.
|
|
1737
|
+
*
|
|
1738
|
+
* See design/04-authorization.md, design/10-error-handling.md, TIM-666.
|
|
1739
|
+
*/
|
|
1740
|
+
/**
|
|
1741
|
+
* Find the first deny page in the chain whose status filter matches.
|
|
1742
|
+
* Returns null when nothing in the chain accepts this status.
|
|
1743
|
+
*
|
|
1744
|
+
* For 4xx statuses, the chain's stored order is correct: every status file
|
|
1745
|
+
* across all segments (inner→outer) before any error.tsx — so a root
|
|
1746
|
+
* `404.tsx` beats a leaf `error.tsx`. This matches `resolve4xx`.
|
|
1747
|
+
*
|
|
1748
|
+
* For 5xx statuses, the match walks per-segment (inner→outer): at each
|
|
1749
|
+
* segment, status files (`503.tsx` → `5xx.tsx`) then `error.tsx`, before
|
|
1750
|
+
* moving to the next segment outward. A leaf `error.tsx` therefore beats a
|
|
1751
|
+
* root `5xx.tsx` — the intentional inverse of the 4xx chain, matching
|
|
1752
|
+
* `resolve5xx`. See TIM-1360.
|
|
1753
|
+
*/
|
|
1754
|
+
function matchDenyEntry(chain, status) {
|
|
1755
|
+
return matchDenyEntries(chain, status)[0] ?? null;
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Return ALL deny page entries whose status filter matches, in traversal
|
|
1759
|
+
* order (best match first). `matchDenyEntry` returns only the first;
|
|
1760
|
+
* late addressing (TIM-1450) needs the full list so the client can find
|
|
1761
|
+
* the first *reachable* owner via its ancestry context.
|
|
1762
|
+
*/
|
|
1763
|
+
function matchDenyEntries(chain, status) {
|
|
1764
|
+
if (status >= 500) return matchDenyEntries5xx(chain, status);
|
|
1765
|
+
const matches = [];
|
|
1766
|
+
for (const entry of chain) if (entry.status === status) matches.push(entry);
|
|
1767
|
+
else if (entry.status === 400 && status >= 400 && status <= 499) matches.push(entry);
|
|
1768
|
+
else if (entry.status === null) matches.push(entry);
|
|
1769
|
+
return matches;
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* 5xx per-segment walk: at each segment (inner→outer), check status files
|
|
1773
|
+
* then error.tsx before moving outward. Mirrors `resolve5xx`.
|
|
1774
|
+
*/
|
|
1775
|
+
function matchDenyEntries5xx(chain, status) {
|
|
1776
|
+
const segmentIndices = [];
|
|
1777
|
+
for (const entry of chain) if (!segmentIndices.includes(entry.segmentIndex)) segmentIndices.push(entry.segmentIndex);
|
|
1778
|
+
segmentIndices.sort((a, b) => b - a);
|
|
1779
|
+
const matches = [];
|
|
1780
|
+
for (const seg of segmentIndices) {
|
|
1781
|
+
for (const entry of chain) {
|
|
1782
|
+
if (entry.segmentIndex !== seg || entry.kind === "error") continue;
|
|
1783
|
+
if (entry.status === status) matches.push(entry);
|
|
1784
|
+
else if (entry.status === 500 && status >= 500 && status <= 599) matches.push(entry);
|
|
1785
|
+
}
|
|
1786
|
+
for (const entry of chain) {
|
|
1787
|
+
if (entry.segmentIndex !== seg || entry.kind !== "error") continue;
|
|
1788
|
+
matches.push(entry);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return matches;
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Decide where the deny page for `status` renders, from the perspective of
|
|
1795
|
+
* the catch site at `selfKey`.
|
|
1796
|
+
*
|
|
1797
|
+
* The match itself is unchanged — first entry in fallback order wins, so
|
|
1798
|
+
* every path picks the same file. What this adds is the position: a chain
|
|
1799
|
+
* built at segment i spans i outward to root, so a matched entry is owned
|
|
1800
|
+
* either by the catcher or by one of its ancestors.
|
|
1801
|
+
*
|
|
1802
|
+
* - Owned by the catcher → `render`. The catcher already sits at the
|
|
1803
|
+
* owning segment's position.
|
|
1804
|
+
* - Owned by an ancestor → `hoist`. Rendering here would wrap the deny page
|
|
1805
|
+
* in every layout between the owner and the catcher. The caller re-throws
|
|
1806
|
+
* with `ownerKey` stamped on the signal so the boundary at that segment
|
|
1807
|
+
* renders it instead — inside the owner's layout, outside the rest.
|
|
1808
|
+
*
|
|
1809
|
+
* Returns null when no entry matches; the caller re-throws unannotated and
|
|
1810
|
+
* the re-render fallback resolves it.
|
|
1811
|
+
*
|
|
1812
|
+
* See design/04-authorization.md §"Where a Deny Page Renders", TIM-1356.
|
|
1813
|
+
*/
|
|
1814
|
+
function placeDenyPage(chain, status, data, selfKey) {
|
|
1815
|
+
const entry = matchDenyEntry(chain, status);
|
|
1816
|
+
if (!entry) return null;
|
|
1817
|
+
if (entry.ownerKey !== selfKey) {
|
|
1818
|
+
if (entry.isMdx) return null;
|
|
1819
|
+
return {
|
|
1820
|
+
kind: "hoist",
|
|
1821
|
+
ownerKey: entry.ownerKey
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
return {
|
|
1825
|
+
kind: "render",
|
|
1826
|
+
element: renderDenyEntry(entry, status, data)
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Build the element for a matched deny chain entry with the props contract
|
|
1831
|
+
* the file's convention documents:
|
|
1832
|
+
*
|
|
1833
|
+
* - Status-code files: { status, dangerouslyPassData }
|
|
1834
|
+
* - error.tsx (TSX): { error, digest, reset } via ErrorReconstituter, plus
|
|
1835
|
+
* { status, dangerouslyPassData } for dual-shape implementations. Without
|
|
1836
|
+
* the Error prop, any error.tsx written per the docs (reading
|
|
1837
|
+
* error.message) crashes — turning a clean deny(404) into a 500. TIM-1081.
|
|
1838
|
+
* - error.mdx: plain { status } — server component, static content.
|
|
1839
|
+
*/
|
|
1840
|
+
function renderDenyEntry(entry, status, data) {
|
|
1841
|
+
const h = createElement;
|
|
1842
|
+
return h(StatusPageMarker, null, renderDenyPage(entry, status, data, h));
|
|
1843
|
+
}
|
|
1844
|
+
function renderDenyPage(entry, status, data, h) {
|
|
1845
|
+
if (entry.kind !== "error") return h(entry.component, {
|
|
1846
|
+
status,
|
|
1847
|
+
dangerouslyPassData: data
|
|
1848
|
+
});
|
|
1849
|
+
if (entry.isMdx) return h(entry.component, { status });
|
|
1850
|
+
return h(ErrorReconstituter, {
|
|
1851
|
+
error: {
|
|
1852
|
+
message: `Access denied with status ${status}`,
|
|
1853
|
+
name: "DenySignal"
|
|
1854
|
+
},
|
|
1855
|
+
digest: null,
|
|
1856
|
+
reset: void 0,
|
|
1857
|
+
component: entry.component,
|
|
1858
|
+
status,
|
|
1859
|
+
dangerouslyPassData: data
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* The whole of what an in-tree catch site does with a `DenySignal`, shared
|
|
1864
|
+
* by all three of them (`PageDenyBoundary`, `TracedLayout`, `AccessGate`).
|
|
1865
|
+
*
|
|
1866
|
+
* Returns the deny page element when this catch site owns the matched file.
|
|
1867
|
+
* Otherwise it throws — annotated with `ownerKey` when an ancestor owns the
|
|
1868
|
+
* file, bare when nothing matched at all.
|
|
1869
|
+
*
|
|
1870
|
+
* `setDenyStatus` runs on the hoist path too, and must run *before* the
|
|
1871
|
+
* throw: on the document path the hoisted row fails the Fizz shell and the
|
|
1872
|
+
* re-render fallback serves the page, and the ALS status is what the
|
|
1873
|
+
* pipeline commits for it. The RSC payload path never reads it — a Flight
|
|
1874
|
+
* payload response is always 200 and the row itself carries the deny (TIM-1495).
|
|
1875
|
+
*
|
|
1876
|
+
* Either branch leaves part of the matched chain unmounted — hoisting sends
|
|
1877
|
+
* the render to a boundary that discards everything below it, and a layout
|
|
1878
|
+
* that denies returns the deny page instead of its own child outlet. What the
|
|
1879
|
+
* client then learns about the tree is decided by the boundary that renders
|
|
1880
|
+
* the page, at the commit that puts it on screen (`navigation-commit.ts`).
|
|
1881
|
+
*/
|
|
1882
|
+
function handleCaughtDeny(chain, error, selfKey) {
|
|
1883
|
+
const placement = chain ? placeDenyPage(chain, error.status, error.data, selfKey) : null;
|
|
1884
|
+
if (placement?.kind === "render") {
|
|
1885
|
+
setDenyStatus(error.status);
|
|
1886
|
+
return placement.element;
|
|
1887
|
+
}
|
|
1888
|
+
if (placement?.kind === "hoist") {
|
|
1889
|
+
setDenyStatus(error.status);
|
|
1890
|
+
error.owners = [placement.ownerKey];
|
|
1891
|
+
}
|
|
1892
|
+
throw error;
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Set the deny status in the request context ALS.
|
|
1896
|
+
* Called from AccessGate / PageDenyBoundary when a DenySignal is caught.
|
|
1897
|
+
* The document path reads this after render to set the HTTP status code.
|
|
1898
|
+
*/
|
|
1899
|
+
function setDenyStatus(status) {
|
|
1900
|
+
const store = requestContextAls.getStore();
|
|
1901
|
+
if (store) store.denyStatus = status;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Record the first denied segment index in the request context ALS.
|
|
1905
|
+
* Called from AccessGate when a DenySignal is caught during render.
|
|
1906
|
+
* Only the first (shallowest) denial is recorded — deeper denials are
|
|
1907
|
+
* unreachable because the gate short-circuits the subtree.
|
|
1908
|
+
* See TIM-1363.
|
|
1909
|
+
*/
|
|
1910
|
+
function setFirstDeniedSegmentIndex(index) {
|
|
1911
|
+
const store = requestContextAls.getStore();
|
|
1912
|
+
if (store && store._firstDeniedSegmentIndex === void 0) store._firstDeniedSegmentIndex = index;
|
|
1913
|
+
}
|
|
1914
|
+
//#endregion
|
|
1915
|
+
//#region src/server/access-gate.tsx
|
|
1916
|
+
/**
|
|
1917
|
+
* AccessGate and SlotAccessGate — framework-injected async server components.
|
|
1918
|
+
*
|
|
1919
|
+
* AccessGate wraps each segment's layout in the element tree. It calls the
|
|
1920
|
+
* segment's access.ts before the layout renders. If access.ts calls deny()
|
|
1921
|
+
* or redirect(), the signal propagates as a render-phase throw — caught by
|
|
1922
|
+
* the flush controller to produce the correct HTTP status code.
|
|
1923
|
+
*
|
|
1924
|
+
* SlotAccessGate wraps parallel slot content. On denial, it renders the
|
|
1925
|
+
* graceful degradation chain: denied.tsx → default.tsx → null. Slot denial
|
|
1926
|
+
* does not affect the HTTP status code.
|
|
1927
|
+
*
|
|
1928
|
+
* See design/04-authorization.md and design/02-rendering-pipeline.md §"AccessGate"
|
|
1929
|
+
*/
|
|
1930
|
+
/**
|
|
1931
|
+
* Framework-injected access gate for segments.
|
|
1932
|
+
*
|
|
1933
|
+
* Calls accessFn during render — inside `renderToReadableStream`, sharing
|
|
1934
|
+
* the same `React.cache` scope as layouts and pages. A `requireUser()`
|
|
1935
|
+
* call in access.ts populates the cache; the same call in any layout is
|
|
1936
|
+
* a free hit. Access executes exactly once per request. See TIM-1363.
|
|
1937
|
+
*
|
|
1938
|
+
* access.ts is a pure gate — return values are discarded. The layout below
|
|
1939
|
+
* gets the same data by calling the same cached functions (React.cache dedup).
|
|
1940
|
+
*
|
|
1941
|
+
* On denial the gate renders the matching deny page only when this segment
|
|
1942
|
+
* owns it. Because the gate is wrapped OUTSIDE its own layout, that render
|
|
1943
|
+
* omits the denying segment's layout — deliberate, and the one place the
|
|
1944
|
+
* in-tree path diverges from `renderDenyPage`'s inclusive slice: a layout
|
|
1945
|
+
* whose access.ts denied must never run and serialize protected data into
|
|
1946
|
+
* the Flight stream (TIM-1074). An ancestor-owned page is hoisted instead,
|
|
1947
|
+
* because the gate also sits inside every layout above it and rendering
|
|
1948
|
+
* here would wrap the deny page in all of them (TIM-1356).
|
|
1949
|
+
*
|
|
1950
|
+
* For skipped segments (skippedSegment=true), deny pages are NOT rendered
|
|
1951
|
+
* in-tree — the client's cached layout chrome would wrap them incorrectly.
|
|
1952
|
+
* Instead, the deny is recorded in ALS and rethrown so the response path
|
|
1953
|
+
* can return rscErrorEnvelope for a client hard-navigate.
|
|
1954
|
+
*/
|
|
1955
|
+
function AccessGate(props) {
|
|
1956
|
+
const { accessFn, segmentName, segmentKey, segmentIndex, denyPages, skippedSegment, children } = props;
|
|
1957
|
+
return accessGateFallback(accessFn, segmentName, segmentKey, segmentIndex, skippedSegment ? void 0 : denyPages, skippedSegment, children);
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Calls accessFn during render with OTEL instrumentation.
|
|
1961
|
+
* Records the first denied segment index in ALS for post-render consumers.
|
|
1962
|
+
*/
|
|
1963
|
+
async function accessGateFallback(accessFn, segmentName, segmentKey, segmentIndex, denyPages, skippedSegment, children) {
|
|
1964
|
+
try {
|
|
1965
|
+
await withSpan("timber.access", { "timber.segment": segmentName ?? "unknown" }, async () => {
|
|
1966
|
+
try {
|
|
1967
|
+
await accessFn();
|
|
1968
|
+
await setSpanAttribute("timber.result", "pass");
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
if (isDenySignal(error)) {
|
|
1971
|
+
await setSpanAttribute("timber.result", "deny");
|
|
1972
|
+
await setSpanAttribute("timber.deny_status", error.status);
|
|
1973
|
+
if (error.sourceFile) await setSpanAttribute("timber.deny_file", error.sourceFile);
|
|
1974
|
+
} else if (isRedirectSignal(error)) await setSpanAttribute("timber.result", "redirect");
|
|
1975
|
+
throw error;
|
|
1976
|
+
}
|
|
1977
|
+
});
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
if (isDenySignal(error)) {
|
|
1980
|
+
if (segmentIndex !== void 0) setFirstDeniedSegmentIndex(segmentIndex);
|
|
1981
|
+
if (skippedSegment) {
|
|
1982
|
+
error._hardNavigate = true;
|
|
1983
|
+
throw error;
|
|
1984
|
+
}
|
|
1985
|
+
if (denyPages) return handleCaughtDeny(denyPages, error, segmentKey);
|
|
1986
|
+
}
|
|
1987
|
+
throw error;
|
|
1988
|
+
}
|
|
1989
|
+
return children;
|
|
1990
|
+
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Framework-injected access gate for parallel slots.
|
|
1993
|
+
*
|
|
1994
|
+
* On denial, graceful degradation: denied.tsx → default.tsx → null.
|
|
1995
|
+
* The HTTP status code is unaffected — slot denial is a UI concern, not
|
|
1996
|
+
* a protocol concern. The parent layout and sibling slots still render.
|
|
1997
|
+
*
|
|
1998
|
+
* DeniedComponent is passed instead of a pre-built element so that
|
|
1999
|
+
* DenySignal.data can be forwarded as the dangerouslyPassData prop
|
|
2000
|
+
* and the slot name can be passed as the slot prop. See TIM-488.
|
|
2001
|
+
*
|
|
2002
|
+
* redirect() in slot access.ts is a dev-mode error — redirecting from a
|
|
2003
|
+
* slot doesn't make architectural sense.
|
|
2004
|
+
*/
|
|
2005
|
+
async function SlotAccessGate(props) {
|
|
2006
|
+
const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children } = props;
|
|
2007
|
+
try {
|
|
2008
|
+
await accessFn();
|
|
2009
|
+
} catch (error) {
|
|
2010
|
+
if (isDenySignal(error)) return buildDeniedFallback(DeniedComponent, slotName, error.data, createElement) ?? defaultFallback ?? null;
|
|
2011
|
+
if (isRedirectSignal(error)) {
|
|
2012
|
+
if (isDebug()) console.error("[timber] redirect() is not allowed in slot access.ts. Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. If you need to redirect, move the logic to the parent segment's access.ts.");
|
|
2013
|
+
return buildDeniedFallback(DeniedComponent, slotName, void 0, createElement) ?? defaultFallback ?? null;
|
|
2014
|
+
}
|
|
2015
|
+
if (isDebug()) console.warn("[timber] Unhandled error in slot access.ts. Use deny() for access control, not unhandled throws.", error);
|
|
2016
|
+
throw error;
|
|
2017
|
+
}
|
|
2018
|
+
return children;
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Build the denied fallback element dynamically with DenySignal data.
|
|
2022
|
+
* Returns null if no DeniedComponent is available.
|
|
2023
|
+
*/
|
|
2024
|
+
function buildDeniedFallback(DeniedComponent, slotName, data, createElement) {
|
|
2025
|
+
if (!DeniedComponent) return null;
|
|
2026
|
+
return createElement(DeniedComponent, {
|
|
2027
|
+
slot: slotName,
|
|
2028
|
+
dangerouslyPassData: data
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
//#endregion
|
|
2032
|
+
//#region src/server/status-code-resolver.ts
|
|
2033
|
+
/**
|
|
2034
|
+
* Look up `{statusStr}` then `{categoryKey}` (e.g. "4xx" / "5xx") in a
|
|
2035
|
+
* status-file group on a single segment. Shared by all three fallback
|
|
2036
|
+
* chains — the only structural difference between component 4xx,
|
|
2037
|
+
* component 5xx, and JSON resolution is *which* group is searched and
|
|
2038
|
+
* how the per-segment loop is layered around it.
|
|
2039
|
+
*/
|
|
2040
|
+
function lookupInGroup(group, statusStr, categoryKey, segmentIndex, status) {
|
|
2041
|
+
if (!group) return null;
|
|
2042
|
+
const exact = group[statusStr];
|
|
2043
|
+
if (exact) return {
|
|
2044
|
+
file: exact,
|
|
2045
|
+
status,
|
|
2046
|
+
kind: "exact",
|
|
2047
|
+
segmentIndex
|
|
2048
|
+
};
|
|
2049
|
+
const category = group[categoryKey];
|
|
2050
|
+
if (category) return {
|
|
2051
|
+
file: category,
|
|
2052
|
+
status,
|
|
2053
|
+
kind: "category",
|
|
2054
|
+
segmentIndex
|
|
2055
|
+
};
|
|
2056
|
+
return null;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Resolve the status-code file to render for a given HTTP status code.
|
|
2060
|
+
*
|
|
2061
|
+
* Walks the segment chain from leaf to root following the fallback chain
|
|
2062
|
+
* defined in design/10-error-handling.md. Returns null if no file is found
|
|
2063
|
+
* (caller should render the framework default).
|
|
2064
|
+
*
|
|
2065
|
+
* @param status - The HTTP status code (4xx or 5xx).
|
|
2066
|
+
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
2067
|
+
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
2068
|
+
* @param maxIndex - Optional upper bound on the segment index to search.
|
|
2069
|
+
* When set, the resolver only considers segments `0..maxIndex` (inclusive),
|
|
2070
|
+
* skipping any segments deeper in the tree. Used by the deny renderers to
|
|
2071
|
+
* prevent resolving a status file below a denied segment — matching
|
|
2072
|
+
* `buildDenyPageChain`'s start-at-i semantics. The error renderers omit
|
|
2073
|
+
* this parameter, so the full chain is searched — a 500 from a leaf page
|
|
2074
|
+
* should use the leaf's own `5xx.tsx`. See TIM-1358.
|
|
2075
|
+
*/
|
|
2076
|
+
function resolveStatusFile(status, segments, format = "component", maxIndex) {
|
|
2077
|
+
if (status < 400 || status > 599) return null;
|
|
2078
|
+
if (format === "json") return resolveJson(status, segments, maxIndex);
|
|
2079
|
+
if (status <= 499) return resolve4xx(status, segments, maxIndex);
|
|
2080
|
+
return resolve5xx(status, segments, maxIndex);
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* 4xx component fallback chain — two separate full passes leaf→root.
|
|
2084
|
+
*
|
|
2085
|
+
* The passes must be separate (not interleaved per-segment) so that a
|
|
2086
|
+
* root-level `404.tsx` beats a leaf-level `error.tsx`. The 5xx chain
|
|
2087
|
+
* inverts this and is per-segment: a leaf's `error.tsx` beats a root's
|
|
2088
|
+
* `5xx.tsx`. This asymmetry is the only reason these two functions exist
|
|
2089
|
+
* separately.
|
|
2090
|
+
*
|
|
2091
|
+
* Pass 1 — {status}.tsx → 4xx.tsx (statusFiles)
|
|
2092
|
+
* Pass 2 — error.tsx (error)
|
|
2093
|
+
*/
|
|
2094
|
+
function resolve4xx(status, segments, maxIndex) {
|
|
2095
|
+
const statusStr = String(status);
|
|
2096
|
+
const start = maxIndex !== void 0 ? Math.min(segments.length - 1, maxIndex) : segments.length - 1;
|
|
2097
|
+
for (let i = start; i >= 0; i--) {
|
|
2098
|
+
const r = lookupInGroup(segments[i].statusFiles, statusStr, "4xx", i, status);
|
|
2099
|
+
if (r) return r;
|
|
2100
|
+
}
|
|
2101
|
+
for (let i = start; i >= 0; i--) {
|
|
2102
|
+
const errorFile = segments[i].error;
|
|
2103
|
+
if (errorFile) return {
|
|
2104
|
+
file: errorFile,
|
|
2105
|
+
status,
|
|
2106
|
+
kind: "error",
|
|
2107
|
+
segmentIndex: i
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
return null;
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* 5xx component fallback chain — single pass, per-segment leaf→root.
|
|
2114
|
+
*
|
|
2115
|
+
* At each segment: {status}.tsx → 5xx.tsx → error.tsx. A leaf's
|
|
2116
|
+
* `error.tsx` therefore beats a root's `5xx.tsx`, which is the
|
|
2117
|
+
* intentional inverse of the 4xx chain.
|
|
2118
|
+
*/
|
|
2119
|
+
function resolve5xx(status, segments, maxIndex) {
|
|
2120
|
+
const statusStr = String(status);
|
|
2121
|
+
const start = maxIndex !== void 0 ? Math.min(segments.length - 1, maxIndex) : segments.length - 1;
|
|
2122
|
+
for (let i = start; i >= 0; i--) {
|
|
2123
|
+
const segment = segments[i];
|
|
2124
|
+
const r = lookupInGroup(segment.statusFiles, statusStr, "5xx", i, status);
|
|
2125
|
+
if (r) return r;
|
|
2126
|
+
if (segment.error) return {
|
|
2127
|
+
file: segment.error,
|
|
2128
|
+
status,
|
|
2129
|
+
kind: "error",
|
|
2130
|
+
segmentIndex: i
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
return null;
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* JSON fallback chain (for both 4xx and 5xx) — single pass leaf→root.
|
|
2137
|
+
*
|
|
2138
|
+
* At each segment: {status}.json → {category}.json. No legacy compat,
|
|
2139
|
+
* no error.tsx — the JSON chain terminates at the category catch-all
|
|
2140
|
+
* and the caller falls back to a bare-JSON framework default.
|
|
2141
|
+
*/
|
|
2142
|
+
function resolveJson(status, segments, maxIndex) {
|
|
2143
|
+
const statusStr = String(status);
|
|
2144
|
+
const categoryKey = status >= 500 ? "5xx" : "4xx";
|
|
2145
|
+
const start = maxIndex !== void 0 ? Math.min(segments.length - 1, maxIndex) : segments.length - 1;
|
|
2146
|
+
for (let i = start; i >= 0; i--) {
|
|
2147
|
+
const r = lookupInGroup(segments[i].jsonStatusFiles, statusStr, categoryKey, i, status);
|
|
2148
|
+
if (r) return r;
|
|
2149
|
+
}
|
|
2150
|
+
return null;
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Resolve the denial file for a parallel route slot.
|
|
2154
|
+
*
|
|
2155
|
+
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
2156
|
+
* Fallback chain: denied.tsx → default.tsx → null.
|
|
2157
|
+
*
|
|
2158
|
+
* @param slotNode - The segment node for the slot (segmentType === 'slot').
|
|
2159
|
+
*/
|
|
2160
|
+
function resolveSlotDenied(slotNode) {
|
|
2161
|
+
const slotName = slotNode.segmentName.replace(/^@/, "");
|
|
2162
|
+
if (slotNode.denied) return {
|
|
2163
|
+
file: slotNode.denied,
|
|
2164
|
+
slotName,
|
|
2165
|
+
kind: "denied"
|
|
2166
|
+
};
|
|
2167
|
+
if (slotNode.default) return {
|
|
2168
|
+
file: slotNode.default,
|
|
2169
|
+
slotName,
|
|
2170
|
+
kind: "default"
|
|
2171
|
+
};
|
|
2172
|
+
return null;
|
|
2173
|
+
}
|
|
2174
|
+
//#endregion
|
|
2175
|
+
//#region src/server/csrf.ts
|
|
2176
|
+
/** HTTP methods that are considered safe (no mutation). */
|
|
2177
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
2178
|
+
"GET",
|
|
2179
|
+
"HEAD",
|
|
2180
|
+
"OPTIONS"
|
|
2181
|
+
]);
|
|
2182
|
+
/**
|
|
2183
|
+
* Derive the request's scheme from trusted sources.
|
|
2184
|
+
*
|
|
2185
|
+
* Priority:
|
|
2186
|
+
* 1. X-Forwarded-Proto (set by reverse proxies / load balancers)
|
|
2187
|
+
* 2. Request URL protocol
|
|
2188
|
+
*
|
|
2189
|
+
* Falls back to 'https' (fail closed) if neither is available.
|
|
2190
|
+
*/
|
|
2191
|
+
function deriveRequestScheme(req) {
|
|
2192
|
+
const forwarded = req.headers.get("X-Forwarded-Proto");
|
|
2193
|
+
if (forwarded) {
|
|
2194
|
+
const proto = forwarded.split(",")[0].trim().toLowerCase();
|
|
2195
|
+
if (proto === "http" || proto === "https") return proto;
|
|
2196
|
+
}
|
|
2197
|
+
try {
|
|
2198
|
+
return new URL(req.url).protocol.replace(":", "");
|
|
2199
|
+
} catch {
|
|
2200
|
+
return "https";
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Validate the Origin header against the request's full origin
|
|
2205
|
+
* (scheme + host + port).
|
|
2206
|
+
*
|
|
2207
|
+
* For mutation methods (POST, PUT, PATCH, DELETE):
|
|
2208
|
+
* - If `csrf: false`, skip validation.
|
|
2209
|
+
* - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
|
|
2210
|
+
* - Otherwise, Origin must match the derived request origin.
|
|
2211
|
+
*
|
|
2212
|
+
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
2213
|
+
*/
|
|
2214
|
+
function validateCsrf(req, config) {
|
|
2215
|
+
if (SAFE_METHODS.has(req.method)) return { ok: true };
|
|
2216
|
+
if (config.csrf === false) return { ok: true };
|
|
2217
|
+
const origin = req.headers.get("Origin");
|
|
2218
|
+
if (!origin) return {
|
|
2219
|
+
ok: false,
|
|
2220
|
+
status: 403
|
|
2221
|
+
};
|
|
2222
|
+
if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
|
|
2223
|
+
ok: false,
|
|
2224
|
+
status: 403
|
|
2225
|
+
};
|
|
2226
|
+
const host = req.headers.get("Host");
|
|
2227
|
+
if (!host) return {
|
|
2228
|
+
ok: false,
|
|
2229
|
+
status: 403
|
|
2230
|
+
};
|
|
2231
|
+
let originOrigin;
|
|
2232
|
+
try {
|
|
2233
|
+
originOrigin = new URL(origin).origin;
|
|
2234
|
+
} catch {
|
|
2235
|
+
return {
|
|
2236
|
+
ok: false,
|
|
2237
|
+
status: 403
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
const scheme = deriveRequestScheme(req);
|
|
2241
|
+
let expectedOrigin;
|
|
2242
|
+
try {
|
|
2243
|
+
expectedOrigin = new URL(`${scheme}://${host}`).origin;
|
|
2244
|
+
} catch {
|
|
2245
|
+
return {
|
|
2246
|
+
ok: false,
|
|
2247
|
+
status: 403
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
return originOrigin === expectedOrigin ? { ok: true } : {
|
|
2251
|
+
ok: false,
|
|
2252
|
+
status: 403
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
//#endregion
|
|
2256
|
+
//#region src/server/body-limits.ts
|
|
2257
|
+
var KB = 1024;
|
|
2258
|
+
var MB = 1024 * KB;
|
|
2259
|
+
var GB = 1024 * MB;
|
|
2260
|
+
var DEFAULT_LIMITS = {
|
|
2261
|
+
actionBodySize: 1 * MB,
|
|
2262
|
+
uploadBodySize: 10 * MB,
|
|
2263
|
+
maxFields: 100
|
|
2264
|
+
};
|
|
2265
|
+
var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
|
|
2266
|
+
/** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
|
|
2267
|
+
function parseBodySize(size) {
|
|
2268
|
+
const match = SIZE_PATTERN.exec(size.trim());
|
|
2269
|
+
if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
|
|
2270
|
+
const value = Number.parseFloat(match[1]);
|
|
2271
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
2272
|
+
switch (unit) {
|
|
2273
|
+
case "kb": return Math.floor(value * KB);
|
|
2274
|
+
case "mb": return Math.floor(value * MB);
|
|
2275
|
+
case "gb": return Math.floor(value * GB);
|
|
2276
|
+
case "": return Math.floor(value);
|
|
2277
|
+
default: throw new Error(`Unknown size unit: "${unit}"`);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
/** Strict integer pattern: one or more digits, nothing else. */
|
|
2281
|
+
var STRICT_INTEGER_RE = /^\d+$/;
|
|
2282
|
+
/** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
|
|
2283
|
+
function enforceBodyLimits(req, kind, config) {
|
|
2284
|
+
const contentLength = req.headers.get("Content-Length");
|
|
2285
|
+
if (!contentLength) return {
|
|
2286
|
+
ok: false,
|
|
2287
|
+
status: 411
|
|
2288
|
+
};
|
|
2289
|
+
const trimmed = contentLength.trim();
|
|
2290
|
+
if (!STRICT_INTEGER_RE.test(trimmed)) return {
|
|
2291
|
+
ok: false,
|
|
2292
|
+
status: 411
|
|
2293
|
+
};
|
|
2294
|
+
return Number.parseInt(trimmed, 10) <= resolveLimit(kind, config) ? { ok: true } : {
|
|
2295
|
+
ok: false,
|
|
2296
|
+
status: 413
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Resolve the byte limit for a given body kind, using config overrides or defaults.
|
|
2301
|
+
*/
|
|
2302
|
+
function resolveLimit(kind, config) {
|
|
2303
|
+
const userLimits = config.limits;
|
|
2304
|
+
if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
|
|
2305
|
+
return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
|
|
2306
|
+
}
|
|
2307
|
+
//#endregion
|
|
2308
|
+
//#region src/server/metadata-social.ts
|
|
2309
|
+
/**
|
|
2310
|
+
* Render Open Graph metadata into head element descriptors.
|
|
2311
|
+
*
|
|
2312
|
+
* Handles og:title, og:description, og:image (with dimensions/alt),
|
|
2313
|
+
* og:video, og:audio, og:article:author, and other OG properties.
|
|
2314
|
+
*/
|
|
2315
|
+
function renderOpenGraph(og, elements) {
|
|
2316
|
+
const simpleProps = [
|
|
2317
|
+
["og:title", og.title],
|
|
2318
|
+
["og:description", og.description],
|
|
2319
|
+
["og:url", og.url],
|
|
2320
|
+
["og:site_name", og.siteName],
|
|
2321
|
+
["og:locale", og.locale],
|
|
2322
|
+
["og:type", og.type],
|
|
2323
|
+
["og:article:published_time", og.publishedTime],
|
|
2324
|
+
["og:article:modified_time", og.modifiedTime]
|
|
2325
|
+
];
|
|
2326
|
+
for (const [property, content] of simpleProps) if (content) elements.push({
|
|
2327
|
+
tag: "meta",
|
|
2328
|
+
attrs: {
|
|
2329
|
+
property,
|
|
2330
|
+
content
|
|
2331
|
+
}
|
|
2332
|
+
});
|
|
2333
|
+
if (og.images) {
|
|
2334
|
+
if (typeof og.images === "string") elements.push({
|
|
2335
|
+
tag: "meta",
|
|
2336
|
+
attrs: {
|
|
2337
|
+
property: "og:image",
|
|
2338
|
+
content: og.images
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
else {
|
|
2342
|
+
const imgList = Array.isArray(og.images) ? og.images : [og.images];
|
|
2343
|
+
for (const img of imgList) {
|
|
2344
|
+
elements.push({
|
|
2345
|
+
tag: "meta",
|
|
2346
|
+
attrs: {
|
|
2347
|
+
property: "og:image",
|
|
2348
|
+
content: img.url
|
|
2349
|
+
}
|
|
2350
|
+
});
|
|
2351
|
+
if (img.width) elements.push({
|
|
2352
|
+
tag: "meta",
|
|
2353
|
+
attrs: {
|
|
2354
|
+
property: "og:image:width",
|
|
2355
|
+
content: String(img.width)
|
|
2356
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
if (img.height) elements.push({
|
|
2359
|
+
tag: "meta",
|
|
2360
|
+
attrs: {
|
|
2361
|
+
property: "og:image:height",
|
|
2362
|
+
content: String(img.height)
|
|
2363
|
+
}
|
|
2364
|
+
});
|
|
2365
|
+
if (img.alt) elements.push({
|
|
2366
|
+
tag: "meta",
|
|
2367
|
+
attrs: {
|
|
2368
|
+
property: "og:image:alt",
|
|
2369
|
+
content: img.alt
|
|
2370
|
+
}
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
if (og.videos) for (const video of og.videos) elements.push({
|
|
2376
|
+
tag: "meta",
|
|
2377
|
+
attrs: {
|
|
2378
|
+
property: "og:video",
|
|
2379
|
+
content: video.url
|
|
2380
|
+
}
|
|
2381
|
+
});
|
|
2382
|
+
if (og.audio) for (const audio of og.audio) elements.push({
|
|
2383
|
+
tag: "meta",
|
|
2384
|
+
attrs: {
|
|
2385
|
+
property: "og:audio",
|
|
2386
|
+
content: audio.url
|
|
2387
|
+
}
|
|
2388
|
+
});
|
|
2389
|
+
if (og.authors) for (const author of og.authors) elements.push({
|
|
2390
|
+
tag: "meta",
|
|
2391
|
+
attrs: {
|
|
2392
|
+
property: "og:article:author",
|
|
2393
|
+
content: author
|
|
2394
|
+
}
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
/**
|
|
2398
|
+
* Render Twitter Card metadata into head element descriptors.
|
|
2399
|
+
*
|
|
2400
|
+
* Handles twitter:card, twitter:site, twitter:title, twitter:image,
|
|
2401
|
+
* twitter:player, and twitter:app (per-platform name/id/url).
|
|
2402
|
+
*/
|
|
2403
|
+
function renderTwitter(tw, elements) {
|
|
2404
|
+
const simpleProps = [
|
|
2405
|
+
["twitter:card", tw.card],
|
|
2406
|
+
["twitter:site", tw.site],
|
|
2407
|
+
["twitter:site:id", tw.siteId],
|
|
2408
|
+
["twitter:title", tw.title],
|
|
2409
|
+
["twitter:description", tw.description],
|
|
2410
|
+
["twitter:creator", tw.creator],
|
|
2411
|
+
["twitter:creator:id", tw.creatorId]
|
|
2412
|
+
];
|
|
2413
|
+
for (const [name, content] of simpleProps) if (content) elements.push({
|
|
2414
|
+
tag: "meta",
|
|
2415
|
+
attrs: {
|
|
2416
|
+
name,
|
|
2417
|
+
content
|
|
2418
|
+
}
|
|
2419
|
+
});
|
|
2420
|
+
if (tw.images) {
|
|
2421
|
+
if (typeof tw.images === "string") elements.push({
|
|
2422
|
+
tag: "meta",
|
|
2423
|
+
attrs: {
|
|
2424
|
+
name: "twitter:image",
|
|
2425
|
+
content: tw.images
|
|
2426
|
+
}
|
|
2427
|
+
});
|
|
2428
|
+
else {
|
|
2429
|
+
const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
|
|
2430
|
+
for (const img of imgList) {
|
|
2431
|
+
const url = typeof img === "string" ? img : img.url;
|
|
2432
|
+
elements.push({
|
|
2433
|
+
tag: "meta",
|
|
2434
|
+
attrs: {
|
|
2435
|
+
name: "twitter:image",
|
|
2436
|
+
content: url
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
if (tw.players) for (const player of tw.players) {
|
|
2443
|
+
elements.push({
|
|
2444
|
+
tag: "meta",
|
|
2445
|
+
attrs: {
|
|
2446
|
+
name: "twitter:player",
|
|
2447
|
+
content: player.playerUrl
|
|
2448
|
+
}
|
|
2449
|
+
});
|
|
2450
|
+
if (player.width) elements.push({
|
|
2451
|
+
tag: "meta",
|
|
2452
|
+
attrs: {
|
|
2453
|
+
name: "twitter:player:width",
|
|
2454
|
+
content: String(player.width)
|
|
2455
|
+
}
|
|
2456
|
+
});
|
|
2457
|
+
if (player.height) elements.push({
|
|
2458
|
+
tag: "meta",
|
|
2459
|
+
attrs: {
|
|
2460
|
+
name: "twitter:player:height",
|
|
2461
|
+
content: String(player.height)
|
|
2462
|
+
}
|
|
2463
|
+
});
|
|
2464
|
+
if (player.streamUrl) elements.push({
|
|
2465
|
+
tag: "meta",
|
|
2466
|
+
attrs: {
|
|
2467
|
+
name: "twitter:player:stream",
|
|
2468
|
+
content: player.streamUrl
|
|
2469
|
+
}
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
if (tw.app) {
|
|
2473
|
+
const platforms = [
|
|
2474
|
+
["iPhone", "iphone"],
|
|
2475
|
+
["iPad", "ipad"],
|
|
2476
|
+
["googlePlay", "googleplay"]
|
|
2477
|
+
];
|
|
2478
|
+
if (tw.app.name) {
|
|
2479
|
+
for (const [key, tag] of platforms) if (tw.app.id?.[key]) elements.push({
|
|
2480
|
+
tag: "meta",
|
|
2481
|
+
attrs: {
|
|
2482
|
+
name: `twitter:app:name:${tag}`,
|
|
2483
|
+
content: tw.app.name
|
|
2484
|
+
}
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
for (const [key, tag] of platforms) {
|
|
2488
|
+
const id = tw.app.id?.[key];
|
|
2489
|
+
if (id) elements.push({
|
|
2490
|
+
tag: "meta",
|
|
2491
|
+
attrs: {
|
|
2492
|
+
name: `twitter:app:id:${tag}`,
|
|
2493
|
+
content: id
|
|
2494
|
+
}
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
for (const [key, tag] of platforms) {
|
|
2498
|
+
const url = tw.app.url?.[key];
|
|
2499
|
+
if (url) elements.push({
|
|
2500
|
+
tag: "meta",
|
|
2501
|
+
attrs: {
|
|
2502
|
+
name: `twitter:app:url:${tag}`,
|
|
2503
|
+
content: url
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
//#endregion
|
|
2510
|
+
//#region src/server/metadata-platform.ts
|
|
2511
|
+
/**
|
|
2512
|
+
* Render icon link elements (favicon, shortcut, apple-touch-icon, custom).
|
|
2513
|
+
*/
|
|
2514
|
+
function renderIcons(icons, elements) {
|
|
2515
|
+
if (icons.icon) {
|
|
2516
|
+
if (typeof icons.icon === "string") elements.push({
|
|
2517
|
+
tag: "link",
|
|
2518
|
+
attrs: {
|
|
2519
|
+
rel: "icon",
|
|
2520
|
+
href: icons.icon
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
else if (Array.isArray(icons.icon)) for (const icon of icons.icon) {
|
|
2524
|
+
const attrs = {
|
|
2525
|
+
rel: "icon",
|
|
2526
|
+
href: icon.url
|
|
2527
|
+
};
|
|
2528
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
2529
|
+
if (icon.type) attrs.type = icon.type;
|
|
2530
|
+
elements.push({
|
|
2531
|
+
tag: "link",
|
|
2532
|
+
attrs
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
if (icons.shortcut) {
|
|
2537
|
+
const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
|
|
2538
|
+
for (const url of urls) elements.push({
|
|
2539
|
+
tag: "link",
|
|
2540
|
+
attrs: {
|
|
2541
|
+
rel: "shortcut icon",
|
|
2542
|
+
href: url
|
|
2543
|
+
}
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
if (icons.apple) {
|
|
2547
|
+
if (typeof icons.apple === "string") elements.push({
|
|
2548
|
+
tag: "link",
|
|
2549
|
+
attrs: {
|
|
2550
|
+
rel: "apple-touch-icon",
|
|
2551
|
+
href: icons.apple
|
|
2552
|
+
}
|
|
2553
|
+
});
|
|
2554
|
+
else if (Array.isArray(icons.apple)) for (const icon of icons.apple) {
|
|
2555
|
+
const attrs = {
|
|
2556
|
+
rel: "apple-touch-icon",
|
|
2557
|
+
href: icon.url
|
|
2558
|
+
};
|
|
2559
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
2560
|
+
elements.push({
|
|
2561
|
+
tag: "link",
|
|
2562
|
+
attrs
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
if (icons.other) for (const icon of icons.other) {
|
|
2567
|
+
const attrs = {
|
|
2568
|
+
rel: icon.rel,
|
|
2569
|
+
href: icon.url
|
|
2570
|
+
};
|
|
2571
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
2572
|
+
if (icon.type) attrs.type = icon.type;
|
|
2573
|
+
elements.push({
|
|
2574
|
+
tag: "link",
|
|
2575
|
+
attrs
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* Render alternate link elements (canonical, hreflang, media, types).
|
|
2581
|
+
*/
|
|
2582
|
+
function renderAlternates(alternates, elements) {
|
|
2583
|
+
if (alternates.canonical) elements.push({
|
|
2584
|
+
tag: "link",
|
|
2585
|
+
attrs: {
|
|
2586
|
+
rel: "canonical",
|
|
2587
|
+
href: alternates.canonical
|
|
2588
|
+
}
|
|
2589
|
+
});
|
|
2590
|
+
if (alternates.languages) for (const [lang, href] of Object.entries(alternates.languages)) elements.push({
|
|
2591
|
+
tag: "link",
|
|
2592
|
+
attrs: {
|
|
2593
|
+
rel: "alternate",
|
|
2594
|
+
hreflang: lang,
|
|
2595
|
+
href
|
|
2596
|
+
}
|
|
2597
|
+
});
|
|
2598
|
+
if (alternates.media) for (const [media, href] of Object.entries(alternates.media)) elements.push({
|
|
2599
|
+
tag: "link",
|
|
2600
|
+
attrs: {
|
|
2601
|
+
rel: "alternate",
|
|
2602
|
+
media,
|
|
2603
|
+
href
|
|
2604
|
+
}
|
|
2605
|
+
});
|
|
2606
|
+
if (alternates.types) for (const [type, href] of Object.entries(alternates.types)) elements.push({
|
|
2607
|
+
tag: "link",
|
|
2608
|
+
attrs: {
|
|
2609
|
+
rel: "alternate",
|
|
2610
|
+
type,
|
|
2611
|
+
href
|
|
2612
|
+
}
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* Render site verification meta tags (Google, Yahoo, Yandex, custom).
|
|
2617
|
+
*/
|
|
2618
|
+
function renderVerification(verification, elements) {
|
|
2619
|
+
const verificationProps = [
|
|
2620
|
+
["google-site-verification", verification.google],
|
|
2621
|
+
["y_key", verification.yahoo],
|
|
2622
|
+
["yandex-verification", verification.yandex]
|
|
2623
|
+
];
|
|
2624
|
+
for (const [name, content] of verificationProps) if (content) elements.push({
|
|
2625
|
+
tag: "meta",
|
|
2626
|
+
attrs: {
|
|
2627
|
+
name,
|
|
2628
|
+
content
|
|
2629
|
+
}
|
|
2630
|
+
});
|
|
2631
|
+
if (verification.other) for (const [name, value] of Object.entries(verification.other)) {
|
|
2632
|
+
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
2633
|
+
elements.push({
|
|
2634
|
+
tag: "meta",
|
|
2635
|
+
attrs: {
|
|
2636
|
+
name,
|
|
2637
|
+
content
|
|
2638
|
+
}
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
/**
|
|
2643
|
+
* Render Apple Web App meta tags and startup image links.
|
|
2644
|
+
*/
|
|
2645
|
+
function renderAppleWebApp(appleWebApp, elements) {
|
|
2646
|
+
if (appleWebApp.capable) elements.push({
|
|
2647
|
+
tag: "meta",
|
|
2648
|
+
attrs: {
|
|
2649
|
+
name: "apple-mobile-web-app-capable",
|
|
2650
|
+
content: "yes"
|
|
2651
|
+
}
|
|
2652
|
+
});
|
|
2653
|
+
if (appleWebApp.title) elements.push({
|
|
2654
|
+
tag: "meta",
|
|
2655
|
+
attrs: {
|
|
2656
|
+
name: "apple-mobile-web-app-title",
|
|
2657
|
+
content: appleWebApp.title
|
|
2658
|
+
}
|
|
2659
|
+
});
|
|
2660
|
+
if (appleWebApp.statusBarStyle) elements.push({
|
|
2661
|
+
tag: "meta",
|
|
2662
|
+
attrs: {
|
|
2663
|
+
name: "apple-mobile-web-app-status-bar-style",
|
|
2664
|
+
content: appleWebApp.statusBarStyle
|
|
2665
|
+
}
|
|
2666
|
+
});
|
|
2667
|
+
if (appleWebApp.startupImage) {
|
|
2668
|
+
const images = Array.isArray(appleWebApp.startupImage) ? appleWebApp.startupImage : [{ url: appleWebApp.startupImage }];
|
|
2669
|
+
for (const img of images) {
|
|
2670
|
+
const attrs = {
|
|
2671
|
+
rel: "apple-touch-startup-image",
|
|
2672
|
+
href: typeof img === "string" ? img : img.url
|
|
2673
|
+
};
|
|
2674
|
+
if (typeof img === "object" && img.media) attrs.media = img.media;
|
|
2675
|
+
elements.push({
|
|
2676
|
+
tag: "link",
|
|
2677
|
+
attrs
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
/**
|
|
2683
|
+
* Render App Links (al:*) meta tags for deep linking across platforms.
|
|
2684
|
+
*/
|
|
2685
|
+
function renderAppLinks(appLinks, elements) {
|
|
2686
|
+
const platformEntries = [
|
|
2687
|
+
["ios", appLinks.ios],
|
|
2688
|
+
["android", appLinks.android],
|
|
2689
|
+
["windows", appLinks.windows],
|
|
2690
|
+
["windows_phone", appLinks.windowsPhone],
|
|
2691
|
+
["windows_universal", appLinks.windowsUniversal]
|
|
2692
|
+
];
|
|
2693
|
+
for (const [platform, entries] of platformEntries) {
|
|
2694
|
+
if (!entries) continue;
|
|
2695
|
+
for (const entry of entries) for (const [key, value] of Object.entries(entry)) if (value !== void 0 && value !== null) elements.push({
|
|
2696
|
+
tag: "meta",
|
|
2697
|
+
attrs: {
|
|
2698
|
+
property: `al:${platform}:${key}`,
|
|
2699
|
+
content: String(value)
|
|
2700
|
+
}
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
if (appLinks.web) {
|
|
2704
|
+
if (appLinks.web.url) elements.push({
|
|
2705
|
+
tag: "meta",
|
|
2706
|
+
attrs: {
|
|
2707
|
+
property: "al:web:url",
|
|
2708
|
+
content: appLinks.web.url
|
|
2709
|
+
}
|
|
2710
|
+
});
|
|
2711
|
+
if (appLinks.web.shouldFallback !== void 0) elements.push({
|
|
2712
|
+
tag: "meta",
|
|
2713
|
+
attrs: {
|
|
2714
|
+
property: "al:web:should_fallback",
|
|
2715
|
+
content: appLinks.web.shouldFallback ? "true" : "false"
|
|
2716
|
+
}
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* Render Apple iTunes smart banner meta tag.
|
|
2722
|
+
*/
|
|
2723
|
+
function renderItunes(itunes, elements) {
|
|
2724
|
+
const parts = [`app-id=${itunes.appId}`];
|
|
2725
|
+
if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
|
|
2726
|
+
if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
|
|
2727
|
+
elements.push({
|
|
2728
|
+
tag: "meta",
|
|
2729
|
+
attrs: {
|
|
2730
|
+
name: "apple-itunes-app",
|
|
2731
|
+
content: parts.join(", ")
|
|
2732
|
+
}
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
//#endregion
|
|
2736
|
+
//#region src/server/metadata-render.ts
|
|
2737
|
+
/**
|
|
2738
|
+
* Convert resolved metadata into an array of head element descriptors.
|
|
2739
|
+
*
|
|
2740
|
+
* Each descriptor has a `tag` ('title', 'meta', 'link') and either
|
|
2741
|
+
* `content` (for <title>) or `attrs` (for <meta>/<link>).
|
|
2742
|
+
*
|
|
2743
|
+
* The framework's MetadataResolver component consumes these descriptors
|
|
2744
|
+
* and renders them into the <head>.
|
|
2745
|
+
*/
|
|
2746
|
+
function renderMetadataToElements(metadata) {
|
|
2747
|
+
const elements = [];
|
|
2748
|
+
if (typeof metadata.title === "string") elements.push({
|
|
2749
|
+
tag: "title",
|
|
2750
|
+
content: metadata.title
|
|
2751
|
+
});
|
|
2752
|
+
const simpleMetaProps = [
|
|
2753
|
+
["description", metadata.description],
|
|
2754
|
+
["generator", metadata.generator],
|
|
2755
|
+
["application-name", metadata.applicationName],
|
|
2756
|
+
["referrer", metadata.referrer],
|
|
2757
|
+
["category", metadata.category],
|
|
2758
|
+
["creator", metadata.creator],
|
|
2759
|
+
["publisher", metadata.publisher]
|
|
2760
|
+
];
|
|
2761
|
+
for (const [name, content] of simpleMetaProps) if (content) elements.push({
|
|
2762
|
+
tag: "meta",
|
|
2763
|
+
attrs: {
|
|
2764
|
+
name,
|
|
2765
|
+
content
|
|
2766
|
+
}
|
|
2767
|
+
});
|
|
2768
|
+
if (metadata.keywords) {
|
|
2769
|
+
const content = Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
|
|
2770
|
+
elements.push({
|
|
2771
|
+
tag: "meta",
|
|
2772
|
+
attrs: {
|
|
2773
|
+
name: "keywords",
|
|
2774
|
+
content
|
|
2775
|
+
}
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
if (metadata.robots) {
|
|
2779
|
+
const content = typeof metadata.robots === "string" ? metadata.robots : renderRobotsObject(metadata.robots);
|
|
2780
|
+
elements.push({
|
|
2781
|
+
tag: "meta",
|
|
2782
|
+
attrs: {
|
|
2783
|
+
name: "robots",
|
|
2784
|
+
content
|
|
2785
|
+
}
|
|
2786
|
+
});
|
|
2787
|
+
if (typeof metadata.robots === "object" && metadata.robots.googleBot) {
|
|
2788
|
+
const gbContent = typeof metadata.robots.googleBot === "string" ? metadata.robots.googleBot : renderRobotsObject(metadata.robots.googleBot);
|
|
2789
|
+
elements.push({
|
|
2790
|
+
tag: "meta",
|
|
2791
|
+
attrs: {
|
|
2792
|
+
name: "googlebot",
|
|
2793
|
+
content: gbContent
|
|
2794
|
+
}
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
if (metadata.openGraph) {
|
|
2799
|
+
const og = { ...metadata.openGraph };
|
|
2800
|
+
if (og.title === void 0 && typeof metadata.title === "string") og.title = metadata.title;
|
|
2801
|
+
if (og.description === void 0 && metadata.description) og.description = metadata.description;
|
|
2802
|
+
renderOpenGraph(og, elements);
|
|
2803
|
+
}
|
|
2804
|
+
if (metadata.twitter) {
|
|
2805
|
+
const tw = { ...metadata.twitter };
|
|
2806
|
+
if (tw.title === void 0) tw.title = metadata.openGraph?.title !== void 0 ? metadata.openGraph.title : typeof metadata.title === "string" ? metadata.title : void 0;
|
|
2807
|
+
if (tw.description === void 0) tw.description = metadata.openGraph?.description !== void 0 ? metadata.openGraph.description : metadata.description;
|
|
2808
|
+
renderTwitter(tw, elements);
|
|
2809
|
+
}
|
|
2810
|
+
if (metadata.icons) renderIcons(metadata.icons, elements);
|
|
2811
|
+
if (metadata.manifest) elements.push({
|
|
2812
|
+
tag: "link",
|
|
2813
|
+
attrs: {
|
|
2814
|
+
rel: "manifest",
|
|
2815
|
+
href: metadata.manifest
|
|
2816
|
+
}
|
|
2817
|
+
});
|
|
2818
|
+
if (metadata.alternates) renderAlternates(metadata.alternates, elements);
|
|
2819
|
+
if (metadata.verification) renderVerification(metadata.verification, elements);
|
|
2820
|
+
if (metadata.formatDetection) {
|
|
2821
|
+
const parts = [];
|
|
2822
|
+
if (metadata.formatDetection.telephone === false) parts.push("telephone=no");
|
|
2823
|
+
if (metadata.formatDetection.email === false) parts.push("email=no");
|
|
2824
|
+
if (metadata.formatDetection.address === false) parts.push("address=no");
|
|
2825
|
+
if (parts.length > 0) elements.push({
|
|
2826
|
+
tag: "meta",
|
|
2827
|
+
attrs: {
|
|
2828
|
+
name: "format-detection",
|
|
2829
|
+
content: parts.join(", ")
|
|
2830
|
+
}
|
|
2831
|
+
});
|
|
2832
|
+
}
|
|
2833
|
+
if (metadata.authors) {
|
|
2834
|
+
const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
|
|
2835
|
+
for (const author of authorList) {
|
|
2836
|
+
if (author.name) elements.push({
|
|
2837
|
+
tag: "meta",
|
|
2838
|
+
attrs: {
|
|
2839
|
+
name: "author",
|
|
2840
|
+
content: author.name
|
|
2841
|
+
}
|
|
2842
|
+
});
|
|
2843
|
+
if (author.url) elements.push({
|
|
2844
|
+
tag: "link",
|
|
2845
|
+
attrs: {
|
|
2846
|
+
rel: "author",
|
|
2847
|
+
href: author.url
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
if (metadata.appleWebApp) renderAppleWebApp(metadata.appleWebApp, elements);
|
|
2853
|
+
if (metadata.appLinks) renderAppLinks(metadata.appLinks, elements);
|
|
2854
|
+
if (metadata.itunes) renderItunes(metadata.itunes, elements);
|
|
2855
|
+
if (metadata.other) for (const [name, value] of Object.entries(metadata.other)) {
|
|
2856
|
+
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
2857
|
+
elements.push({
|
|
2858
|
+
tag: "meta",
|
|
2859
|
+
attrs: {
|
|
2860
|
+
name,
|
|
2861
|
+
content
|
|
2862
|
+
}
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
return elements;
|
|
2866
|
+
}
|
|
2867
|
+
function renderRobotsObject(robots) {
|
|
2868
|
+
const parts = [];
|
|
2869
|
+
if (robots.index === true) parts.push("index");
|
|
2870
|
+
if (robots.index === false) parts.push("noindex");
|
|
2871
|
+
if (robots.follow === true) parts.push("follow");
|
|
2872
|
+
if (robots.follow === false) parts.push("nofollow");
|
|
2873
|
+
return parts.join(", ");
|
|
2874
|
+
}
|
|
2875
|
+
//#endregion
|
|
2876
|
+
//#region src/server/metadata.ts
|
|
2877
|
+
/**
|
|
2878
|
+
* Resolve a title value with an optional template.
|
|
2879
|
+
*
|
|
2880
|
+
* - string → apply template if present
|
|
2881
|
+
* - { absolute: '...' } → use as-is, skip template
|
|
2882
|
+
* - { default: '...' } → use as fallback (no template applied)
|
|
2883
|
+
* - undefined → undefined
|
|
2884
|
+
*/
|
|
2885
|
+
function resolveTitle(title, template) {
|
|
2886
|
+
if (title === void 0 || title === null) return;
|
|
2887
|
+
if (typeof title === "string") return template ? template.replace("%s", title) : title;
|
|
2888
|
+
if (title.absolute !== void 0) return title.absolute;
|
|
2889
|
+
if (title.default !== void 0) return title.default;
|
|
2890
|
+
}
|
|
2891
|
+
/**
|
|
2892
|
+
* Resolve metadata from a segment chain.
|
|
2893
|
+
*
|
|
2894
|
+
* Processes entries from root layout to page (in segment order).
|
|
2895
|
+
* The merge algorithm:
|
|
2896
|
+
* 1. Shallow-merge all keys except title (later wins)
|
|
2897
|
+
* 2. Track the most recent title template
|
|
2898
|
+
* 3. Resolve the final title using the template
|
|
2899
|
+
*
|
|
2900
|
+
* In error state, the page entry is dropped and noindex is injected.
|
|
2901
|
+
*
|
|
2902
|
+
* See design/16-metadata.md §"Merge Algorithm"
|
|
2903
|
+
*/
|
|
2904
|
+
function resolveMetadata(entries, options = {}) {
|
|
2905
|
+
const { errorState = false } = options;
|
|
2906
|
+
const merged = {};
|
|
2907
|
+
let titleTemplate;
|
|
2908
|
+
let lastDefault;
|
|
2909
|
+
let rawTitle;
|
|
2910
|
+
for (const { metadata, isPage } of entries) {
|
|
2911
|
+
if (errorState && isPage) continue;
|
|
2912
|
+
if (metadata.title !== void 0 && typeof metadata.title === "object") {
|
|
2913
|
+
if (metadata.title.template !== void 0) titleTemplate = metadata.title.template;
|
|
2914
|
+
if (metadata.title.default !== void 0) lastDefault = metadata.title.default;
|
|
2915
|
+
}
|
|
2916
|
+
for (const key of Object.keys(metadata)) {
|
|
2917
|
+
if (key === "title") continue;
|
|
2918
|
+
merged[key] = metadata[key];
|
|
2919
|
+
}
|
|
2920
|
+
if (metadata.title !== void 0) rawTitle = metadata.title;
|
|
2921
|
+
}
|
|
2922
|
+
if (errorState) {
|
|
2923
|
+
rawTitle = lastDefault !== void 0 ? { default: lastDefault } : rawTitle;
|
|
2924
|
+
titleTemplate = void 0;
|
|
2925
|
+
}
|
|
2926
|
+
const resolvedTitle = resolveTitle(rawTitle, titleTemplate);
|
|
2927
|
+
if (resolvedTitle !== void 0) merged.title = resolvedTitle;
|
|
2928
|
+
if (errorState) merged.robots = "noindex";
|
|
2929
|
+
return merged;
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* Check if a string is an absolute URL.
|
|
2933
|
+
*/
|
|
2934
|
+
function isAbsoluteUrl(url) {
|
|
2935
|
+
return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//");
|
|
2936
|
+
}
|
|
2937
|
+
/**
|
|
2938
|
+
* Resolve a relative URL against a base URL.
|
|
2939
|
+
*/
|
|
2940
|
+
function resolveUrl(url, base) {
|
|
2941
|
+
if (isAbsoluteUrl(url)) return url;
|
|
2942
|
+
return new URL(url, base).toString();
|
|
2943
|
+
}
|
|
2944
|
+
/**
|
|
2945
|
+
* Resolve relative URLs in metadata fields against metadataBase.
|
|
2946
|
+
*
|
|
2947
|
+
* Returns a new metadata object with URLs resolved. Absolute URLs are not modified.
|
|
2948
|
+
* If metadataBase is not set, returns the metadata unchanged.
|
|
2949
|
+
*/
|
|
2950
|
+
function resolveMetadataUrls(metadata) {
|
|
2951
|
+
const base = metadata.metadataBase;
|
|
2952
|
+
if (!base) return metadata;
|
|
2953
|
+
const result = { ...metadata };
|
|
2954
|
+
if (result.openGraph) {
|
|
2955
|
+
result.openGraph = { ...result.openGraph };
|
|
2956
|
+
if (typeof result.openGraph.images === "string") result.openGraph.images = resolveUrl(result.openGraph.images, base);
|
|
2957
|
+
else if (Array.isArray(result.openGraph.images)) result.openGraph.images = result.openGraph.images.map((img) => ({
|
|
2958
|
+
...img,
|
|
2959
|
+
url: resolveUrl(img.url, base)
|
|
2960
|
+
}));
|
|
2961
|
+
else if (result.openGraph.images) result.openGraph.images = {
|
|
2962
|
+
...result.openGraph.images,
|
|
2963
|
+
url: resolveUrl(result.openGraph.images.url, base)
|
|
2964
|
+
};
|
|
2965
|
+
if (result.openGraph.url && !isAbsoluteUrl(result.openGraph.url)) result.openGraph.url = resolveUrl(result.openGraph.url, base);
|
|
2966
|
+
}
|
|
2967
|
+
if (result.twitter) {
|
|
2968
|
+
result.twitter = { ...result.twitter };
|
|
2969
|
+
if (typeof result.twitter.images === "string") result.twitter.images = resolveUrl(result.twitter.images, base);
|
|
2970
|
+
else if (Array.isArray(result.twitter.images)) {
|
|
2971
|
+
const resolved = result.twitter.images.map((img) => typeof img === "string" ? resolveUrl(img, base) : {
|
|
2972
|
+
...img,
|
|
2973
|
+
url: resolveUrl(img.url, base)
|
|
2974
|
+
});
|
|
2975
|
+
const allStrings = resolved.every((r) => typeof r === "string");
|
|
2976
|
+
result.twitter.images = allStrings ? resolved : resolved;
|
|
2977
|
+
} else if (result.twitter.images) result.twitter.images = {
|
|
2978
|
+
...result.twitter.images,
|
|
2979
|
+
url: resolveUrl(result.twitter.images.url, base)
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
if (result.alternates) {
|
|
2983
|
+
result.alternates = { ...result.alternates };
|
|
2984
|
+
if (result.alternates.canonical && !isAbsoluteUrl(result.alternates.canonical)) result.alternates.canonical = resolveUrl(result.alternates.canonical, base);
|
|
2985
|
+
if (result.alternates.languages) {
|
|
2986
|
+
const langs = {};
|
|
2987
|
+
for (const [lang, url] of Object.entries(result.alternates.languages)) langs[lang] = isAbsoluteUrl(url) ? url : resolveUrl(url, base);
|
|
2988
|
+
result.alternates.languages = langs;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
if (result.icons) {
|
|
2992
|
+
result.icons = { ...result.icons };
|
|
2993
|
+
if (typeof result.icons.icon === "string") result.icons.icon = resolveUrl(result.icons.icon, base);
|
|
2994
|
+
else if (Array.isArray(result.icons.icon)) result.icons.icon = result.icons.icon.map((i) => ({
|
|
2995
|
+
...i,
|
|
2996
|
+
url: resolveUrl(i.url, base)
|
|
2997
|
+
}));
|
|
2998
|
+
if (typeof result.icons.apple === "string") result.icons.apple = resolveUrl(result.icons.apple, base);
|
|
2999
|
+
else if (Array.isArray(result.icons.apple)) result.icons.apple = result.icons.apple.map((i) => ({
|
|
3000
|
+
...i,
|
|
3001
|
+
url: resolveUrl(i.url, base)
|
|
3002
|
+
}));
|
|
3003
|
+
}
|
|
3004
|
+
return result;
|
|
3005
|
+
}
|
|
3006
|
+
//#endregion
|
|
3007
|
+
//#region src/server/route-handler.ts
|
|
3008
|
+
/** All recognized HTTP method export names. */
|
|
3009
|
+
var HTTP_METHODS = [
|
|
3010
|
+
"GET",
|
|
3011
|
+
"POST",
|
|
3012
|
+
"PUT",
|
|
3013
|
+
"PATCH",
|
|
3014
|
+
"DELETE",
|
|
3015
|
+
"HEAD",
|
|
3016
|
+
"OPTIONS"
|
|
3017
|
+
];
|
|
3018
|
+
/**
|
|
3019
|
+
* Resolve the full list of allowed methods for a route module.
|
|
3020
|
+
*
|
|
3021
|
+
* Includes:
|
|
3022
|
+
* - All explicitly exported methods
|
|
3023
|
+
* - HEAD (implicit when GET is exported)
|
|
3024
|
+
* - OPTIONS (always implicit)
|
|
3025
|
+
*/
|
|
3026
|
+
function resolveAllowedMethods(mod) {
|
|
3027
|
+
const methods = [];
|
|
3028
|
+
for (const method of HTTP_METHODS) {
|
|
3029
|
+
if (method === "HEAD" || method === "OPTIONS") continue;
|
|
3030
|
+
if (mod[method]) methods.push(method);
|
|
3031
|
+
}
|
|
3032
|
+
if (mod.GET && !mod.HEAD) methods.push("HEAD");
|
|
3033
|
+
else if (mod.HEAD) methods.push("HEAD");
|
|
3034
|
+
if (!mod.OPTIONS) methods.push("OPTIONS");
|
|
3035
|
+
else methods.push("OPTIONS");
|
|
3036
|
+
return methods;
|
|
3037
|
+
}
|
|
3038
|
+
/**
|
|
3039
|
+
* Handle an incoming request against a route.ts module.
|
|
3040
|
+
*
|
|
3041
|
+
* Dispatches to the named method handler, auto-generates 405/OPTIONS,
|
|
3042
|
+
* and merges response headers from ctx.headers.
|
|
3043
|
+
*/
|
|
3044
|
+
async function handleRouteRequest(mod, ctx) {
|
|
3045
|
+
const method = ctx.req.method.toUpperCase();
|
|
3046
|
+
const allowHeader = resolveAllowedMethods(mod).join(", ");
|
|
3047
|
+
if (method === "OPTIONS") {
|
|
3048
|
+
if (mod.OPTIONS) return runHandler(mod.OPTIONS, ctx);
|
|
3049
|
+
return new Response(null, {
|
|
3050
|
+
status: 204,
|
|
3051
|
+
headers: { Allow: allowHeader }
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
if (method === "HEAD") {
|
|
3055
|
+
if (mod.HEAD) return stripHeadBody(await runHandler(mod.HEAD, ctx), method);
|
|
3056
|
+
if (mod.GET) return stripHeadBody(await runHandler(mod.GET, ctx), method);
|
|
3057
|
+
}
|
|
3058
|
+
const handler = mod[method];
|
|
3059
|
+
if (!handler) return new Response(null, {
|
|
3060
|
+
status: 405,
|
|
3061
|
+
headers: { Allow: allowHeader }
|
|
3062
|
+
});
|
|
3063
|
+
return runHandler(handler, ctx);
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* Run a handler, merge ctx.headers into the response, and catch errors.
|
|
3067
|
+
*/
|
|
3068
|
+
async function runHandler(handler, ctx) {
|
|
3069
|
+
try {
|
|
3070
|
+
return mergeResponseHeaders(await handler(ctx), ctx.headers);
|
|
3071
|
+
} catch (error) {
|
|
3072
|
+
if (isDenySignal(error) || isRedirectSignal(error)) throw error;
|
|
3073
|
+
logRouteError({
|
|
3074
|
+
method: ctx.req.method,
|
|
3075
|
+
path: new URL(ctx.req.url).pathname,
|
|
3076
|
+
error
|
|
3077
|
+
});
|
|
3078
|
+
return new Response(null, { status: 500 });
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* Merge response headers from ctx.headers into the handler's response.
|
|
3083
|
+
* ctx.headers (set by middleware or the handler) are applied to the final response.
|
|
3084
|
+
* Handler-set headers take precedence over ctx.headers.
|
|
3085
|
+
*/
|
|
3086
|
+
function mergeResponseHeaders(res, ctxHeaders) {
|
|
3087
|
+
let hasCtxHeaders = false;
|
|
3088
|
+
ctxHeaders.forEach(() => {
|
|
3089
|
+
hasCtxHeaders = true;
|
|
3090
|
+
});
|
|
3091
|
+
if (!hasCtxHeaders) return res;
|
|
3092
|
+
const merged = new Headers();
|
|
3093
|
+
ctxHeaders.forEach((value, key) => {
|
|
3094
|
+
if (key.toLowerCase() === "set-cookie") merged.append(key, value);
|
|
3095
|
+
else merged.set(key, value);
|
|
3096
|
+
});
|
|
3097
|
+
const resCookies = res.headers.getSetCookie();
|
|
3098
|
+
for (const cookie of resCookies) merged.append("Set-Cookie", cookie);
|
|
3099
|
+
res.headers.forEach((value, key) => {
|
|
3100
|
+
if (key.toLowerCase() !== "set-cookie") merged.set(key, value);
|
|
3101
|
+
});
|
|
3102
|
+
return new Response(res.body, {
|
|
3103
|
+
status: res.status,
|
|
3104
|
+
statusText: res.statusText,
|
|
3105
|
+
headers: merged
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
//#endregion
|
|
3109
|
+
//#region src/server/render-timeout.ts
|
|
3110
|
+
/**
|
|
3111
|
+
* Render timeout utilities for SSR streaming pipeline.
|
|
3112
|
+
*
|
|
3113
|
+
* Provides a RenderTimeoutError class and a helper to create
|
|
3114
|
+
* timeout-guarded AbortSignals. Used to defend against hung RSC
|
|
3115
|
+
* streams and infinite SSR renders.
|
|
3116
|
+
*
|
|
3117
|
+
* Design doc: 02-rendering-pipeline.md §"Streaming Constraints"
|
|
3118
|
+
*/
|
|
3119
|
+
/**
|
|
3120
|
+
* Error thrown when an SSR render or RSC stream read exceeds the
|
|
3121
|
+
* configured timeout. Callers can check `instanceof RenderTimeoutError`
|
|
3122
|
+
* to distinguish timeout from other errors and return a 504 or close
|
|
3123
|
+
* the connection cleanly.
|
|
3124
|
+
*/
|
|
3125
|
+
var RenderTimeoutError = class extends Error {
|
|
3126
|
+
timeoutMs;
|
|
3127
|
+
constructor(timeoutMs, context) {
|
|
3128
|
+
const message = context ? `Render timeout after ${timeoutMs}ms: ${context}` : `Render timeout after ${timeoutMs}ms`;
|
|
3129
|
+
super(message);
|
|
3130
|
+
this.name = "RenderTimeoutError";
|
|
3131
|
+
this.timeoutMs = timeoutMs;
|
|
3132
|
+
}
|
|
3133
|
+
};
|
|
3134
|
+
//#endregion
|
|
3135
|
+
//#region src/server/prebuilt/cache-key.ts
|
|
3136
|
+
/**
|
|
3137
|
+
* Component cache-key hashing for prebuilt (cache.component) entries.
|
|
3138
|
+
*
|
|
3139
|
+
* Key: `sha256(canonicalJson({ props, params })).slice(0, 16)` — see
|
|
3140
|
+
* design/44-render-at-build-time.md §Cache Key. Both the request-time
|
|
3141
|
+
* lookup (prebuilt-runtime.ts) and the post-build capture pass (TIM-1119)
|
|
3142
|
+
* MUST use this exact function, or build artifacts become unaddressable.
|
|
3143
|
+
*
|
|
3144
|
+
* THE IDENTITY CONTRACT (design/44 §Cache Key): cache identity is **JSON
|
|
3145
|
+
* value semantics** — two inputs are the same entry iff they are the same
|
|
3146
|
+
* JSON value. This is an ALLOWLIST, not a denylist: a value participates
|
|
3147
|
+
* in caching iff it is plain JSON data — null, boolean, finite number,
|
|
3148
|
+
* string, dense array, or a plain `Object.prototype` object with only
|
|
3149
|
+
* enumerable string-keyed data properties — plus `undefined` and `-0`,
|
|
3150
|
+
* which hash as distinct sentinels because flight preserves them. Every
|
|
3151
|
+
* value outside that domain (functions, symbols, bigints, accessors,
|
|
3152
|
+
* hidden or symbol-keyed properties, non-`Object.prototype` prototypes
|
|
3153
|
+
* including null, aliased references, sparse arrays, cycles, non-finite
|
|
3154
|
+
* numbers) is UNKEYABLE by definition: `computeComponentCacheKey` returns
|
|
3155
|
+
* null and the caller renders live — slower, never wrong.
|
|
3156
|
+
*
|
|
3157
|
+
* The line between rejected and declared-out-of-domain: values are
|
|
3158
|
+
* REJECTED when the JSON projection itself would be ambiguous or lossy
|
|
3159
|
+
* (hidden/symbol/non-enumerable properties, accessors, exotic
|
|
3160
|
+
* prototypes). Observable state that leaves the projection intact — key
|
|
3161
|
+
* order, reference identity, descriptor metadata (writable/configurable,
|
|
3162
|
+
* frozen/sealed/extensible), proxy behavior — is DECLARED outside
|
|
3163
|
+
* cache.component's supported input domain rather than rejected:
|
|
3164
|
+
* key-order insensitivity is required for determinism, and rejecting
|
|
3165
|
+
* frozen objects would unkey ubiquitous legitimate inputs
|
|
3166
|
+
* (Object.freeze'd config props) to defend against components that
|
|
3167
|
+
* render differently based on property attributes.
|
|
3168
|
+
*
|
|
3169
|
+
* Two hard rules for this walk itself: it must be TOTAL (never throw
|
|
3170
|
+
* anything but UnkeyableValueError) and SIDE-EFFECT-FREE (descriptor
|
|
3171
|
+
* reads only; never invoke user code such as getters).
|
|
3172
|
+
*
|
|
3173
|
+
* Object keys sort recursively so `{a, b}` and `{b, a}` hash identically
|
|
3174
|
+
* (design/13-security.md checklist #14 — cache key determinism; key order
|
|
3175
|
+
* is deliberately NOT part of identity).
|
|
3176
|
+
*/
|
|
3177
|
+
var UnkeyableValueError = class extends Error {};
|
|
3178
|
+
function canonicalize$1(value, seen) {
|
|
3179
|
+
if (value === null) return "null";
|
|
3180
|
+
switch (typeof value) {
|
|
3181
|
+
case "string": return JSON.stringify(value);
|
|
3182
|
+
case "boolean": return value ? "true" : "false";
|
|
3183
|
+
case "number":
|
|
3184
|
+
if (!Number.isFinite(value)) throw new UnkeyableValueError("non-finite number");
|
|
3185
|
+
return Object.is(value, -0) ? "-0" : String(value);
|
|
3186
|
+
case "object": break;
|
|
3187
|
+
case "undefined": return "undefined";
|
|
3188
|
+
default: throw new UnkeyableValueError(`unsupported type: ${typeof value}`);
|
|
3189
|
+
}
|
|
3190
|
+
const obj = value;
|
|
3191
|
+
if (seen.has(obj)) throw new UnkeyableValueError("repeated object reference (cycle or alias)");
|
|
3192
|
+
seen.add(obj);
|
|
3193
|
+
if (Object.getOwnPropertySymbols(obj).length > 0) throw new UnkeyableValueError("symbol-keyed property");
|
|
3194
|
+
{
|
|
3195
|
+
if (Array.isArray(obj)) {
|
|
3196
|
+
if (Object.getOwnPropertyNames(obj).length !== obj.length + 1) throw new UnkeyableValueError("non-index array property");
|
|
3197
|
+
const parts = [];
|
|
3198
|
+
for (let i = 0; i < obj.length; i++) {
|
|
3199
|
+
const desc = Object.getOwnPropertyDescriptor(obj, i);
|
|
3200
|
+
if (desc === void 0) throw new UnkeyableValueError("sparse array hole");
|
|
3201
|
+
if (desc.get !== void 0 || desc.set !== void 0) throw new UnkeyableValueError("accessor property");
|
|
3202
|
+
if (!desc.enumerable) throw new UnkeyableValueError("non-enumerable array element");
|
|
3203
|
+
parts.push(canonicalize$1(desc.value, seen));
|
|
3204
|
+
}
|
|
3205
|
+
return "[" + parts.join(",") + "]";
|
|
3206
|
+
}
|
|
3207
|
+
if (Object.getPrototypeOf(obj) !== Object.prototype) throw new UnkeyableValueError("non-plain object");
|
|
3208
|
+
const keys = Object.keys(obj).sort();
|
|
3209
|
+
if (keys.length !== Object.getOwnPropertyNames(obj).length) throw new UnkeyableValueError("non-enumerable property");
|
|
3210
|
+
const parts = [];
|
|
3211
|
+
for (const key of keys) {
|
|
3212
|
+
const desc = Object.getOwnPropertyDescriptor(obj, key);
|
|
3213
|
+
if (desc.get !== void 0 || desc.set !== void 0) throw new UnkeyableValueError("accessor property");
|
|
3214
|
+
parts.push(JSON.stringify(key) + ":" + canonicalize$1(desc.value, seen));
|
|
3215
|
+
}
|
|
3216
|
+
return "{" + parts.join(",") + "}";
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
/**
|
|
3220
|
+
* Canonicalize a single value, or null if it is unkeyable. The ONE
|
|
3221
|
+
* definition of "faithfully comparable" — every prebuilt code path that
|
|
3222
|
+
* needs value equality (cache keys, slot-param divergence) goes through
|
|
3223
|
+
* this, so the unkeyable rules (accessors, hidden props, aliases,
|
|
3224
|
+
* bigints, …) live in exactly one place.
|
|
3225
|
+
*/
|
|
3226
|
+
function tryCanonicalize(value) {
|
|
3227
|
+
try {
|
|
3228
|
+
return canonicalize$1(value, /* @__PURE__ */ new Set());
|
|
3229
|
+
} catch (error) {
|
|
3230
|
+
return toUnkeyable(error);
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* Totality by construction: ANY throw from the walk means the input is
|
|
3235
|
+
* outside the keyable domain. The walk touches only user-controlled
|
|
3236
|
+
* values through reflection ops — and a Proxy trap can throw arbitrary
|
|
3237
|
+
* errors from any of them (codex review on PR #835) — so converting only
|
|
3238
|
+
* UnkeyableValueError would let hostile/exotic inputs abort a render
|
|
3239
|
+
* that plain live rendering would have served fine. Unexpected error
|
|
3240
|
+
* types are logged for visibility (they would indicate either a proxy
|
|
3241
|
+
* trap or a canonicalizer bug) but still degrade to unkeyable.
|
|
3242
|
+
*/
|
|
3243
|
+
function toUnkeyable(error) {
|
|
3244
|
+
if (!(error instanceof UnkeyableValueError)) console.error("[timber] cache key canonicalization threw — treating input as unkeyable:", error);
|
|
3245
|
+
return null;
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Split a props record into data props (participate in the cache key) and
|
|
3249
|
+
* slot values (React-element holes, excluded from the key) per the
|
|
3250
|
+
* component's declared `slots` option (TIM-1173).
|
|
3251
|
+
*
|
|
3252
|
+
* Every DECLARED slot lands in `slotValues` — present or not — because the
|
|
3253
|
+
* capture render always injects a SlotOutlet placeholder for each declared
|
|
3254
|
+
* slot: the cached shell is identical whether the caller passed the slot
|
|
3255
|
+
* or omitted it, so slot presence must not vary the key. `dataProps` is
|
|
3256
|
+
* everything else and is what gets hashed.
|
|
3257
|
+
*
|
|
3258
|
+
* Returns null when the props OBJECT itself is outside the identity
|
|
3259
|
+
* contract at the top level — accessors, symbol-keyed or non-enumerable
|
|
3260
|
+
* properties, or a non-`Object.prototype` prototype. Splitting such an
|
|
3261
|
+
* object would normalize away observable state (and reading an accessor
|
|
3262
|
+
* would invoke user code, which this walk must never do) BEFORE
|
|
3263
|
+
* `computeComponentCacheKey` gets the chance to reject it — a cached
|
|
3264
|
+
* shell could then be served for inputs a live render distinguishes.
|
|
3265
|
+
* Null means unkeyable: the caller renders live with the original props.
|
|
3266
|
+
* The walk is descriptor-reads only; getters are never invoked. Nested
|
|
3267
|
+
* data-prop values still face the full allowlist when hashed.
|
|
3268
|
+
*/
|
|
3269
|
+
/** Create an own enumerable data property — safe for keys like `__proto__`. */
|
|
3270
|
+
function defineOwn(target, key, value) {
|
|
3271
|
+
Object.defineProperty(target, key, {
|
|
3272
|
+
value,
|
|
3273
|
+
enumerable: true,
|
|
3274
|
+
writable: true,
|
|
3275
|
+
configurable: true
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
function splitSlotProps(props, slots) {
|
|
3279
|
+
try {
|
|
3280
|
+
if (Object.getPrototypeOf(props) !== Object.prototype) return null;
|
|
3281
|
+
if (Object.getOwnPropertySymbols(props).length > 0) return null;
|
|
3282
|
+
const slotSet = new Set(slots);
|
|
3283
|
+
const dataProps = {};
|
|
3284
|
+
const slotValues = {};
|
|
3285
|
+
for (const key of Object.getOwnPropertyNames(props)) {
|
|
3286
|
+
const desc = Object.getOwnPropertyDescriptor(props, key);
|
|
3287
|
+
if (desc === void 0 || desc.get !== void 0 || desc.set !== void 0 || !desc.enumerable) return null;
|
|
3288
|
+
defineOwn(slotSet.has(key) ? slotValues : dataProps, key, desc.value);
|
|
3289
|
+
}
|
|
3290
|
+
for (const slot of slotSet) if (!Object.hasOwn(slotValues, slot)) defineOwn(slotValues, slot, void 0);
|
|
3291
|
+
return {
|
|
3292
|
+
dataProps,
|
|
3293
|
+
slotValues
|
|
3294
|
+
};
|
|
3295
|
+
} catch (error) {
|
|
3296
|
+
return toUnkeyable(error);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
/**
|
|
3300
|
+
* Compute the cache-entry hash for a component render:
|
|
3301
|
+
* `sha256({props, params}).slice(0, 16)` over canonical JSON.
|
|
3302
|
+
*
|
|
3303
|
+
* Returns null when props (or params) contain unkeyable values — the
|
|
3304
|
+
* caller must fall back to dynamic rendering.
|
|
3305
|
+
*/
|
|
3306
|
+
function computeComponentCacheKey(props, params) {
|
|
3307
|
+
let canonical;
|
|
3308
|
+
try {
|
|
3309
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3310
|
+
canonical = "{\"params\":" + canonicalize$1({ ...params }, seen) + ",\"props\":" + canonicalize$1(props, seen) + "}";
|
|
3311
|
+
} catch (error) {
|
|
3312
|
+
return toUnkeyable(error);
|
|
3313
|
+
}
|
|
3314
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
3315
|
+
}
|
|
3316
|
+
//#endregion
|
|
3317
|
+
//#region src/client/slot-context.ts
|
|
3318
|
+
/**
|
|
3319
|
+
* SlotContext — delivers live slot content to SlotOutlet holes inside
|
|
3320
|
+
* cached component shells (TIM-1173).
|
|
3321
|
+
*
|
|
3322
|
+
* A `cache.component(Fn, { slots: [...] })` shell is captured with a
|
|
3323
|
+
* stable <SlotOutlet slot={name} /> client reference in place of each
|
|
3324
|
+
* declared slot prop. At request time the revived shell is wrapped in a
|
|
3325
|
+
* SlotsProvider carrying the live slot values; each outlet reads its
|
|
3326
|
+
* value from this context by name.
|
|
3327
|
+
*
|
|
3328
|
+
* Providers MERGE per-key with their parent: an inner SlotsProvider
|
|
3329
|
+
* inherits the outer's values and overrides only the keys it declares.
|
|
3330
|
+
* Two sibling instances each have their own provider and resolve
|
|
3331
|
+
* independently. Layouts use a reserved slot name (LAYOUT_CHILDREN_SLOT)
|
|
3332
|
+
* that cannot collide with user-declared slot names (TIM-1191).
|
|
3333
|
+
*
|
|
3334
|
+
* Generalizes the original layout children pattern (TIM-1181) from a
|
|
3335
|
+
* single implicit `children` hole on layouts to explicitly declared,
|
|
3336
|
+
* named slot props on any cached component. Layouts use this same
|
|
3337
|
+
* context with the reserved LAYOUT_CHILDREN_SLOT key (TIM-1191).
|
|
3338
|
+
*
|
|
3339
|
+
* SINGLETON GUARANTEE: globalThis + Symbol.for — the RSC client bundler
|
|
3340
|
+
* can duplicate this module across chunks; globalThis guarantees a single
|
|
3341
|
+
* context instance.
|
|
3342
|
+
*
|
|
3343
|
+
* See design/45-cache-lifetimes.md §Slot Components.
|
|
3344
|
+
*/
|
|
3345
|
+
var CTX_KEY = Symbol.for("__timber_slot_ctx");
|
|
3346
|
+
function getOrCreateContext() {
|
|
3347
|
+
const existing = globalThis[CTX_KEY];
|
|
3348
|
+
if (existing !== void 0) return existing;
|
|
3349
|
+
if (typeof React.createContext === "function") {
|
|
3350
|
+
const ctx = React.createContext(null);
|
|
3351
|
+
globalThis[CTX_KEY] = ctx;
|
|
3352
|
+
return ctx;
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
var SlotContext = getOrCreateContext();
|
|
3356
|
+
//#endregion
|
|
3357
|
+
//#region src/client/slot-outlet.tsx
|
|
3358
|
+
/**
|
|
3359
|
+
* SlotOutlet — the hole in a cached component shell (TIM-1173).
|
|
3360
|
+
*
|
|
3361
|
+
* At capture time the prebuilt runtime renders the wrapped component with
|
|
3362
|
+
* <SlotOutlet slot={name} /> in place of each declared slot prop. Being a
|
|
3363
|
+
* client component, it serializes into the flight payload as a stable,
|
|
3364
|
+
* deterministic client reference — the same bytes regardless of what live
|
|
3365
|
+
* content will fill the hole. At request time the revived shell sits under
|
|
3366
|
+
* a SlotsProvider and each outlet reads its live value from SlotContext.
|
|
3367
|
+
*
|
|
3368
|
+
* See design/45-cache-lifetimes.md §Slot Components.
|
|
3369
|
+
*/
|
|
3370
|
+
function SlotOutlet({ slot }) {
|
|
3371
|
+
const values = useContext(SlotContext);
|
|
3372
|
+
return values ? values[slot] ?? null : null;
|
|
3373
|
+
}
|
|
3374
|
+
//#endregion
|
|
3375
|
+
//#region src/client/slot-provider.tsx
|
|
3376
|
+
/**
|
|
3377
|
+
* SlotsProvider — wraps a revived cached shell to supply live slot content
|
|
3378
|
+
* to the SlotOutlet holes inside it (TIM-1173).
|
|
3379
|
+
*
|
|
3380
|
+
* Values MERGE with the nearest parent provider so that nesting a
|
|
3381
|
+
* cache.component SlotsProvider inside a layout SlotsProvider preserves
|
|
3382
|
+
* the layout's `children` slot while adding the component's own slots.
|
|
3383
|
+
* Inner values override outer ones with the same name (spread order).
|
|
3384
|
+
*
|
|
3385
|
+
* Tree structure per cached-component instance:
|
|
3386
|
+
* SlotsProvider(values={children: <Live />})
|
|
3387
|
+
* └── revived shell
|
|
3388
|
+
* └── SlotOutlet(slot="children") → reads context → <Live />
|
|
3389
|
+
*
|
|
3390
|
+
* See design/45-cache-lifetimes.md §Slot Components.
|
|
3391
|
+
*/
|
|
3392
|
+
function SlotsProvider({ values, children }) {
|
|
3393
|
+
const parent = useContext(SlotContext);
|
|
3394
|
+
const merged = parent ? {
|
|
3395
|
+
...parent,
|
|
3396
|
+
...values
|
|
3397
|
+
} : values;
|
|
3398
|
+
return createElement(SlotContext.Provider, { value: merged }, children);
|
|
3399
|
+
}
|
|
3400
|
+
/**
|
|
3401
|
+
* Is this value the framework-injected layout children slot outlet
|
|
3402
|
+
* (`<SlotOutlet slot={LAYOUT_CHILDREN_SLOT} />`)? Layout components
|
|
3403
|
+
* receive this as their `children` prop — a stable client reference
|
|
3404
|
+
* whose cache key is stripped so layout `cache.component` hits work
|
|
3405
|
+
* (design/45 §Layout Propagation).
|
|
3406
|
+
*
|
|
3407
|
+
* Uses a framework-reserved slot name (LAYOUT_CHILDREN_SLOT) that
|
|
3408
|
+
* cannot collide with user-declared slot names, preventing context
|
|
3409
|
+
* shadowing when a cached component with its own SlotsProvider is
|
|
3410
|
+
* nested inside a layout. See TIM-1191.
|
|
3411
|
+
*
|
|
3412
|
+
* Total: the probes run against a user-controlled value, and a Proxy
|
|
3413
|
+
* trap can throw — that must classify as "not the outlet" (the
|
|
3414
|
+
* canonicalizer then rejects the value as unkeyable → live render), not
|
|
3415
|
+
* abort the request (codex P2 on PR #890).
|
|
3416
|
+
*/
|
|
3417
|
+
function isChildrenSlotOutletElement(value) {
|
|
3418
|
+
try {
|
|
3419
|
+
if (value == null || typeof value !== "object") return false;
|
|
3420
|
+
if (!("type" in value)) return false;
|
|
3421
|
+
const el = value;
|
|
3422
|
+
return el.type === SlotOutlet && el.props?.slot === "\0timber:children";
|
|
3423
|
+
} catch {
|
|
3424
|
+
return false;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
/**
|
|
3428
|
+
* Dense array of non-empty strings — no holes, no non-string entries.
|
|
3429
|
+
* Total: the reads run against a user-controlled value at module load,
|
|
3430
|
+
* and a throwing Proxy trap must classify as invalid, not crash startup.
|
|
3431
|
+
*/
|
|
3432
|
+
function isDenseStringArray(value) {
|
|
3433
|
+
try {
|
|
3434
|
+
if (!Array.isArray(value)) return false;
|
|
3435
|
+
for (let i = 0; i < value.length; i++) {
|
|
3436
|
+
if (!Object.hasOwn(value, i)) return false;
|
|
3437
|
+
const entry = value[i];
|
|
3438
|
+
if (typeof entry !== "string" || entry.length === 0) return false;
|
|
3439
|
+
}
|
|
3440
|
+
return true;
|
|
3441
|
+
} catch {
|
|
3442
|
+
return false;
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
/**
|
|
3446
|
+
* Validate the `slots` option. Both the production and dev wrappers gate
|
|
3447
|
+
* on this — slot substitution is active in dev exactly when it would be
|
|
3448
|
+
* active in production, so the tree shape a component sees never
|
|
3449
|
+
* diverges between the two.
|
|
3450
|
+
*/
|
|
3451
|
+
function resolveSlotNames(id, options) {
|
|
3452
|
+
const slots = options.slots;
|
|
3453
|
+
if (slots === void 0) return { kind: "none" };
|
|
3454
|
+
if (!isDenseStringArray(slots)) {
|
|
3455
|
+
console.warn(`[timber] cache.component "${id}": invalid slots declaration — expected an array of non-empty prop-name strings. Slot caching is disabled.`);
|
|
3456
|
+
return { kind: "disabled" };
|
|
3457
|
+
}
|
|
3458
|
+
if (slots.length === 0) return { kind: "none" };
|
|
3459
|
+
if (!(options.ttl !== void 0 || options.tags != null)) {
|
|
3460
|
+
console.warn(`[timber] cache.component "${id}": slots require a runtime lifetime (ttl and/or tags). Slot caching is disabled.`);
|
|
3461
|
+
return { kind: "disabled" };
|
|
3462
|
+
}
|
|
3463
|
+
return {
|
|
3464
|
+
kind: "active",
|
|
3465
|
+
slots
|
|
3466
|
+
};
|
|
3467
|
+
}
|
|
3468
|
+
/**
|
|
3469
|
+
* Split the live props into the cacheable shell render (data props plus a
|
|
3470
|
+
* deterministic SlotOutlet placeholder per declared slot — identical
|
|
3471
|
+
* whether or not the caller passed the slot) and the per-instance live
|
|
3472
|
+
* slot values.
|
|
3473
|
+
*
|
|
3474
|
+
* Null when the props object itself is unkeyable at the top level
|
|
3475
|
+
* (accessors, hidden properties, exotic prototype — see splitSlotProps):
|
|
3476
|
+
* the caller must render live with the original props.
|
|
3477
|
+
*/
|
|
3478
|
+
function prepareSlotRender(props, slots) {
|
|
3479
|
+
const split = splitSlotProps(props ?? {}, slots);
|
|
3480
|
+
if (split === null) return null;
|
|
3481
|
+
const { dataProps, slotValues } = split;
|
|
3482
|
+
const shellProps = { ...dataProps };
|
|
3483
|
+
for (const slot of slots) Object.defineProperty(shellProps, slot, {
|
|
3484
|
+
value: createElement(SlotOutlet, { slot }),
|
|
3485
|
+
enumerable: true,
|
|
3486
|
+
writable: true,
|
|
3487
|
+
configurable: true
|
|
3488
|
+
});
|
|
3489
|
+
return {
|
|
3490
|
+
shellProps,
|
|
3491
|
+
dataProps,
|
|
3492
|
+
slotValues
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
/**
|
|
3496
|
+
* Wrap a rendered shell (revived payload or live element) in a
|
|
3497
|
+
* per-instance SlotsProvider feeding the live values to its outlets.
|
|
3498
|
+
*/
|
|
3499
|
+
function wrapInSlotsProvider(shell, slotValues) {
|
|
3500
|
+
return createElement(SlotsProvider, {
|
|
3501
|
+
values: slotValues,
|
|
3502
|
+
children: shell
|
|
3503
|
+
});
|
|
3504
|
+
}
|
|
3505
|
+
//#endregion
|
|
3506
|
+
//#region src/server/prebuilt/key-discipline.ts
|
|
3507
|
+
var REQUEST_CONTEXT_FIELDS = {
|
|
3508
|
+
headers: "headers()",
|
|
3509
|
+
parsedCookies: "cookies()",
|
|
3510
|
+
searchParams: "searchParams"
|
|
3511
|
+
};
|
|
3512
|
+
/** Rate-limit interval: one diagnostic per component per 60 seconds. */
|
|
3513
|
+
var RATE_LIMIT_MS = 6e4;
|
|
3514
|
+
/** Last emission timestamp per component ID. */
|
|
3515
|
+
var lastEmitted = /* @__PURE__ */ new Map();
|
|
3516
|
+
var strict = false;
|
|
3517
|
+
/**
|
|
3518
|
+
* Whether the proxy should be created for this component on this render.
|
|
3519
|
+
* Returns true when strict mode is on (always check) or enough time has
|
|
3520
|
+
* elapsed since the last diagnostic for this component.
|
|
3521
|
+
*/
|
|
3522
|
+
function shouldCheckKeyDiscipline(id) {
|
|
3523
|
+
if (strict) return true;
|
|
3524
|
+
const last = lastEmitted.get(id);
|
|
3525
|
+
if (last === void 0) return true;
|
|
3526
|
+
return Date.now() - last >= RATE_LIMIT_MS;
|
|
3527
|
+
}
|
|
3528
|
+
/**
|
|
3529
|
+
* Wrap a store with a proxy that records per-request field access.
|
|
3530
|
+
* The proxy forwards all reads/writes transparently — it only observes.
|
|
3531
|
+
*/
|
|
3532
|
+
function createKeyDisciplineProxy(store) {
|
|
3533
|
+
const accessedApis = /* @__PURE__ */ new Set();
|
|
3534
|
+
return {
|
|
3535
|
+
proxy: new Proxy(store, { get(target, prop, receiver) {
|
|
3536
|
+
const api = REQUEST_CONTEXT_FIELDS[prop];
|
|
3537
|
+
if (api) accessedApis.add(api);
|
|
3538
|
+
return Reflect.get(target, prop, receiver);
|
|
3539
|
+
} }),
|
|
3540
|
+
accessedApis
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
/**
|
|
3544
|
+
* Emit the key-discipline diagnostic or throw in strict mode.
|
|
3545
|
+
*
|
|
3546
|
+
* In strict mode: throws an error so the tainted payload is never cached.
|
|
3547
|
+
* In default mode: emits a structured error-level diagnostic through the
|
|
3548
|
+
* logger (same path as render errors), rate-limited per component.
|
|
3549
|
+
*/
|
|
3550
|
+
function emitKeyDisciplineWarning(id, apis) {
|
|
3551
|
+
const apiList = Array.from(apis).join(", ");
|
|
3552
|
+
if (strict) throw new Error(`[timber] cache.component key-discipline violation: "${id}" reads ${apiList} during a cache capture render. The cache key does not include request-derived values — caching this output would serve one user's data to another. Either pass request-derived values as props (included in the cache key) or remove the ${apiList} call. To downgrade this to a warning, set cache.strictKeyDiscipline: false in timber.config.ts.`);
|
|
3553
|
+
const now = Date.now();
|
|
3554
|
+
const last = lastEmitted.get(id);
|
|
3555
|
+
if (last !== void 0 && now - last < RATE_LIMIT_MS) return;
|
|
3556
|
+
lastEmitted.set(id, now);
|
|
3557
|
+
getLogger().error("cache.component cross-user data leak risk", {
|
|
3558
|
+
component: id,
|
|
3559
|
+
accessedApis: Array.from(apis),
|
|
3560
|
+
message: `cache.component "${id}" reads ${apiList} but the cache key is computed from props and segment params only. If the output varies by ${apiList}, different users may see each other's cached content. Either pass request-derived values as props (included in the cache key) or remove the ${apiList} call.`
|
|
3561
|
+
});
|
|
3562
|
+
}
|
|
3563
|
+
//#endregion
|
|
3564
|
+
//#region src/server/prebuilt/capture-state.ts
|
|
3565
|
+
/** Thrown by the wrapper when a capture renders its own component. */
|
|
3566
|
+
var PrebuiltCaptureCycleError = class extends Error {
|
|
3567
|
+
constructor(componentId) {
|
|
3568
|
+
super(`[timber] cache.component "${componentId}" renders itself (directly or through other components) — prerendering it would recurse forever. Remove the cycle or drop { prerender: true } on this component.`);
|
|
3569
|
+
this.name = "PrebuiltCaptureCycleError";
|
|
3570
|
+
}
|
|
3571
|
+
};
|
|
3572
|
+
var active = null;
|
|
3573
|
+
/**
|
|
3574
|
+
* Mark a capture as active. The builder calls this immediately before
|
|
3575
|
+
* rendering an entry and MUST pair it with `endCapture()` in a finally.
|
|
3576
|
+
*/
|
|
3577
|
+
function beginCapture(outerId) {
|
|
3578
|
+
active = {
|
|
3579
|
+
outerId,
|
|
3580
|
+
nested: /* @__PURE__ */ new Map()
|
|
3581
|
+
};
|
|
3582
|
+
}
|
|
3583
|
+
/**
|
|
3584
|
+
* End the active capture and return the wrapped components that rendered
|
|
3585
|
+
* inside it (first options object wins per inner ID).
|
|
3586
|
+
*/
|
|
3587
|
+
function endCapture() {
|
|
3588
|
+
const nested = active?.nested;
|
|
3589
|
+
active = null;
|
|
3590
|
+
if (!nested) return [];
|
|
3591
|
+
return Array.from(nested, ([innerId, options]) => ({
|
|
3592
|
+
innerId,
|
|
3593
|
+
options
|
|
3594
|
+
}));
|
|
3595
|
+
}
|
|
3596
|
+
/** Whether a build-time capture is currently rendering. */
|
|
3597
|
+
function isCaptureActive() {
|
|
3598
|
+
return active !== null;
|
|
3599
|
+
}
|
|
3600
|
+
/**
|
|
3601
|
+
* Called by the `__prebuilt` wrapper on every render. No-op unless a
|
|
3602
|
+
* capture is active. Throws `PrebuiltCaptureCycleError` when the wrapper
|
|
3603
|
+
* belongs to the component currently being captured.
|
|
3604
|
+
*/
|
|
3605
|
+
function noteWrapperRender(innerId, options) {
|
|
3606
|
+
if (active === null) return;
|
|
3607
|
+
if (innerId === active.outerId) throw new PrebuiltCaptureCycleError(innerId);
|
|
3608
|
+
if (!active.nested.has(innerId)) active.nested.set(innerId, options);
|
|
3609
|
+
}
|
|
3610
|
+
//#endregion
|
|
3611
|
+
//#region src/server/prebuilt-runtime.ts
|
|
3612
|
+
/**
|
|
3613
|
+
* Prebuilt component runtime — registration target for the timber-prebuilt
|
|
3614
|
+
* transform (plugins/prebuilt.ts) and the request-time half of
|
|
3615
|
+
* cache.component (TIM-1140).
|
|
3616
|
+
*
|
|
3617
|
+
* `cache.component(fn, opts)` call sites are rewritten at build time to
|
|
3618
|
+
* `__prebuilt(stableId, fn, opts)` (production) or `__prebuiltDev(name, fn)`
|
|
3619
|
+
* (dev). Both are exported via #server-internal — they are framework
|
|
3620
|
+
* plumbing, never called by user code directly.
|
|
3621
|
+
*
|
|
3622
|
+
* Cache hits are served by FLIGHT REVIVAL: the cached `.flight` payload is
|
|
3623
|
+
* decoded back into React elements inside the RSC environment with
|
|
3624
|
+
* `createFromReadableStream` (the flight client running in the flight
|
|
3625
|
+
* server — plugin-rsc wires the server-decode manifest so client
|
|
3626
|
+
* references revive as re-serializable reference proxies). The revived
|
|
3627
|
+
* element is returned from the wrapper and the outer render re-serializes
|
|
3628
|
+
* it like any other subtree. This is the same mechanism as Next.js
|
|
3629
|
+
* `"use cache"` (next/src/server/use-cache/use-cache-wrapper.ts).
|
|
3630
|
+
*
|
|
3631
|
+
* What a hit skips: all user work — data fetching, MDX compilation, the
|
|
3632
|
+
* component function itself never runs. What it re-pays: React
|
|
3633
|
+
* re-serializes the already-resolved tree (cost proportional to payload
|
|
3634
|
+
* size, not to data latency). The alternative — splicing raw payload bytes
|
|
3635
|
+
* into the outer stream — avoids re-serialization but requires byte-level
|
|
3636
|
+
* coupling to the flight wire format (row framing, ID rebasing, marker
|
|
3637
|
+
* detection); it was implemented and reviewed on PR #835 and deliberately
|
|
3638
|
+
* replaced by revival. See design/44-render-at-build-time.md
|
|
3639
|
+
* §Request-Time Injection.
|
|
3640
|
+
*
|
|
3641
|
+
* Misses render the original component live via the wrapper's own closure,
|
|
3642
|
+
* inside the React pass — segment params from ALS, deny()/redirect()
|
|
3643
|
+
* signals, Suspense, and error boundaries behave exactly as an unwrapped
|
|
3644
|
+
* component. `fallbackOnMissingSegmentParams: false` throws instead.
|
|
3645
|
+
*/
|
|
3646
|
+
/** Default timeout for component re-renders (overridden by renderTimeoutMs). */
|
|
3647
|
+
var DEFAULT_COMPONENT_TIMEOUT_MS = 3e4;
|
|
3648
|
+
/** Singleflight for component re-renders (inline and background). */
|
|
3649
|
+
var componentSingleflight = createSingleflight({ timeoutMs: DEFAULT_COMPONENT_TIMEOUT_MS });
|
|
3650
|
+
var componentTimeoutMs = DEFAULT_COMPONENT_TIMEOUT_MS;
|
|
3651
|
+
var warnedNoWaitUntil = false;
|
|
3652
|
+
/**
|
|
3653
|
+
* Server-side component registry, keyed by stable component ID. Populated at
|
|
3654
|
+
* module load time by `__prebuilt` calls; consumed by the post-build capture
|
|
3655
|
+
* pass (TIM-1119). Never serialized — component functions are not
|
|
3656
|
+
* Flight-serializable.
|
|
3657
|
+
*/
|
|
3658
|
+
var registry = /* @__PURE__ */ new Map();
|
|
3659
|
+
/**
|
|
3660
|
+
* Resolve the params the cache key may faithfully hash, or null if this
|
|
3661
|
+
* render is unkeyable.
|
|
3662
|
+
*
|
|
3663
|
+
* A wrapper rendered inside a parallel slot reads params merged with that
|
|
3664
|
+
* slot's overrides (`getSegmentParams`: `{...segmentParams, ...slotParams}`)
|
|
3665
|
+
* — but the cache key hashes only the route-level params, and the wrapper
|
|
3666
|
+
* cannot know which slot it renders under. The route params are usable
|
|
3667
|
+
* only when every slot's merged view canonicalizes identically to them;
|
|
3668
|
+
* otherwise two renders with the same key could see different effective
|
|
3669
|
+
* inputs and a hit could serve the WRONG cached subtree — so the render
|
|
3670
|
+
* goes live: slower, never wrong. (Build-time capture doesn't model slot
|
|
3671
|
+
* params either, so a divergent request could never have a faithful
|
|
3672
|
+
* entry. Codex review on PR #835; revisit with segment-path-scoped keys
|
|
3673
|
+
* if slot-scoped prebuilt is ever needed.)
|
|
3674
|
+
*
|
|
3675
|
+
* Comparison happens through `tryCanonicalize` — the same single
|
|
3676
|
+
* definition of value equality the cache key uses — so anything it
|
|
3677
|
+
* cannot represent faithfully (bigint-coerced codec params, accessors,
|
|
3678
|
+
* aliases, …) is unkeyable here too, and this guard can never throw.
|
|
3679
|
+
*/
|
|
3680
|
+
function resolveKeyParams(store) {
|
|
3681
|
+
const main = store.segmentParams ?? {};
|
|
3682
|
+
const slots = store.slotParams;
|
|
3683
|
+
if (!slots || Object.keys(slots).length === 0) return main;
|
|
3684
|
+
const mainCanonical = tryCanonicalize({ ...main });
|
|
3685
|
+
if (mainCanonical === null) return null;
|
|
3686
|
+
for (const slotParams of Object.values(slots)) {
|
|
3687
|
+
const merged = tryCanonicalize({
|
|
3688
|
+
...main,
|
|
3689
|
+
...slotParams
|
|
3690
|
+
});
|
|
3691
|
+
if (merged === null || merged !== mainCanonical) return null;
|
|
3692
|
+
}
|
|
3693
|
+
return main;
|
|
3694
|
+
}
|
|
3695
|
+
/**
|
|
3696
|
+
* Mirror the production cache-key decision for a slot render, with no
|
|
3697
|
+
* cache IO — used by the dev wrapper so slot substitution happens in dev
|
|
3698
|
+
* exactly when production would serve the cache path rather than the
|
|
3699
|
+
* live fallback (dev/prod parity, codex P2 on PR #890): requires an ALS
|
|
3700
|
+
* store, keyable params (slot-param divergence unkeys), and keyable data
|
|
3701
|
+
* props (after the children slot outlet strip).
|
|
3702
|
+
*/
|
|
3703
|
+
function isSlotRenderKeyable(dataProps) {
|
|
3704
|
+
const store = requestContextAls.getStore();
|
|
3705
|
+
if (!store) return false;
|
|
3706
|
+
const keyParams = resolveKeyParams(store);
|
|
3707
|
+
if (keyParams === null) return false;
|
|
3708
|
+
let keyProps = dataProps;
|
|
3709
|
+
if (isChildrenSlotOutletElement(keyProps.children)) {
|
|
3710
|
+
const { children: _children, ...rest } = keyProps;
|
|
3711
|
+
keyProps = rest;
|
|
3712
|
+
}
|
|
3713
|
+
return computeComponentCacheKey(keyProps, keyParams) !== null;
|
|
3714
|
+
}
|
|
3715
|
+
/** Wrap payload bytes as a single-chunk ReadableStream for the decoder. */
|
|
3716
|
+
function bytesToStream(bytes) {
|
|
3717
|
+
return new ReadableStream({ start(controller) {
|
|
3718
|
+
controller.enqueue(bytes);
|
|
3719
|
+
controller.close();
|
|
3720
|
+
} });
|
|
3721
|
+
}
|
|
3722
|
+
/** Whether the component has a runtime lifetime (ttl or tags). */
|
|
3723
|
+
function hasRuntimeLifetime(options) {
|
|
3724
|
+
return options.ttl !== void 0 || options.tags !== void 0 && options.tags !== null;
|
|
3725
|
+
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Resolve tags from options (may be static array or function of props).
|
|
3728
|
+
* Function-form tags receive DATA props only — slot props are excluded so
|
|
3729
|
+
* the tags fn never sees SlotOutlet placeholders (capture paths pass
|
|
3730
|
+
* placeholder-substituted props) and tags stay deterministic per key.
|
|
3731
|
+
*/
|
|
3732
|
+
function resolveTags(options, props) {
|
|
3733
|
+
if (!options.tags) return [];
|
|
3734
|
+
if (Array.isArray(options.tags)) return options.tags;
|
|
3735
|
+
const slots = options.slots;
|
|
3736
|
+
const fnProps = slots && slots.length > 0 ? splitSlotProps(props, slots)?.dataProps ?? props : props;
|
|
3737
|
+
return options.tags(fnProps);
|
|
3738
|
+
}
|
|
3739
|
+
/**
|
|
3740
|
+
* Serve a slot component from the cache: split live slot values out of the
|
|
3741
|
+
* props, look up / capture the shell (data props + a deterministic
|
|
3742
|
+
* SlotOutlet placeholder per slot), and wrap the revived shell in a
|
|
3743
|
+
* per-instance SlotsProvider that feeds the live values to the outlets.
|
|
3744
|
+
*
|
|
3745
|
+
* Null on any miss — the caller renders live with the original props.
|
|
3746
|
+
*/
|
|
3747
|
+
async function tryReviveSlotShell(id, options, slots, props) {
|
|
3748
|
+
const render = prepareSlotRender(props, slots);
|
|
3749
|
+
if (render === null) return null;
|
|
3750
|
+
const { shellProps, dataProps, slotValues } = render;
|
|
3751
|
+
const revived = await tryRevivePayload(id, options, shellProps, dataProps);
|
|
3752
|
+
if (revived === null) return null;
|
|
3753
|
+
return { element: wrapInSlotsProvider(revived.element, slotValues) };
|
|
3754
|
+
}
|
|
3755
|
+
var rscFns = null;
|
|
3756
|
+
function requireRscFunctions() {
|
|
3757
|
+
if (!rscFns) throw new Error("[timber] RSC functions not wired — setRscFunctions() must be called at boot");
|
|
3758
|
+
return rscFns;
|
|
3759
|
+
}
|
|
3760
|
+
/**
|
|
3761
|
+
* Revive flight payload bytes into a React element.
|
|
3762
|
+
* Returns null on any failure (corrupt payload, missing decoder).
|
|
3763
|
+
*/
|
|
3764
|
+
async function revivePayload(id, payload, outcome) {
|
|
3765
|
+
try {
|
|
3766
|
+
const { createFromReadableStream } = requireRscFunctions();
|
|
3767
|
+
return { element: await createFromReadableStream(bytesToStream(payload)) };
|
|
3768
|
+
} catch (error) {
|
|
3769
|
+
console.error(`[timber] failed to revive prebuilt payload for "${id}" — ${outcome}:`, error);
|
|
3770
|
+
return null;
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
/** Ceiling on component re-render capture (design/13 A2). Uses configured renderTimeoutMs. */
|
|
3774
|
+
function getCaptureTimeoutMs() {
|
|
3775
|
+
return componentTimeoutMs;
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Render a component in isolation and capture its flight payload bytes.
|
|
3779
|
+
* Used for inline re-renders (tombstone/miss) and SWR background re-renders.
|
|
3780
|
+
*
|
|
3781
|
+
* The stream read is timeout-guarded and the reader is cancelled on
|
|
3782
|
+
* expiry (design/13 A2, A3).
|
|
3783
|
+
*/
|
|
3784
|
+
async function captureComponentPayload(id, component, props) {
|
|
3785
|
+
const { renderToReadableStream } = requireRscFunctions();
|
|
3786
|
+
const element = createElement(component, props);
|
|
3787
|
+
let renderErrored = false;
|
|
3788
|
+
const store = requestContextAls.getStore();
|
|
3789
|
+
const discipline = store && shouldCheckKeyDiscipline(id) ? createKeyDisciplineProxy(store) : null;
|
|
3790
|
+
const doCapture = async () => {
|
|
3791
|
+
beginCapture(id);
|
|
3792
|
+
try {
|
|
3793
|
+
const reader = renderToReadableStream(element, { onError() {
|
|
3794
|
+
renderErrored = true;
|
|
3795
|
+
return "";
|
|
3796
|
+
} }).getReader();
|
|
3797
|
+
const chunks = [];
|
|
3798
|
+
let totalLength = 0;
|
|
3799
|
+
const deadline = Date.now() + getCaptureTimeoutMs();
|
|
3800
|
+
try {
|
|
3801
|
+
for (;;) {
|
|
3802
|
+
const remaining = deadline - Date.now();
|
|
3803
|
+
if (remaining <= 0) throw new Error(`[timber] component re-render capture exceeded ${getCaptureTimeoutMs()}ms`);
|
|
3804
|
+
let timer;
|
|
3805
|
+
const { done, value } = await Promise.race([reader.read(), new Promise((_, reject) => {
|
|
3806
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`[timber] component re-render stream read timed out`)), remaining);
|
|
3807
|
+
})]);
|
|
3808
|
+
clearTimeout(timer);
|
|
3809
|
+
if (done) break;
|
|
3810
|
+
chunks.push(value);
|
|
3811
|
+
totalLength += value.byteLength;
|
|
3812
|
+
}
|
|
3813
|
+
} finally {
|
|
3814
|
+
reader.cancel().catch(() => {});
|
|
3815
|
+
}
|
|
3816
|
+
if (renderErrored) throw new Error("[timber] component re-render produced error rows — not caching");
|
|
3817
|
+
const result = new Uint8Array(totalLength);
|
|
3818
|
+
let offset = 0;
|
|
3819
|
+
for (const chunk of chunks) {
|
|
3820
|
+
result.set(chunk, offset);
|
|
3821
|
+
offset += chunk.byteLength;
|
|
3822
|
+
}
|
|
3823
|
+
return result;
|
|
3824
|
+
} finally {
|
|
3825
|
+
endCapture();
|
|
3826
|
+
}
|
|
3827
|
+
};
|
|
3828
|
+
const bytes = discipline ? await requestContextAls.run(discipline.proxy, doCapture) : await doCapture();
|
|
3829
|
+
if (discipline && discipline.accessedApis.size > 0) emitKeyDisciplineWarning(id, discipline.accessedApis);
|
|
3830
|
+
return bytes;
|
|
3831
|
+
}
|
|
3832
|
+
/**
|
|
3833
|
+
* Inline re-render: capture flight bytes, store in overlay, revive for
|
|
3834
|
+
* the current request. Singleflight-coalesced by overlay key.
|
|
3835
|
+
*/
|
|
3836
|
+
async function inlineRerender(id, component, props, cacheKey, options, hasSeed, outcome) {
|
|
3837
|
+
const tags = resolveTags(options, props);
|
|
3838
|
+
const oKey = overlayKey(id, cacheKey);
|
|
3839
|
+
const sfKey = `prebuilt:${id}:${cacheKey}\0${lastInvalidationEpochFor(oKey, tags)}`;
|
|
3840
|
+
try {
|
|
3841
|
+
return await revivePayload(id, await componentSingleflight.do(sfKey, async (signal) => {
|
|
3842
|
+
const startEpoch = currentInvalidationEpoch();
|
|
3843
|
+
const bytes = await captureComponentPayload(id, component, props);
|
|
3844
|
+
if (!signal.aborted && hasRuntimeLifetime(options) && !wasInvalidatedSince(startEpoch, oKey, tags)) await storeOverlayEntry(id, cacheKey, bytes, {
|
|
3845
|
+
ttl: options.ttl,
|
|
3846
|
+
tags,
|
|
3847
|
+
hasSeed
|
|
3848
|
+
});
|
|
3849
|
+
return bytes;
|
|
3850
|
+
}), outcome);
|
|
3851
|
+
} catch (error) {
|
|
3852
|
+
console.error(`[timber] cache.component re-render failed for "${id}" — ${outcome}:`, error);
|
|
3853
|
+
return null;
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
/**
|
|
3857
|
+
* Snapshot the request context values needed for a background re-render.
|
|
3858
|
+
* Creates a FRESH store with copied values — the original ALS store is
|
|
3859
|
+
* not referenced after capture.
|
|
3860
|
+
*/
|
|
3861
|
+
function snapshotRequestContext(store) {
|
|
3862
|
+
return {
|
|
3863
|
+
headers: new Headers(store.headers),
|
|
3864
|
+
originalHeaders: new Headers(store.originalHeaders),
|
|
3865
|
+
cookieHeader: store.cookieHeader,
|
|
3866
|
+
parsedCookies: store.parsedCookies ? new Map(store.parsedCookies) : void 0,
|
|
3867
|
+
searchParams: new URLSearchParams(store.searchParams),
|
|
3868
|
+
searchString: store.searchString,
|
|
3869
|
+
segmentParams: store.segmentParams ? { ...store.segmentParams } : void 0,
|
|
3870
|
+
cookieJar: /* @__PURE__ */ new Map(),
|
|
3871
|
+
flushed: false,
|
|
3872
|
+
mutableContext: false
|
|
3873
|
+
};
|
|
3874
|
+
}
|
|
3875
|
+
/**
|
|
3876
|
+
* Kick off a background re-render via waitUntil. The re-render runs inside
|
|
3877
|
+
* a fresh ALS scope with snapshotted request context.
|
|
3878
|
+
*/
|
|
3879
|
+
function backgroundRerender(id, component, props, cacheKey, options, hasSeed, snapshot) {
|
|
3880
|
+
const waitUntil = getWaitUntil();
|
|
3881
|
+
const tags = resolveTags(options, props);
|
|
3882
|
+
const oKey = overlayKey(id, cacheKey);
|
|
3883
|
+
const startEpoch = currentInvalidationEpoch();
|
|
3884
|
+
const sfKey = `prebuilt-bg:${id}:${cacheKey}\0${lastInvalidationEpochFor(oKey, tags)}`;
|
|
3885
|
+
const work = requestContextAls.run(snapshot, () => componentSingleflight.do(sfKey, async (signal) => {
|
|
3886
|
+
const bytes = await captureComponentPayload(id, component, props);
|
|
3887
|
+
if (!signal.aborted && !wasInvalidatedSince(startEpoch, oKey, tags)) await storeOverlayEntry(id, cacheKey, bytes, {
|
|
3888
|
+
ttl: options.ttl,
|
|
3889
|
+
tags,
|
|
3890
|
+
hasSeed
|
|
3891
|
+
});
|
|
3892
|
+
}).catch((error) => {
|
|
3893
|
+
console.error(`[timber] cache.component background re-render failed for "${id}":`, error);
|
|
3894
|
+
}));
|
|
3895
|
+
if (waitUntil) waitUntil(work);
|
|
3896
|
+
else if (!warnedNoWaitUntil) {
|
|
3897
|
+
warnedNoWaitUntil = true;
|
|
3898
|
+
console.warn("[timber] cache.component SWR background re-render has no waitUntil — the re-render may be killed when the handler returns on serverless platforms. Ensure the adapter wires runWithWaitUntil().");
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
/**
|
|
3902
|
+
* Try to serve this render from the prebuilt cache: overlay → artifact →
|
|
3903
|
+
* live render. Handles ISR (SWR, tombstones, re-renders) per design/45.
|
|
3904
|
+
*
|
|
3905
|
+
* Null on any miss — the caller renders live.
|
|
3906
|
+
*/
|
|
3907
|
+
async function tryRevivePayload(id, options, props, keyPropsOverride) {
|
|
3908
|
+
const hasSource = hasPrebuiltPayloadSource();
|
|
3909
|
+
const hasRuntime = hasRuntimeLifetime(options);
|
|
3910
|
+
if (!hasSource && !hasRuntime) return null;
|
|
3911
|
+
const store = requestContextAls.getStore();
|
|
3912
|
+
if (!store) return null;
|
|
3913
|
+
const keyParams = (options.prerender ? await isParamIndependent(id) : false) ? {} : resolveKeyParams(store);
|
|
3914
|
+
if (keyParams === null) return null;
|
|
3915
|
+
let keyProps = keyPropsOverride ?? props ?? {};
|
|
3916
|
+
if (isChildrenSlotOutletElement(keyProps.children)) {
|
|
3917
|
+
const { children: _children, ...rest } = keyProps;
|
|
3918
|
+
keyProps = rest;
|
|
3919
|
+
}
|
|
3920
|
+
const cacheKey = computeComponentCacheKey(keyProps, keyParams);
|
|
3921
|
+
if (cacheKey === null) return null;
|
|
3922
|
+
const outcome = options.fallbackOnMissingSegmentParams === false ? "failing the render (fallbackOnMissingSegmentParams: false)" : "rendering dynamically";
|
|
3923
|
+
const component = registry.get(id)?.component;
|
|
3924
|
+
if (hasRuntime) {
|
|
3925
|
+
const overlayResult = await lookupOverlay(id, cacheKey);
|
|
3926
|
+
if (overlayResult) {
|
|
3927
|
+
const overlayHasSeed = overlayResult.record?.s ?? false;
|
|
3928
|
+
if (overlayResult.kind === "tombstone") {
|
|
3929
|
+
if (component) return await inlineRerender(id, component, props, cacheKey, options, true, outcome);
|
|
3930
|
+
return null;
|
|
3931
|
+
}
|
|
3932
|
+
if (overlayResult.kind === "fresh" && overlayResult.payload) return await revivePayload(id, overlayResult.payload, outcome);
|
|
3933
|
+
if (overlayResult.kind === "stale" && overlayResult.payload) {
|
|
3934
|
+
if (options.staleWhileRevalidate && component) {
|
|
3935
|
+
backgroundRerender(id, component, props, cacheKey, options, overlayHasSeed, snapshotRequestContext(store));
|
|
3936
|
+
return await revivePayload(id, overlayResult.payload, outcome);
|
|
3937
|
+
}
|
|
3938
|
+
if (component) return await inlineRerender(id, component, props, cacheKey, options, overlayHasSeed, outcome);
|
|
3939
|
+
return null;
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
if (hasSource) {
|
|
3944
|
+
const artifactPayload = await lookupPrebuiltPayload(id, cacheKey);
|
|
3945
|
+
if (artifactPayload !== null) {
|
|
3946
|
+
if (options.ttl !== void 0) {
|
|
3947
|
+
const buildTs = (await getManifestEntry(id, cacheKey))?.buildTimestamp ?? 0;
|
|
3948
|
+
if ((Date.now() - buildTs) / 1e3 > options.ttl) {
|
|
3949
|
+
if (options.staleWhileRevalidate && component) {
|
|
3950
|
+
backgroundRerender(id, component, props, cacheKey, options, true, snapshotRequestContext(store));
|
|
3951
|
+
return await revivePayload(id, artifactPayload, outcome);
|
|
3952
|
+
}
|
|
3953
|
+
if (component) return await inlineRerender(id, component, props, cacheKey, options, true, outcome);
|
|
3954
|
+
return null;
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
return await revivePayload(id, artifactPayload, outcome);
|
|
3958
|
+
}
|
|
3959
|
+
if (options.prerender) console.warn(`[timber] cache.component cache miss: "${id}" with params ${JSON.stringify(store.segmentParams ?? {})} — ${outcome}`);
|
|
3960
|
+
}
|
|
3961
|
+
if (hasRuntime && component && options.fallbackOnMissingSegmentParams !== false) return await inlineRerender(id, component, props, cacheKey, options, false, outcome);
|
|
3962
|
+
return null;
|
|
3963
|
+
}
|
|
3964
|
+
/**
|
|
3965
|
+
* Production wrapper injected by the timber-prebuilt transform. Registers
|
|
3966
|
+
* the component under its stable ID and returns a wrapper component that
|
|
3967
|
+
* either revives the cached payload (hit) or renders the original
|
|
3968
|
+
* component live (miss).
|
|
3969
|
+
*/
|
|
3970
|
+
function __prebuilt(id, component, options = {}) {
|
|
3971
|
+
const resolution = resolveSlotNames(id, options);
|
|
3972
|
+
const slots = resolution.kind === "active" ? resolution.slots : null;
|
|
3973
|
+
if (resolution.kind === "active" && options.prerender) {
|
|
3974
|
+
console.warn(`[timber] cache.component "${id}": slots are runtime-tier only — prerender is ignored (the build cannot enumerate slot content). Use ttl/tags for the cache lifetime.`);
|
|
3975
|
+
options = {
|
|
3976
|
+
...options,
|
|
3977
|
+
prerender: void 0
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
if (resolution.kind === "disabled") options = {
|
|
3981
|
+
...options,
|
|
3982
|
+
slots: void 0
|
|
3983
|
+
};
|
|
3984
|
+
registry.set(id, {
|
|
3985
|
+
id,
|
|
3986
|
+
component,
|
|
3987
|
+
options
|
|
3988
|
+
});
|
|
3989
|
+
const wrapper = async (props) => {
|
|
3990
|
+
noteWrapperRender(id, options);
|
|
3991
|
+
if (isCaptureActive()) return createElement(component, props);
|
|
3992
|
+
const revived = slots ? await tryReviveSlotShell(id, options, slots, props) : await tryRevivePayload(id, options, props);
|
|
3993
|
+
if (revived !== null) return revived.element;
|
|
3994
|
+
if (options.fallbackOnMissingSegmentParams === false) throw new Error(`[timber] cache.component "${id}": no prebuilt payload for the current params and fallbackOnMissingSegmentParams is false`);
|
|
3995
|
+
return createElement(component, props);
|
|
3996
|
+
};
|
|
3997
|
+
Object.defineProperty(wrapper, "name", { value: `Prebuilt(${id})` });
|
|
3998
|
+
return wrapper;
|
|
3999
|
+
}
|
|
4000
|
+
/** Look up a registration by stable component ID. */
|
|
4001
|
+
function getPrebuiltRegistration(id) {
|
|
4002
|
+
return registry.get(id);
|
|
4003
|
+
}
|
|
4004
|
+
/** All registrations, for the post-build capture pass (TIM-1119). */
|
|
4005
|
+
function getPrebuiltRegistry() {
|
|
4006
|
+
return registry;
|
|
4007
|
+
}
|
|
4008
|
+
/**
|
|
4009
|
+
* Dev-mode wrapper injected by the timber-prebuilt transform: a no-op that
|
|
4010
|
+
* renders the component dynamically on every request, logging once per
|
|
4011
|
+
* component on first render so the production behavior difference is visible.
|
|
4012
|
+
* See design/44 §Dev Mode.
|
|
4013
|
+
*/
|
|
4014
|
+
function __prebuiltDev(name, component, options = {}) {
|
|
4015
|
+
const resolution = resolveSlotNames(name, options);
|
|
4016
|
+
const slots = resolution.kind === "active" ? resolution.slots : null;
|
|
4017
|
+
let logged = false;
|
|
4018
|
+
const wrapper = (props) => {
|
|
4019
|
+
if (!logged) {
|
|
4020
|
+
logged = true;
|
|
4021
|
+
console.log(`[timber] cache.component: "${name}" — rendering dynamically (dev mode)`);
|
|
4022
|
+
}
|
|
4023
|
+
if (slots) {
|
|
4024
|
+
const render = prepareSlotRender(props, slots);
|
|
4025
|
+
if (render !== null && isSlotRenderKeyable(render.dataProps)) return wrapInSlotsProvider(createElement(component, render.shellProps), render.slotValues);
|
|
4026
|
+
}
|
|
4027
|
+
return createElement(component, props);
|
|
4028
|
+
};
|
|
4029
|
+
return wrapper;
|
|
4030
|
+
}
|
|
4031
|
+
//#endregion
|
|
4032
|
+
export { AccessGate, DEFAULT_LIMITS, DenySignal, METADATA_ROUTE_CONVENTIONS, RedirectSignal, RenderError, RenderTimeoutError, SlotAccessGate, __prebuilt, __prebuiltDev, callOnRequestError, canonicalize, classifyMetadataRoute, coerce, collectEarlyHintHeaders, computeComponentCacheKey, createPipeline, enforceBodyLimits, executeAction, formatLinkHeader, generateTraceId, getCookie, getHeader, getLogger, getMetadataRouteAutoLink, getMetadataRouteServePath, getPrebuiltRegistration, getPrebuiltRegistry, getSearchParams, getSegmentParams, getSetCookieHeaders, handleRouteRequest, hasOnRequestError, hasPrebuiltPayloadSource, loadInstrumentation, logCacheMiss, logMiddlewareError, logMiddlewareShortCircuit, logProxyError, logRenderError, logRequestCompleted, logRequestReceived, logSlowRequest, logSwrRefetchFailed, logWaitUntilRejected, logWaitUntilUnsupported, markResponseFlushed, parseBodySize, renderMetadataToElements, replaceTraceId, resolveAllowedMethods, resolveMetadata, resolveMetadataUrls, resolveSlotDenied, resolveStatusFile, resolveTitle, runMiddleware, runProxy, runWithEarlyHintsSender, runWithRequestContext, runWithTraceId, sendEarlyHints103, setLogger, setMutableCookieContext, setPrebuiltPayloadSource, setSegmentParams, validateCsrf };
|
|
4033
|
+
|
|
4034
|
+
//# sourceMappingURL=internal.js.map
|