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