@timber-js/app 0.2.0-alpha.6 → 0.2.0-alpha.61
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/dist/_chunks/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
- package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
- package/dist/_chunks/{debug-gwlJkDuf.js → debug-ECi_61pb.js} +2 -2
- package/dist/_chunks/debug-ECi_61pb.js.map +1 -0
- package/dist/_chunks/define-CT98cU9c.js +121 -0
- package/dist/_chunks/define-CT98cU9c.js.map +1 -0
- package/dist/_chunks/define-TK8C1M3x.js +279 -0
- package/dist/_chunks/define-TK8C1M3x.js.map +1 -0
- package/dist/_chunks/define-cookie-BWr_52kY.js +93 -0
- package/dist/_chunks/define-cookie-BWr_52kY.js.map +1 -0
- package/dist/_chunks/error-boundary-DpZJBCqh.js +211 -0
- package/dist/_chunks/error-boundary-DpZJBCqh.js.map +1 -0
- package/dist/_chunks/{format-DviM89f0.js → format-cX7wzEp2.js} +2 -2
- package/dist/_chunks/{format-DviM89f0.js.map → format-cX7wzEp2.js.map} +1 -1
- package/dist/_chunks/{interception-BOoWmLUA.js → interception-Cey5DCGr.js} +129 -77
- package/dist/_chunks/interception-Cey5DCGr.js.map +1 -0
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/{request-context-DIkVh_jG.js → request-context-rju2rbga.js} +97 -69
- package/dist/_chunks/request-context-rju2rbga.js.map +1 -0
- package/dist/_chunks/segment-context-CyaM1mrD.js +72 -0
- package/dist/_chunks/segment-context-CyaM1mrD.js.map +1 -0
- package/dist/_chunks/stale-reload-BSSym1MJ.js +64 -0
- package/dist/_chunks/stale-reload-BSSym1MJ.js.map +1 -0
- package/dist/_chunks/{tracing-Cwn7697K.js → tracing-VYETCQsg.js} +17 -3
- package/dist/_chunks/{tracing-Cwn7697K.js.map → tracing-VYETCQsg.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-wEXY2JQB.js} +1 -1
- package/dist/_chunks/use-query-states-wEXY2JQB.js.map +1 -0
- package/dist/_chunks/wrappers-BaG1bnM3.js +63 -0
- package/dist/_chunks/wrappers-BaG1bnM3.js.map +1 -0
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts +17 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +56 -13
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/fast-hash.d.ts +22 -0
- package/dist/cache/fast-hash.d.ts.map +1 -0
- package/dist/cache/index.d.ts +5 -2
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +90 -20
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/register-cached-function.d.ts.map +1 -1
- package/dist/cache/singleflight.d.ts +18 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts +10 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -125
- package/dist/client/error-reconstituter.d.ts +54 -0
- package/dist/client/error-reconstituter.d.ts.map +1 -0
- package/dist/client/form.d.ts +2 -2
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +433 -252
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-pending-store.d.ts +78 -0
- package/dist/client/link-pending-store.d.ts.map +1 -0
- package/dist/client/link.d.ts +23 -9
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +2 -2
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/router.d.ts +25 -3
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +36 -2
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +63 -0
- package/dist/client/segment-outlet.d.ts.map +1 -0
- package/dist/client/stale-reload.d.ts +15 -0
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +1 -1
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +3 -3
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +1 -1
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/codec.d.ts +21 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/cookies/define-cookie.d.ts +34 -13
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -83
- package/dist/fonts/css.d.ts +1 -0
- package/dist/fonts/css.d.ts.map +1 -1
- package/dist/index.d.ts +127 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +665 -242
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts +100 -0
- package/dist/params/define.d.ts.map +1 -0
- package/dist/params/index.d.ts +8 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +4 -0
- package/dist/plugins/adapter-build.d.ts +1 -1
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts +2 -2
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/build-report.d.ts +3 -3
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts +32 -0
- package/dist/plugins/client-chunks.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-browser-logs.d.ts +84 -0
- package/dist/plugins/dev-browser-logs.d.ts.map +1 -0
- package/dist/plugins/dev-error-overlay.d.ts +26 -1
- package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
- package/dist/plugins/dev-logs.d.ts +1 -1
- package/dist/plugins/dev-logs.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +9 -2
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts +1 -1
- package/dist/plugins/mdx.d.ts.map +1 -1
- 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 +6 -5
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +2 -2
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/status-file-lint.d.ts +2 -1
- package/dist/routing/status-file-lint.d.ts.map +1 -1
- package/dist/routing/types.d.ts +16 -4
- package/dist/routing/types.d.ts.map +1 -1
- 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 +12 -0
- package/dist/rsc-runtime/ssr.d.ts.map +1 -1
- package/dist/search-params/codecs.d.ts +1 -1
- package/dist/search-params/define.d.ts +159 -0
- package/dist/search-params/define.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +4 -5
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +4 -474
- package/dist/search-params/registry.d.ts +1 -1
- package/dist/search-params/wrappers.d.ts +53 -0
- package/dist/search-params/wrappers.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +4 -0
- package/dist/server/access-gate.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/server/actions.d.ts +1 -1
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +25 -4
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +2 -2
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/debug.d.ts +1 -1
- package/dist/server/default-logger.d.ts +22 -0
- package/dist/server/default-logger.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/early-hints.d.ts +13 -5
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +4 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +4 -3
- 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 +42 -0
- package/dist/server/flight-scripts.d.ts.map +1 -0
- package/dist/server/flush.d.ts.map +1 -1
- package/dist/server/form-data.d.ts +29 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts +51 -11
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1977 -1648
- package/dist/server/index.js.map +1 -1
- package/dist/server/logger.d.ts +25 -7
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +113 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -0
- package/dist/server/pipeline-interception.d.ts +1 -1
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +20 -6
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +30 -3
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/render-timeout.d.ts +51 -0
- package/dist/server/render-timeout.d.ts.map +1 -0
- package/dist/server/request-context.d.ts +65 -38
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-handler.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +9 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/api-handler.d.ts +2 -2
- package/dist/server/rsc-entry/api-handler.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts +26 -13
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +48 -5
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +8 -3
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +3 -3
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +10 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-bridge.d.ts +1 -1
- package/dist/server/rsc-entry/ssr-bridge.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +19 -4
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts +22 -0
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +39 -21
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts +50 -0
- package/dist/server/ssr-wrappers.d.ts.map +1 -0
- package/dist/server/status-code-resolver.d.ts +1 -1
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/stream-utils.d.ts +36 -0
- package/dist/server/stream-utils.d.ts.map +1 -0
- package/dist/server/tracing.d.ts +10 -0
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +20 -13
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +1 -3
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version-skew.d.ts +61 -0
- package/dist/server/version-skew.d.ts.map +1 -0
- package/dist/server/waituntil-bridge.d.ts.map +1 -1
- package/dist/shared/merge-search-params.d.ts +22 -0
- package/dist/shared/merge-search-params.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/navigation-client.d.ts +1 -1
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +1 -1
- 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/package.json +17 -17
- package/src/adapters/compress-module.ts +24 -4
- package/src/adapters/nitro.ts +58 -9
- package/src/cache/fast-hash.ts +34 -0
- package/src/cache/index.ts +5 -2
- package/src/cache/register-cached-function.ts +7 -3
- package/src/cache/singleflight.ts +62 -4
- package/src/cache/timber-cache.ts +40 -29
- package/src/cli.ts +0 -0
- package/src/client/browser-entry.ts +151 -99
- package/src/client/error-boundary.tsx +18 -1
- package/src/client/error-reconstituter.tsx +65 -0
- package/src/client/form.tsx +2 -2
- package/src/client/index.ts +10 -1
- package/src/client/link-pending-store.ts +136 -0
- package/src/client/link.tsx +137 -22
- package/src/client/navigation-context.ts +6 -5
- package/src/client/router.ts +117 -60
- package/src/client/rsc-fetch.ts +90 -2
- package/src/client/segment-cache.ts +1 -1
- package/src/client/segment-context.ts +6 -1
- package/src/client/segment-merger.ts +2 -8
- package/src/client/segment-outlet.tsx +86 -0
- package/src/client/stale-reload.ts +73 -6
- package/src/client/top-loader.tsx +10 -9
- package/src/client/transition-root.tsx +20 -2
- package/src/client/use-params.ts +4 -4
- package/src/client/use-query-states.ts +2 -2
- package/src/codec.ts +21 -0
- package/src/cookies/define-cookie.ts +71 -20
- package/src/fonts/css.ts +2 -1
- package/src/index.ts +297 -85
- package/src/params/define.ts +327 -0
- package/src/params/index.ts +28 -0
- package/src/plugins/adapter-build.ts +8 -2
- package/src/plugins/build-manifest.ts +13 -2
- package/src/plugins/build-report.ts +3 -3
- package/src/plugins/cache-transform.ts +1 -1
- package/src/plugins/client-chunks.ts +65 -0
- package/src/plugins/content.ts +1 -1
- package/src/plugins/dev-browser-logs.ts +284 -0
- package/src/plugins/dev-error-overlay.ts +70 -1
- package/src/plugins/dev-logs.ts +1 -1
- package/src/plugins/dev-server.ts +41 -7
- package/src/plugins/entries.ts +6 -8
- package/src/plugins/fonts.ts +102 -55
- package/src/plugins/mdx.ts +1 -1
- package/src/plugins/routing.ts +57 -17
- package/src/plugins/server-action-exports.ts +1 -1
- package/src/plugins/server-bundle.ts +32 -1
- package/src/plugins/shims.ts +69 -31
- package/src/plugins/static-build.ts +10 -6
- package/src/routing/codegen.ts +109 -88
- package/src/routing/scanner.ts +86 -7
- package/src/routing/status-file-lint.ts +3 -2
- package/src/routing/types.ts +17 -4
- package/src/rsc-runtime/rsc.ts +2 -0
- package/src/rsc-runtime/ssr.ts +50 -0
- package/src/rsc-runtime/vendor-types.d.ts +7 -0
- package/src/search-params/codecs.ts +1 -1
- package/src/search-params/define.ts +518 -0
- package/src/search-params/index.ts +12 -18
- package/src/search-params/registry.ts +1 -1
- package/src/search-params/wrappers.ts +85 -0
- package/src/server/access-gate.tsx +40 -9
- package/src/server/action-client.ts +8 -2
- package/src/server/action-encryption.ts +144 -0
- package/src/server/action-handler.ts +20 -3
- package/src/server/actions.ts +1 -1
- package/src/server/als-registry.ts +25 -4
- package/src/server/build-manifest.ts +10 -4
- package/src/server/compress.ts +25 -7
- package/src/server/debug.ts +1 -1
- package/src/server/default-logger.ts +99 -0
- package/src/server/deny-renderer.ts +5 -3
- package/src/server/early-hints.ts +36 -15
- package/src/server/error-boundary-wrapper.ts +58 -15
- package/src/server/fallback-error.ts +29 -14
- package/src/server/flight-injection-state.ts +113 -0
- package/src/server/flight-scripts.ts +62 -0
- package/src/server/flush.ts +2 -1
- package/src/server/form-data.ts +76 -0
- package/src/server/html-injectors.ts +277 -117
- package/src/server/index.ts +9 -4
- package/src/server/logger.ts +44 -36
- package/src/server/node-stream-transforms.ts +509 -0
- package/src/server/pipeline-interception.ts +1 -1
- package/src/server/pipeline.ts +148 -41
- package/src/server/primitives.ts +47 -5
- package/src/server/render-timeout.ts +108 -0
- package/src/server/request-context.ts +125 -119
- package/src/server/route-element-builder.ts +107 -115
- package/src/server/route-handler.ts +2 -1
- package/src/server/route-matcher.ts +9 -2
- package/src/server/rsc-entry/api-handler.ts +8 -8
- package/src/server/rsc-entry/error-renderer.ts +286 -81
- package/src/server/rsc-entry/helpers.ts +134 -5
- package/src/server/rsc-entry/index.ts +177 -76
- package/src/server/rsc-entry/rsc-payload.ts +91 -18
- package/src/server/rsc-entry/rsc-stream.ts +74 -18
- package/src/server/rsc-entry/ssr-bridge.ts +2 -2
- package/src/server/rsc-entry/ssr-renderer.ts +152 -34
- package/src/server/slot-resolver.ts +231 -220
- package/src/server/ssr-entry.ts +211 -32
- package/src/server/ssr-render.ts +289 -67
- package/src/server/ssr-wrappers.tsx +139 -0
- package/src/server/status-code-resolver.ts +1 -1
- package/src/server/stream-utils.ts +213 -0
- package/src/server/tracing.ts +23 -0
- package/src/server/tree-builder.ts +92 -58
- package/src/server/types.ts +1 -3
- package/src/server/version-skew.ts +104 -0
- package/src/server/waituntil-bridge.ts +4 -1
- package/src/shared/merge-search-params.ts +55 -0
- package/src/shims/font-google.ts +1 -1
- package/src/shims/navigation-client.ts +1 -1
- package/src/shims/navigation.ts +2 -1
- package/src/utils/state-machine.ts +111 -0
- package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
- package/dist/_chunks/debug-gwlJkDuf.js.map +0 -1
- package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
- package/dist/_chunks/request-context-DIkVh_jG.js.map +0 -1
- package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
- package/dist/_chunks/ssr-data-MjmprTmO.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.map +0 -1
- package/dist/client/error-boundary.js.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/cookies/index.js.map +0 -1
- package/dist/plugins/dynamic-transform.d.ts +0 -72
- package/dist/plugins/dynamic-transform.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/create.d.ts +0 -106
- package/dist/search-params/create.d.ts.map +0 -1
- package/dist/search-params/index.js.map +0 -1
- package/dist/server/prerender.d.ts +0 -77
- package/dist/server/prerender.d.ts.map +0 -1
- package/dist/server/response-cache.d.ts +0 -53
- package/dist/server/response-cache.d.ts.map +0 -1
- package/src/client/link-status-provider.tsx +0 -30
- package/src/plugins/dynamic-transform.ts +0 -161
- package/src/search-params/analyze.ts +0 -192
- package/src/search-params/builtin-codecs.ts +0 -228
- package/src/search-params/create.ts +0 -321
- package/src/server/prerender.ts +0 -139
- package/src/server/response-cache.ts +0 -277
package/dist/server/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { n as isDevMode, t as isDebug } from "../_chunks/debug-
|
|
2
|
-
import { a as warnDenyAfterFlush, c as warnRedirectInAccess, d as warnSlowSlotWithoutSuspense, f as warnStaticRequestApi, i as warnCacheRequestProps, l as warnRedirectInSlotAccess, n as WarningId, o as warnDenyInSuspense, p as warnSuspenseWrappingChildren, r as setViteServer, s as warnDynamicApiInStaticBuild, t as formatSize, u as warnRedirectInSuspense } from "../_chunks/format-
|
|
3
|
-
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-
|
|
4
|
-
import { a as timingAls, i as revalidationAls, n as formFlashAls, s as waitUntilAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-
|
|
5
|
-
import { a as
|
|
6
|
-
import {
|
|
1
|
+
import { n as isDevMode, t as isDebug } from "../_chunks/debug-ECi_61pb.js";
|
|
2
|
+
import { a as warnDenyAfterFlush, c as warnRedirectInAccess, d as warnSlowSlotWithoutSuspense, f as warnStaticRequestApi, i as warnCacheRequestProps, l as warnRedirectInSlotAccess, n as WarningId, o as warnDenyInSuspense, p as warnSuspenseWrappingChildren, r as setViteServer, s as warnDynamicApiInStaticBuild, t as formatSize, u as warnRedirectInSuspense } from "../_chunks/format-cX7wzEp2.js";
|
|
3
|
+
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-BU684ls2.js";
|
|
4
|
+
import { a as timingAls, i as revalidationAls, n as formFlashAls, s as waitUntilAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-Ba7URUIn.js";
|
|
5
|
+
import { a as headers, c as rawSegmentParams, d as setMutableCookieContext, f as setSegmentParams, i as getSetCookieHeaders, n as cookies, o as markResponseFlushed, r as getRequestSearchString, s as rawSearchParams, t as applyRequestHeaderOverlay, u as runWithRequestContext } from "../_chunks/request-context-rju2rbga.js";
|
|
6
|
+
import { r as mergePreservedSearchParams } from "../_chunks/segment-context-CyaM1mrD.js";
|
|
7
|
+
import { a as getTraceStore, c as setSpanAttribute, d as withSpan, i as getOtelTraceId, l as spanId, o as replaceTraceId, r as generateTraceId, s as runWithTraceId, t as addSpanEvent, u as traceId } from "../_chunks/tracing-VYETCQsg.js";
|
|
8
|
+
import "../_chunks/error-boundary-DpZJBCqh.js";
|
|
7
9
|
import { readFile } from "node:fs/promises";
|
|
10
|
+
import "react";
|
|
8
11
|
//#region src/server/waituntil-bridge.ts
|
|
9
12
|
/**
|
|
10
13
|
* Per-request waitUntil bridge — ALS bridge for platform adapters.
|
|
@@ -170,12 +173,26 @@ var ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
|
170
173
|
* Use `redirectExternal()` for external redirects with an allow-list.
|
|
171
174
|
*
|
|
172
175
|
* @param path - Relative path (e.g. '/login', 'settings', '/login?returnTo=/dash')
|
|
173
|
-
* @param
|
|
176
|
+
* @param statusOrOptions - HTTP status code (3xx, default 302) or options object.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* // Simple redirect
|
|
180
|
+
* redirect('/login');
|
|
181
|
+
*
|
|
182
|
+
* // With status code
|
|
183
|
+
* redirect('/login', 301);
|
|
184
|
+
*
|
|
185
|
+
* // With preserved search params
|
|
186
|
+
* redirect(`/docs/${version}/${slug}`, { preserveSearchParams: ['foo'] });
|
|
174
187
|
*/
|
|
175
|
-
function redirect(path,
|
|
188
|
+
function redirect(path, statusOrOptions) {
|
|
189
|
+
const status = typeof statusOrOptions === "number" ? statusOrOptions : statusOrOptions?.status ?? 302;
|
|
190
|
+
const preserveSearchParams = typeof statusOrOptions === "object" ? statusOrOptions.preserveSearchParams : void 0;
|
|
176
191
|
if (status < 300 || status > 399) throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
|
|
177
192
|
if (ABSOLUTE_URL_RE.test(path)) throw new Error(`redirect() only accepts relative URLs. Got absolute URL: "${path}". Use redirectExternal(url, allowList) for external redirects.`);
|
|
178
|
-
|
|
193
|
+
let resolvedPath = path;
|
|
194
|
+
if (preserveSearchParams) resolvedPath = mergePreservedSearchParams(path, getRequestSearchString(), preserveSearchParams);
|
|
195
|
+
throw new RedirectSignal(resolvedPath, status);
|
|
179
196
|
}
|
|
180
197
|
/**
|
|
181
198
|
* Permanent redirect to a relative path. Shorthand for `redirect(path, 308)`.
|
|
@@ -184,9 +201,13 @@ function redirect(path, status = 302) {
|
|
|
184
201
|
* will replay POST requests to the new location. This matches Next.js behavior.
|
|
185
202
|
*
|
|
186
203
|
* @param path - Relative path (e.g. '/new-page', '/dashboard')
|
|
204
|
+
* @param options - Optional redirect options (e.g. preserveSearchParams).
|
|
187
205
|
*/
|
|
188
|
-
function permanentRedirect(path) {
|
|
189
|
-
redirect(path,
|
|
206
|
+
function permanentRedirect(path, options) {
|
|
207
|
+
redirect(path, {
|
|
208
|
+
status: 308,
|
|
209
|
+
...options
|
|
210
|
+
});
|
|
190
211
|
}
|
|
191
212
|
/**
|
|
192
213
|
* Redirect to an external URL. The hostname must be in the provided allow-list.
|
|
@@ -547,27 +568,102 @@ function extractUserFrames(stack) {
|
|
|
547
568
|
return userFrames;
|
|
548
569
|
}
|
|
549
570
|
//#endregion
|
|
571
|
+
//#region src/server/default-logger.ts
|
|
572
|
+
/**
|
|
573
|
+
* DefaultLogger — human-readable stderr logging when no custom logger is configured.
|
|
574
|
+
*
|
|
575
|
+
* Ships as the fallback so production deployments always have error visibility,
|
|
576
|
+
* even without an `instrumentation.ts` logger export. Output is one line per
|
|
577
|
+
* event, designed for `fly logs`, `kubectl logs`, Cloudflare dashboard tails, etc.
|
|
578
|
+
*
|
|
579
|
+
* Format:
|
|
580
|
+
* [timber] ERROR message key=value key=value trace_id=4bf92f35
|
|
581
|
+
* [timber] WARN message key=value key=value trace_id=4bf92f35
|
|
582
|
+
* [timber] INFO message method=GET path=/dashboard status=200 durationMs=43 trace_id=4bf92f35
|
|
583
|
+
*
|
|
584
|
+
* Behavior:
|
|
585
|
+
* - Suppressed entirely in dev mode (dev logging handles all output)
|
|
586
|
+
* - `debug` suppressed unless TIMBER_DEBUG is set
|
|
587
|
+
* - Replaced entirely when a custom logger is set via `setLogger()`
|
|
588
|
+
*
|
|
589
|
+
* See design/17-logging.md §"DefaultLogger"
|
|
590
|
+
*/
|
|
591
|
+
/**
|
|
592
|
+
* Format data fields as `key=value` pairs for human-readable output.
|
|
593
|
+
* - `error` key is serialized via formatSsrError for stack trace cleanup
|
|
594
|
+
* - `trace_id` is truncated to 8 chars for readability (full ID in OTEL)
|
|
595
|
+
* - Other values are stringified inline
|
|
596
|
+
*/
|
|
597
|
+
function formatDataFields(data) {
|
|
598
|
+
if (!data) return "";
|
|
599
|
+
const parts = [];
|
|
600
|
+
let traceId;
|
|
601
|
+
for (const [key, value] of Object.entries(data)) {
|
|
602
|
+
if (key === "trace_id") {
|
|
603
|
+
traceId = typeof value === "string" ? value : String(value);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (key === "error") {
|
|
607
|
+
parts.push(`error=${formatSsrError(value)}`);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (value === void 0 || value === null) continue;
|
|
611
|
+
parts.push(`${key}=${value}`);
|
|
612
|
+
}
|
|
613
|
+
if (traceId) parts.push(`trace_id=${traceId.slice(0, 8)}`);
|
|
614
|
+
return parts.length > 0 ? " " + parts.join(" ") : "";
|
|
615
|
+
}
|
|
616
|
+
/** Pad level string to fixed width for alignment. */
|
|
617
|
+
function padLevel(level) {
|
|
618
|
+
return level.padEnd(5);
|
|
619
|
+
}
|
|
620
|
+
function createDefaultLogger() {
|
|
621
|
+
return {
|
|
622
|
+
error(msg, data) {
|
|
623
|
+
const fields = formatDataFields(data);
|
|
624
|
+
process.stderr.write(`[timber] ${padLevel("ERROR")} ${msg}${fields}\n`);
|
|
625
|
+
},
|
|
626
|
+
warn(msg, data) {
|
|
627
|
+
const fields = formatDataFields(data);
|
|
628
|
+
process.stderr.write(`[timber] ${padLevel("WARN")} ${msg}${fields}\n`);
|
|
629
|
+
},
|
|
630
|
+
info(msg, data) {
|
|
631
|
+
if (isDevMode()) return;
|
|
632
|
+
if (!isDebug()) return;
|
|
633
|
+
const fields = formatDataFields(data);
|
|
634
|
+
process.stderr.write(`[timber] ${padLevel("INFO")} ${msg}${fields}\n`);
|
|
635
|
+
},
|
|
636
|
+
debug(msg, data) {
|
|
637
|
+
if (isDevMode()) return;
|
|
638
|
+
if (!isDebug()) return;
|
|
639
|
+
const fields = formatDataFields(data);
|
|
640
|
+
process.stderr.write(`[timber] ${padLevel("DEBUG")} ${msg}${fields}\n`);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
550
645
|
//#region src/server/logger.ts
|
|
551
646
|
/**
|
|
552
647
|
* Logger — structured logging with environment-aware formatting.
|
|
553
648
|
*
|
|
554
|
-
* timber.js
|
|
555
|
-
*
|
|
556
|
-
*
|
|
649
|
+
* timber.js ships a DefaultLogger that writes human-readable lines to stderr
|
|
650
|
+
* in production. Users can export a custom logger from instrumentation.ts to
|
|
651
|
+
* replace it with pino, winston, or any TimberLogger-compatible object.
|
|
557
652
|
*
|
|
558
653
|
* See design/17-logging.md §"Production Logging"
|
|
559
654
|
*/
|
|
560
|
-
var _logger =
|
|
655
|
+
var _logger = createDefaultLogger();
|
|
561
656
|
/**
|
|
562
657
|
* Set the user-provided logger. Called by the instrumentation loader
|
|
563
|
-
* when it finds a `logger` export in instrumentation.ts.
|
|
658
|
+
* when it finds a `logger` export in instrumentation.ts. Replaces
|
|
659
|
+
* the DefaultLogger entirely.
|
|
564
660
|
*/
|
|
565
661
|
function setLogger(logger) {
|
|
566
662
|
_logger = logger;
|
|
567
663
|
}
|
|
568
664
|
/**
|
|
569
|
-
* Get the current logger
|
|
570
|
-
*
|
|
665
|
+
* Get the current logger. Always non-null — returns DefaultLogger when
|
|
666
|
+
* no custom logger is configured.
|
|
571
667
|
*/
|
|
572
668
|
function getLogger() {
|
|
573
669
|
return _logger;
|
|
@@ -587,50 +683,51 @@ function withTraceContext(data) {
|
|
|
587
683
|
}
|
|
588
684
|
/** Log a completed request. Level: info. */
|
|
589
685
|
function logRequestCompleted(data) {
|
|
590
|
-
_logger
|
|
686
|
+
_logger.info("request completed", withTraceContext(data));
|
|
591
687
|
}
|
|
592
688
|
/** Log request received. Level: debug. */
|
|
593
689
|
function logRequestReceived(data) {
|
|
594
|
-
_logger
|
|
690
|
+
_logger.debug("request received", withTraceContext(data));
|
|
595
691
|
}
|
|
596
692
|
/** Log a slow request warning. Level: warn. */
|
|
597
693
|
function logSlowRequest(data) {
|
|
598
|
-
_logger
|
|
694
|
+
_logger.warn("slow request exceeded threshold", withTraceContext(data));
|
|
599
695
|
}
|
|
600
696
|
/** Log middleware short-circuit. Level: debug. */
|
|
601
697
|
function logMiddlewareShortCircuit(data) {
|
|
602
|
-
_logger
|
|
698
|
+
_logger.debug("middleware short-circuited", withTraceContext(data));
|
|
603
699
|
}
|
|
604
700
|
/** Log unhandled error in middleware phase. Level: error. */
|
|
605
701
|
function logMiddlewareError(data) {
|
|
606
|
-
|
|
607
|
-
else if (isDebug()) console.error("[timber] middleware error", data.error);
|
|
702
|
+
_logger.error("unhandled error in middleware phase", withTraceContext(data));
|
|
608
703
|
}
|
|
609
704
|
/** Log unhandled render-phase error. Level: error. */
|
|
610
705
|
function logRenderError(data) {
|
|
611
|
-
|
|
612
|
-
else if (isDebug()) console.error("[timber] render error:", formatSsrError(data.error));
|
|
706
|
+
_logger.error("unhandled render-phase error", withTraceContext(data));
|
|
613
707
|
}
|
|
614
708
|
/** Log proxy.ts uncaught error. Level: error. */
|
|
615
709
|
function logProxyError(data) {
|
|
616
|
-
|
|
617
|
-
|
|
710
|
+
_logger.error("proxy.ts threw uncaught error", withTraceContext(data));
|
|
711
|
+
}
|
|
712
|
+
/** Log unhandled error in route handler. Level: error. */
|
|
713
|
+
function logRouteError(data) {
|
|
714
|
+
_logger.error("unhandled route handler error", withTraceContext(data));
|
|
618
715
|
}
|
|
619
716
|
/** Log waitUntil() adapter missing (once at startup). Level: warn. */
|
|
620
717
|
function logWaitUntilUnsupported() {
|
|
621
|
-
_logger
|
|
718
|
+
_logger.warn("adapter does not support waitUntil()");
|
|
622
719
|
}
|
|
623
720
|
/** Log waitUntil() promise rejection. Level: warn. */
|
|
624
721
|
function logWaitUntilRejected(data) {
|
|
625
|
-
_logger
|
|
722
|
+
_logger.warn("waitUntil() promise rejected", withTraceContext(data));
|
|
626
723
|
}
|
|
627
724
|
/** Log staleWhileRevalidate refetch failure. Level: warn. */
|
|
628
725
|
function logSwrRefetchFailed(data) {
|
|
629
|
-
_logger
|
|
726
|
+
_logger.warn("staleWhileRevalidate refetch failed", withTraceContext(data));
|
|
630
727
|
}
|
|
631
728
|
/** Log cache miss. Level: debug. */
|
|
632
729
|
function logCacheMiss(data) {
|
|
633
|
-
_logger
|
|
730
|
+
_logger.debug("timber.cache MISS", withTraceContext(data));
|
|
634
731
|
}
|
|
635
732
|
//#endregion
|
|
636
733
|
//#region src/server/instrumentation.ts
|
|
@@ -695,733 +792,689 @@ function hasOnRequestError() {
|
|
|
695
792
|
return _onRequestError !== null;
|
|
696
793
|
}
|
|
697
794
|
//#endregion
|
|
698
|
-
//#region src/server/
|
|
699
|
-
/**
|
|
700
|
-
* Metadata route helpers for the request pipeline.
|
|
701
|
-
*
|
|
702
|
-
* Handles serving static metadata files and serializing sitemap responses.
|
|
703
|
-
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
704
|
-
*
|
|
705
|
-
* See design/16-metadata.md §"Metadata Routes"
|
|
706
|
-
*/
|
|
707
|
-
/**
|
|
708
|
-
* Content types that are text-based and should include charset=utf-8.
|
|
709
|
-
* Binary formats (images) should not include charset.
|
|
710
|
-
*/
|
|
711
|
-
var TEXT_CONTENT_TYPES = new Set([
|
|
712
|
-
"application/xml",
|
|
713
|
-
"text/plain",
|
|
714
|
-
"application/json",
|
|
715
|
-
"application/manifest+json",
|
|
716
|
-
"image/svg+xml"
|
|
717
|
-
]);
|
|
795
|
+
//#region src/server/metadata-social.ts
|
|
718
796
|
/**
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
* Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
|
|
722
|
-
* are served as-is with the appropriate Content-Type header.
|
|
723
|
-
* Text files include charset=utf-8; binary files do not.
|
|
797
|
+
* Render Open Graph metadata into head element descriptors.
|
|
724
798
|
*
|
|
725
|
-
*
|
|
799
|
+
* Handles og:title, og:description, og:image (with dimensions/alt),
|
|
800
|
+
* og:video, og:audio, og:article:author, and other OG properties.
|
|
726
801
|
*/
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
"
|
|
733
|
-
"
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
802
|
+
function renderOpenGraph(og, elements) {
|
|
803
|
+
const simpleProps = [
|
|
804
|
+
["og:title", og.title],
|
|
805
|
+
["og:description", og.description],
|
|
806
|
+
["og:url", og.url],
|
|
807
|
+
["og:site_name", og.siteName],
|
|
808
|
+
["og:locale", og.locale],
|
|
809
|
+
["og:type", og.type],
|
|
810
|
+
["og:article:published_time", og.publishedTime],
|
|
811
|
+
["og:article:modified_time", og.modifiedTime]
|
|
812
|
+
];
|
|
813
|
+
for (const [property, content] of simpleProps) if (content) elements.push({
|
|
814
|
+
tag: "meta",
|
|
815
|
+
attrs: {
|
|
816
|
+
property,
|
|
817
|
+
content
|
|
818
|
+
}
|
|
738
819
|
});
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
820
|
+
if (og.images) if (typeof og.images === "string") elements.push({
|
|
821
|
+
tag: "meta",
|
|
822
|
+
attrs: {
|
|
823
|
+
property: "og:image",
|
|
824
|
+
content: og.images
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
else {
|
|
828
|
+
const imgList = Array.isArray(og.images) ? og.images : [og.images];
|
|
829
|
+
for (const img of imgList) {
|
|
830
|
+
elements.push({
|
|
831
|
+
tag: "meta",
|
|
832
|
+
attrs: {
|
|
833
|
+
property: "og:image",
|
|
834
|
+
content: img.url
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
if (img.width) elements.push({
|
|
838
|
+
tag: "meta",
|
|
839
|
+
attrs: {
|
|
840
|
+
property: "og:image:width",
|
|
841
|
+
content: String(img.width)
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
if (img.height) elements.push({
|
|
845
|
+
tag: "meta",
|
|
846
|
+
attrs: {
|
|
847
|
+
property: "og:image:height",
|
|
848
|
+
content: String(img.height)
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
if (img.alt) elements.push({
|
|
852
|
+
tag: "meta",
|
|
853
|
+
attrs: {
|
|
854
|
+
property: "og:image:alt",
|
|
855
|
+
content: img.alt
|
|
856
|
+
}
|
|
857
|
+
});
|
|
750
858
|
}
|
|
751
|
-
if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
|
|
752
|
-
if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
|
|
753
|
-
xml += "\n </url>";
|
|
754
|
-
return xml;
|
|
755
|
-
}).join("\n")}\n</urlset>`;
|
|
756
|
-
}
|
|
757
|
-
/** Escape special XML characters. */
|
|
758
|
-
function escapeXml(str) {
|
|
759
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
760
|
-
}
|
|
761
|
-
//#endregion
|
|
762
|
-
//#region src/server/pipeline-interception.ts
|
|
763
|
-
/**
|
|
764
|
-
* Check if an intercepting route applies for this soft navigation.
|
|
765
|
-
*
|
|
766
|
-
* Matches the target pathname against interception rewrites, constrained
|
|
767
|
-
* by the source URL (X-Timber-URL header — where the user navigates FROM).
|
|
768
|
-
*
|
|
769
|
-
* Returns the source pathname to re-match if interception applies, or null.
|
|
770
|
-
*/
|
|
771
|
-
function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
|
|
772
|
-
for (const rewrite of rewrites) {
|
|
773
|
-
if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
|
|
774
|
-
if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
|
|
775
859
|
}
|
|
776
|
-
|
|
860
|
+
if (og.videos) for (const video of og.videos) elements.push({
|
|
861
|
+
tag: "meta",
|
|
862
|
+
attrs: {
|
|
863
|
+
property: "og:video",
|
|
864
|
+
content: video.url
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
if (og.audio) for (const audio of og.audio) elements.push({
|
|
868
|
+
tag: "meta",
|
|
869
|
+
attrs: {
|
|
870
|
+
property: "og:audio",
|
|
871
|
+
content: audio.url
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
if (og.authors) for (const author of og.authors) elements.push({
|
|
875
|
+
tag: "meta",
|
|
876
|
+
attrs: {
|
|
877
|
+
property: "og:article:author",
|
|
878
|
+
content: author
|
|
879
|
+
}
|
|
880
|
+
});
|
|
777
881
|
}
|
|
778
882
|
/**
|
|
779
|
-
*
|
|
883
|
+
* Render Twitter Card metadata into head element descriptors.
|
|
780
884
|
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
885
|
+
* Handles twitter:card, twitter:site, twitter:title, twitter:image,
|
|
886
|
+
* twitter:player, and twitter:app (per-platform name/id/url).
|
|
783
887
|
*/
|
|
784
|
-
function
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
888
|
+
function renderTwitter(tw, elements) {
|
|
889
|
+
const simpleProps = [
|
|
890
|
+
["twitter:card", tw.card],
|
|
891
|
+
["twitter:site", tw.site],
|
|
892
|
+
["twitter:site:id", tw.siteId],
|
|
893
|
+
["twitter:title", tw.title],
|
|
894
|
+
["twitter:description", tw.description],
|
|
895
|
+
["twitter:creator", tw.creator],
|
|
896
|
+
["twitter:creator:id", tw.creatorId]
|
|
897
|
+
];
|
|
898
|
+
for (const [name, content] of simpleProps) if (content) elements.push({
|
|
899
|
+
tag: "meta",
|
|
900
|
+
attrs: {
|
|
901
|
+
name,
|
|
902
|
+
content
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
if (tw.images) if (typeof tw.images === "string") elements.push({
|
|
906
|
+
tag: "meta",
|
|
907
|
+
attrs: {
|
|
908
|
+
name: "twitter:image",
|
|
909
|
+
content: tw.images
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
else {
|
|
913
|
+
const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
|
|
914
|
+
for (const img of imgList) {
|
|
915
|
+
const url = typeof img === "string" ? img : img.url;
|
|
916
|
+
elements.push({
|
|
917
|
+
tag: "meta",
|
|
918
|
+
attrs: {
|
|
919
|
+
name: "twitter:image",
|
|
920
|
+
content: url
|
|
921
|
+
}
|
|
922
|
+
});
|
|
795
923
|
}
|
|
796
|
-
if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
|
|
797
|
-
pi++;
|
|
798
924
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
* Pipeline stages (in order):
|
|
807
|
-
* proxy.ts → canonicalize → route match → 103 Early Hints → middleware.ts → render
|
|
808
|
-
*
|
|
809
|
-
* Each stage is a pure function or returns a Response to short-circuit.
|
|
810
|
-
* Each request gets a trace ID, structured logging, and OTEL spans.
|
|
811
|
-
*
|
|
812
|
-
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
|
|
813
|
-
* and design/17-logging.md §"Production Logging"
|
|
814
|
-
*/
|
|
815
|
-
/**
|
|
816
|
-
* Create the request handler from a pipeline configuration.
|
|
817
|
-
*
|
|
818
|
-
* Returns a function that processes an incoming Request through all pipeline stages
|
|
819
|
-
* and produces a Response. This is the top-level entry point for the server.
|
|
820
|
-
*/
|
|
821
|
-
function createPipeline(config) {
|
|
822
|
-
const { proxy, matchRoute, render, earlyHints, stripTrailingSlash = true, slowRequestMs = 3e3, enableServerTiming = false, onPipelineError } = config;
|
|
823
|
-
let activeRequests = 0;
|
|
824
|
-
return async (req) => {
|
|
825
|
-
const url = new URL(req.url);
|
|
826
|
-
const method = req.method;
|
|
827
|
-
const path = url.pathname;
|
|
828
|
-
const startTime = performance.now();
|
|
829
|
-
activeRequests++;
|
|
830
|
-
return runWithTraceId(generateTraceId(), async () => {
|
|
831
|
-
return runWithRequestContext(req, async () => {
|
|
832
|
-
const runRequest = async () => {
|
|
833
|
-
logRequestReceived({
|
|
834
|
-
method,
|
|
835
|
-
path
|
|
836
|
-
});
|
|
837
|
-
const response = await withSpan("http.server.request", {
|
|
838
|
-
"http.request.method": method,
|
|
839
|
-
"url.path": path
|
|
840
|
-
}, async () => {
|
|
841
|
-
const otelIds = await getOtelTraceId();
|
|
842
|
-
if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
843
|
-
let result;
|
|
844
|
-
if (proxy || config.proxyLoader) result = await runProxyPhase(req, method, path);
|
|
845
|
-
else result = await handleRequest(req, method, path);
|
|
846
|
-
await setSpanAttribute("http.response.status_code", result.status);
|
|
847
|
-
if (enableServerTiming) {
|
|
848
|
-
const serverTiming = getServerTimingHeader();
|
|
849
|
-
if (serverTiming) {
|
|
850
|
-
result = ensureMutableResponse(result);
|
|
851
|
-
result.headers.set("Server-Timing", serverTiming);
|
|
852
|
-
}
|
|
853
|
-
} else {
|
|
854
|
-
const totalMs = Math.round(performance.now() - startTime);
|
|
855
|
-
result = ensureMutableResponse(result);
|
|
856
|
-
result.headers.set("Server-Timing", `total;dur=${totalMs}`);
|
|
857
|
-
}
|
|
858
|
-
return result;
|
|
859
|
-
});
|
|
860
|
-
const durationMs = Math.round(performance.now() - startTime);
|
|
861
|
-
const status = response.status;
|
|
862
|
-
const concurrency = activeRequests;
|
|
863
|
-
activeRequests--;
|
|
864
|
-
logRequestCompleted({
|
|
865
|
-
method,
|
|
866
|
-
path,
|
|
867
|
-
status,
|
|
868
|
-
durationMs,
|
|
869
|
-
concurrency
|
|
870
|
-
});
|
|
871
|
-
if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
|
|
872
|
-
method,
|
|
873
|
-
path,
|
|
874
|
-
durationMs,
|
|
875
|
-
threshold: slowRequestMs,
|
|
876
|
-
concurrency
|
|
877
|
-
});
|
|
878
|
-
return response;
|
|
879
|
-
};
|
|
880
|
-
return enableServerTiming ? runWithTimingCollector(runRequest) : runRequest();
|
|
881
|
-
});
|
|
925
|
+
if (tw.players) for (const player of tw.players) {
|
|
926
|
+
elements.push({
|
|
927
|
+
tag: "meta",
|
|
928
|
+
attrs: {
|
|
929
|
+
name: "twitter:player",
|
|
930
|
+
content: player.playerUrl
|
|
931
|
+
}
|
|
882
932
|
});
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
else proxyExport = config.proxy;
|
|
889
|
-
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
890
|
-
return await withSpan("timber.proxy", {}, () => enableServerTiming ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn());
|
|
891
|
-
} catch (error) {
|
|
892
|
-
logProxyError({ error });
|
|
893
|
-
await fireOnRequestError(error, req, "proxy");
|
|
894
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "proxy");
|
|
895
|
-
return new Response(null, { status: 500 });
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
async function handleRequest(req, method, path) {
|
|
899
|
-
const result = canonicalize(new URL(req.url).pathname, stripTrailingSlash);
|
|
900
|
-
if (!result.ok) return new Response(null, { status: result.status });
|
|
901
|
-
const canonicalPathname = result.pathname;
|
|
902
|
-
if (config.matchMetadataRoute) {
|
|
903
|
-
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
904
|
-
if (metaMatch) try {
|
|
905
|
-
if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
|
|
906
|
-
const mod = await metaMatch.file.load();
|
|
907
|
-
if (typeof mod.default !== "function") return new Response("Metadata route must export a default function", { status: 500 });
|
|
908
|
-
const handlerResult = await mod.default();
|
|
909
|
-
if (handlerResult instanceof Response) return handlerResult;
|
|
910
|
-
const contentType = metaMatch.contentType;
|
|
911
|
-
let body;
|
|
912
|
-
if (typeof handlerResult === "string") body = handlerResult;
|
|
913
|
-
else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
|
|
914
|
-
else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
|
|
915
|
-
else body = typeof handlerResult === "string" ? handlerResult : String(handlerResult);
|
|
916
|
-
return new Response(body, {
|
|
917
|
-
status: 200,
|
|
918
|
-
headers: { "Content-Type": `${contentType}; charset=utf-8` }
|
|
919
|
-
});
|
|
920
|
-
} catch (error) {
|
|
921
|
-
logRenderError({
|
|
922
|
-
method,
|
|
923
|
-
path,
|
|
924
|
-
error
|
|
925
|
-
});
|
|
926
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "metadata-route");
|
|
927
|
-
return new Response(null, { status: 500 });
|
|
933
|
+
if (player.width) elements.push({
|
|
934
|
+
tag: "meta",
|
|
935
|
+
attrs: {
|
|
936
|
+
name: "twitter:player:width",
|
|
937
|
+
content: String(player.width)
|
|
928
938
|
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
if (intercepted) {
|
|
936
|
-
const sourceMatch = matchRoute(intercepted.sourcePathname);
|
|
937
|
-
if (sourceMatch) {
|
|
938
|
-
match = sourceMatch;
|
|
939
|
-
interception = { targetPathname: canonicalPathname };
|
|
940
|
-
}
|
|
939
|
+
});
|
|
940
|
+
if (player.height) elements.push({
|
|
941
|
+
tag: "meta",
|
|
942
|
+
attrs: {
|
|
943
|
+
name: "twitter:player:height",
|
|
944
|
+
content: String(player.height)
|
|
941
945
|
}
|
|
942
|
-
}
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
946
|
+
});
|
|
947
|
+
if (player.streamUrl) elements.push({
|
|
948
|
+
tag: "meta",
|
|
949
|
+
attrs: {
|
|
950
|
+
name: "twitter:player:stream",
|
|
951
|
+
content: player.streamUrl
|
|
947
952
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
if (
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
searchParams: new URL(req.url).searchParams,
|
|
963
|
-
earlyHints: (hints) => {
|
|
964
|
-
for (const hint of hints) {
|
|
965
|
-
let value = `<${hint.href}>; rel=${hint.rel}`;
|
|
966
|
-
if (hint.as !== void 0) value += `; as=${hint.as}`;
|
|
967
|
-
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
968
|
-
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
969
|
-
responseHeaders.append("Link", value);
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
};
|
|
973
|
-
try {
|
|
974
|
-
setMutableCookieContext(true);
|
|
975
|
-
const middlewareFn = () => runMiddleware(match.middleware, ctx);
|
|
976
|
-
const middlewareResponse = await withSpan("timber.middleware", {}, () => enableServerTiming ? withTiming("mw", "middleware.ts", middlewareFn) : middlewareFn());
|
|
977
|
-
setMutableCookieContext(false);
|
|
978
|
-
if (middlewareResponse) {
|
|
979
|
-
const finalResponse = ensureMutableResponse(middlewareResponse);
|
|
980
|
-
applyCookieJar(finalResponse.headers);
|
|
981
|
-
logMiddlewareShortCircuit({
|
|
982
|
-
method,
|
|
983
|
-
path,
|
|
984
|
-
status: finalResponse.status
|
|
985
|
-
});
|
|
986
|
-
return finalResponse;
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
if (tw.app) {
|
|
956
|
+
const platforms = [
|
|
957
|
+
["iPhone", "iphone"],
|
|
958
|
+
["iPad", "ipad"],
|
|
959
|
+
["googlePlay", "googleplay"]
|
|
960
|
+
];
|
|
961
|
+
if (tw.app.name) {
|
|
962
|
+
for (const [key, tag] of platforms) if (tw.app.id?.[key]) elements.push({
|
|
963
|
+
tag: "meta",
|
|
964
|
+
attrs: {
|
|
965
|
+
name: `twitter:app:name:${tag}`,
|
|
966
|
+
content: tw.app.name
|
|
987
967
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
headers: responseHeaders
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
responseHeaders.set("Location", error.location);
|
|
1001
|
-
return new Response(null, {
|
|
1002
|
-
status: error.status,
|
|
1003
|
-
headers: responseHeaders
|
|
1004
|
-
});
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
for (const [key, tag] of platforms) {
|
|
971
|
+
const id = tw.app.id?.[key];
|
|
972
|
+
if (id) elements.push({
|
|
973
|
+
tag: "meta",
|
|
974
|
+
attrs: {
|
|
975
|
+
name: `twitter:app:id:${tag}`,
|
|
976
|
+
content: id
|
|
1005
977
|
}
|
|
1006
|
-
|
|
1007
|
-
logMiddlewareError({
|
|
1008
|
-
method,
|
|
1009
|
-
path,
|
|
1010
|
-
error
|
|
1011
|
-
});
|
|
1012
|
-
await fireOnRequestError(error, req, "handler");
|
|
1013
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "middleware");
|
|
1014
|
-
return new Response(null, { status: 500 });
|
|
1015
|
-
}
|
|
978
|
+
});
|
|
1016
979
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
if (error instanceof RedirectSignal) {
|
|
1026
|
-
responseHeaders.set("Location", error.location);
|
|
1027
|
-
return new Response(null, {
|
|
1028
|
-
status: error.status,
|
|
1029
|
-
headers: responseHeaders
|
|
1030
|
-
});
|
|
1031
|
-
}
|
|
1032
|
-
logRenderError({
|
|
1033
|
-
method,
|
|
1034
|
-
path,
|
|
1035
|
-
error
|
|
980
|
+
for (const [key, tag] of platforms) {
|
|
981
|
+
const url = tw.app.url?.[key];
|
|
982
|
+
if (url) elements.push({
|
|
983
|
+
tag: "meta",
|
|
984
|
+
attrs: {
|
|
985
|
+
name: `twitter:app:url:${tag}`,
|
|
986
|
+
content: url
|
|
987
|
+
}
|
|
1036
988
|
});
|
|
1037
|
-
await fireOnRequestError(error, req, "render");
|
|
1038
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "render");
|
|
1039
|
-
if (config.renderFallbackError) try {
|
|
1040
|
-
return await config.renderFallbackError(error, req, responseHeaders);
|
|
1041
|
-
} catch {}
|
|
1042
|
-
return new Response(null, { status: 500 });
|
|
1043
989
|
}
|
|
1044
990
|
}
|
|
1045
991
|
}
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/server/metadata-platform.ts
|
|
1046
994
|
/**
|
|
1047
|
-
*
|
|
1048
|
-
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
995
|
+
* Render icon link elements (favicon, shortcut, apple-touch-icon, custom).
|
|
1049
996
|
*/
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
997
|
+
function renderIcons(icons, elements) {
|
|
998
|
+
if (icons.icon) {
|
|
999
|
+
if (typeof icons.icon === "string") elements.push({
|
|
1000
|
+
tag: "link",
|
|
1001
|
+
attrs: {
|
|
1002
|
+
rel: "icon",
|
|
1003
|
+
href: icons.icon
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
else if (Array.isArray(icons.icon)) for (const icon of icons.icon) {
|
|
1007
|
+
const attrs = {
|
|
1008
|
+
rel: "icon",
|
|
1009
|
+
href: icon.url
|
|
1010
|
+
};
|
|
1011
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
1012
|
+
if (icon.type) attrs.type = icon.type;
|
|
1013
|
+
elements.push({
|
|
1014
|
+
tag: "link",
|
|
1015
|
+
attrs
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (icons.shortcut) {
|
|
1020
|
+
const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
|
|
1021
|
+
for (const url of urls) elements.push({
|
|
1022
|
+
tag: "link",
|
|
1023
|
+
attrs: {
|
|
1024
|
+
rel: "shortcut icon",
|
|
1025
|
+
href: url
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
if (icons.apple) {
|
|
1030
|
+
if (typeof icons.apple === "string") elements.push({
|
|
1031
|
+
tag: "link",
|
|
1032
|
+
attrs: {
|
|
1033
|
+
rel: "apple-touch-icon",
|
|
1034
|
+
href: icons.apple
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
else if (Array.isArray(icons.apple)) for (const icon of icons.apple) {
|
|
1038
|
+
const attrs = {
|
|
1039
|
+
rel: "apple-touch-icon",
|
|
1040
|
+
href: icon.url
|
|
1041
|
+
};
|
|
1042
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
1043
|
+
elements.push({
|
|
1044
|
+
tag: "link",
|
|
1045
|
+
attrs
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (icons.other) for (const icon of icons.other) {
|
|
1050
|
+
const attrs = {
|
|
1051
|
+
rel: icon.rel,
|
|
1052
|
+
href: icon.url
|
|
1053
|
+
};
|
|
1054
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
1055
|
+
if (icon.type) attrs.type = icon.type;
|
|
1056
|
+
elements.push({
|
|
1057
|
+
tag: "link",
|
|
1058
|
+
attrs
|
|
1094
1059
|
});
|
|
1095
1060
|
}
|
|
1096
1061
|
}
|
|
1097
|
-
//#endregion
|
|
1098
|
-
//#region src/server/build-manifest.ts
|
|
1099
1062
|
/**
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
* Walks segments root → leaf, collecting CSS for each layout and page.
|
|
1103
|
-
* Deduplicates while preserving order (root layout CSS first).
|
|
1063
|
+
* Render alternate link elements (canonical, hreflang, media, types).
|
|
1104
1064
|
*/
|
|
1105
|
-
function
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
if (!cssFiles) continue;
|
|
1112
|
-
for (const url of cssFiles) if (!seen.has(url)) {
|
|
1113
|
-
seen.add(url);
|
|
1114
|
-
result.push(url);
|
|
1065
|
+
function renderAlternates(alternates, elements) {
|
|
1066
|
+
if (alternates.canonical) elements.push({
|
|
1067
|
+
tag: "link",
|
|
1068
|
+
attrs: {
|
|
1069
|
+
rel: "canonical",
|
|
1070
|
+
href: alternates.canonical
|
|
1115
1071
|
}
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1072
|
+
});
|
|
1073
|
+
if (alternates.languages) for (const [lang, href] of Object.entries(alternates.languages)) elements.push({
|
|
1074
|
+
tag: "link",
|
|
1075
|
+
attrs: {
|
|
1076
|
+
rel: "alternate",
|
|
1077
|
+
hreflang: lang,
|
|
1078
|
+
href
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
if (alternates.media) for (const [media, href] of Object.entries(alternates.media)) elements.push({
|
|
1082
|
+
tag: "link",
|
|
1083
|
+
attrs: {
|
|
1084
|
+
rel: "alternate",
|
|
1085
|
+
media,
|
|
1086
|
+
href
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
if (alternates.types) for (const [type, href] of Object.entries(alternates.types)) elements.push({
|
|
1090
|
+
tag: "link",
|
|
1091
|
+
attrs: {
|
|
1092
|
+
rel: "alternate",
|
|
1093
|
+
type,
|
|
1094
|
+
href
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1118
1097
|
}
|
|
1119
1098
|
/**
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
1123
|
-
* Deduplicates by href while preserving order.
|
|
1099
|
+
* Render site verification meta tags (Google, Yahoo, Yandex, custom).
|
|
1124
1100
|
*/
|
|
1125
|
-
function
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1101
|
+
function renderVerification(verification, elements) {
|
|
1102
|
+
const verificationProps = [
|
|
1103
|
+
["google-site-verification", verification.google],
|
|
1104
|
+
["y_key", verification.yahoo],
|
|
1105
|
+
["yandex-verification", verification.yandex]
|
|
1106
|
+
];
|
|
1107
|
+
for (const [name, content] of verificationProps) if (content) elements.push({
|
|
1108
|
+
tag: "meta",
|
|
1109
|
+
attrs: {
|
|
1110
|
+
name,
|
|
1111
|
+
content
|
|
1135
1112
|
}
|
|
1113
|
+
});
|
|
1114
|
+
if (verification.other) for (const [name, value] of Object.entries(verification.other)) {
|
|
1115
|
+
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
1116
|
+
elements.push({
|
|
1117
|
+
tag: "meta",
|
|
1118
|
+
attrs: {
|
|
1119
|
+
name,
|
|
1120
|
+
content
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1136
1123
|
}
|
|
1137
|
-
return result;
|
|
1138
1124
|
}
|
|
1139
1125
|
/**
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
1143
|
-
* for each layout and page. Deduplicates across segments.
|
|
1126
|
+
* Render Apple Web App meta tags and startup image links.
|
|
1144
1127
|
*/
|
|
1145
|
-
function
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1128
|
+
function renderAppleWebApp(appleWebApp, elements) {
|
|
1129
|
+
if (appleWebApp.capable) elements.push({
|
|
1130
|
+
tag: "meta",
|
|
1131
|
+
attrs: {
|
|
1132
|
+
name: "apple-mobile-web-app-capable",
|
|
1133
|
+
content: "yes"
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
if (appleWebApp.title) elements.push({
|
|
1137
|
+
tag: "meta",
|
|
1138
|
+
attrs: {
|
|
1139
|
+
name: "apple-mobile-web-app-title",
|
|
1140
|
+
content: appleWebApp.title
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
if (appleWebApp.statusBarStyle) elements.push({
|
|
1144
|
+
tag: "meta",
|
|
1145
|
+
attrs: {
|
|
1146
|
+
name: "apple-mobile-web-app-status-bar-style",
|
|
1147
|
+
content: appleWebApp.statusBarStyle
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
if (appleWebApp.startupImage) {
|
|
1151
|
+
const images = Array.isArray(appleWebApp.startupImage) ? appleWebApp.startupImage : [{ url: appleWebApp.startupImage }];
|
|
1152
|
+
for (const img of images) {
|
|
1153
|
+
const attrs = {
|
|
1154
|
+
rel: "apple-touch-startup-image",
|
|
1155
|
+
href: typeof img === "string" ? img : img.url
|
|
1156
|
+
};
|
|
1157
|
+
if (typeof img === "object" && img.media) attrs.media = img.media;
|
|
1158
|
+
elements.push({
|
|
1159
|
+
tag: "link",
|
|
1160
|
+
attrs
|
|
1161
|
+
});
|
|
1155
1162
|
}
|
|
1156
1163
|
}
|
|
1157
|
-
return result;
|
|
1158
1164
|
}
|
|
1159
|
-
//#endregion
|
|
1160
|
-
//#region src/server/early-hints.ts
|
|
1161
1165
|
/**
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
* Early Hints are sent before the final response to let the browser
|
|
1165
|
-
* start fetching critical resources (CSS, fonts, JS) while the server
|
|
1166
|
-
* is still rendering.
|
|
1167
|
-
*
|
|
1168
|
-
* The framework collects hints from two sources:
|
|
1169
|
-
* 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
|
|
1170
|
-
* 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
|
|
1171
|
-
*
|
|
1172
|
-
* Both are emitted as Link headers. Cloudflare CDN automatically converts
|
|
1173
|
-
* Link headers into 103 Early Hints responses.
|
|
1174
|
-
*
|
|
1175
|
-
* Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
1166
|
+
* Render App Links (al:*) meta tags for deep linking across platforms.
|
|
1176
1167
|
*/
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1168
|
+
function renderAppLinks(appLinks, elements) {
|
|
1169
|
+
const platformEntries = [
|
|
1170
|
+
["ios", appLinks.ios],
|
|
1171
|
+
["android", appLinks.android],
|
|
1172
|
+
["windows", appLinks.windows],
|
|
1173
|
+
["windows_phone", appLinks.windowsPhone],
|
|
1174
|
+
["windows_universal", appLinks.windowsUniversal]
|
|
1175
|
+
];
|
|
1176
|
+
for (const [platform, entries] of platformEntries) {
|
|
1177
|
+
if (!entries) continue;
|
|
1178
|
+
for (const entry of entries) for (const [key, value] of Object.entries(entry)) if (value !== void 0 && value !== null) elements.push({
|
|
1179
|
+
tag: "meta",
|
|
1180
|
+
attrs: {
|
|
1181
|
+
property: `al:${platform}:${key}`,
|
|
1182
|
+
content: String(value)
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
if (appLinks.web) {
|
|
1187
|
+
if (appLinks.web.url) elements.push({
|
|
1188
|
+
tag: "meta",
|
|
1189
|
+
attrs: {
|
|
1190
|
+
property: "al:web:url",
|
|
1191
|
+
content: appLinks.web.url
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
if (appLinks.web.shouldFallback !== void 0) elements.push({
|
|
1195
|
+
tag: "meta",
|
|
1196
|
+
attrs: {
|
|
1197
|
+
property: "al:web:should_fallback",
|
|
1198
|
+
content: appLinks.web.shouldFallback ? "true" : "false"
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1192
1202
|
}
|
|
1193
1203
|
/**
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1196
|
-
* Walks the build manifest to emit hints for:
|
|
1197
|
-
* - CSS stylesheets (rel=preload; as=style)
|
|
1198
|
-
* - Font assets (rel=preload; as=font; crossorigin)
|
|
1199
|
-
* - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
|
|
1200
|
-
*
|
|
1201
|
-
* Also emits global CSS from the `_global` manifest key. Route files
|
|
1202
|
-
* are server components that don't appear in the client bundle, so
|
|
1203
|
-
* per-route CSS keying doesn't work with the RSC plugin. The `_global`
|
|
1204
|
-
* key contains all CSS assets from the client build — fine for early
|
|
1205
|
-
* hints since they're just prefetch signals.
|
|
1206
|
-
*
|
|
1207
|
-
* Returns formatted Link header strings, deduplicated, root → leaf order.
|
|
1208
|
-
* Returns an empty array in dev mode (manifest is empty).
|
|
1204
|
+
* Render Apple iTunes smart banner meta tag.
|
|
1209
1205
|
*/
|
|
1210
|
-
function
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1206
|
+
function renderItunes(itunes, elements) {
|
|
1207
|
+
const parts = [`app-id=${itunes.appId}`];
|
|
1208
|
+
if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
|
|
1209
|
+
if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
|
|
1210
|
+
elements.push({
|
|
1211
|
+
tag: "meta",
|
|
1212
|
+
attrs: {
|
|
1213
|
+
name: "apple-itunes-app",
|
|
1214
|
+
content: parts.join(", ")
|
|
1217
1215
|
}
|
|
1218
|
-
};
|
|
1219
|
-
for (const url of collectRouteCss(segments, manifest)) add(formatLinkHeader({
|
|
1220
|
-
href: url,
|
|
1221
|
-
rel: "preload",
|
|
1222
|
-
as: "style"
|
|
1223
|
-
}));
|
|
1224
|
-
for (const url of manifest.css["_global"] ?? []) add(formatLinkHeader({
|
|
1225
|
-
href: url,
|
|
1226
|
-
rel: "preload",
|
|
1227
|
-
as: "style"
|
|
1228
|
-
}));
|
|
1229
|
-
for (const font of collectRouteFonts(segments, manifest)) add(formatLinkHeader({
|
|
1230
|
-
href: font.href,
|
|
1231
|
-
rel: "preload",
|
|
1232
|
-
as: "font",
|
|
1233
|
-
crossOrigin: "anonymous"
|
|
1234
|
-
}));
|
|
1235
|
-
if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(formatLinkHeader({
|
|
1236
|
-
href: url,
|
|
1237
|
-
rel: "modulepreload"
|
|
1238
|
-
}));
|
|
1239
|
-
return result;
|
|
1240
|
-
}
|
|
1241
|
-
//#endregion
|
|
1242
|
-
//#region src/server/early-hints-sender.ts
|
|
1243
|
-
/**
|
|
1244
|
-
* Per-request 103 Early Hints sender — ALS bridge for platform adapters.
|
|
1245
|
-
*
|
|
1246
|
-
* The pipeline collects Link headers for CSS, fonts, and JS chunks at
|
|
1247
|
-
* route-match time. On platforms that support it (Node.js v18.11+, Bun),
|
|
1248
|
-
* the adapter can send these as a 103 Early Hints interim response before
|
|
1249
|
-
* the final response is ready.
|
|
1250
|
-
*
|
|
1251
|
-
* This module provides an ALS-based bridge: the generated entry point
|
|
1252
|
-
* (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
|
|
1253
|
-
* binding a per-request sender function. The pipeline calls
|
|
1254
|
-
* `sendEarlyHints103()` to fire the 103 if a sender is available.
|
|
1255
|
-
*
|
|
1256
|
-
* On platforms where 103 is handled at the CDN level (e.g., Cloudflare
|
|
1257
|
-
* converts Link headers into 103 automatically), no sender is installed
|
|
1258
|
-
* and `sendEarlyHints103()` is a no-op.
|
|
1259
|
-
*
|
|
1260
|
-
* Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
1261
|
-
*/
|
|
1262
|
-
/**
|
|
1263
|
-
* Run a function with a per-request early hints sender installed.
|
|
1264
|
-
*
|
|
1265
|
-
* Called by generated entry points (e.g., Nitro node-server/bun) to
|
|
1266
|
-
* bind the platform's writeEarlyHints capability for the request duration.
|
|
1267
|
-
*/
|
|
1268
|
-
function runWithEarlyHintsSender(sender, fn) {
|
|
1269
|
-
return earlyHintsSenderAls.run(sender, fn);
|
|
1270
|
-
}
|
|
1271
|
-
/**
|
|
1272
|
-
* Send collected Link headers as a 103 Early Hints response.
|
|
1273
|
-
*
|
|
1274
|
-
* No-op if no sender is installed for the current request (e.g., on
|
|
1275
|
-
* Cloudflare where the CDN handles 103 automatically, or in dev mode).
|
|
1276
|
-
*
|
|
1277
|
-
* Non-fatal: errors from the sender are caught and silently ignored.
|
|
1278
|
-
*/
|
|
1279
|
-
function sendEarlyHints103(links) {
|
|
1280
|
-
if (!links.length) return;
|
|
1281
|
-
const sender = earlyHintsSenderAls.getStore();
|
|
1282
|
-
if (!sender) return;
|
|
1283
|
-
try {
|
|
1284
|
-
sender(links);
|
|
1285
|
-
} catch {}
|
|
1216
|
+
});
|
|
1286
1217
|
}
|
|
1287
1218
|
//#endregion
|
|
1288
|
-
//#region src/server/
|
|
1219
|
+
//#region src/server/metadata-render.ts
|
|
1289
1220
|
/**
|
|
1290
|
-
*
|
|
1221
|
+
* Convert resolved metadata into an array of head element descriptors.
|
|
1291
1222
|
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
* 2. Wrap in status-code error boundaries (fallback chain)
|
|
1295
|
-
* 3. Wrap in AccessGate (if segment has access.ts)
|
|
1296
|
-
* 4. Pass as children to the segment's layout
|
|
1297
|
-
* 5. Repeat up the segment chain to root
|
|
1223
|
+
* Each descriptor has a `tag` ('title', 'meta', 'link') and either
|
|
1224
|
+
* `content` (for <title>) or `attrs` (for <meta>/<link>).
|
|
1298
1225
|
*
|
|
1299
|
-
*
|
|
1226
|
+
* The framework's MetadataResolver component consumes these descriptors
|
|
1227
|
+
* and renders them into the <head>.
|
|
1300
1228
|
*/
|
|
1301
|
-
|
|
1302
|
-
const
|
|
1303
|
-
if (
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
tree: null,
|
|
1307
|
-
isApiRoute: true
|
|
1308
|
-
};
|
|
1309
|
-
const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
|
|
1310
|
-
if (!PageComponent) throw new Error(`[timber] No page component found for route at ${leaf.urlPath}. Each route must have a page.tsx or route.ts.`);
|
|
1311
|
-
let element = createElement(PageComponent, {
|
|
1312
|
-
params,
|
|
1313
|
-
searchParams
|
|
1229
|
+
function renderMetadataToElements(metadata) {
|
|
1230
|
+
const elements = [];
|
|
1231
|
+
if (typeof metadata.title === "string") elements.push({
|
|
1232
|
+
tag: "title",
|
|
1233
|
+
content: metadata.title
|
|
1314
1234
|
});
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1235
|
+
const simpleMetaProps = [
|
|
1236
|
+
["description", metadata.description],
|
|
1237
|
+
["generator", metadata.generator],
|
|
1238
|
+
["application-name", metadata.applicationName],
|
|
1239
|
+
["referrer", metadata.referrer],
|
|
1240
|
+
["category", metadata.category],
|
|
1241
|
+
["creator", metadata.creator],
|
|
1242
|
+
["publisher", metadata.publisher]
|
|
1243
|
+
];
|
|
1244
|
+
for (const [name, content] of simpleMetaProps) if (content) elements.push({
|
|
1245
|
+
tag: "meta",
|
|
1246
|
+
attrs: {
|
|
1247
|
+
name,
|
|
1248
|
+
content
|
|
1327
1249
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
searchParams,
|
|
1337
|
-
children: element
|
|
1338
|
-
});
|
|
1250
|
+
});
|
|
1251
|
+
if (metadata.keywords) {
|
|
1252
|
+
const content = Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
|
|
1253
|
+
elements.push({
|
|
1254
|
+
tag: "meta",
|
|
1255
|
+
attrs: {
|
|
1256
|
+
name: "keywords",
|
|
1257
|
+
content
|
|
1339
1258
|
}
|
|
1340
|
-
}
|
|
1259
|
+
});
|
|
1341
1260
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
* Slots have their own access.ts (SlotAccessGate) and error boundaries.
|
|
1351
|
-
* On access denial: denied.tsx → default.tsx → null (graceful degradation).
|
|
1352
|
-
*/
|
|
1353
|
-
async function buildSlotElement(slotNode, params, searchParams, loadModule, createElement, errorBoundaryComponent) {
|
|
1354
|
-
const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
|
|
1355
|
-
const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
|
|
1356
|
-
if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {
|
|
1357
|
-
params,
|
|
1358
|
-
searchParams
|
|
1359
|
-
}) : null;
|
|
1360
|
-
let element = createElement(PageComponent, {
|
|
1361
|
-
params,
|
|
1362
|
-
searchParams
|
|
1363
|
-
});
|
|
1364
|
-
element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement, errorBoundaryComponent);
|
|
1365
|
-
if (slotNode.access) {
|
|
1366
|
-
const accessFn = (await loadModule(slotNode.access)).default;
|
|
1367
|
-
const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default;
|
|
1368
|
-
element = createElement("timber:slot-access-gate", {
|
|
1369
|
-
accessFn,
|
|
1370
|
-
params,
|
|
1371
|
-
searchParams,
|
|
1372
|
-
deniedFallback: DeniedComponent ? createElement(DeniedComponent, {
|
|
1373
|
-
slot: slotNode.segmentName.replace(/^@/, ""),
|
|
1374
|
-
dangerouslyPassData: void 0
|
|
1375
|
-
}) : null,
|
|
1376
|
-
defaultFallback: DefaultComponent ? createElement(DefaultComponent, {
|
|
1377
|
-
params,
|
|
1378
|
-
searchParams
|
|
1379
|
-
}) : null,
|
|
1380
|
-
children: element
|
|
1261
|
+
if (metadata.robots) {
|
|
1262
|
+
const content = typeof metadata.robots === "string" ? metadata.robots : renderRobotsObject(metadata.robots);
|
|
1263
|
+
elements.push({
|
|
1264
|
+
tag: "meta",
|
|
1265
|
+
attrs: {
|
|
1266
|
+
name: "robots",
|
|
1267
|
+
content
|
|
1268
|
+
}
|
|
1381
1269
|
});
|
|
1270
|
+
if (typeof metadata.robots === "object" && metadata.robots.googleBot) {
|
|
1271
|
+
const gbContent = typeof metadata.robots.googleBot === "string" ? metadata.robots.googleBot : renderRobotsObject(metadata.robots.googleBot);
|
|
1272
|
+
elements.push({
|
|
1273
|
+
tag: "meta",
|
|
1274
|
+
attrs: {
|
|
1275
|
+
name: "googlebot",
|
|
1276
|
+
content: gbContent
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1382
1280
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
* 3. error.tsx (general error boundary)
|
|
1392
|
-
*
|
|
1393
|
-
* This creates the fallback chain described in design/10-error-handling.md.
|
|
1394
|
-
*/
|
|
1395
|
-
async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
|
|
1396
|
-
if (segment.statusFiles) {
|
|
1397
|
-
for (const [key, file] of segment.statusFiles) if (key !== "4xx" && key !== "5xx") {
|
|
1398
|
-
const status = parseInt(key, 10);
|
|
1399
|
-
if (!isNaN(status)) {
|
|
1400
|
-
const Component = (await loadModule(file)).default;
|
|
1401
|
-
if (Component) element = createElement(errorBoundaryComponent, {
|
|
1402
|
-
fallbackComponent: Component,
|
|
1403
|
-
status,
|
|
1404
|
-
children: element
|
|
1405
|
-
});
|
|
1406
|
-
}
|
|
1281
|
+
if (metadata.openGraph) renderOpenGraph(metadata.openGraph, elements);
|
|
1282
|
+
if (metadata.twitter) renderTwitter(metadata.twitter, elements);
|
|
1283
|
+
if (metadata.icons) renderIcons(metadata.icons, elements);
|
|
1284
|
+
if (metadata.manifest) elements.push({
|
|
1285
|
+
tag: "link",
|
|
1286
|
+
attrs: {
|
|
1287
|
+
rel: "manifest",
|
|
1288
|
+
href: metadata.manifest
|
|
1407
1289
|
}
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1290
|
+
});
|
|
1291
|
+
if (metadata.alternates) renderAlternates(metadata.alternates, elements);
|
|
1292
|
+
if (metadata.verification) renderVerification(metadata.verification, elements);
|
|
1293
|
+
if (metadata.formatDetection) {
|
|
1294
|
+
const parts = [];
|
|
1295
|
+
if (metadata.formatDetection.telephone === false) parts.push("telephone=no");
|
|
1296
|
+
if (metadata.formatDetection.email === false) parts.push("email=no");
|
|
1297
|
+
if (metadata.formatDetection.address === false) parts.push("address=no");
|
|
1298
|
+
if (parts.length > 0) elements.push({
|
|
1299
|
+
tag: "meta",
|
|
1300
|
+
attrs: {
|
|
1301
|
+
name: "format-detection",
|
|
1302
|
+
content: parts.join(", ")
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
if (metadata.authors) {
|
|
1307
|
+
const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
|
|
1308
|
+
for (const author of authorList) {
|
|
1309
|
+
if (author.name) elements.push({
|
|
1310
|
+
tag: "meta",
|
|
1311
|
+
attrs: {
|
|
1312
|
+
name: "author",
|
|
1313
|
+
content: author.name
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
if (author.url) elements.push({
|
|
1317
|
+
tag: "link",
|
|
1318
|
+
attrs: {
|
|
1319
|
+
rel: "author",
|
|
1320
|
+
href: author.url
|
|
1321
|
+
}
|
|
1414
1322
|
});
|
|
1415
1323
|
}
|
|
1416
1324
|
}
|
|
1417
|
-
if (
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1325
|
+
if (metadata.appleWebApp) renderAppleWebApp(metadata.appleWebApp, elements);
|
|
1326
|
+
if (metadata.appLinks) renderAppLinks(metadata.appLinks, elements);
|
|
1327
|
+
if (metadata.itunes) renderItunes(metadata.itunes, elements);
|
|
1328
|
+
if (metadata.other) for (const [name, value] of Object.entries(metadata.other)) {
|
|
1329
|
+
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
1330
|
+
elements.push({
|
|
1331
|
+
tag: "meta",
|
|
1332
|
+
attrs: {
|
|
1333
|
+
name,
|
|
1334
|
+
content
|
|
1335
|
+
}
|
|
1422
1336
|
});
|
|
1423
1337
|
}
|
|
1424
|
-
return
|
|
1338
|
+
return elements;
|
|
1339
|
+
}
|
|
1340
|
+
function renderRobotsObject(robots) {
|
|
1341
|
+
const parts = [];
|
|
1342
|
+
if (robots.index === true) parts.push("index");
|
|
1343
|
+
if (robots.index === false) parts.push("noindex");
|
|
1344
|
+
if (robots.follow === true) parts.push("follow");
|
|
1345
|
+
if (robots.follow === false) parts.push("nofollow");
|
|
1346
|
+
return parts.join(", ");
|
|
1347
|
+
}
|
|
1348
|
+
//#endregion
|
|
1349
|
+
//#region src/server/metadata.ts
|
|
1350
|
+
/**
|
|
1351
|
+
* Resolve a title value with an optional template.
|
|
1352
|
+
*
|
|
1353
|
+
* - string → apply template if present
|
|
1354
|
+
* - { absolute: '...' } → use as-is, skip template
|
|
1355
|
+
* - { default: '...' } → use as fallback (no template applied)
|
|
1356
|
+
* - undefined → undefined
|
|
1357
|
+
*/
|
|
1358
|
+
function resolveTitle(title, template) {
|
|
1359
|
+
if (title === void 0 || title === null) return;
|
|
1360
|
+
if (typeof title === "string") return template ? template.replace("%s", title) : title;
|
|
1361
|
+
if (title.absolute !== void 0) return title.absolute;
|
|
1362
|
+
if (title.default !== void 0) return title.default;
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Resolve metadata from a segment chain.
|
|
1366
|
+
*
|
|
1367
|
+
* Processes entries from root layout to page (in segment order).
|
|
1368
|
+
* The merge algorithm:
|
|
1369
|
+
* 1. Shallow-merge all keys except title (later wins)
|
|
1370
|
+
* 2. Track the most recent title template
|
|
1371
|
+
* 3. Resolve the final title using the template
|
|
1372
|
+
*
|
|
1373
|
+
* In error state, the page entry is dropped and noindex is injected.
|
|
1374
|
+
*
|
|
1375
|
+
* See design/16-metadata.md §"Merge Algorithm"
|
|
1376
|
+
*/
|
|
1377
|
+
function resolveMetadata(entries, options = {}) {
|
|
1378
|
+
const { errorState = false } = options;
|
|
1379
|
+
const merged = {};
|
|
1380
|
+
let titleTemplate;
|
|
1381
|
+
let lastDefault;
|
|
1382
|
+
let rawTitle;
|
|
1383
|
+
for (const { metadata, isPage } of entries) {
|
|
1384
|
+
if (errorState && isPage) continue;
|
|
1385
|
+
if (metadata.title !== void 0 && typeof metadata.title === "object") {
|
|
1386
|
+
if (metadata.title.template !== void 0) titleTemplate = metadata.title.template;
|
|
1387
|
+
if (metadata.title.default !== void 0) lastDefault = metadata.title.default;
|
|
1388
|
+
}
|
|
1389
|
+
for (const key of Object.keys(metadata)) {
|
|
1390
|
+
if (key === "title") continue;
|
|
1391
|
+
merged[key] = metadata[key];
|
|
1392
|
+
}
|
|
1393
|
+
if (metadata.title !== void 0) rawTitle = metadata.title;
|
|
1394
|
+
}
|
|
1395
|
+
if (errorState) {
|
|
1396
|
+
rawTitle = lastDefault !== void 0 ? { default: lastDefault } : rawTitle;
|
|
1397
|
+
titleTemplate = void 0;
|
|
1398
|
+
}
|
|
1399
|
+
const resolvedTitle = resolveTitle(rawTitle, titleTemplate);
|
|
1400
|
+
if (resolvedTitle !== void 0) merged.title = resolvedTitle;
|
|
1401
|
+
if (errorState) merged.robots = "noindex";
|
|
1402
|
+
return merged;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Check if a string is an absolute URL.
|
|
1406
|
+
*/
|
|
1407
|
+
function isAbsoluteUrl(url) {
|
|
1408
|
+
return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//");
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Resolve a relative URL against a base URL.
|
|
1412
|
+
*/
|
|
1413
|
+
function resolveUrl(url, base) {
|
|
1414
|
+
if (isAbsoluteUrl(url)) return url;
|
|
1415
|
+
return new URL(url, base).toString();
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Resolve relative URLs in metadata fields against metadataBase.
|
|
1419
|
+
*
|
|
1420
|
+
* Returns a new metadata object with URLs resolved. Absolute URLs are not modified.
|
|
1421
|
+
* If metadataBase is not set, returns the metadata unchanged.
|
|
1422
|
+
*/
|
|
1423
|
+
function resolveMetadataUrls(metadata) {
|
|
1424
|
+
const base = metadata.metadataBase;
|
|
1425
|
+
if (!base) return metadata;
|
|
1426
|
+
const result = { ...metadata };
|
|
1427
|
+
if (result.openGraph) {
|
|
1428
|
+
result.openGraph = { ...result.openGraph };
|
|
1429
|
+
if (typeof result.openGraph.images === "string") result.openGraph.images = resolveUrl(result.openGraph.images, base);
|
|
1430
|
+
else if (Array.isArray(result.openGraph.images)) result.openGraph.images = result.openGraph.images.map((img) => ({
|
|
1431
|
+
...img,
|
|
1432
|
+
url: resolveUrl(img.url, base)
|
|
1433
|
+
}));
|
|
1434
|
+
else if (result.openGraph.images) result.openGraph.images = {
|
|
1435
|
+
...result.openGraph.images,
|
|
1436
|
+
url: resolveUrl(result.openGraph.images.url, base)
|
|
1437
|
+
};
|
|
1438
|
+
if (result.openGraph.url && !isAbsoluteUrl(result.openGraph.url)) result.openGraph.url = resolveUrl(result.openGraph.url, base);
|
|
1439
|
+
}
|
|
1440
|
+
if (result.twitter) {
|
|
1441
|
+
result.twitter = { ...result.twitter };
|
|
1442
|
+
if (typeof result.twitter.images === "string") result.twitter.images = resolveUrl(result.twitter.images, base);
|
|
1443
|
+
else if (Array.isArray(result.twitter.images)) {
|
|
1444
|
+
const resolved = result.twitter.images.map((img) => typeof img === "string" ? resolveUrl(img, base) : {
|
|
1445
|
+
...img,
|
|
1446
|
+
url: resolveUrl(img.url, base)
|
|
1447
|
+
});
|
|
1448
|
+
const allStrings = resolved.every((r) => typeof r === "string");
|
|
1449
|
+
result.twitter.images = allStrings ? resolved : resolved;
|
|
1450
|
+
} else if (result.twitter.images) result.twitter.images = {
|
|
1451
|
+
...result.twitter.images,
|
|
1452
|
+
url: resolveUrl(result.twitter.images.url, base)
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
if (result.alternates) {
|
|
1456
|
+
result.alternates = { ...result.alternates };
|
|
1457
|
+
if (result.alternates.canonical && !isAbsoluteUrl(result.alternates.canonical)) result.alternates.canonical = resolveUrl(result.alternates.canonical, base);
|
|
1458
|
+
if (result.alternates.languages) {
|
|
1459
|
+
const langs = {};
|
|
1460
|
+
for (const [lang, url] of Object.entries(result.alternates.languages)) langs[lang] = isAbsoluteUrl(url) ? url : resolveUrl(url, base);
|
|
1461
|
+
result.alternates.languages = langs;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
if (result.icons) {
|
|
1465
|
+
result.icons = { ...result.icons };
|
|
1466
|
+
if (typeof result.icons.icon === "string") result.icons.icon = resolveUrl(result.icons.icon, base);
|
|
1467
|
+
else if (Array.isArray(result.icons.icon)) result.icons.icon = result.icons.icon.map((i) => ({
|
|
1468
|
+
...i,
|
|
1469
|
+
url: resolveUrl(i.url, base)
|
|
1470
|
+
}));
|
|
1471
|
+
if (typeof result.icons.apple === "string") result.icons.apple = resolveUrl(result.icons.apple, base);
|
|
1472
|
+
else if (Array.isArray(result.icons.apple)) result.icons.apple = result.icons.apple.map((i) => ({
|
|
1473
|
+
...i,
|
|
1474
|
+
url: resolveUrl(i.url, base)
|
|
1475
|
+
}));
|
|
1476
|
+
}
|
|
1477
|
+
return result;
|
|
1425
1478
|
}
|
|
1426
1479
|
//#endregion
|
|
1427
1480
|
//#region src/server/access-gate.tsx
|
|
@@ -1454,24 +1507,21 @@ async function wrapWithErrorBoundaries(segment, element, loadModule, createEleme
|
|
|
1454
1507
|
* gets the same data by calling the same cached functions (React.cache dedup).
|
|
1455
1508
|
*/
|
|
1456
1509
|
function AccessGate(props) {
|
|
1457
|
-
const { accessFn,
|
|
1510
|
+
const { accessFn, segmentName, verdict, children } = props;
|
|
1458
1511
|
if (verdict !== void 0) {
|
|
1459
1512
|
if (verdict === "pass") return children;
|
|
1460
1513
|
throw verdict;
|
|
1461
1514
|
}
|
|
1462
|
-
return accessGateFallback(accessFn,
|
|
1515
|
+
return accessGateFallback(accessFn, segmentName, children);
|
|
1463
1516
|
}
|
|
1464
1517
|
/**
|
|
1465
1518
|
* Async fallback for AccessGate when no pre-computed verdict is available.
|
|
1466
1519
|
* Calls accessFn with OTEL instrumentation.
|
|
1467
1520
|
*/
|
|
1468
|
-
async function accessGateFallback(accessFn,
|
|
1521
|
+
async function accessGateFallback(accessFn, segmentName, children) {
|
|
1469
1522
|
await withSpan("timber.access", { "timber.segment": segmentName ?? "unknown" }, async () => {
|
|
1470
1523
|
try {
|
|
1471
|
-
await accessFn({
|
|
1472
|
-
params,
|
|
1473
|
-
searchParams
|
|
1474
|
-
});
|
|
1524
|
+
await accessFn({ params: await rawSegmentParams() });
|
|
1475
1525
|
await setSpanAttribute("timber.result", "pass");
|
|
1476
1526
|
} catch (error) {
|
|
1477
1527
|
if (error instanceof DenySignal) {
|
|
@@ -1491,1093 +1541,1312 @@ async function accessGateFallback(accessFn, params, searchParams, segmentName, c
|
|
|
1491
1541
|
* The HTTP status code is unaffected — slot denial is a UI concern, not
|
|
1492
1542
|
* a protocol concern. The parent layout and sibling slots still render.
|
|
1493
1543
|
*
|
|
1544
|
+
* DeniedComponent is passed instead of a pre-built element so that
|
|
1545
|
+
* DenySignal.data can be forwarded as the dangerouslyPassData prop
|
|
1546
|
+
* and the slot name can be passed as the slot prop. See TIM-488.
|
|
1547
|
+
*
|
|
1494
1548
|
* redirect() in slot access.ts is a dev-mode error — redirecting from a
|
|
1495
1549
|
* slot doesn't make architectural sense.
|
|
1496
1550
|
*/
|
|
1497
1551
|
async function SlotAccessGate(props) {
|
|
1498
|
-
const { accessFn,
|
|
1552
|
+
const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children } = props;
|
|
1499
1553
|
try {
|
|
1500
|
-
await accessFn({
|
|
1501
|
-
params,
|
|
1502
|
-
searchParams
|
|
1503
|
-
});
|
|
1554
|
+
await accessFn({ params: await rawSegmentParams() });
|
|
1504
1555
|
} catch (error) {
|
|
1505
|
-
if (error instanceof DenySignal) return
|
|
1556
|
+
if (error instanceof DenySignal) return buildDeniedFallback(DeniedComponent, slotName, error.data, createElement) ?? defaultFallback ?? null;
|
|
1506
1557
|
if (error instanceof RedirectSignal) {
|
|
1507
1558
|
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.");
|
|
1508
|
-
return
|
|
1559
|
+
return buildDeniedFallback(DeniedComponent, slotName, void 0, createElement) ?? defaultFallback ?? null;
|
|
1509
1560
|
}
|
|
1510
1561
|
if (isDebug()) console.warn("[timber] Unhandled error in slot access.ts. Use deny() for access control, not unhandled throws.", error);
|
|
1511
1562
|
throw error;
|
|
1512
1563
|
}
|
|
1513
1564
|
return children;
|
|
1514
1565
|
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Build the denied fallback element dynamically with DenySignal data.
|
|
1568
|
+
* Returns null if no DeniedComponent is available.
|
|
1569
|
+
*/
|
|
1570
|
+
function buildDeniedFallback(DeniedComponent, slotName, data, createElement) {
|
|
1571
|
+
if (!DeniedComponent) return null;
|
|
1572
|
+
return createElement(DeniedComponent, {
|
|
1573
|
+
slot: slotName,
|
|
1574
|
+
dangerouslyPassData: data
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1515
1577
|
//#endregion
|
|
1516
|
-
//#region src/server/
|
|
1578
|
+
//#region src/server/route-element-builder.ts
|
|
1517
1579
|
/**
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1580
|
+
* Thrown when a defineSegmentParams codec's parse() fails.
|
|
1581
|
+
* The pipeline catches this and responds with 404.
|
|
1520
1582
|
*/
|
|
1521
|
-
var
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1583
|
+
var ParamCoercionError = class extends Error {
|
|
1584
|
+
constructor(message) {
|
|
1585
|
+
super(message);
|
|
1586
|
+
this.name = "ParamCoercionError";
|
|
1587
|
+
}
|
|
1525
1588
|
};
|
|
1589
|
+
//#endregion
|
|
1590
|
+
//#region src/server/version-skew.ts
|
|
1526
1591
|
/**
|
|
1527
|
-
*
|
|
1592
|
+
* Version Skew Detection — graceful recovery when stale clients hit new deployments.
|
|
1528
1593
|
*
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
1531
|
-
*
|
|
1594
|
+
* When a new version of the app is deployed, clients with open tabs still have
|
|
1595
|
+
* the old JavaScript bundle. Without version skew handling, these stale clients
|
|
1596
|
+
* will experience:
|
|
1532
1597
|
*
|
|
1533
|
-
*
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
1598
|
+
* 1. Server action calls that crash (action IDs are content-hashed)
|
|
1599
|
+
* 2. Chunk load failures (old filenames gone from CDN)
|
|
1600
|
+
* 3. RSC payload mismatches (component references differ between builds)
|
|
1601
|
+
*
|
|
1602
|
+
* This module implements deployment ID comparison:
|
|
1603
|
+
* - A per-build deployment ID is generated at build time (see build-manifest.ts)
|
|
1604
|
+
* - The client sends it via `X-Timber-Deployment-Id` header on every RSC/action request
|
|
1605
|
+
* - The server compares it against the current build's ID
|
|
1606
|
+
* - On mismatch: signal the client to reload (not crash)
|
|
1607
|
+
*
|
|
1608
|
+
* The deployment ID is always-on in production. Dev mode skips the check
|
|
1609
|
+
* (HMR handles code updates without full reloads).
|
|
1610
|
+
*
|
|
1611
|
+
* See design/25-production-deployments.md, TIM-446
|
|
1536
1612
|
*/
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
}
|
|
1613
|
+
/** Header sent by the client with every RSC/action request. */
|
|
1614
|
+
var DEPLOYMENT_ID_HEADER = "X-Timber-Deployment-Id";
|
|
1615
|
+
/** Response header that signals the client to do a full page reload. */
|
|
1616
|
+
var RELOAD_HEADER = "X-Timber-Reload";
|
|
1542
1617
|
/**
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1545
|
-
* Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
|
|
1546
|
-
* Pass 3 — error.tsx (leaf → root)
|
|
1618
|
+
* The current build's deployment ID. Set at startup from the manifest init
|
|
1619
|
+
* module (globalThis.__TIMBER_DEPLOYMENT_ID__). Null in dev mode.
|
|
1547
1620
|
*/
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
segmentIndex: i
|
|
1578
|
-
};
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
for (let i = segments.length - 1; i >= 0; i--) if (segments[i].error) return {
|
|
1582
|
-
file: segments[i].error,
|
|
1583
|
-
status,
|
|
1584
|
-
kind: "error",
|
|
1585
|
-
segmentIndex: i
|
|
1621
|
+
var currentDeploymentId = null;
|
|
1622
|
+
/**
|
|
1623
|
+
* Check if a request's deployment ID matches the current build.
|
|
1624
|
+
*
|
|
1625
|
+
* Returns `{ ok: true }` when:
|
|
1626
|
+
* - Dev mode (no deployment ID set — HMR handles updates)
|
|
1627
|
+
* - No deployment ID header (initial page load, non-RSC request)
|
|
1628
|
+
* - Deployment IDs match
|
|
1629
|
+
*
|
|
1630
|
+
* Returns `{ ok: false }` when:
|
|
1631
|
+
* - Client sends a deployment ID that differs from the current build
|
|
1632
|
+
*/
|
|
1633
|
+
function checkVersionSkew(req) {
|
|
1634
|
+
if (!currentDeploymentId) return {
|
|
1635
|
+
ok: true,
|
|
1636
|
+
clientId: null
|
|
1637
|
+
};
|
|
1638
|
+
const clientId = req.headers.get(DEPLOYMENT_ID_HEADER);
|
|
1639
|
+
if (!clientId) return {
|
|
1640
|
+
ok: true,
|
|
1641
|
+
clientId: null
|
|
1642
|
+
};
|
|
1643
|
+
if (clientId === currentDeploymentId) return {
|
|
1644
|
+
ok: true,
|
|
1645
|
+
clientId
|
|
1646
|
+
};
|
|
1647
|
+
return {
|
|
1648
|
+
ok: false,
|
|
1649
|
+
clientId
|
|
1586
1650
|
};
|
|
1587
|
-
return null;
|
|
1588
1651
|
}
|
|
1589
1652
|
/**
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
* No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
|
|
1653
|
+
* Apply version skew reload headers to a response.
|
|
1654
|
+
* Sets X-Timber-Reload: 1 to signal the client to do a full page reload.
|
|
1593
1655
|
*/
|
|
1594
|
-
function
|
|
1595
|
-
|
|
1596
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1597
|
-
const segment = segments[i];
|
|
1598
|
-
if (!segment.jsonStatusFiles) continue;
|
|
1599
|
-
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
1600
|
-
if (exact) return {
|
|
1601
|
-
file: exact,
|
|
1602
|
-
status,
|
|
1603
|
-
kind: "exact",
|
|
1604
|
-
segmentIndex: i
|
|
1605
|
-
};
|
|
1606
|
-
const category = segment.jsonStatusFiles.get("4xx");
|
|
1607
|
-
if (category) return {
|
|
1608
|
-
file: category,
|
|
1609
|
-
status,
|
|
1610
|
-
kind: "category",
|
|
1611
|
-
segmentIndex: i
|
|
1612
|
-
};
|
|
1613
|
-
}
|
|
1614
|
-
return null;
|
|
1656
|
+
function applyReloadHeaders(headers) {
|
|
1657
|
+
headers.set(RELOAD_HEADER, "1");
|
|
1615
1658
|
}
|
|
1659
|
+
//#endregion
|
|
1660
|
+
//#region src/server/pipeline-metadata.ts
|
|
1616
1661
|
/**
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1662
|
+
* Metadata route helpers for the request pipeline.
|
|
1663
|
+
*
|
|
1664
|
+
* Handles serving static metadata files and serializing sitemap responses.
|
|
1665
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
1666
|
+
*
|
|
1667
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
1619
1668
|
*/
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1669
|
+
/**
|
|
1670
|
+
* Content types that are text-based and should include charset=utf-8.
|
|
1671
|
+
* Binary formats (images) should not include charset.
|
|
1672
|
+
*/
|
|
1673
|
+
var TEXT_CONTENT_TYPES = new Set([
|
|
1674
|
+
"application/xml",
|
|
1675
|
+
"text/plain",
|
|
1676
|
+
"application/json",
|
|
1677
|
+
"application/manifest+json",
|
|
1678
|
+
"image/svg+xml"
|
|
1679
|
+
]);
|
|
1680
|
+
/**
|
|
1681
|
+
* Serve a static metadata file by reading it from disk.
|
|
1682
|
+
*
|
|
1683
|
+
* Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
|
|
1684
|
+
* are served as-is with the appropriate Content-Type header.
|
|
1685
|
+
* Text files include charset=utf-8; binary files do not.
|
|
1686
|
+
*
|
|
1687
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
1688
|
+
*/
|
|
1689
|
+
async function serveStaticMetadataFile(metaMatch) {
|
|
1690
|
+
const { contentType, file } = metaMatch;
|
|
1691
|
+
const isText = TEXT_CONTENT_TYPES.has(contentType);
|
|
1692
|
+
const body = await readFile(file.filePath);
|
|
1693
|
+
const headers = {
|
|
1694
|
+
"Content-Type": isText ? `${contentType}; charset=utf-8` : contentType,
|
|
1695
|
+
"Content-Length": String(body.byteLength)
|
|
1696
|
+
};
|
|
1697
|
+
return new Response(body, {
|
|
1698
|
+
status: 200,
|
|
1699
|
+
headers
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Serialize a sitemap array to XML.
|
|
1704
|
+
* Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
|
|
1705
|
+
*/
|
|
1706
|
+
function serializeSitemap(entries) {
|
|
1707
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => {
|
|
1708
|
+
let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
|
|
1709
|
+
if (e.lastModified) {
|
|
1710
|
+
const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
|
|
1711
|
+
xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
|
|
1639
1712
|
}
|
|
1640
|
-
if (
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1713
|
+
if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
|
|
1714
|
+
if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
|
|
1715
|
+
xml += "\n </url>";
|
|
1716
|
+
return xml;
|
|
1717
|
+
}).join("\n")}\n</urlset>`;
|
|
1718
|
+
}
|
|
1719
|
+
/** Escape special XML characters. */
|
|
1720
|
+
function escapeXml(str) {
|
|
1721
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1648
1722
|
}
|
|
1723
|
+
//#endregion
|
|
1724
|
+
//#region src/server/pipeline-interception.ts
|
|
1649
1725
|
/**
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
1726
|
+
* Check if an intercepting route applies for this soft navigation.
|
|
1727
|
+
*
|
|
1728
|
+
* Matches the target pathname against interception rewrites, constrained
|
|
1729
|
+
* by the source URL (X-Timber-URL header — where the user navigates FROM).
|
|
1730
|
+
*
|
|
1731
|
+
* Returns the source pathname to re-match if interception applies, or null.
|
|
1653
1732
|
*/
|
|
1654
|
-
function
|
|
1655
|
-
const
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
if (!segment.jsonStatusFiles) continue;
|
|
1659
|
-
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
1660
|
-
if (exact) return {
|
|
1661
|
-
file: exact,
|
|
1662
|
-
status,
|
|
1663
|
-
kind: "exact",
|
|
1664
|
-
segmentIndex: i
|
|
1665
|
-
};
|
|
1666
|
-
const category = segment.jsonStatusFiles.get("5xx");
|
|
1667
|
-
if (category) return {
|
|
1668
|
-
file: category,
|
|
1669
|
-
status,
|
|
1670
|
-
kind: "category",
|
|
1671
|
-
segmentIndex: i
|
|
1672
|
-
};
|
|
1733
|
+
function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
|
|
1734
|
+
for (const rewrite of rewrites) {
|
|
1735
|
+
if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
|
|
1736
|
+
if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
|
|
1673
1737
|
}
|
|
1674
1738
|
return null;
|
|
1675
1739
|
}
|
|
1676
1740
|
/**
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
1679
|
-
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
1680
|
-
* Fallback chain: denied.tsx → default.tsx → null.
|
|
1741
|
+
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
1681
1742
|
*
|
|
1682
|
-
*
|
|
1743
|
+
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
1744
|
+
* Static segments must match exactly.
|
|
1683
1745
|
*/
|
|
1684
|
-
function
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1746
|
+
function pathnameMatchesPattern(pathname, pattern) {
|
|
1747
|
+
const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
|
|
1748
|
+
const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
1749
|
+
let pi = 0;
|
|
1750
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
1751
|
+
const segment = patternParts[i];
|
|
1752
|
+
if (segment.startsWith("[...") || segment.startsWith("[[...")) return pi < pathParts.length || segment.startsWith("[[...");
|
|
1753
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
1754
|
+
if (pi >= pathParts.length) return false;
|
|
1755
|
+
pi++;
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
|
|
1759
|
+
pi++;
|
|
1760
|
+
}
|
|
1761
|
+
return pi === pathParts.length;
|
|
1697
1762
|
}
|
|
1698
1763
|
//#endregion
|
|
1699
|
-
//#region src/server/
|
|
1764
|
+
//#region src/server/pipeline.ts
|
|
1700
1765
|
/**
|
|
1701
|
-
*
|
|
1766
|
+
* Request pipeline — the central dispatch for all timber.js requests.
|
|
1702
1767
|
*
|
|
1703
|
-
*
|
|
1704
|
-
*
|
|
1705
|
-
* throws) caught before flush produce correct HTTP status codes.
|
|
1768
|
+
* Pipeline stages (in order):
|
|
1769
|
+
* proxy.ts → canonicalize → route match → 103 Early Hints → middleware.ts → render
|
|
1706
1770
|
*
|
|
1707
|
-
*
|
|
1771
|
+
* Each stage is a pure function or returns a Response to short-circuit.
|
|
1772
|
+
* Each request gets a trace ID, structured logging, and OTEL spans.
|
|
1773
|
+
*
|
|
1774
|
+
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
|
|
1775
|
+
* and design/17-logging.md §"Production Logging"
|
|
1708
1776
|
*/
|
|
1709
1777
|
/**
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
* The flush controller:
|
|
1713
|
-
* 1. Calls the render function to start renderToReadableStream
|
|
1714
|
-
* 2. Waits for shellReady (onShellReady)
|
|
1715
|
-
* 3. If a render-phase signal was thrown (deny, redirect, error), produces
|
|
1716
|
-
* the correct HTTP status code
|
|
1717
|
-
* 4. If the shell rendered successfully, commits the status and streams
|
|
1778
|
+
* Run segment param coercion on the matched route's segments.
|
|
1718
1779
|
*
|
|
1719
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
1722
|
-
* - `RenderError` → HTTP status from error (default 500)
|
|
1723
|
-
* - Unhandled error → HTTP 500
|
|
1780
|
+
* Loads params.ts modules from segments that have them, extracts the
|
|
1781
|
+
* segmentParams definition, and coerces raw string params through codecs.
|
|
1782
|
+
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
1724
1783
|
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
* @returns The committed HTTP Response.
|
|
1728
|
-
*/
|
|
1729
|
-
async function flushResponse(renderFn, options = {}) {
|
|
1730
|
-
const { responseHeaders = new Headers(), defaultStatus = 200 } = options;
|
|
1731
|
-
let renderResult;
|
|
1732
|
-
try {
|
|
1733
|
-
renderResult = await renderFn();
|
|
1734
|
-
} catch (error) {
|
|
1735
|
-
return handleSignal(error, responseHeaders);
|
|
1736
|
-
}
|
|
1737
|
-
try {
|
|
1738
|
-
await renderResult.shellReady;
|
|
1739
|
-
} catch (error) {
|
|
1740
|
-
return handleSignal(error, responseHeaders);
|
|
1741
|
-
}
|
|
1742
|
-
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
|
1743
|
-
return {
|
|
1744
|
-
response: new Response(renderResult.stream, {
|
|
1745
|
-
status: defaultStatus,
|
|
1746
|
-
headers: responseHeaders
|
|
1747
|
-
}),
|
|
1748
|
-
status: defaultStatus,
|
|
1749
|
-
isRedirect: false,
|
|
1750
|
-
isDenial: false
|
|
1751
|
-
};
|
|
1752
|
-
}
|
|
1753
|
-
/**
|
|
1754
|
-
* Handle a render-phase signal and produce the correct HTTP response.
|
|
1784
|
+
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
1785
|
+
* See design/07-routing.md §"Where Coercion Runs"
|
|
1755
1786
|
*/
|
|
1756
|
-
function
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
}
|
|
1787
|
+
async function coerceSegmentParams(match) {
|
|
1788
|
+
const segments = match.segments;
|
|
1789
|
+
for (const segment of segments) {
|
|
1790
|
+
if (!segment.params) continue;
|
|
1791
|
+
const segmentParamsDef = (await segment.params.load()).segmentParams;
|
|
1792
|
+
if (!segmentParamsDef || typeof segmentParamsDef.parse !== "function") continue;
|
|
1793
|
+
try {
|
|
1794
|
+
const coerced = segmentParamsDef.parse(match.params);
|
|
1795
|
+
Object.assign(match.params, coerced);
|
|
1796
|
+
} catch (err) {
|
|
1797
|
+
throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
|
|
1798
|
+
}
|
|
1768
1799
|
}
|
|
1769
|
-
if (error instanceof DenySignal) return {
|
|
1770
|
-
response: new Response(null, {
|
|
1771
|
-
status: error.status,
|
|
1772
|
-
headers: responseHeaders
|
|
1773
|
-
}),
|
|
1774
|
-
status: error.status,
|
|
1775
|
-
isRedirect: false,
|
|
1776
|
-
isDenial: true
|
|
1777
|
-
};
|
|
1778
|
-
if (error instanceof RenderError) return {
|
|
1779
|
-
response: new Response(null, {
|
|
1780
|
-
status: error.status,
|
|
1781
|
-
headers: responseHeaders
|
|
1782
|
-
}),
|
|
1783
|
-
status: error.status,
|
|
1784
|
-
isRedirect: false,
|
|
1785
|
-
isDenial: false
|
|
1786
|
-
};
|
|
1787
|
-
console.error("[timber] Unhandled render-phase error:", error);
|
|
1788
|
-
return {
|
|
1789
|
-
response: new Response(null, {
|
|
1790
|
-
status: 500,
|
|
1791
|
-
headers: responseHeaders
|
|
1792
|
-
}),
|
|
1793
|
-
status: 500,
|
|
1794
|
-
isRedirect: false,
|
|
1795
|
-
isDenial: false
|
|
1796
|
-
};
|
|
1797
1800
|
}
|
|
1798
|
-
//#endregion
|
|
1799
|
-
//#region src/server/csrf.ts
|
|
1800
|
-
/** HTTP methods that are considered safe (no mutation). */
|
|
1801
|
-
var SAFE_METHODS = new Set([
|
|
1802
|
-
"GET",
|
|
1803
|
-
"HEAD",
|
|
1804
|
-
"OPTIONS"
|
|
1805
|
-
]);
|
|
1806
1801
|
/**
|
|
1807
|
-
*
|
|
1808
|
-
*
|
|
1809
|
-
* For mutation methods (POST, PUT, PATCH, DELETE):
|
|
1810
|
-
* - If `csrf: false`, skip validation.
|
|
1811
|
-
* - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
|
|
1812
|
-
* - Otherwise, Origin's host must match the request's Host header.
|
|
1802
|
+
* Create the request handler from a pipeline configuration.
|
|
1813
1803
|
*
|
|
1814
|
-
*
|
|
1804
|
+
* Returns a function that processes an incoming Request through all pipeline stages
|
|
1805
|
+
* and produces a Response. This is the top-level entry point for the server.
|
|
1815
1806
|
*/
|
|
1816
|
-
function
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1807
|
+
function createPipeline(config) {
|
|
1808
|
+
const { proxy, matchRoute, render, earlyHints, stripTrailingSlash = true, slowRequestMs = 3e3, serverTiming = "total", onPipelineError } = config;
|
|
1809
|
+
let activeRequests = 0;
|
|
1810
|
+
return async (req) => {
|
|
1811
|
+
const url = new URL(req.url);
|
|
1812
|
+
const method = req.method;
|
|
1813
|
+
const path = url.pathname;
|
|
1814
|
+
const startTime = performance.now();
|
|
1815
|
+
activeRequests++;
|
|
1816
|
+
return runWithTraceId(generateTraceId(), async () => {
|
|
1817
|
+
return runWithRequestContext(req, async () => {
|
|
1818
|
+
const runRequest = async () => {
|
|
1819
|
+
logRequestReceived({
|
|
1820
|
+
method,
|
|
1821
|
+
path
|
|
1822
|
+
});
|
|
1823
|
+
const response = await withSpan("http.server.request", {
|
|
1824
|
+
"http.request.method": method,
|
|
1825
|
+
"url.path": path
|
|
1826
|
+
}, async () => {
|
|
1827
|
+
const otelIds = await getOtelTraceId();
|
|
1828
|
+
if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
1829
|
+
let result;
|
|
1830
|
+
if (proxy || config.proxyLoader) result = await runProxyPhase(req, method, path);
|
|
1831
|
+
else result = await handleRequest(req, method, path);
|
|
1832
|
+
await setSpanAttribute("http.response.status_code", result.status);
|
|
1833
|
+
if (serverTiming === "detailed") {
|
|
1834
|
+
const timingHeader = getServerTimingHeader();
|
|
1835
|
+
if (timingHeader) {
|
|
1836
|
+
result = ensureMutableResponse(result);
|
|
1837
|
+
result.headers.set("Server-Timing", timingHeader);
|
|
1838
|
+
}
|
|
1839
|
+
} else if (serverTiming === "total") {
|
|
1840
|
+
const totalMs = Math.round(performance.now() - startTime);
|
|
1841
|
+
result = ensureMutableResponse(result);
|
|
1842
|
+
result.headers.set("Server-Timing", `total;dur=${totalMs}`);
|
|
1843
|
+
}
|
|
1844
|
+
return result;
|
|
1845
|
+
});
|
|
1846
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
1847
|
+
const status = response.status;
|
|
1848
|
+
const concurrency = activeRequests;
|
|
1849
|
+
activeRequests--;
|
|
1850
|
+
logRequestCompleted({
|
|
1851
|
+
method,
|
|
1852
|
+
path,
|
|
1853
|
+
status,
|
|
1854
|
+
durationMs,
|
|
1855
|
+
concurrency
|
|
1856
|
+
});
|
|
1857
|
+
if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
|
|
1858
|
+
method,
|
|
1859
|
+
path,
|
|
1860
|
+
durationMs,
|
|
1861
|
+
threshold: slowRequestMs,
|
|
1862
|
+
concurrency
|
|
1863
|
+
});
|
|
1864
|
+
return response;
|
|
1865
|
+
};
|
|
1866
|
+
return serverTiming === "detailed" ? runWithTimingCollector(runRequest) : runRequest();
|
|
1867
|
+
});
|
|
1868
|
+
});
|
|
1832
1869
|
};
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
}
|
|
1870
|
+
async function runProxyPhase(req, method, path) {
|
|
1871
|
+
try {
|
|
1872
|
+
let proxyExport;
|
|
1873
|
+
if (config.proxyLoader) proxyExport = (await config.proxyLoader()).default;
|
|
1874
|
+
else proxyExport = config.proxy;
|
|
1875
|
+
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
1876
|
+
return await withSpan("timber.proxy", {}, () => serverTiming === "detailed" ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn());
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
logProxyError({ error });
|
|
1879
|
+
await fireOnRequestError(error, req, "proxy");
|
|
1880
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "proxy");
|
|
1881
|
+
return new Response(null, { status: 500 });
|
|
1882
|
+
}
|
|
1841
1883
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
};
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
const unit = (match[2] ?? "").toLowerCase();
|
|
1864
|
-
switch (unit) {
|
|
1865
|
-
case "kb": return Math.floor(value * KB);
|
|
1866
|
-
case "mb": return Math.floor(value * MB);
|
|
1867
|
-
case "gb": return Math.floor(value * GB);
|
|
1868
|
-
case "": return Math.floor(value);
|
|
1869
|
-
default: throw new Error(`Unknown size unit: "${unit}"`);
|
|
1884
|
+
/**
|
|
1885
|
+
* Build a redirect Response from a RedirectSignal.
|
|
1886
|
+
*
|
|
1887
|
+
* For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
|
|
1888
|
+
* so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
1889
|
+
* turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
1890
|
+
* createFromFetch. See design/19-client-navigation.md.
|
|
1891
|
+
*/
|
|
1892
|
+
function buildRedirectResponse(signal, req, headers) {
|
|
1893
|
+
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
1894
|
+
headers.set("X-Timber-Redirect", signal.location);
|
|
1895
|
+
return new Response(null, {
|
|
1896
|
+
status: 204,
|
|
1897
|
+
headers
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
headers.set("Location", signal.location);
|
|
1901
|
+
return new Response(null, {
|
|
1902
|
+
status: signal.status,
|
|
1903
|
+
headers
|
|
1904
|
+
});
|
|
1870
1905
|
}
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
* Handles og:title, og:description, og:image (with dimensions/alt),
|
|
1903
|
-
* og:video, og:audio, og:article:author, and other OG properties.
|
|
1904
|
-
*/
|
|
1905
|
-
function renderOpenGraph(og, elements) {
|
|
1906
|
-
const simpleProps = [
|
|
1907
|
-
["og:title", og.title],
|
|
1908
|
-
["og:description", og.description],
|
|
1909
|
-
["og:url", og.url],
|
|
1910
|
-
["og:site_name", og.siteName],
|
|
1911
|
-
["og:locale", og.locale],
|
|
1912
|
-
["og:type", og.type],
|
|
1913
|
-
["og:article:published_time", og.publishedTime],
|
|
1914
|
-
["og:article:modified_time", og.modifiedTime]
|
|
1915
|
-
];
|
|
1916
|
-
for (const [property, content] of simpleProps) if (content) elements.push({
|
|
1917
|
-
tag: "meta",
|
|
1918
|
-
attrs: {
|
|
1919
|
-
property,
|
|
1920
|
-
content
|
|
1906
|
+
async function handleRequest(req, method, path) {
|
|
1907
|
+
const result = canonicalize(new URL(req.url).pathname, stripTrailingSlash);
|
|
1908
|
+
if (!result.ok) return new Response(null, { status: result.status });
|
|
1909
|
+
const canonicalPathname = result.pathname;
|
|
1910
|
+
if (config.matchMetadataRoute) {
|
|
1911
|
+
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
1912
|
+
if (metaMatch) try {
|
|
1913
|
+
if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
|
|
1914
|
+
const mod = await metaMatch.file.load();
|
|
1915
|
+
if (typeof mod.default !== "function") return new Response("Metadata route must export a default function", { status: 500 });
|
|
1916
|
+
const handlerResult = await mod.default();
|
|
1917
|
+
if (handlerResult instanceof Response) return handlerResult;
|
|
1918
|
+
const contentType = metaMatch.contentType;
|
|
1919
|
+
let body;
|
|
1920
|
+
if (typeof handlerResult === "string") body = handlerResult;
|
|
1921
|
+
else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
|
|
1922
|
+
else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
|
|
1923
|
+
else body = typeof handlerResult === "string" ? handlerResult : String(handlerResult);
|
|
1924
|
+
return new Response(body, {
|
|
1925
|
+
status: 200,
|
|
1926
|
+
headers: { "Content-Type": `${contentType}; charset=utf-8` }
|
|
1927
|
+
});
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
logRenderError({
|
|
1930
|
+
method,
|
|
1931
|
+
path,
|
|
1932
|
+
error
|
|
1933
|
+
});
|
|
1934
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "metadata-route");
|
|
1935
|
+
return new Response(null, { status: 500 });
|
|
1936
|
+
}
|
|
1921
1937
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1938
|
+
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
1939
|
+
if (!checkVersionSkew(req).ok) {
|
|
1940
|
+
const reloadHeaders = new Headers();
|
|
1941
|
+
applyReloadHeaders(reloadHeaders);
|
|
1942
|
+
return new Response(null, {
|
|
1943
|
+
status: 204,
|
|
1944
|
+
headers: reloadHeaders
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1928
1947
|
}
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
const
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1948
|
+
let match = matchRoute(canonicalPathname);
|
|
1949
|
+
let interception;
|
|
1950
|
+
const sourceUrl = req.headers.get("X-Timber-URL");
|
|
1951
|
+
if (sourceUrl && config.interceptionRewrites?.length) {
|
|
1952
|
+
const intercepted = findInterceptionMatch(canonicalPathname, sourceUrl, config.interceptionRewrites);
|
|
1953
|
+
if (intercepted) {
|
|
1954
|
+
const sourceMatch = matchRoute(intercepted.sourcePathname);
|
|
1955
|
+
if (sourceMatch) {
|
|
1956
|
+
match = sourceMatch;
|
|
1957
|
+
interception = { targetPathname: canonicalPathname };
|
|
1938
1958
|
}
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
if (!match) {
|
|
1962
|
+
if (config.renderNoMatch) {
|
|
1963
|
+
const responseHeaders = new Headers();
|
|
1964
|
+
return config.renderNoMatch(req, responseHeaders);
|
|
1965
|
+
}
|
|
1966
|
+
return new Response(null, { status: 404 });
|
|
1967
|
+
}
|
|
1968
|
+
const responseHeaders = new Headers();
|
|
1969
|
+
const requestHeaderOverlay = new Headers();
|
|
1970
|
+
responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
|
|
1971
|
+
if (earlyHints) try {
|
|
1972
|
+
await earlyHints(match, req, responseHeaders);
|
|
1973
|
+
} catch {}
|
|
1974
|
+
try {
|
|
1975
|
+
await coerceSegmentParams(match);
|
|
1976
|
+
} catch (error) {
|
|
1977
|
+
if (error instanceof ParamCoercionError) {
|
|
1978
|
+
const leafSegment = match.segments[match.segments.length - 1];
|
|
1979
|
+
if (leafSegment.route && !leafSegment.page) return new Response(null, { status: 404 });
|
|
1980
|
+
if (config.renderNoMatch) return config.renderNoMatch(req, responseHeaders);
|
|
1981
|
+
return new Response(null, { status: 404 });
|
|
1982
|
+
}
|
|
1983
|
+
throw error;
|
|
1984
|
+
}
|
|
1985
|
+
setSegmentParams(match.params);
|
|
1986
|
+
if (match.middleware) {
|
|
1987
|
+
const ctx = {
|
|
1988
|
+
req,
|
|
1989
|
+
requestHeaders: requestHeaderOverlay,
|
|
1990
|
+
headers: responseHeaders,
|
|
1991
|
+
segmentParams: match.params,
|
|
1992
|
+
earlyHints: (hints) => {
|
|
1993
|
+
for (const hint of hints) {
|
|
1994
|
+
let value;
|
|
1995
|
+
if (hint.as !== void 0) value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
1996
|
+
else value = `<${hint.href}>; rel=${hint.rel}`;
|
|
1997
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
1998
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
1999
|
+
responseHeaders.append("Link", value);
|
|
2000
|
+
}
|
|
1945
2001
|
}
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
2002
|
+
};
|
|
2003
|
+
try {
|
|
2004
|
+
setMutableCookieContext(true);
|
|
2005
|
+
const middlewareFn = () => runMiddleware(match.middleware, ctx);
|
|
2006
|
+
const middlewareResponse = await withSpan("timber.middleware", {}, () => serverTiming === "detailed" ? withTiming("mw", "middleware.ts", middlewareFn) : middlewareFn());
|
|
2007
|
+
setMutableCookieContext(false);
|
|
2008
|
+
if (middlewareResponse) {
|
|
2009
|
+
const finalResponse = ensureMutableResponse(middlewareResponse);
|
|
2010
|
+
applyCookieJar(finalResponse.headers);
|
|
2011
|
+
logMiddlewareShortCircuit({
|
|
2012
|
+
method,
|
|
2013
|
+
path,
|
|
2014
|
+
status: finalResponse.status
|
|
2015
|
+
});
|
|
2016
|
+
return finalResponse;
|
|
1952
2017
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2018
|
+
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
2019
|
+
} catch (error) {
|
|
2020
|
+
setMutableCookieContext(false);
|
|
2021
|
+
if (error instanceof RedirectSignal) {
|
|
2022
|
+
applyCookieJar(responseHeaders);
|
|
2023
|
+
return buildRedirectResponse(error, req, responseHeaders);
|
|
1959
2024
|
}
|
|
2025
|
+
if (error instanceof DenySignal) return new Response(null, { status: error.status });
|
|
2026
|
+
logMiddlewareError({
|
|
2027
|
+
method,
|
|
2028
|
+
path,
|
|
2029
|
+
error
|
|
2030
|
+
});
|
|
2031
|
+
await fireOnRequestError(error, req, "handler");
|
|
2032
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "middleware");
|
|
2033
|
+
return new Response(null, { status: 500 });
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
applyCookieJar(responseHeaders);
|
|
2037
|
+
try {
|
|
2038
|
+
const renderFn = () => render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
2039
|
+
const response = await withSpan("timber.render", { "http.route": canonicalPathname }, () => serverTiming === "detailed" ? withTiming("render", "RSC + SSR render", renderFn) : renderFn());
|
|
2040
|
+
markResponseFlushed();
|
|
2041
|
+
return response;
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
if (error instanceof DenySignal) return new Response(null, { status: error.status });
|
|
2044
|
+
if (error instanceof RedirectSignal) return buildRedirectResponse(error, req, responseHeaders);
|
|
2045
|
+
logRenderError({
|
|
2046
|
+
method,
|
|
2047
|
+
path,
|
|
2048
|
+
error
|
|
1960
2049
|
});
|
|
2050
|
+
await fireOnRequestError(error, req, "render");
|
|
2051
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "render");
|
|
2052
|
+
if (config.renderFallbackError) try {
|
|
2053
|
+
return await config.renderFallbackError(error, req, responseHeaders);
|
|
2054
|
+
} catch {}
|
|
2055
|
+
return new Response(null, { status: 500 });
|
|
1961
2056
|
}
|
|
1962
2057
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
property: "og:audio",
|
|
1974
|
-
content: audio.url
|
|
1975
|
-
}
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Fire the user's onRequestError hook with request context.
|
|
2061
|
+
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
2062
|
+
*/
|
|
2063
|
+
async function fireOnRequestError(error, req, phase) {
|
|
2064
|
+
const url = new URL(req.url);
|
|
2065
|
+
const headersObj = {};
|
|
2066
|
+
req.headers.forEach((v, k) => {
|
|
2067
|
+
headersObj[k] = v;
|
|
1976
2068
|
});
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2069
|
+
await callOnRequestError(error, {
|
|
2070
|
+
method: req.method,
|
|
2071
|
+
path: url.pathname,
|
|
2072
|
+
headers: headersObj
|
|
2073
|
+
}, {
|
|
2074
|
+
phase,
|
|
2075
|
+
routePath: url.pathname,
|
|
2076
|
+
routeType: "page",
|
|
2077
|
+
traceId: traceId()
|
|
1983
2078
|
});
|
|
1984
2079
|
}
|
|
1985
2080
|
/**
|
|
1986
|
-
*
|
|
2081
|
+
* Apply all Set-Cookie headers from the cookie jar to a Headers object.
|
|
2082
|
+
* Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
|
|
2083
|
+
*/
|
|
2084
|
+
function applyCookieJar(headers) {
|
|
2085
|
+
for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Ensure a Response has mutable headers so the pipeline can safely append
|
|
2089
|
+
* Set-Cookie and Server-Timing entries.
|
|
1987
2090
|
*
|
|
1988
|
-
*
|
|
1989
|
-
*
|
|
2091
|
+
* `Response.redirect()` and some platform-level responses return objects
|
|
2092
|
+
* with immutable headers. Calling `.set()` or `.append()` on them throws
|
|
2093
|
+
* `TypeError: immutable`. This helper detects the immutable case by
|
|
2094
|
+
* attempting a no-op write and, on failure, clones into a fresh Response
|
|
2095
|
+
* with mutable headers.
|
|
1990
2096
|
*/
|
|
1991
|
-
function
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
for (const [name, content] of simpleProps) if (content) elements.push({
|
|
2002
|
-
tag: "meta",
|
|
2003
|
-
attrs: {
|
|
2004
|
-
name,
|
|
2005
|
-
content
|
|
2006
|
-
}
|
|
2007
|
-
});
|
|
2008
|
-
if (tw.images) if (typeof tw.images === "string") elements.push({
|
|
2009
|
-
tag: "meta",
|
|
2010
|
-
attrs: {
|
|
2011
|
-
name: "twitter:image",
|
|
2012
|
-
content: tw.images
|
|
2013
|
-
}
|
|
2014
|
-
});
|
|
2015
|
-
else {
|
|
2016
|
-
const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
|
|
2017
|
-
for (const img of imgList) {
|
|
2018
|
-
const url = typeof img === "string" ? img : img.url;
|
|
2019
|
-
elements.push({
|
|
2020
|
-
tag: "meta",
|
|
2021
|
-
attrs: {
|
|
2022
|
-
name: "twitter:image",
|
|
2023
|
-
content: url
|
|
2024
|
-
}
|
|
2025
|
-
});
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
if (tw.players) for (const player of tw.players) {
|
|
2029
|
-
elements.push({
|
|
2030
|
-
tag: "meta",
|
|
2031
|
-
attrs: {
|
|
2032
|
-
name: "twitter:player",
|
|
2033
|
-
content: player.playerUrl
|
|
2034
|
-
}
|
|
2035
|
-
});
|
|
2036
|
-
if (player.width) elements.push({
|
|
2037
|
-
tag: "meta",
|
|
2038
|
-
attrs: {
|
|
2039
|
-
name: "twitter:player:width",
|
|
2040
|
-
content: String(player.width)
|
|
2041
|
-
}
|
|
2042
|
-
});
|
|
2043
|
-
if (player.height) elements.push({
|
|
2044
|
-
tag: "meta",
|
|
2045
|
-
attrs: {
|
|
2046
|
-
name: "twitter:player:height",
|
|
2047
|
-
content: String(player.height)
|
|
2048
|
-
}
|
|
2049
|
-
});
|
|
2050
|
-
if (player.streamUrl) elements.push({
|
|
2051
|
-
tag: "meta",
|
|
2052
|
-
attrs: {
|
|
2053
|
-
name: "twitter:player:stream",
|
|
2054
|
-
content: player.streamUrl
|
|
2055
|
-
}
|
|
2097
|
+
function ensureMutableResponse(response) {
|
|
2098
|
+
try {
|
|
2099
|
+
response.headers.set("X-Timber-Probe", "1");
|
|
2100
|
+
response.headers.delete("X-Timber-Probe");
|
|
2101
|
+
return response;
|
|
2102
|
+
} catch {
|
|
2103
|
+
return new Response(response.body, {
|
|
2104
|
+
status: response.status,
|
|
2105
|
+
statusText: response.statusText,
|
|
2106
|
+
headers: new Headers(response.headers)
|
|
2056
2107
|
});
|
|
2057
2108
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
attrs: {
|
|
2078
|
-
name: `twitter:app:id:${tag}`,
|
|
2079
|
-
content: id
|
|
2080
|
-
}
|
|
2081
|
-
});
|
|
2109
|
+
}
|
|
2110
|
+
//#endregion
|
|
2111
|
+
//#region src/server/build-manifest.ts
|
|
2112
|
+
/**
|
|
2113
|
+
* Collect all CSS files needed for a matched route's segment chain.
|
|
2114
|
+
*
|
|
2115
|
+
* Walks segments root → leaf, collecting CSS for each layout and page.
|
|
2116
|
+
* Deduplicates while preserving order (root layout CSS first).
|
|
2117
|
+
*/
|
|
2118
|
+
function collectRouteCss(segments, manifest) {
|
|
2119
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2120
|
+
const result = [];
|
|
2121
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2122
|
+
if (!file) continue;
|
|
2123
|
+
const cssFiles = manifest.css[file.filePath];
|
|
2124
|
+
if (!cssFiles) continue;
|
|
2125
|
+
for (const url of cssFiles) if (!seen.has(url)) {
|
|
2126
|
+
seen.add(url);
|
|
2127
|
+
result.push(url);
|
|
2082
2128
|
}
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2129
|
+
}
|
|
2130
|
+
return result;
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Collect all font entries needed for a matched route's segment chain.
|
|
2134
|
+
*
|
|
2135
|
+
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
2136
|
+
* Deduplicates by href while preserving order.
|
|
2137
|
+
*/
|
|
2138
|
+
function collectRouteFonts(segments, manifest) {
|
|
2139
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2140
|
+
const result = [];
|
|
2141
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2142
|
+
if (!file) continue;
|
|
2143
|
+
const fonts = manifest.fonts[file.filePath];
|
|
2144
|
+
if (!fonts) continue;
|
|
2145
|
+
for (const entry of fonts) if (!seen.has(entry.href)) {
|
|
2146
|
+
seen.add(entry.href);
|
|
2147
|
+
result.push(entry);
|
|
2092
2148
|
}
|
|
2093
2149
|
}
|
|
2150
|
+
return result;
|
|
2094
2151
|
}
|
|
2095
|
-
//#endregion
|
|
2096
|
-
//#region src/server/metadata-platform.ts
|
|
2097
2152
|
/**
|
|
2098
|
-
*
|
|
2153
|
+
* Collect modulepreload URLs for a matched route's segment chain.
|
|
2154
|
+
*
|
|
2155
|
+
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
2156
|
+
* for each layout and page. Deduplicates across segments.
|
|
2099
2157
|
*/
|
|
2100
|
-
function
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
const attrs = {
|
|
2111
|
-
rel: "icon",
|
|
2112
|
-
href: icon.url
|
|
2113
|
-
};
|
|
2114
|
-
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
2115
|
-
if (icon.type) attrs.type = icon.type;
|
|
2116
|
-
elements.push({
|
|
2117
|
-
tag: "link",
|
|
2118
|
-
attrs
|
|
2119
|
-
});
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
if (icons.shortcut) {
|
|
2123
|
-
const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
|
|
2124
|
-
for (const url of urls) elements.push({
|
|
2125
|
-
tag: "link",
|
|
2126
|
-
attrs: {
|
|
2127
|
-
rel: "shortcut icon",
|
|
2128
|
-
href: url
|
|
2129
|
-
}
|
|
2130
|
-
});
|
|
2131
|
-
}
|
|
2132
|
-
if (icons.apple) {
|
|
2133
|
-
if (typeof icons.apple === "string") elements.push({
|
|
2134
|
-
tag: "link",
|
|
2135
|
-
attrs: {
|
|
2136
|
-
rel: "apple-touch-icon",
|
|
2137
|
-
href: icons.apple
|
|
2138
|
-
}
|
|
2139
|
-
});
|
|
2140
|
-
else if (Array.isArray(icons.apple)) for (const icon of icons.apple) {
|
|
2141
|
-
const attrs = {
|
|
2142
|
-
rel: "apple-touch-icon",
|
|
2143
|
-
href: icon.url
|
|
2144
|
-
};
|
|
2145
|
-
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
2146
|
-
elements.push({
|
|
2147
|
-
tag: "link",
|
|
2148
|
-
attrs
|
|
2149
|
-
});
|
|
2158
|
+
function collectRouteModulepreloads(segments, manifest) {
|
|
2159
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2160
|
+
const result = [];
|
|
2161
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2162
|
+
if (!file) continue;
|
|
2163
|
+
const preloads = manifest.modulepreload[file.filePath];
|
|
2164
|
+
if (!preloads) continue;
|
|
2165
|
+
for (const url of preloads) if (!seen.has(url)) {
|
|
2166
|
+
seen.add(url);
|
|
2167
|
+
result.push(url);
|
|
2150
2168
|
}
|
|
2151
2169
|
}
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2170
|
+
return result;
|
|
2171
|
+
}
|
|
2172
|
+
//#endregion
|
|
2173
|
+
//#region src/server/early-hints.ts
|
|
2174
|
+
/**
|
|
2175
|
+
* 103 Early Hints utilities.
|
|
2176
|
+
*
|
|
2177
|
+
* Early Hints are sent before the final response to let the browser
|
|
2178
|
+
* start fetching critical resources (CSS, fonts, JS) while the server
|
|
2179
|
+
* is still rendering.
|
|
2180
|
+
*
|
|
2181
|
+
* The framework collects hints from two sources:
|
|
2182
|
+
* 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
|
|
2183
|
+
* 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
|
|
2184
|
+
*
|
|
2185
|
+
* Both are emitted as Link headers. Cloudflare CDN automatically converts
|
|
2186
|
+
* Link headers into 103 Early Hints responses.
|
|
2187
|
+
*
|
|
2188
|
+
* Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
2189
|
+
*/
|
|
2190
|
+
/**
|
|
2191
|
+
* Format a single EarlyHint as a Link header value.
|
|
2192
|
+
*
|
|
2193
|
+
* Attribute order: `as` before `rel` to match Cloudflare CDN's cached
|
|
2194
|
+
* Early Hints format. Cloudflare caches Link headers from 200 responses
|
|
2195
|
+
* and re-emits them as 103 Early Hints on subsequent requests. If our
|
|
2196
|
+
* attribute order differs from Cloudflare's cached copy, the browser
|
|
2197
|
+
* sees two preload headers for the same URL (different attribute order)
|
|
2198
|
+
* and warns "Preload was ignored." Matching the order ensures the
|
|
2199
|
+
* browser deduplicates them correctly.
|
|
2200
|
+
*
|
|
2201
|
+
* Examples:
|
|
2202
|
+
* `</styles/root.css>; as=style; rel=preload`
|
|
2203
|
+
* `</fonts/inter.woff2>; as=font; rel=preload; crossorigin=anonymous`
|
|
2204
|
+
* `</_timber/client.js>; rel=modulepreload`
|
|
2205
|
+
* `<https://fonts.googleapis.com>; rel=preconnect`
|
|
2206
|
+
*/
|
|
2207
|
+
function formatLinkHeader(hint) {
|
|
2208
|
+
if (hint.as !== void 0) {
|
|
2209
|
+
let value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
2210
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
2211
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
2212
|
+
return value;
|
|
2163
2213
|
}
|
|
2214
|
+
let value = `<${hint.href}>; rel=${hint.rel}`;
|
|
2215
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
2216
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
2217
|
+
return value;
|
|
2164
2218
|
}
|
|
2165
2219
|
/**
|
|
2166
|
-
*
|
|
2220
|
+
* Collect all Link header strings for a matched route's segment chain.
|
|
2221
|
+
*
|
|
2222
|
+
* Walks the build manifest to emit hints for:
|
|
2223
|
+
* - CSS stylesheets (as=style; rel=preload)
|
|
2224
|
+
* - Font assets (as=font; rel=preload; crossorigin)
|
|
2225
|
+
* - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
|
|
2226
|
+
*
|
|
2227
|
+
* Also emits global CSS from the `_global` manifest key. Route files
|
|
2228
|
+
* are server components that don't appear in the client bundle, so
|
|
2229
|
+
* per-route CSS keying doesn't work with the RSC plugin. The `_global`
|
|
2230
|
+
* key contains all CSS assets from the client build — fine for early
|
|
2231
|
+
* hints since they're just prefetch signals.
|
|
2232
|
+
*
|
|
2233
|
+
* Returns formatted Link header strings, deduplicated by URL, root → leaf order.
|
|
2234
|
+
* Returns an empty array in dev mode (manifest is empty).
|
|
2167
2235
|
*/
|
|
2168
|
-
function
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
});
|
|
2176
|
-
if (alternates.languages) for (const [lang, href] of Object.entries(alternates.languages)) elements.push({
|
|
2177
|
-
tag: "link",
|
|
2178
|
-
attrs: {
|
|
2179
|
-
rel: "alternate",
|
|
2180
|
-
hreflang: lang,
|
|
2181
|
-
href
|
|
2182
|
-
}
|
|
2183
|
-
});
|
|
2184
|
-
if (alternates.media) for (const [media, href] of Object.entries(alternates.media)) elements.push({
|
|
2185
|
-
tag: "link",
|
|
2186
|
-
attrs: {
|
|
2187
|
-
rel: "alternate",
|
|
2188
|
-
media,
|
|
2189
|
-
href
|
|
2190
|
-
}
|
|
2191
|
-
});
|
|
2192
|
-
if (alternates.types) for (const [type, href] of Object.entries(alternates.types)) elements.push({
|
|
2193
|
-
tag: "link",
|
|
2194
|
-
attrs: {
|
|
2195
|
-
rel: "alternate",
|
|
2196
|
-
type,
|
|
2197
|
-
href
|
|
2236
|
+
function collectEarlyHintHeaders(segments, manifest, options) {
|
|
2237
|
+
const result = [];
|
|
2238
|
+
const seenUrls = /* @__PURE__ */ new Set();
|
|
2239
|
+
const add = (url, header) => {
|
|
2240
|
+
if (!seenUrls.has(url)) {
|
|
2241
|
+
seenUrls.add(url);
|
|
2242
|
+
result.push(header);
|
|
2198
2243
|
}
|
|
2199
|
-
}
|
|
2244
|
+
};
|
|
2245
|
+
for (const url of collectRouteCss(segments, manifest)) add(url, formatLinkHeader({
|
|
2246
|
+
href: url,
|
|
2247
|
+
rel: "preload",
|
|
2248
|
+
as: "style"
|
|
2249
|
+
}));
|
|
2250
|
+
for (const url of manifest.css["_global"] ?? []) add(url, formatLinkHeader({
|
|
2251
|
+
href: url,
|
|
2252
|
+
rel: "preload",
|
|
2253
|
+
as: "style"
|
|
2254
|
+
}));
|
|
2255
|
+
for (const font of collectRouteFonts(segments, manifest)) add(font.href, formatLinkHeader({
|
|
2256
|
+
href: font.href,
|
|
2257
|
+
rel: "preload",
|
|
2258
|
+
as: "font",
|
|
2259
|
+
crossOrigin: "anonymous"
|
|
2260
|
+
}));
|
|
2261
|
+
if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(url, formatLinkHeader({
|
|
2262
|
+
href: url,
|
|
2263
|
+
rel: "modulepreload"
|
|
2264
|
+
}));
|
|
2265
|
+
return result;
|
|
2266
|
+
}
|
|
2267
|
+
//#endregion
|
|
2268
|
+
//#region src/server/early-hints-sender.ts
|
|
2269
|
+
/**
|
|
2270
|
+
* Per-request 103 Early Hints sender — ALS bridge for platform adapters.
|
|
2271
|
+
*
|
|
2272
|
+
* The pipeline collects Link headers for CSS, fonts, and JS chunks at
|
|
2273
|
+
* route-match time. On platforms that support it (Node.js v18.11+, Bun),
|
|
2274
|
+
* the adapter can send these as a 103 Early Hints interim response before
|
|
2275
|
+
* the final response is ready.
|
|
2276
|
+
*
|
|
2277
|
+
* This module provides an ALS-based bridge: the generated entry point
|
|
2278
|
+
* (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
|
|
2279
|
+
* binding a per-request sender function. The pipeline calls
|
|
2280
|
+
* `sendEarlyHints103()` to fire the 103 if a sender is available.
|
|
2281
|
+
*
|
|
2282
|
+
* On platforms where 103 is handled at the CDN level (e.g., Cloudflare
|
|
2283
|
+
* converts Link headers into 103 automatically), no sender is installed
|
|
2284
|
+
* and `sendEarlyHints103()` is a no-op.
|
|
2285
|
+
*
|
|
2286
|
+
* Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
2287
|
+
*/
|
|
2288
|
+
/**
|
|
2289
|
+
* Run a function with a per-request early hints sender installed.
|
|
2290
|
+
*
|
|
2291
|
+
* Called by generated entry points (e.g., Nitro node-server/bun) to
|
|
2292
|
+
* bind the platform's writeEarlyHints capability for the request duration.
|
|
2293
|
+
*/
|
|
2294
|
+
function runWithEarlyHintsSender(sender, fn) {
|
|
2295
|
+
return earlyHintsSenderAls.run(sender, fn);
|
|
2200
2296
|
}
|
|
2201
2297
|
/**
|
|
2202
|
-
*
|
|
2298
|
+
* Send collected Link headers as a 103 Early Hints response.
|
|
2299
|
+
*
|
|
2300
|
+
* No-op if no sender is installed for the current request (e.g., on
|
|
2301
|
+
* Cloudflare where the CDN handles 103 automatically, or in dev mode).
|
|
2302
|
+
*
|
|
2303
|
+
* Non-fatal: errors from the sender are caught and silently ignored.
|
|
2203
2304
|
*/
|
|
2204
|
-
function
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
tag: "meta",
|
|
2212
|
-
attrs: {
|
|
2213
|
-
name,
|
|
2214
|
-
content
|
|
2215
|
-
}
|
|
2216
|
-
});
|
|
2217
|
-
if (verification.other) for (const [name, value] of Object.entries(verification.other)) {
|
|
2218
|
-
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
2219
|
-
elements.push({
|
|
2220
|
-
tag: "meta",
|
|
2221
|
-
attrs: {
|
|
2222
|
-
name,
|
|
2223
|
-
content
|
|
2224
|
-
}
|
|
2225
|
-
});
|
|
2226
|
-
}
|
|
2305
|
+
function sendEarlyHints103(links) {
|
|
2306
|
+
if (!links.length) return;
|
|
2307
|
+
const sender = earlyHintsSenderAls.getStore();
|
|
2308
|
+
if (!sender) return;
|
|
2309
|
+
try {
|
|
2310
|
+
sender(links);
|
|
2311
|
+
} catch {}
|
|
2227
2312
|
}
|
|
2313
|
+
//#endregion
|
|
2314
|
+
//#region src/server/tree-builder.ts
|
|
2228
2315
|
/**
|
|
2229
|
-
*
|
|
2316
|
+
* Build the unified element tree from a matched segment chain.
|
|
2317
|
+
*
|
|
2318
|
+
* Construction is bottom-up:
|
|
2319
|
+
* 1. Start with the page component (leaf segment)
|
|
2320
|
+
* 2. Wrap in status-code error boundaries (fallback chain)
|
|
2321
|
+
* 3. Wrap in AccessGate (if segment has access.ts)
|
|
2322
|
+
* 4. Pass as children to the segment's layout
|
|
2323
|
+
* 5. Repeat up the segment chain to root
|
|
2324
|
+
*
|
|
2325
|
+
* Parallel slots are resolved at each layout level and composed as named props.
|
|
2230
2326
|
*/
|
|
2231
|
-
function
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
}
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
}
|
|
2252
|
-
});
|
|
2253
|
-
if (appleWebApp.startupImage) {
|
|
2254
|
-
const images = Array.isArray(appleWebApp.startupImage) ? appleWebApp.startupImage : [{ url: appleWebApp.startupImage }];
|
|
2255
|
-
for (const img of images) {
|
|
2256
|
-
const attrs = {
|
|
2257
|
-
rel: "apple-touch-startup-image",
|
|
2258
|
-
href: typeof img === "string" ? img : img.url
|
|
2259
|
-
};
|
|
2260
|
-
if (typeof img === "object" && img.media) attrs.media = img.media;
|
|
2261
|
-
elements.push({
|
|
2262
|
-
tag: "link",
|
|
2263
|
-
attrs
|
|
2327
|
+
async function buildElementTree(config) {
|
|
2328
|
+
const { segments, loadModule, createElement, errorBoundaryComponent } = config;
|
|
2329
|
+
if (segments.length === 0) throw new Error("[timber] buildElementTree: empty segment chain");
|
|
2330
|
+
const leaf = segments[segments.length - 1];
|
|
2331
|
+
if (leaf.route && !leaf.page) return {
|
|
2332
|
+
tree: null,
|
|
2333
|
+
isApiRoute: true
|
|
2334
|
+
};
|
|
2335
|
+
const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
|
|
2336
|
+
if (!PageComponent) throw new Error(`[timber] No page component found for route at ${leaf.urlPath}. Each route must have a page.tsx or route.ts.`);
|
|
2337
|
+
let element = createElement(PageComponent, {});
|
|
2338
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2339
|
+
const segment = segments[i];
|
|
2340
|
+
element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent);
|
|
2341
|
+
if (segment.access) {
|
|
2342
|
+
const accessFn = (await loadModule(segment.access)).default;
|
|
2343
|
+
element = createElement("timber:access-gate", {
|
|
2344
|
+
accessFn,
|
|
2345
|
+
segmentName: segment.segmentName,
|
|
2346
|
+
children: element
|
|
2264
2347
|
});
|
|
2265
2348
|
}
|
|
2349
|
+
if (segment.layout) {
|
|
2350
|
+
const LayoutComponent = (await loadModule(segment.layout)).default;
|
|
2351
|
+
if (LayoutComponent) {
|
|
2352
|
+
const slotProps = {};
|
|
2353
|
+
if (segment.slots.size > 0) for (const [slotName, slotNode] of segment.slots) slotProps[slotName] = await buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent);
|
|
2354
|
+
element = createElement(LayoutComponent, {
|
|
2355
|
+
...slotProps,
|
|
2356
|
+
children: element
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2266
2360
|
}
|
|
2361
|
+
return {
|
|
2362
|
+
tree: element,
|
|
2363
|
+
isApiRoute: false
|
|
2364
|
+
};
|
|
2267
2365
|
}
|
|
2268
2366
|
/**
|
|
2269
|
-
*
|
|
2367
|
+
* Build the element tree for a parallel slot.
|
|
2368
|
+
*
|
|
2369
|
+
* Slots have their own access.ts (SlotAccessGate) and error boundaries.
|
|
2370
|
+
* On access denial: denied.tsx → default.tsx → null (graceful degradation).
|
|
2270
2371
|
*/
|
|
2271
|
-
function
|
|
2272
|
-
const
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
}
|
|
2289
|
-
if (appLinks.web) {
|
|
2290
|
-
if (appLinks.web.url) elements.push({
|
|
2291
|
-
tag: "meta",
|
|
2292
|
-
attrs: {
|
|
2293
|
-
property: "al:web:url",
|
|
2294
|
-
content: appLinks.web.url
|
|
2295
|
-
}
|
|
2296
|
-
});
|
|
2297
|
-
if (appLinks.web.shouldFallback !== void 0) elements.push({
|
|
2298
|
-
tag: "meta",
|
|
2299
|
-
attrs: {
|
|
2300
|
-
property: "al:web:should_fallback",
|
|
2301
|
-
content: appLinks.web.shouldFallback ? "true" : "false"
|
|
2302
|
-
}
|
|
2372
|
+
async function buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent) {
|
|
2373
|
+
const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
|
|
2374
|
+
const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
|
|
2375
|
+
if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {}) : null;
|
|
2376
|
+
let element = createElement(PageComponent, {});
|
|
2377
|
+
element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement, errorBoundaryComponent);
|
|
2378
|
+
if (slotNode.access) {
|
|
2379
|
+
const accessFn = (await loadModule(slotNode.access)).default;
|
|
2380
|
+
const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default ?? null;
|
|
2381
|
+
const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {}) : null;
|
|
2382
|
+
element = createElement("timber:slot-access-gate", {
|
|
2383
|
+
accessFn,
|
|
2384
|
+
DeniedComponent,
|
|
2385
|
+
slotName: slotNode.segmentName.replace(/^@/, ""),
|
|
2386
|
+
createElement,
|
|
2387
|
+
defaultFallback,
|
|
2388
|
+
children: element
|
|
2303
2389
|
});
|
|
2304
2390
|
}
|
|
2391
|
+
return element;
|
|
2305
2392
|
}
|
|
2393
|
+
/** MDX/markdown extensions — these are server components that cannot be passed as function props. */
|
|
2394
|
+
var MDX_EXTENSIONS = new Set(["mdx", "md"]);
|
|
2306
2395
|
/**
|
|
2307
|
-
*
|
|
2396
|
+
* Check if a route file is an MDX/markdown file based on its extension.
|
|
2397
|
+
* MDX components are server components by default and cannot cross the
|
|
2398
|
+
* RSC→client boundary as function props. They must be pre-rendered as
|
|
2399
|
+
* elements and passed as fallbackElement instead of fallbackComponent.
|
|
2308
2400
|
*/
|
|
2309
|
-
function
|
|
2310
|
-
|
|
2311
|
-
if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
|
|
2312
|
-
if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
|
|
2313
|
-
elements.push({
|
|
2314
|
-
tag: "meta",
|
|
2315
|
-
attrs: {
|
|
2316
|
-
name: "apple-itunes-app",
|
|
2317
|
-
content: parts.join(", ")
|
|
2318
|
-
}
|
|
2319
|
-
});
|
|
2401
|
+
function isMdxFile(file) {
|
|
2402
|
+
return MDX_EXTENSIONS.has(file.extension);
|
|
2320
2403
|
}
|
|
2321
|
-
//#endregion
|
|
2322
|
-
//#region src/server/metadata-render.ts
|
|
2323
2404
|
/**
|
|
2324
|
-
*
|
|
2405
|
+
* Wrap an element with error boundaries from a segment's status-code files.
|
|
2325
2406
|
*
|
|
2326
|
-
*
|
|
2327
|
-
*
|
|
2407
|
+
* Wrapping order (innermost to outermost):
|
|
2408
|
+
* 1. Specific status files (503.tsx, 429.tsx, etc.)
|
|
2409
|
+
* 2. Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
2410
|
+
* 3. error.tsx (general error boundary)
|
|
2328
2411
|
*
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2412
|
+
* This creates the fallback chain described in design/10-error-handling.md.
|
|
2413
|
+
*
|
|
2414
|
+
* MDX status files are server components and cannot be passed as function
|
|
2415
|
+
* props to TimberErrorBoundary (a 'use client' component). Instead, they
|
|
2416
|
+
* are pre-rendered as elements and passed as fallbackElement. The error
|
|
2417
|
+
* boundary renders the element directly when an error is caught.
|
|
2418
|
+
* See TIM-503.
|
|
2331
2419
|
*/
|
|
2332
|
-
function
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
for (const [name, content] of simpleMetaProps) if (content) elements.push({
|
|
2348
|
-
tag: "meta",
|
|
2349
|
-
attrs: {
|
|
2350
|
-
name,
|
|
2351
|
-
content
|
|
2352
|
-
}
|
|
2353
|
-
});
|
|
2354
|
-
if (metadata.keywords) {
|
|
2355
|
-
const content = Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
|
|
2356
|
-
elements.push({
|
|
2357
|
-
tag: "meta",
|
|
2358
|
-
attrs: {
|
|
2359
|
-
name: "keywords",
|
|
2360
|
-
content
|
|
2361
|
-
}
|
|
2362
|
-
});
|
|
2363
|
-
}
|
|
2364
|
-
if (metadata.robots) {
|
|
2365
|
-
const content = typeof metadata.robots === "string" ? metadata.robots : renderRobotsObject(metadata.robots);
|
|
2366
|
-
elements.push({
|
|
2367
|
-
tag: "meta",
|
|
2368
|
-
attrs: {
|
|
2369
|
-
name: "robots",
|
|
2370
|
-
content
|
|
2371
|
-
}
|
|
2372
|
-
});
|
|
2373
|
-
if (typeof metadata.robots === "object" && metadata.robots.googleBot) {
|
|
2374
|
-
const gbContent = typeof metadata.robots.googleBot === "string" ? metadata.robots.googleBot : renderRobotsObject(metadata.robots.googleBot);
|
|
2375
|
-
elements.push({
|
|
2376
|
-
tag: "meta",
|
|
2377
|
-
attrs: {
|
|
2378
|
-
name: "googlebot",
|
|
2379
|
-
content: gbContent
|
|
2380
|
-
}
|
|
2381
|
-
});
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2384
|
-
if (metadata.openGraph) renderOpenGraph(metadata.openGraph, elements);
|
|
2385
|
-
if (metadata.twitter) renderTwitter(metadata.twitter, elements);
|
|
2386
|
-
if (metadata.icons) renderIcons(metadata.icons, elements);
|
|
2387
|
-
if (metadata.manifest) elements.push({
|
|
2388
|
-
tag: "link",
|
|
2389
|
-
attrs: {
|
|
2390
|
-
rel: "manifest",
|
|
2391
|
-
href: metadata.manifest
|
|
2392
|
-
}
|
|
2393
|
-
});
|
|
2394
|
-
if (metadata.alternates) renderAlternates(metadata.alternates, elements);
|
|
2395
|
-
if (metadata.verification) renderVerification(metadata.verification, elements);
|
|
2396
|
-
if (metadata.formatDetection) {
|
|
2397
|
-
const parts = [];
|
|
2398
|
-
if (metadata.formatDetection.telephone === false) parts.push("telephone=no");
|
|
2399
|
-
if (metadata.formatDetection.email === false) parts.push("email=no");
|
|
2400
|
-
if (metadata.formatDetection.address === false) parts.push("address=no");
|
|
2401
|
-
if (parts.length > 0) elements.push({
|
|
2402
|
-
tag: "meta",
|
|
2403
|
-
attrs: {
|
|
2404
|
-
name: "format-detection",
|
|
2405
|
-
content: parts.join(", ")
|
|
2420
|
+
async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
|
|
2421
|
+
if (segment.statusFiles) {
|
|
2422
|
+
for (const [key, file] of segment.statusFiles) if (key !== "4xx" && key !== "5xx") {
|
|
2423
|
+
const status = parseInt(key, 10);
|
|
2424
|
+
if (!isNaN(status)) {
|
|
2425
|
+
const Component = (await loadModule(file)).default;
|
|
2426
|
+
if (Component) element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
|
|
2427
|
+
fallbackElement: createElement(Component, { status }),
|
|
2428
|
+
status,
|
|
2429
|
+
children: element
|
|
2430
|
+
} : {
|
|
2431
|
+
fallbackComponent: Component,
|
|
2432
|
+
status,
|
|
2433
|
+
children: element
|
|
2434
|
+
});
|
|
2406
2435
|
}
|
|
2407
|
-
});
|
|
2408
|
-
}
|
|
2409
|
-
if (metadata.authors) {
|
|
2410
|
-
const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
|
|
2411
|
-
for (const author of authorList) {
|
|
2412
|
-
if (author.name) elements.push({
|
|
2413
|
-
tag: "meta",
|
|
2414
|
-
attrs: {
|
|
2415
|
-
name: "author",
|
|
2416
|
-
content: author.name
|
|
2417
|
-
}
|
|
2418
|
-
});
|
|
2419
|
-
if (author.url) elements.push({
|
|
2420
|
-
tag: "link",
|
|
2421
|
-
attrs: {
|
|
2422
|
-
rel: "author",
|
|
2423
|
-
href: author.url
|
|
2424
|
-
}
|
|
2425
|
-
});
|
|
2426
2436
|
}
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2437
|
+
for (const [key, file] of segment.statusFiles) if (key === "4xx" || key === "5xx") {
|
|
2438
|
+
const Component = (await loadModule(file)).default;
|
|
2439
|
+
if (Component) {
|
|
2440
|
+
const categoryStatus = key === "4xx" ? 400 : 500;
|
|
2441
|
+
element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
|
|
2442
|
+
fallbackElement: createElement(Component, {}),
|
|
2443
|
+
status: categoryStatus,
|
|
2444
|
+
children: element
|
|
2445
|
+
} : {
|
|
2446
|
+
fallbackComponent: Component,
|
|
2447
|
+
status: categoryStatus,
|
|
2448
|
+
children: element
|
|
2449
|
+
});
|
|
2438
2450
|
}
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
if (segment.error) {
|
|
2454
|
+
const ErrorComponent = (await loadModule(segment.error)).default;
|
|
2455
|
+
if (ErrorComponent) element = createElement(errorBoundaryComponent, isMdxFile(segment.error) ? {
|
|
2456
|
+
fallbackElement: createElement(ErrorComponent, {}),
|
|
2457
|
+
children: element
|
|
2458
|
+
} : {
|
|
2459
|
+
fallbackComponent: ErrorComponent,
|
|
2460
|
+
children: element
|
|
2439
2461
|
});
|
|
2440
2462
|
}
|
|
2441
|
-
return
|
|
2442
|
-
}
|
|
2443
|
-
function renderRobotsObject(robots) {
|
|
2444
|
-
const parts = [];
|
|
2445
|
-
if (robots.index === true) parts.push("index");
|
|
2446
|
-
if (robots.index === false) parts.push("noindex");
|
|
2447
|
-
if (robots.follow === true) parts.push("follow");
|
|
2448
|
-
if (robots.follow === false) parts.push("nofollow");
|
|
2449
|
-
return parts.join(", ");
|
|
2463
|
+
return element;
|
|
2450
2464
|
}
|
|
2451
2465
|
//#endregion
|
|
2452
|
-
//#region src/server/
|
|
2466
|
+
//#region src/server/status-code-resolver.ts
|
|
2453
2467
|
/**
|
|
2454
|
-
*
|
|
2455
|
-
*
|
|
2456
|
-
* - string → apply template if present
|
|
2457
|
-
* - { absolute: '...' } → use as-is, skip template
|
|
2458
|
-
* - { default: '...' } → use as fallback (no template applied)
|
|
2459
|
-
* - undefined → undefined
|
|
2468
|
+
* Maps legacy file convention names to their corresponding HTTP status codes.
|
|
2469
|
+
* Only used in the 4xx component fallback chain.
|
|
2460
2470
|
*/
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
}
|
|
2471
|
+
var LEGACY_FILE_TO_STATUS = {
|
|
2472
|
+
"not-found": 404,
|
|
2473
|
+
"forbidden": 403,
|
|
2474
|
+
"unauthorized": 401
|
|
2475
|
+
};
|
|
2467
2476
|
/**
|
|
2468
|
-
* Resolve
|
|
2469
|
-
*
|
|
2470
|
-
* Processes entries from root layout to page (in segment order).
|
|
2471
|
-
* The merge algorithm:
|
|
2472
|
-
* 1. Shallow-merge all keys except title (later wins)
|
|
2473
|
-
* 2. Track the most recent title template
|
|
2474
|
-
* 3. Resolve the final title using the template
|
|
2477
|
+
* Resolve the status-code file to render for a given HTTP status code.
|
|
2475
2478
|
*
|
|
2476
|
-
*
|
|
2479
|
+
* Walks the segment chain from leaf to root following the fallback chain
|
|
2480
|
+
* defined in design/10-error-handling.md. Returns null if no file is found
|
|
2481
|
+
* (caller should render the framework default).
|
|
2477
2482
|
*
|
|
2478
|
-
*
|
|
2483
|
+
* @param status - The HTTP status code (4xx or 5xx).
|
|
2484
|
+
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
2485
|
+
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
2479
2486
|
*/
|
|
2480
|
-
function
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2487
|
+
function resolveStatusFile(status, segments, format = "component") {
|
|
2488
|
+
if (status >= 400 && status <= 499) return format === "json" ? resolve4xxJson(status, segments) : resolve4xx(status, segments);
|
|
2489
|
+
if (status >= 500 && status <= 599) return format === "json" ? resolve5xxJson(status, segments) : resolve5xx(status, segments);
|
|
2490
|
+
return null;
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* 4xx component fallback chain (three separate passes):
|
|
2494
|
+
* Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
|
|
2495
|
+
* Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
|
|
2496
|
+
* Pass 3 — error.tsx (leaf → root)
|
|
2497
|
+
*/
|
|
2498
|
+
function resolve4xx(status, segments) {
|
|
2499
|
+
const statusStr = String(status);
|
|
2500
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2501
|
+
const segment = segments[i];
|
|
2502
|
+
if (!segment.statusFiles) continue;
|
|
2503
|
+
const exact = segment.statusFiles.get(statusStr);
|
|
2504
|
+
if (exact) return {
|
|
2505
|
+
file: exact,
|
|
2506
|
+
status,
|
|
2507
|
+
kind: "exact",
|
|
2508
|
+
segmentIndex: i
|
|
2509
|
+
};
|
|
2510
|
+
const category = segment.statusFiles.get("4xx");
|
|
2511
|
+
if (category) return {
|
|
2512
|
+
file: category,
|
|
2513
|
+
status,
|
|
2514
|
+
kind: "category",
|
|
2515
|
+
segmentIndex: i
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2519
|
+
const segment = segments[i];
|
|
2520
|
+
if (!segment.legacyStatusFiles) continue;
|
|
2521
|
+
for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) if (legacyStatus === status) {
|
|
2522
|
+
const file = segment.legacyStatusFiles.get(name);
|
|
2523
|
+
if (file) return {
|
|
2524
|
+
file,
|
|
2525
|
+
status,
|
|
2526
|
+
kind: "legacy",
|
|
2527
|
+
segmentIndex: i
|
|
2528
|
+
};
|
|
2491
2529
|
}
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2530
|
+
}
|
|
2531
|
+
for (let i = segments.length - 1; i >= 0; i--) if (segments[i].error) return {
|
|
2532
|
+
file: segments[i].error,
|
|
2533
|
+
status,
|
|
2534
|
+
kind: "error",
|
|
2535
|
+
segmentIndex: i
|
|
2536
|
+
};
|
|
2537
|
+
return null;
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* 4xx JSON fallback chain (single pass):
|
|
2541
|
+
* Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
|
|
2542
|
+
* No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
|
|
2543
|
+
*/
|
|
2544
|
+
function resolve4xxJson(status, segments) {
|
|
2545
|
+
const statusStr = String(status);
|
|
2546
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2547
|
+
const segment = segments[i];
|
|
2548
|
+
if (!segment.jsonStatusFiles) continue;
|
|
2549
|
+
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
2550
|
+
if (exact) return {
|
|
2551
|
+
file: exact,
|
|
2552
|
+
status,
|
|
2553
|
+
kind: "exact",
|
|
2554
|
+
segmentIndex: i
|
|
2555
|
+
};
|
|
2556
|
+
const category = segment.jsonStatusFiles.get("4xx");
|
|
2557
|
+
if (category) return {
|
|
2558
|
+
file: category,
|
|
2559
|
+
status,
|
|
2560
|
+
kind: "category",
|
|
2561
|
+
segmentIndex: i
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
return null;
|
|
2565
|
+
}
|
|
2566
|
+
/**
|
|
2567
|
+
* 5xx component fallback chain (single pass, per-segment):
|
|
2568
|
+
* At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
|
|
2569
|
+
*/
|
|
2570
|
+
function resolve5xx(status, segments) {
|
|
2571
|
+
const statusStr = String(status);
|
|
2572
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2573
|
+
const segment = segments[i];
|
|
2574
|
+
if (segment.statusFiles) {
|
|
2575
|
+
const exact = segment.statusFiles.get(statusStr);
|
|
2576
|
+
if (exact) return {
|
|
2577
|
+
file: exact,
|
|
2578
|
+
status,
|
|
2579
|
+
kind: "exact",
|
|
2580
|
+
segmentIndex: i
|
|
2581
|
+
};
|
|
2582
|
+
const category = segment.statusFiles.get("5xx");
|
|
2583
|
+
if (category) return {
|
|
2584
|
+
file: category,
|
|
2585
|
+
status,
|
|
2586
|
+
kind: "category",
|
|
2587
|
+
segmentIndex: i
|
|
2588
|
+
};
|
|
2495
2589
|
}
|
|
2496
|
-
if (
|
|
2590
|
+
if (segment.error) return {
|
|
2591
|
+
file: segment.error,
|
|
2592
|
+
status,
|
|
2593
|
+
kind: "error",
|
|
2594
|
+
segmentIndex: i
|
|
2595
|
+
};
|
|
2497
2596
|
}
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2597
|
+
return null;
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* 5xx JSON fallback chain (single pass):
|
|
2601
|
+
* At each segment (leaf → root): {status}.json → 5xx.json
|
|
2602
|
+
* No error.tsx equivalent — JSON chain terminates at category catch-all.
|
|
2603
|
+
*/
|
|
2604
|
+
function resolve5xxJson(status, segments) {
|
|
2605
|
+
const statusStr = String(status);
|
|
2606
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2607
|
+
const segment = segments[i];
|
|
2608
|
+
if (!segment.jsonStatusFiles) continue;
|
|
2609
|
+
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
2610
|
+
if (exact) return {
|
|
2611
|
+
file: exact,
|
|
2612
|
+
status,
|
|
2613
|
+
kind: "exact",
|
|
2614
|
+
segmentIndex: i
|
|
2615
|
+
};
|
|
2616
|
+
const category = segment.jsonStatusFiles.get("5xx");
|
|
2617
|
+
if (category) return {
|
|
2618
|
+
file: category,
|
|
2619
|
+
status,
|
|
2620
|
+
kind: "category",
|
|
2621
|
+
segmentIndex: i
|
|
2622
|
+
};
|
|
2501
2623
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2624
|
+
return null;
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* Resolve the denial file for a parallel route slot.
|
|
2628
|
+
*
|
|
2629
|
+
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
2630
|
+
* Fallback chain: denied.tsx → default.tsx → null.
|
|
2631
|
+
*
|
|
2632
|
+
* @param slotNode - The segment node for the slot (segmentType === 'slot').
|
|
2633
|
+
*/
|
|
2634
|
+
function resolveSlotDenied(slotNode) {
|
|
2635
|
+
const slotName = slotNode.segmentName.replace(/^@/, "");
|
|
2636
|
+
if (slotNode.denied) return {
|
|
2637
|
+
file: slotNode.denied,
|
|
2638
|
+
slotName,
|
|
2639
|
+
kind: "denied"
|
|
2640
|
+
};
|
|
2641
|
+
if (slotNode.default) return {
|
|
2642
|
+
file: slotNode.default,
|
|
2643
|
+
slotName,
|
|
2644
|
+
kind: "default"
|
|
2645
|
+
};
|
|
2646
|
+
return null;
|
|
2506
2647
|
}
|
|
2648
|
+
//#endregion
|
|
2649
|
+
//#region src/server/flush.ts
|
|
2650
|
+
/**
|
|
2651
|
+
* Flush controller for timber.js rendering.
|
|
2652
|
+
*
|
|
2653
|
+
* Holds the response until `onShellReady` fires, then commits the HTTP status
|
|
2654
|
+
* code and flushes the shell. Render-phase signals (deny, redirect, unhandled
|
|
2655
|
+
* throws) caught before flush produce correct HTTP status codes.
|
|
2656
|
+
*
|
|
2657
|
+
* See design/02-rendering-pipeline.md §"The Flush Point" and §"The Hold Window"
|
|
2658
|
+
*/
|
|
2507
2659
|
/**
|
|
2508
|
-
*
|
|
2660
|
+
* Execute a render and hold the response until the shell is ready.
|
|
2661
|
+
*
|
|
2662
|
+
* The flush controller:
|
|
2663
|
+
* 1. Calls the render function to start renderToReadableStream
|
|
2664
|
+
* 2. Waits for shellReady (onShellReady)
|
|
2665
|
+
* 3. If a render-phase signal was thrown (deny, redirect, error), produces
|
|
2666
|
+
* the correct HTTP status code
|
|
2667
|
+
* 4. If the shell rendered successfully, commits the status and streams
|
|
2668
|
+
*
|
|
2669
|
+
* Render-phase signals caught before flush:
|
|
2670
|
+
* - `DenySignal` → HTTP 4xx with appropriate status code
|
|
2671
|
+
* - `RedirectSignal` → HTTP 3xx with Location header
|
|
2672
|
+
* - `RenderError` → HTTP status from error (default 500)
|
|
2673
|
+
* - Unhandled error → HTTP 500
|
|
2674
|
+
*
|
|
2675
|
+
* @param renderFn - Function that starts the React render.
|
|
2676
|
+
* @param options - Flush configuration.
|
|
2677
|
+
* @returns The committed HTTP Response.
|
|
2509
2678
|
*/
|
|
2510
|
-
function
|
|
2511
|
-
|
|
2679
|
+
async function flushResponse(renderFn, options = {}) {
|
|
2680
|
+
const { responseHeaders = new Headers(), defaultStatus = 200 } = options;
|
|
2681
|
+
let renderResult;
|
|
2682
|
+
try {
|
|
2683
|
+
renderResult = await renderFn();
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
return handleSignal(error, responseHeaders);
|
|
2686
|
+
}
|
|
2687
|
+
try {
|
|
2688
|
+
await renderResult.shellReady;
|
|
2689
|
+
} catch (error) {
|
|
2690
|
+
return handleSignal(error, responseHeaders);
|
|
2691
|
+
}
|
|
2692
|
+
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
|
2693
|
+
return {
|
|
2694
|
+
response: new Response(renderResult.stream, {
|
|
2695
|
+
status: defaultStatus,
|
|
2696
|
+
headers: responseHeaders
|
|
2697
|
+
}),
|
|
2698
|
+
status: defaultStatus,
|
|
2699
|
+
isRedirect: false,
|
|
2700
|
+
isDenial: false
|
|
2701
|
+
};
|
|
2512
2702
|
}
|
|
2513
2703
|
/**
|
|
2514
|
-
*
|
|
2704
|
+
* Handle a render-phase signal and produce the correct HTTP response.
|
|
2515
2705
|
*/
|
|
2516
|
-
function
|
|
2517
|
-
if (
|
|
2518
|
-
|
|
2706
|
+
function handleSignal(error, responseHeaders) {
|
|
2707
|
+
if (error instanceof RedirectSignal) {
|
|
2708
|
+
responseHeaders.set("Location", error.location);
|
|
2709
|
+
return {
|
|
2710
|
+
response: new Response(null, {
|
|
2711
|
+
status: error.status,
|
|
2712
|
+
headers: responseHeaders
|
|
2713
|
+
}),
|
|
2714
|
+
status: error.status,
|
|
2715
|
+
isRedirect: true,
|
|
2716
|
+
isDenial: false
|
|
2717
|
+
};
|
|
2718
|
+
}
|
|
2719
|
+
if (error instanceof DenySignal) return {
|
|
2720
|
+
response: new Response(null, {
|
|
2721
|
+
status: error.status,
|
|
2722
|
+
headers: responseHeaders
|
|
2723
|
+
}),
|
|
2724
|
+
status: error.status,
|
|
2725
|
+
isRedirect: false,
|
|
2726
|
+
isDenial: true
|
|
2727
|
+
};
|
|
2728
|
+
if (error instanceof RenderError) return {
|
|
2729
|
+
response: new Response(null, {
|
|
2730
|
+
status: error.status,
|
|
2731
|
+
headers: responseHeaders
|
|
2732
|
+
}),
|
|
2733
|
+
status: error.status,
|
|
2734
|
+
isRedirect: false,
|
|
2735
|
+
isDenial: false
|
|
2736
|
+
};
|
|
2737
|
+
logRenderError({
|
|
2738
|
+
method: "",
|
|
2739
|
+
path: "",
|
|
2740
|
+
error
|
|
2741
|
+
});
|
|
2742
|
+
return {
|
|
2743
|
+
response: new Response(null, {
|
|
2744
|
+
status: 500,
|
|
2745
|
+
headers: responseHeaders
|
|
2746
|
+
}),
|
|
2747
|
+
status: 500,
|
|
2748
|
+
isRedirect: false,
|
|
2749
|
+
isDenial: false
|
|
2750
|
+
};
|
|
2519
2751
|
}
|
|
2752
|
+
//#endregion
|
|
2753
|
+
//#region src/server/csrf.ts
|
|
2754
|
+
/** HTTP methods that are considered safe (no mutation). */
|
|
2755
|
+
var SAFE_METHODS = new Set([
|
|
2756
|
+
"GET",
|
|
2757
|
+
"HEAD",
|
|
2758
|
+
"OPTIONS"
|
|
2759
|
+
]);
|
|
2520
2760
|
/**
|
|
2521
|
-
*
|
|
2761
|
+
* Validate the Origin header against the request's Host.
|
|
2522
2762
|
*
|
|
2523
|
-
*
|
|
2524
|
-
* If
|
|
2763
|
+
* For mutation methods (POST, PUT, PATCH, DELETE):
|
|
2764
|
+
* - If `csrf: false`, skip validation.
|
|
2765
|
+
* - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
|
|
2766
|
+
* - Otherwise, Origin's host must match the request's Host header.
|
|
2767
|
+
*
|
|
2768
|
+
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
2525
2769
|
*/
|
|
2526
|
-
function
|
|
2527
|
-
|
|
2528
|
-
if (
|
|
2529
|
-
const
|
|
2530
|
-
if (
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
}
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
});
|
|
2551
|
-
const allStrings = resolved.every((r) => typeof r === "string");
|
|
2552
|
-
result.twitter.images = allStrings ? resolved : resolved;
|
|
2553
|
-
} else if (result.twitter.images) result.twitter.images = {
|
|
2554
|
-
...result.twitter.images,
|
|
2555
|
-
url: resolveUrl(result.twitter.images.url, base)
|
|
2770
|
+
function validateCsrf(req, config) {
|
|
2771
|
+
if (SAFE_METHODS.has(req.method)) return { ok: true };
|
|
2772
|
+
if (config.csrf === false) return { ok: true };
|
|
2773
|
+
const origin = req.headers.get("Origin");
|
|
2774
|
+
if (!origin) return {
|
|
2775
|
+
ok: false,
|
|
2776
|
+
status: 403
|
|
2777
|
+
};
|
|
2778
|
+
if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
|
|
2779
|
+
ok: false,
|
|
2780
|
+
status: 403
|
|
2781
|
+
};
|
|
2782
|
+
const host = req.headers.get("Host");
|
|
2783
|
+
if (!host) return {
|
|
2784
|
+
ok: false,
|
|
2785
|
+
status: 403
|
|
2786
|
+
};
|
|
2787
|
+
let originHost;
|
|
2788
|
+
try {
|
|
2789
|
+
originHost = new URL(origin).host;
|
|
2790
|
+
} catch {
|
|
2791
|
+
return {
|
|
2792
|
+
ok: false,
|
|
2793
|
+
status: 403
|
|
2556
2794
|
};
|
|
2557
2795
|
}
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2796
|
+
return originHost === host ? { ok: true } : {
|
|
2797
|
+
ok: false,
|
|
2798
|
+
status: 403
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
//#endregion
|
|
2802
|
+
//#region src/server/body-limits.ts
|
|
2803
|
+
var KB = 1024;
|
|
2804
|
+
var MB = 1024 * KB;
|
|
2805
|
+
var GB = 1024 * MB;
|
|
2806
|
+
var DEFAULT_LIMITS = {
|
|
2807
|
+
actionBodySize: 1 * MB,
|
|
2808
|
+
uploadBodySize: 10 * MB,
|
|
2809
|
+
maxFields: 100
|
|
2810
|
+
};
|
|
2811
|
+
var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
|
|
2812
|
+
/** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
|
|
2813
|
+
function parseBodySize(size) {
|
|
2814
|
+
const match = SIZE_PATTERN.exec(size.trim());
|
|
2815
|
+
if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
|
|
2816
|
+
const value = Number.parseFloat(match[1]);
|
|
2817
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
2818
|
+
switch (unit) {
|
|
2819
|
+
case "kb": return Math.floor(value * KB);
|
|
2820
|
+
case "mb": return Math.floor(value * MB);
|
|
2821
|
+
case "gb": return Math.floor(value * GB);
|
|
2822
|
+
case "": return Math.floor(value);
|
|
2823
|
+
default: throw new Error(`Unknown size unit: "${unit}"`);
|
|
2579
2824
|
}
|
|
2580
|
-
|
|
2825
|
+
}
|
|
2826
|
+
/** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
|
|
2827
|
+
function enforceBodyLimits(req, kind, config) {
|
|
2828
|
+
const contentLength = req.headers.get("Content-Length");
|
|
2829
|
+
if (!contentLength) return {
|
|
2830
|
+
ok: false,
|
|
2831
|
+
status: 411
|
|
2832
|
+
};
|
|
2833
|
+
const bodySize = Number.parseInt(contentLength, 10);
|
|
2834
|
+
if (Number.isNaN(bodySize)) return {
|
|
2835
|
+
ok: false,
|
|
2836
|
+
status: 411
|
|
2837
|
+
};
|
|
2838
|
+
return bodySize <= resolveLimit(kind, config) ? { ok: true } : {
|
|
2839
|
+
ok: false,
|
|
2840
|
+
status: 413
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Resolve the byte limit for a given body kind, using config overrides or defaults.
|
|
2845
|
+
*/
|
|
2846
|
+
function resolveLimit(kind, config) {
|
|
2847
|
+
const userLimits = config.limits;
|
|
2848
|
+
if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
|
|
2849
|
+
return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
|
|
2581
2850
|
}
|
|
2582
2851
|
//#endregion
|
|
2583
2852
|
//#region src/server/form-data.ts
|
|
@@ -2690,6 +2959,35 @@ var coerce = {
|
|
|
2690
2959
|
} catch {
|
|
2691
2960
|
return;
|
|
2692
2961
|
}
|
|
2962
|
+
},
|
|
2963
|
+
date(value) {
|
|
2964
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2965
|
+
if (value instanceof Date) return value;
|
|
2966
|
+
if (typeof value !== "string") return void 0;
|
|
2967
|
+
const date = new Date(value);
|
|
2968
|
+
if (Number.isNaN(date.getTime())) return void 0;
|
|
2969
|
+
const ymdMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
2970
|
+
if (ymdMatch) {
|
|
2971
|
+
const inputYear = Number(ymdMatch[1]);
|
|
2972
|
+
const inputMonth = Number(ymdMatch[2]);
|
|
2973
|
+
const inputDay = Number(ymdMatch[3]);
|
|
2974
|
+
const isUTC = value.length === 10 || value.endsWith("Z");
|
|
2975
|
+
const parsedYear = isUTC ? date.getUTCFullYear() : date.getFullYear();
|
|
2976
|
+
const parsedMonth = isUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
|
|
2977
|
+
const parsedDay = isUTC ? date.getUTCDate() : date.getDate();
|
|
2978
|
+
if (inputYear !== parsedYear || inputMonth !== parsedMonth || inputDay !== parsedDay) return;
|
|
2979
|
+
}
|
|
2980
|
+
return date;
|
|
2981
|
+
},
|
|
2982
|
+
file(options) {
|
|
2983
|
+
return (value) => {
|
|
2984
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2985
|
+
if (!(value instanceof File)) return void 0;
|
|
2986
|
+
if (value.size === 0 && value.name === "") return void 0;
|
|
2987
|
+
if (options?.maxSize !== void 0 && value.size > options.maxSize) return;
|
|
2988
|
+
if (options?.accept !== void 0 && !options.accept.includes(value.type)) return;
|
|
2989
|
+
return value;
|
|
2990
|
+
};
|
|
2693
2991
|
}
|
|
2694
2992
|
};
|
|
2695
2993
|
//#endregion
|
|
@@ -2822,6 +3120,7 @@ function createActionClient(config = {}) {
|
|
|
2822
3120
|
const ctx = await runActionMiddleware(config.middleware);
|
|
2823
3121
|
let rawInput;
|
|
2824
3122
|
if (args.length === 2 && args[1] instanceof FormData) rawInput = schema ? parseFormData(args[1]) : args[1];
|
|
3123
|
+
else if (args.length === 1 && args[0] instanceof FormData) rawInput = schema ? parseFormData(args[0]) : args[0];
|
|
2825
3124
|
else rawInput = args[0];
|
|
2826
3125
|
if (config.fileSizeLimit !== void 0 && rawInput && typeof rawInput === "object") {
|
|
2827
3126
|
const fileSizeErrors = validateFileSizes(rawInput, config.fileSizeLimit);
|
|
@@ -3162,7 +3461,11 @@ async function runHandler(handler, ctx) {
|
|
|
3162
3461
|
try {
|
|
3163
3462
|
return mergeResponseHeaders(await handler(ctx), ctx.headers);
|
|
3164
3463
|
} catch (error) {
|
|
3165
|
-
|
|
3464
|
+
logRouteError({
|
|
3465
|
+
method: ctx.req.method,
|
|
3466
|
+
path: new URL(ctx.req.url).pathname,
|
|
3467
|
+
error
|
|
3468
|
+
});
|
|
3166
3469
|
return new Response(null, { status: 500 });
|
|
3167
3470
|
}
|
|
3168
3471
|
}
|
|
@@ -3187,6 +3490,32 @@ function mergeResponseHeaders(res, ctxHeaders) {
|
|
|
3187
3490
|
});
|
|
3188
3491
|
}
|
|
3189
3492
|
//#endregion
|
|
3190
|
-
|
|
3493
|
+
//#region src/server/render-timeout.ts
|
|
3494
|
+
/**
|
|
3495
|
+
* Render timeout utilities for SSR streaming pipeline.
|
|
3496
|
+
*
|
|
3497
|
+
* Provides a RenderTimeoutError class and a helper to create
|
|
3498
|
+
* timeout-guarded AbortSignals. Used to defend against hung RSC
|
|
3499
|
+
* streams and infinite SSR renders.
|
|
3500
|
+
*
|
|
3501
|
+
* Design doc: 02-rendering-pipeline.md §"Streaming Constraints"
|
|
3502
|
+
*/
|
|
3503
|
+
/**
|
|
3504
|
+
* Error thrown when an SSR render or RSC stream read exceeds the
|
|
3505
|
+
* configured timeout. Callers can check `instanceof RenderTimeoutError`
|
|
3506
|
+
* to distinguish timeout from other errors and return a 504 or close
|
|
3507
|
+
* the connection cleanly.
|
|
3508
|
+
*/
|
|
3509
|
+
var RenderTimeoutError = class extends Error {
|
|
3510
|
+
timeoutMs;
|
|
3511
|
+
constructor(timeoutMs, context) {
|
|
3512
|
+
const message = context ? `Render timeout after ${timeoutMs}ms: ${context}` : `Render timeout after ${timeoutMs}ms`;
|
|
3513
|
+
super(message);
|
|
3514
|
+
this.name = "RenderTimeoutError";
|
|
3515
|
+
this.timeoutMs = timeoutMs;
|
|
3516
|
+
}
|
|
3517
|
+
};
|
|
3518
|
+
//#endregion
|
|
3519
|
+
export { AccessGate, ActionError, DEFAULT_LIMITS, DenySignal, METADATA_ROUTE_CONVENTIONS, RedirectSignal, RedirectType, RenderError, RenderTimeoutError, SlotAccessGate, WarningId, addSpanEvent, buildElementTree, buildNoJsResponse, callOnRequestError, canonicalize, classifyMetadataRoute, coerce, collectEarlyHintHeaders, cookies, createActionClient, createPipeline, deny, enforceBodyLimits, executeAction, flushResponse, formatLinkHeader, generateTraceId, getFormFlash, getLogger, getMetadataRouteAutoLink, getMetadataRouteServePath, getSetCookieHeaders, handleRouteRequest, hasOnRequestError, headers, isRscActionRequest, loadInstrumentation, logCacheMiss, logMiddlewareError, logMiddlewareShortCircuit, logProxyError, logRenderError, logRequestCompleted, logRequestReceived, logSlowRequest, logSwrRefetchFailed, logWaitUntilRejected, logWaitUntilUnsupported, markResponseFlushed, notFound, parseBodySize, parseFormData, permanentRedirect, rawSearchParams, rawSegmentParams, redirect, redirectExternal, renderMetadataToElements, replaceTraceId, resolveAllowedMethods, resolveMetadata, resolveMetadataUrls, resolveSlotDenied, resolveStatusFile, resolveTitle, revalidatePath, revalidateTag, runMiddleware, runProxy, runWithEarlyHintsSender, runWithRequestContext, runWithTraceId, sendEarlyHints103, setLogger, setMutableCookieContext, setSegmentParams, setViteServer, spanId, traceId, validateCsrf, validated, waitUntil, warnCacheRequestProps, warnDenyAfterFlush, warnDenyInSuspense, warnDynamicApiInStaticBuild, warnRedirectInAccess, warnRedirectInSlotAccess, warnRedirectInSuspense, warnSlowSlotWithoutSuspense, warnStaticRequestApi, warnSuspenseWrappingChildren, withSpan };
|
|
3191
3520
|
|
|
3192
3521
|
//# sourceMappingURL=index.js.map
|