@timber-js/app 0.2.0-alpha.4 → 0.2.0-alpha.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/dist/_chunks/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
- package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
- package/dist/_chunks/debug-ECi_61pb.js +108 -0
- package/dist/_chunks/debug-ECi_61pb.js.map +1 -0
- package/dist/_chunks/define-cookie-BmKbSyp0.js +93 -0
- package/dist/_chunks/define-cookie-BmKbSyp0.js.map +1 -0
- package/dist/_chunks/error-boundary-BAN3751q.js +211 -0
- package/dist/_chunks/error-boundary-BAN3751q.js.map +1 -0
- package/dist/_chunks/{format-CwdaB0_2.js → format-cX7wzEp2.js} +2 -2
- package/dist/_chunks/{format-CwdaB0_2.js.map → format-cX7wzEp2.js.map} +1 -1
- package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
- package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/{request-context-CZJi4CuK.js → request-context-BxYIJM24.js} +93 -69
- package/dist/_chunks/request-context-BxYIJM24.js.map +1 -0
- package/dist/_chunks/segment-context-C6byCyZU.js +69 -0
- package/dist/_chunks/segment-context-C6byCyZU.js.map +1 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
- package/dist/_chunks/{tracing-Cwn7697K.js → tracing-CuXiCP5p.js} +17 -3
- package/dist/_chunks/{tracing-Cwn7697K.js.map → tracing-CuXiCP5p.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
- package/dist/_chunks/wrappers-C6J0nNji.js +331 -0
- package/dist/_chunks/wrappers-C6J0nNji.js.map +1 -0
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts +17 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +56 -13
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/fast-hash.d.ts +22 -0
- package/dist/cache/fast-hash.d.ts.map +1 -0
- package/dist/cache/index.d.ts +5 -2
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +88 -18
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/register-cached-function.d.ts.map +1 -1
- package/dist/cache/singleflight.d.ts +18 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts +10 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -125
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +213 -93
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +22 -8
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +2 -2
- package/dist/client/router.d.ts +25 -3
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +23 -2
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts +15 -0
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +1 -1
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +2 -2
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +1 -1
- package/dist/codec.d.ts +21 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/cookies/define-cookie.d.ts +33 -12
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -83
- package/dist/fonts/css.d.ts +1 -0
- package/dist/fonts/css.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +4 -2
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/index.d.ts +112 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +635 -233
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts +76 -0
- package/dist/params/define.d.ts.map +1 -0
- package/dist/params/index.d.ts +8 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +104 -0
- package/dist/params/index.js.map +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.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/dev-error-overlay.d.ts +26 -1
- package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +7 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +9 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts +6 -0
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +2 -2
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/status-file-lint.d.ts +2 -1
- package/dist/routing/status-file-lint.d.ts.map +1 -1
- package/dist/routing/types.d.ts +6 -4
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/rsc-runtime/ssr.d.ts +12 -0
- package/dist/rsc-runtime/ssr.d.ts.map +1 -1
- package/dist/search-params/codecs.d.ts +1 -1
- package/dist/search-params/define.d.ts +153 -0
- package/dist/search-params/define.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +4 -5
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -474
- package/dist/search-params/registry.d.ts +1 -1
- package/dist/search-params/wrappers.d.ts +53 -0
- package/dist/search-params/wrappers.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +4 -0
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/action-encryption.d.ts +76 -0
- package/dist/server/action-encryption.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +18 -4
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +2 -2
- package/dist/server/debug.d.ts +46 -15
- package/dist/server/debug.d.ts.map +1 -1
- package/dist/server/default-logger.d.ts +22 -0
- package/dist/server/default-logger.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/early-hints.d.ts +13 -5
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +4 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/flight-injection-state.d.ts +78 -0
- package/dist/server/flight-injection-state.d.ts.map +1 -0
- package/dist/server/flight-scripts.d.ts +39 -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 +5 -11
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1975 -1649
- package/dist/server/index.js.map +1 -1
- package/dist/server/logger.d.ts +24 -7
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +77 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +7 -4
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +30 -3
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/render-timeout.d.ts +51 -0
- package/dist/server/render-timeout.d.ts.map +1 -0
- package/dist/server/request-context.d.ts +65 -38
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-handler.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +2 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +46 -3
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +6 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +9 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/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/tracing.d.ts +10 -0
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +19 -12
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +1 -3
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version-skew.d.ts +61 -0
- package/dist/server/version-skew.d.ts.map +1 -0
- package/dist/server/waituntil-bridge.d.ts.map +1 -1
- package/dist/shared/merge-search-params.d.ts +22 -0
- package/dist/shared/merge-search-params.d.ts.map +1 -0
- package/dist/shims/navigation-client.d.ts +1 -1
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +1 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/dist/utils/state-machine.d.ts +80 -0
- package/dist/utils/state-machine.d.ts.map +1 -0
- package/package.json +17 -14
- package/src/adapters/compress-module.ts +24 -4
- package/src/adapters/nitro.ts +58 -9
- package/src/cache/fast-hash.ts +34 -0
- package/src/cache/index.ts +5 -2
- package/src/cache/register-cached-function.ts +7 -3
- package/src/cache/singleflight.ts +62 -4
- package/src/cache/timber-cache.ts +34 -26
- package/src/cli.ts +0 -0
- package/src/client/browser-entry.ts +94 -90
- package/src/client/error-boundary.tsx +18 -1
- package/src/client/index.ts +10 -1
- package/src/client/link.tsx +78 -19
- package/src/client/navigation-context.ts +2 -2
- package/src/client/router.ts +105 -60
- package/src/client/rsc-fetch.ts +63 -2
- package/src/client/segment-cache.ts +1 -1
- package/src/client/segment-context.ts +6 -1
- package/src/client/segment-merger.ts +2 -8
- package/src/client/stale-reload.ts +32 -6
- package/src/client/top-loader.tsx +10 -9
- package/src/client/transition-root.tsx +7 -1
- package/src/client/use-params.ts +3 -3
- package/src/client/use-query-states.ts +1 -1
- package/src/codec.ts +21 -0
- package/src/cookies/define-cookie.ts +69 -18
- package/src/fonts/css.ts +2 -1
- package/src/fonts/local.ts +7 -3
- package/src/index.ts +280 -85
- package/src/params/define.ts +260 -0
- package/src/params/index.ts +28 -0
- package/src/plugins/adapter-build.ts +6 -0
- package/src/plugins/build-manifest.ts +11 -0
- package/src/plugins/client-chunks.ts +65 -0
- package/src/plugins/dev-error-overlay.ts +70 -1
- package/src/plugins/dev-server.ts +38 -4
- package/src/plugins/entries.ts +12 -11
- package/src/plugins/fonts.ts +171 -19
- package/src/plugins/mdx.ts +9 -5
- package/src/plugins/routing.ts +40 -14
- package/src/plugins/server-bundle.ts +32 -1
- package/src/plugins/shims.ts +1 -1
- package/src/plugins/static-build.ts +8 -4
- package/src/routing/codegen.ts +109 -88
- package/src/routing/scanner.ts +55 -6
- package/src/routing/status-file-lint.ts +2 -1
- package/src/routing/types.ts +7 -4
- package/src/rsc-runtime/rsc.ts +2 -0
- package/src/rsc-runtime/ssr.ts +50 -0
- package/src/rsc-runtime/vendor-types.d.ts +7 -0
- package/src/search-params/codecs.ts +1 -1
- package/src/search-params/define.ts +504 -0
- package/src/search-params/index.ts +12 -18
- package/src/search-params/registry.ts +1 -1
- package/src/search-params/wrappers.ts +85 -0
- package/src/server/access-gate.tsx +40 -9
- package/src/server/action-client.ts +14 -5
- package/src/server/action-encryption.ts +144 -0
- package/src/server/action-handler.ts +19 -2
- package/src/server/als-registry.ts +18 -4
- package/src/server/build-manifest.ts +4 -4
- package/src/server/compress.ts +25 -7
- package/src/server/debug.ts +55 -17
- package/src/server/default-logger.ts +98 -0
- package/src/server/deny-renderer.ts +2 -1
- package/src/server/early-hints.ts +36 -15
- package/src/server/error-boundary-wrapper.ts +57 -14
- package/src/server/flight-injection-state.ts +152 -0
- package/src/server/flight-scripts.ts +59 -0
- package/src/server/flush.ts +2 -1
- package/src/server/form-data.ts +76 -0
- package/src/server/html-injectors.ts +103 -66
- package/src/server/index.ts +9 -4
- package/src/server/logger.ts +38 -35
- package/src/server/node-stream-transforms.ts +381 -0
- package/src/server/pipeline.ts +131 -39
- package/src/server/primitives.ts +47 -5
- package/src/server/render-timeout.ts +108 -0
- package/src/server/request-context.ts +112 -119
- package/src/server/route-element-builder.ts +106 -114
- package/src/server/route-handler.ts +2 -1
- package/src/server/route-matcher.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +5 -3
- package/src/server/rsc-entry/helpers.ts +122 -3
- package/src/server/rsc-entry/index.ts +125 -49
- package/src/server/rsc-entry/rsc-payload.ts +52 -12
- package/src/server/rsc-entry/rsc-stream.ts +33 -8
- package/src/server/rsc-entry/ssr-renderer.ts +40 -13
- package/src/server/slot-resolver.ts +199 -210
- package/src/server/ssr-entry.ts +168 -22
- package/src/server/ssr-render.ts +289 -67
- package/src/server/tracing.ts +23 -0
- package/src/server/tree-builder.ts +91 -57
- package/src/server/types.ts +1 -3
- package/src/server/version-skew.ts +104 -0
- package/src/server/waituntil-bridge.ts +4 -1
- package/src/shared/merge-search-params.ts +48 -0
- package/src/shims/navigation-client.ts +1 -1
- package/src/shims/navigation.ts +1 -1
- package/src/utils/state-machine.ts +111 -0
- package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
- package/dist/_chunks/debug-B4WUeqJ-.js +0 -75
- package/dist/_chunks/debug-B4WUeqJ-.js.map +0 -1
- package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
- package/dist/_chunks/request-context-CZJi4CuK.js.map +0 -1
- package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
- package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
- package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
- package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
- package/dist/client/error-boundary.js.map +0 -1
- package/dist/cookies/index.js.map +0 -1
- package/dist/plugins/dynamic-transform.d.ts +0 -72
- package/dist/plugins/dynamic-transform.d.ts.map +0 -1
- package/dist/search-params/analyze.d.ts +0 -54
- package/dist/search-params/analyze.d.ts.map +0 -1
- package/dist/search-params/builtin-codecs.d.ts +0 -105
- package/dist/search-params/builtin-codecs.d.ts.map +0 -1
- package/dist/search-params/create.d.ts +0 -106
- package/dist/search-params/create.d.ts.map +0 -1
- package/dist/search-params/index.js.map +0 -1
- package/dist/server/prerender.d.ts +0 -77
- package/dist/server/prerender.d.ts.map +0 -1
- package/dist/server/response-cache.d.ts +0 -53
- package/dist/server/response-cache.d.ts.map +0 -1
- package/src/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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js native stream transforms for SSR HTML post-processing.
|
|
3
|
+
*
|
|
4
|
+
* These are Node.js Transform stream equivalents of the Web Stream
|
|
5
|
+
* transforms in html-injectors.ts. Used on Node.js/Bun where native
|
|
6
|
+
* streams (C++ backed) are faster than Web Streams (JS reimplementation).
|
|
7
|
+
*
|
|
8
|
+
* The transforms are pure string operations on HTML chunks — the same
|
|
9
|
+
* logic as the Web Stream versions, just wrapped in Node.js Transform
|
|
10
|
+
* instead of Web TransformStream.
|
|
11
|
+
*
|
|
12
|
+
* Architecture:
|
|
13
|
+
* renderToPipeableStream → pipe(errorHandler) → pipe(headInjector)
|
|
14
|
+
* → pipe(flightInjector) → Readable.toWeb() → Response
|
|
15
|
+
*
|
|
16
|
+
* All chunks stay in C++ Node.js stream buffers until the final
|
|
17
|
+
* Readable.toWeb() conversion for the Response body.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Transform } from 'node:stream';
|
|
21
|
+
import { createGzip, constants } from 'node:zlib';
|
|
22
|
+
|
|
23
|
+
import { createMachine } from '../utils/state-machine.js';
|
|
24
|
+
import { flightChunkScript } from './flight-scripts.js';
|
|
25
|
+
import {
|
|
26
|
+
flightInjectionTransitions,
|
|
27
|
+
isSuffixStripped,
|
|
28
|
+
isHtmlDone,
|
|
29
|
+
isPullDone,
|
|
30
|
+
type FlightInjectionState,
|
|
31
|
+
type FlightInjectionEvent,
|
|
32
|
+
} from './flight-injection-state.js';
|
|
33
|
+
import { withTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
34
|
+
import { logStreamingError } from './logger.js';
|
|
35
|
+
|
|
36
|
+
// ─── Head Injection ──────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Node.js Transform that injects HTML content before </head>.
|
|
40
|
+
*
|
|
41
|
+
* Equivalent to injectHead() in html-injectors.ts. Streams chunks
|
|
42
|
+
* through immediately, keeping only a small trailing buffer to handle
|
|
43
|
+
* </head> split across chunk boundaries.
|
|
44
|
+
*/
|
|
45
|
+
export function createNodeHeadInjector(headHtml: string): Transform {
|
|
46
|
+
if (!headHtml) {
|
|
47
|
+
return new Transform({
|
|
48
|
+
transform(chunk, _enc, cb) {
|
|
49
|
+
cb(null, chunk);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const target = '</head>';
|
|
55
|
+
const tailLen = target.length - 1;
|
|
56
|
+
let injected = false;
|
|
57
|
+
let tail = '';
|
|
58
|
+
|
|
59
|
+
return new Transform({
|
|
60
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
61
|
+
if (injected) {
|
|
62
|
+
callback(null, chunk);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const text = tail + chunk.toString('utf-8');
|
|
67
|
+
const tagIndex = text.indexOf(target);
|
|
68
|
+
|
|
69
|
+
if (tagIndex !== -1) {
|
|
70
|
+
const before = text.slice(0, tagIndex);
|
|
71
|
+
const after = text.slice(tagIndex);
|
|
72
|
+
this.push(Buffer.from(before + headHtml + after, 'utf-8'));
|
|
73
|
+
injected = true;
|
|
74
|
+
tail = '';
|
|
75
|
+
callback();
|
|
76
|
+
} else {
|
|
77
|
+
const safeEnd = Math.max(0, text.length - tailLen);
|
|
78
|
+
if (safeEnd > 0) {
|
|
79
|
+
this.push(Buffer.from(text.slice(0, safeEnd), 'utf-8'));
|
|
80
|
+
}
|
|
81
|
+
tail = text.slice(safeEnd);
|
|
82
|
+
callback();
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
flush(callback) {
|
|
86
|
+
if (!injected && tail) {
|
|
87
|
+
this.push(Buffer.from(tail, 'utf-8'));
|
|
88
|
+
}
|
|
89
|
+
callback();
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── RSC Flight Injection ────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Node.js Transform that merges RSC script tags into the HTML stream.
|
|
98
|
+
*
|
|
99
|
+
* Equivalent to injectRscPayload() in html-injectors.ts. Combines
|
|
100
|
+
* createInlinedRscStream + createFlightInjectionTransform into a single
|
|
101
|
+
* Node.js Transform.
|
|
102
|
+
*
|
|
103
|
+
* 1. Strips `</body></html>` from the shell so all subsequent content
|
|
104
|
+
* is at `<body>` level.
|
|
105
|
+
* 2. Reads RSC chunks from the provided ReadableStream and injects them
|
|
106
|
+
* as `<script>` tags after HTML chunks.
|
|
107
|
+
* 3. Re-emits `</body></html>` at the very end.
|
|
108
|
+
*
|
|
109
|
+
* The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
|
|
110
|
+
* stream). We read from it using the Web API — this is the one bridge
|
|
111
|
+
* point between Web Streams and Node.js streams in the pipeline.
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* Options for the Node.js flight injector.
|
|
115
|
+
*/
|
|
116
|
+
export interface NodeFlightInjectorOptions {
|
|
117
|
+
/**
|
|
118
|
+
* Timeout in milliseconds for individual RSC stream reads.
|
|
119
|
+
* If a single `rscReader.read()` call does not resolve within
|
|
120
|
+
* this duration, the read is aborted and the stream errors with
|
|
121
|
+
* a RenderTimeoutError. Default: 30000 (30s).
|
|
122
|
+
*/
|
|
123
|
+
renderTimeoutMs?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createNodeFlightInjector(
|
|
127
|
+
rscStream: ReadableStream<Uint8Array> | undefined,
|
|
128
|
+
options?: NodeFlightInjectorOptions
|
|
129
|
+
): Transform {
|
|
130
|
+
if (!rscStream) {
|
|
131
|
+
return new Transform({
|
|
132
|
+
transform(chunk, _enc, cb) {
|
|
133
|
+
cb(null, chunk);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const timeoutMs = options?.renderTimeoutMs ?? 30_000;
|
|
139
|
+
const suffix = '</body></html>';
|
|
140
|
+
const suffixBuf = Buffer.from(suffix, 'utf-8');
|
|
141
|
+
const rscReader = rscStream.getReader();
|
|
142
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
143
|
+
|
|
144
|
+
const machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
|
|
145
|
+
initial: { phase: 'init' },
|
|
146
|
+
transitions: flightInjectionTransitions,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Stored promise from pullLoop — awaited in flush() via .then()
|
|
150
|
+
// instead of polling. Matches the Web Streams pattern in
|
|
151
|
+
// html-injectors.ts (pullPromise.then(finish)).
|
|
152
|
+
let pullPromise: Promise<void> | null = null;
|
|
153
|
+
|
|
154
|
+
// pullLoop reads RSC chunks and pushes them directly to the transform
|
|
155
|
+
// output as <script> tags. This ensures RSC data is delivered to the
|
|
156
|
+
// browser as soon as it's available — not deferred until the next HTML
|
|
157
|
+
// chunk. Critical for streaming: the shell RSC payload must arrive
|
|
158
|
+
// with the shell HTML so hydration can start before Suspense resolves.
|
|
159
|
+
|
|
160
|
+
async function pullLoop(stream: Transform): Promise<void> {
|
|
161
|
+
// Yield once so the first transform() call can emit the bootstrap
|
|
162
|
+
// signal before we start pushing data chunks.
|
|
163
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
164
|
+
try {
|
|
165
|
+
for (;;) {
|
|
166
|
+
// Guard each RSC read with a timeout so a permanently hung
|
|
167
|
+
// RSC stream (e.g. a Suspense component with a fetch that
|
|
168
|
+
// never resolves) eventually aborts instead of blocking
|
|
169
|
+
// forever. When timeoutMs <= 0, the guard is disabled.
|
|
170
|
+
// See design/02-rendering-pipeline.md §"Streaming Constraints".
|
|
171
|
+
const readPromise = rscReader.read();
|
|
172
|
+
const { done, value } =
|
|
173
|
+
timeoutMs > 0
|
|
174
|
+
? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
|
|
175
|
+
: await readPromise;
|
|
176
|
+
if (done) {
|
|
177
|
+
machine.send({ type: 'PULL_DONE' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
181
|
+
const scriptBuf = Buffer.from(flightChunkScript(decoded), 'utf-8');
|
|
182
|
+
// Push directly to the transform output — don't wait for an
|
|
183
|
+
// HTML chunk to trigger drainPending.
|
|
184
|
+
stream.push(scriptBuf);
|
|
185
|
+
// Yield between reads so HTML chunks get a chance to flow
|
|
186
|
+
// through transform() first — but only while HTML is still
|
|
187
|
+
// streaming. Once flush() fires (all HTML emitted), drain
|
|
188
|
+
// remaining RSC chunks without yielding.
|
|
189
|
+
if (!isHtmlDone(machine.state)) {
|
|
190
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// On timeout, cancel the RSC reader to release resources.
|
|
195
|
+
if (err instanceof RenderTimeoutError) {
|
|
196
|
+
rscReader.cancel(err).catch(() => {});
|
|
197
|
+
}
|
|
198
|
+
machine.send({ type: 'PULL_ERROR', error: err });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// No bootstrap script here — the init script is in <head> via
|
|
203
|
+
// flightInitScript() (see flight-scripts.ts). This ensures __timber_f
|
|
204
|
+
// exists before any chunk scripts execute.
|
|
205
|
+
|
|
206
|
+
const transform = new Transform({
|
|
207
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
208
|
+
const isFirst = machine.state.phase === 'init';
|
|
209
|
+
if (isFirst) {
|
|
210
|
+
machine.send({ type: 'FIRST_CHUNK' });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (isSuffixStripped(machine.state)) {
|
|
214
|
+
transform.push(chunk);
|
|
215
|
+
callback();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const text = chunk.toString('utf-8');
|
|
220
|
+
const idx = text.indexOf(suffix);
|
|
221
|
+
if (idx !== -1) {
|
|
222
|
+
machine.send({ type: 'SUFFIX_FOUND' });
|
|
223
|
+
const before = text.slice(0, idx);
|
|
224
|
+
const after = text.slice(idx + suffix.length);
|
|
225
|
+
if (before) transform.push(Buffer.from(before, 'utf-8'));
|
|
226
|
+
if (after) transform.push(Buffer.from(after, 'utf-8'));
|
|
227
|
+
} else {
|
|
228
|
+
transform.push(chunk);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Start the pull loop on the first HTML chunk to stream RSC
|
|
232
|
+
// data chunks alongside the HTML. The __timber_f init script is
|
|
233
|
+
// already in <head> (via flightInitScript), so no bootstrap needed.
|
|
234
|
+
// Store the promise so flush() can await it instead of polling.
|
|
235
|
+
if (isFirst) {
|
|
236
|
+
pullPromise = pullLoop(transform);
|
|
237
|
+
}
|
|
238
|
+
callback();
|
|
239
|
+
},
|
|
240
|
+
flush(callback) {
|
|
241
|
+
// All HTML chunks have been emitted. Transition to flushing —
|
|
242
|
+
// the pull loop will stop yielding between RSC reads since
|
|
243
|
+
// isHtmlDone() now returns true.
|
|
244
|
+
machine.send({ type: 'HTML_DONE' });
|
|
245
|
+
|
|
246
|
+
const finish = () => {
|
|
247
|
+
if (machine.state.phase === 'error') {
|
|
248
|
+
const err = machine.state.error;
|
|
249
|
+
transform.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const hadSuffix =
|
|
253
|
+
(machine.state.phase === 'done' && machine.state.hadSuffix) ||
|
|
254
|
+
(machine.state.phase === 'flushing' && machine.state.hadSuffix);
|
|
255
|
+
if (hadSuffix) {
|
|
256
|
+
transform.push(suffixBuf);
|
|
257
|
+
}
|
|
258
|
+
callback();
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
if (isPullDone(machine.state)) {
|
|
262
|
+
finish();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Wait for the RSC pull loop promise to resolve instead of
|
|
266
|
+
// polling with setImmediate. This matches the Web Streams
|
|
267
|
+
// pattern in html-injectors.ts: `pullPromise.then(finish)`.
|
|
268
|
+
// No CPU spin, no busy-poll — just a Promise chain.
|
|
269
|
+
if (!pullPromise) {
|
|
270
|
+
pullPromise = pullLoop(transform);
|
|
271
|
+
}
|
|
272
|
+
pullPromise.then(finish, (err) => {
|
|
273
|
+
machine.send({ type: 'PULL_ERROR', error: err });
|
|
274
|
+
finish();
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return transform;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── Error Handling ──────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
const NOINDEX_SCRIPT =
|
|
285
|
+
'<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Node.js Transform that catches post-shell streaming errors.
|
|
289
|
+
*
|
|
290
|
+
* Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts.
|
|
291
|
+
* Catches errors from React's streaming phase (deny/throw inside Suspense
|
|
292
|
+
* after the shell has flushed) and closes the stream cleanly.
|
|
293
|
+
*/
|
|
294
|
+
export function createNodeErrorHandler(signal?: AbortSignal): Transform {
|
|
295
|
+
const transform = new Transform({
|
|
296
|
+
transform(chunk, _encoding, callback) {
|
|
297
|
+
callback(null, chunk);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
transform.on('error', (error) => {
|
|
302
|
+
const isAbort =
|
|
303
|
+
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
304
|
+
(error instanceof Error && error.name === 'AbortError') ||
|
|
305
|
+
signal?.aborted;
|
|
306
|
+
|
|
307
|
+
if (isAbort) {
|
|
308
|
+
transform.end();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
logStreamingError({ error });
|
|
313
|
+
transform.push(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
|
|
314
|
+
transform.end();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return transform;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Compression ─────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
323
|
+
'text/html',
|
|
324
|
+
'text/css',
|
|
325
|
+
'text/plain',
|
|
326
|
+
'text/xml',
|
|
327
|
+
'text/javascript',
|
|
328
|
+
'text/x-component',
|
|
329
|
+
'application/json',
|
|
330
|
+
'application/javascript',
|
|
331
|
+
'application/xml',
|
|
332
|
+
'application/xhtml+xml',
|
|
333
|
+
'application/rss+xml',
|
|
334
|
+
'application/atom+xml',
|
|
335
|
+
'image/svg+xml',
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Create a Node.js gzip Transform using native node:zlib.
|
|
340
|
+
*
|
|
341
|
+
* Uses `createGzip()` which is backed by C++ zlib — significantly faster
|
|
342
|
+
* than the Web Streams `CompressionStream` API (which is a JS wrapper
|
|
343
|
+
* around the same zlib but with per-chunk Promise overhead).
|
|
344
|
+
*
|
|
345
|
+
* Returns null if the response shouldn't be compressed (wrong content type,
|
|
346
|
+
* client doesn't accept gzip, already encoded, etc.).
|
|
347
|
+
*/
|
|
348
|
+
export function createNodeGzipCompressor(
|
|
349
|
+
requestHeaders: Headers,
|
|
350
|
+
responseHeaders: Headers
|
|
351
|
+
): Transform | null {
|
|
352
|
+
// Check Accept-Encoding
|
|
353
|
+
const acceptEncoding = requestHeaders.get('accept-encoding') || '';
|
|
354
|
+
if (!acceptEncoding.includes('gzip')) return null;
|
|
355
|
+
|
|
356
|
+
// Check content type is compressible
|
|
357
|
+
const contentType = responseHeaders.get('content-type') || '';
|
|
358
|
+
const mimeType = contentType.split(';')[0].trim().toLowerCase();
|
|
359
|
+
if (!COMPRESSIBLE_TYPES.has(mimeType)) return null;
|
|
360
|
+
|
|
361
|
+
// Don't double-compress
|
|
362
|
+
if (responseHeaders.has('content-encoding')) return null;
|
|
363
|
+
|
|
364
|
+
// Set response headers for gzip
|
|
365
|
+
responseHeaders.set('content-encoding', 'gzip');
|
|
366
|
+
responseHeaders.delete('content-length');
|
|
367
|
+
const existingVary = responseHeaders.get('vary');
|
|
368
|
+
if (existingVary) {
|
|
369
|
+
if (!existingVary.toLowerCase().includes('accept-encoding')) {
|
|
370
|
+
responseHeaders.set('vary', existingVary + ', Accept-Encoding');
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
responseHeaders.set('vary', 'Accept-Encoding');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Z_SYNC_FLUSH ensures each chunk is flushed to the output immediately.
|
|
377
|
+
// Without it, gzip buffers internally and the browser doesn't receive
|
|
378
|
+
// the HTML shell until the gzip stream closes — breaking streaming.
|
|
379
|
+
// ~2–5% size overhead vs Z_NO_FLUSH but preserves correct streaming.
|
|
380
|
+
return createGzip({ flush: constants.Z_SYNC_FLUSH });
|
|
381
|
+
}
|
package/src/server/pipeline.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
setMutableCookieContext,
|
|
22
22
|
getSetCookieHeaders,
|
|
23
23
|
markResponseFlushed,
|
|
24
|
+
setSegmentParams,
|
|
24
25
|
} from './request-context.js';
|
|
25
26
|
import {
|
|
26
27
|
generateTraceId,
|
|
@@ -42,6 +43,8 @@ import {
|
|
|
42
43
|
} from './logger.js';
|
|
43
44
|
import { callOnRequestError } from './instrumentation.js';
|
|
44
45
|
import { RedirectSignal, DenySignal } from './primitives.js';
|
|
46
|
+
import { ParamCoercionError } from './route-element-builder.js';
|
|
47
|
+
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
45
48
|
import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.js';
|
|
46
49
|
import { findInterceptionMatch } from './pipeline-interception.js';
|
|
47
50
|
import type { MiddlewareContext } from './types.js';
|
|
@@ -117,12 +120,15 @@ export interface PipelineConfig {
|
|
|
117
120
|
*/
|
|
118
121
|
interceptionRewrites?: import('#/routing/interception.js').InterceptionRewrite[];
|
|
119
122
|
/**
|
|
120
|
-
*
|
|
121
|
-
* Only enable in dev mode — exposes internal timing data.
|
|
123
|
+
* Control Server-Timing header output.
|
|
122
124
|
*
|
|
123
|
-
*
|
|
125
|
+
* - `'detailed'` — per-phase breakdown (proxy, middleware, render).
|
|
126
|
+
* - `'total'` — single `total;dur=N` entry (production-safe).
|
|
127
|
+
* - `false` — no Server-Timing header at all.
|
|
128
|
+
*
|
|
129
|
+
* Default: `'total'`.
|
|
124
130
|
*/
|
|
125
|
-
|
|
131
|
+
serverTiming?: 'detailed' | 'total' | false;
|
|
126
132
|
/**
|
|
127
133
|
* Dev pipeline error callback — called when a pipeline phase (proxy,
|
|
128
134
|
* middleware, render) catches an unhandled error. Used to wire the error
|
|
@@ -149,6 +155,42 @@ export interface PipelineConfig {
|
|
|
149
155
|
) => Response | Promise<Response>;
|
|
150
156
|
}
|
|
151
157
|
|
|
158
|
+
// ─── Param Coercion ────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run segment param coercion on the matched route's segments.
|
|
162
|
+
*
|
|
163
|
+
* Loads params.ts modules from segments that have them, extracts the
|
|
164
|
+
* segmentParams definition, and coerces raw string params through codecs.
|
|
165
|
+
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
166
|
+
*
|
|
167
|
+
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
168
|
+
* See design/07-routing.md §"Where Coercion Runs"
|
|
169
|
+
*/
|
|
170
|
+
async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
171
|
+
const segments = match.segments as unknown as import('./route-matcher.js').ManifestSegmentNode[];
|
|
172
|
+
|
|
173
|
+
for (const segment of segments) {
|
|
174
|
+
// Only process segments that have a params.ts convention file
|
|
175
|
+
if (!segment.params) continue;
|
|
176
|
+
|
|
177
|
+
const mod = (await segment.params.load()) as Record<string, unknown>;
|
|
178
|
+
const segmentParamsDef = mod.segmentParams as
|
|
179
|
+
| { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
|
|
180
|
+
| undefined;
|
|
181
|
+
|
|
182
|
+
if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const coerced = segmentParamsDef.parse(match.params);
|
|
186
|
+
// Merge coerced values back into match.params
|
|
187
|
+
Object.assign(match.params, coerced);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
152
194
|
// ─── Pipeline ──────────────────────────────────────────────────────────────
|
|
153
195
|
|
|
154
196
|
/**
|
|
@@ -165,7 +207,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
165
207
|
earlyHints,
|
|
166
208
|
stripTrailingSlash = true,
|
|
167
209
|
slowRequestMs = 3000,
|
|
168
|
-
|
|
210
|
+
serverTiming = 'total',
|
|
169
211
|
onPipelineError,
|
|
170
212
|
} = config;
|
|
171
213
|
|
|
@@ -216,25 +258,25 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
216
258
|
// DevSpanProcessor reads this for tree/summary output.
|
|
217
259
|
await setSpanAttribute('http.response.status_code', result.status);
|
|
218
260
|
|
|
219
|
-
// Append Server-Timing header.
|
|
220
|
-
// In dev mode: detailed per-phase breakdown (proxy, middleware, render).
|
|
221
|
-
// In production: single total duration — safe to expose, no phase names.
|
|
261
|
+
// Append Server-Timing header based on configured mode.
|
|
222
262
|
// Response.redirect() creates immutable headers, so we must
|
|
223
263
|
// ensure mutability before writing Server-Timing.
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
264
|
+
if (serverTiming === 'detailed') {
|
|
265
|
+
// Detailed: per-phase breakdown (proxy, middleware, render).
|
|
266
|
+
const timingHeader = getServerTimingHeader();
|
|
267
|
+
if (timingHeader) {
|
|
227
268
|
result = ensureMutableResponse(result);
|
|
228
|
-
result.headers.set('Server-Timing',
|
|
269
|
+
result.headers.set('Server-Timing', timingHeader);
|
|
229
270
|
}
|
|
230
|
-
} else {
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
271
|
+
} else if (serverTiming === 'total') {
|
|
272
|
+
// Total only: single `total;dur=N` — no phase names.
|
|
273
|
+
// Prevents information disclosure while giving browser
|
|
274
|
+
// DevTools useful timing data.
|
|
234
275
|
const totalMs = Math.round(performance.now() - startTime);
|
|
235
276
|
result = ensureMutableResponse(result);
|
|
236
277
|
result.headers.set('Server-Timing', `total;dur=${totalMs}`);
|
|
237
278
|
}
|
|
279
|
+
// serverTiming === false: no header at all
|
|
238
280
|
|
|
239
281
|
return result;
|
|
240
282
|
}
|
|
@@ -254,7 +296,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
254
296
|
return response;
|
|
255
297
|
};
|
|
256
298
|
|
|
257
|
-
return
|
|
299
|
+
return serverTiming === 'detailed' ? runWithTimingCollector(runRequest) : runRequest();
|
|
258
300
|
});
|
|
259
301
|
});
|
|
260
302
|
};
|
|
@@ -272,7 +314,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
272
314
|
}
|
|
273
315
|
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
274
316
|
return await withSpan('timber.proxy', {}, () =>
|
|
275
|
-
|
|
317
|
+
serverTiming === 'detailed' ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
|
|
276
318
|
);
|
|
277
319
|
} catch (error) {
|
|
278
320
|
// Uncaught proxy.ts error → bare HTTP 500
|
|
@@ -283,6 +325,24 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
283
325
|
}
|
|
284
326
|
}
|
|
285
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Build a redirect Response from a RedirectSignal.
|
|
330
|
+
*
|
|
331
|
+
* For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
|
|
332
|
+
* so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
333
|
+
* turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
334
|
+
* createFromFetch. See design/19-client-navigation.md.
|
|
335
|
+
*/
|
|
336
|
+
function buildRedirectResponse(signal: RedirectSignal, req: Request, headers: Headers): Response {
|
|
337
|
+
const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
338
|
+
if (isRsc) {
|
|
339
|
+
headers.set('X-Timber-Redirect', signal.location);
|
|
340
|
+
return new Response(null, { status: 204, headers });
|
|
341
|
+
}
|
|
342
|
+
headers.set('Location', signal.location);
|
|
343
|
+
return new Response(null, { status: signal.status, headers });
|
|
344
|
+
}
|
|
345
|
+
|
|
286
346
|
async function handleRequest(req: Request, method: string, path: string): Promise<Response> {
|
|
287
347
|
// Stage 1: URL canonicalization
|
|
288
348
|
const url = new URL(req.url);
|
|
@@ -339,6 +399,21 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
339
399
|
}
|
|
340
400
|
}
|
|
341
401
|
|
|
402
|
+
// Stage 1c: Version skew detection (TIM-446).
|
|
403
|
+
// For RSC payload requests (client navigation), check if the client's
|
|
404
|
+
// deployment ID matches the current build. On mismatch, signal the
|
|
405
|
+
// client to do a full page reload instead of returning an RSC payload
|
|
406
|
+
// that references mismatched module IDs.
|
|
407
|
+
const isRscRequest = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
408
|
+
if (isRscRequest) {
|
|
409
|
+
const skewCheck = checkVersionSkew(req);
|
|
410
|
+
if (!skewCheck.ok) {
|
|
411
|
+
const reloadHeaders = new Headers();
|
|
412
|
+
applyReloadHeaders(reloadHeaders);
|
|
413
|
+
return new Response(null, { status: 204, headers: reloadHeaders });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
342
417
|
// Stage 2: Route matching
|
|
343
418
|
let match = matchRoute(canonicalPathname);
|
|
344
419
|
let interception: InterceptionContext | undefined;
|
|
@@ -397,18 +472,42 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
397
472
|
}
|
|
398
473
|
}
|
|
399
474
|
|
|
475
|
+
// Stage 2c: Param coercion (before middleware)
|
|
476
|
+
// Load params.ts modules from matched segments and coerce raw string
|
|
477
|
+
// params through defineSegmentParams codecs. Coercion failure → 404
|
|
478
|
+
// (middleware never runs). See design/07-routing.md §"Where Coercion Runs"
|
|
479
|
+
try {
|
|
480
|
+
await coerceSegmentParams(match);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if (error instanceof ParamCoercionError) {
|
|
483
|
+
return new Response(null, { status: 404 });
|
|
484
|
+
}
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Store coerced segment params in ALS so components can access them
|
|
489
|
+
// via rawSegmentParams() instead of receiving them as a prop.
|
|
490
|
+
// See design/07-routing.md §"params.ts — Convention File for Typed Params"
|
|
491
|
+
setSegmentParams(match.params);
|
|
492
|
+
|
|
400
493
|
// Stage 3: Leaf middleware.ts (only the leaf route's middleware runs)
|
|
401
494
|
if (match.middleware) {
|
|
402
495
|
const ctx: MiddlewareContext = {
|
|
403
496
|
req,
|
|
404
497
|
requestHeaders: requestHeaderOverlay,
|
|
405
498
|
headers: responseHeaders,
|
|
406
|
-
|
|
407
|
-
searchParams: new URL(req.url).searchParams,
|
|
499
|
+
segmentParams: match.params,
|
|
408
500
|
earlyHints: (hints) => {
|
|
409
501
|
for (const hint of hints) {
|
|
410
|
-
|
|
411
|
-
|
|
502
|
+
// Match Cloudflare's cached Early Hints attribute order: `as` before `rel`.
|
|
503
|
+
// Cloudflare caches Link headers and re-emits them on subsequent 200s.
|
|
504
|
+
// If our order differs, the browser sees duplicate preloads and warns.
|
|
505
|
+
let value: string;
|
|
506
|
+
if (hint.as !== undefined) {
|
|
507
|
+
value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
508
|
+
} else {
|
|
509
|
+
value = `<${hint.href}>; rel=${hint.rel}`;
|
|
510
|
+
}
|
|
412
511
|
if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
|
|
413
512
|
if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
414
513
|
responseHeaders.append('Link', value);
|
|
@@ -421,7 +520,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
421
520
|
setMutableCookieContext(true);
|
|
422
521
|
const middlewareFn = () => runMiddleware(match.middleware!, ctx);
|
|
423
522
|
const middlewareResponse = await withSpan('timber.middleware', {}, () =>
|
|
424
|
-
|
|
523
|
+
serverTiming === 'detailed'
|
|
524
|
+
? withTiming('mw', 'middleware.ts', middlewareFn)
|
|
525
|
+
: middlewareFn()
|
|
425
526
|
);
|
|
426
527
|
setMutableCookieContext(false);
|
|
427
528
|
if (middlewareResponse) {
|
|
@@ -438,20 +539,10 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
438
539
|
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
439
540
|
} catch (error) {
|
|
440
541
|
setMutableCookieContext(false);
|
|
441
|
-
// RedirectSignal from middleware → HTTP redirect (not an error)
|
|
442
|
-
// For RSC payload requests (client navigation), return 204 + X-Timber-Redirect
|
|
443
|
-
// so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
444
|
-
// turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
445
|
-
// createFromFetch. See design/19-client-navigation.md.
|
|
542
|
+
// RedirectSignal from middleware → HTTP redirect (not an error)
|
|
446
543
|
if (error instanceof RedirectSignal) {
|
|
447
544
|
applyCookieJar(responseHeaders);
|
|
448
|
-
|
|
449
|
-
if (isRsc) {
|
|
450
|
-
responseHeaders.set('X-Timber-Redirect', error.location);
|
|
451
|
-
return new Response(null, { status: 204, headers: responseHeaders });
|
|
452
|
-
}
|
|
453
|
-
responseHeaders.set('Location', error.location);
|
|
454
|
-
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
545
|
+
return buildRedirectResponse(error, req, responseHeaders);
|
|
455
546
|
}
|
|
456
547
|
// DenySignal from middleware → HTTP deny status
|
|
457
548
|
if (error instanceof DenySignal) {
|
|
@@ -476,7 +567,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
476
567
|
const renderFn = () =>
|
|
477
568
|
render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
478
569
|
const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
|
|
479
|
-
|
|
570
|
+
serverTiming === 'detailed'
|
|
571
|
+
? withTiming('render', 'RSC + SSR render', renderFn)
|
|
572
|
+
: renderFn()
|
|
480
573
|
);
|
|
481
574
|
markResponseFlushed();
|
|
482
575
|
return response;
|
|
@@ -486,10 +579,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
486
579
|
if (error instanceof DenySignal) {
|
|
487
580
|
return new Response(null, { status: error.status });
|
|
488
581
|
}
|
|
489
|
-
// RedirectSignal leaked from render — honour the redirect
|
|
582
|
+
// RedirectSignal leaked from render — honour the redirect
|
|
490
583
|
if (error instanceof RedirectSignal) {
|
|
491
|
-
|
|
492
|
-
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
584
|
+
return buildRedirectResponse(error, req, responseHeaders);
|
|
493
585
|
}
|
|
494
586
|
logRenderError({ method, path, error });
|
|
495
587
|
await fireOnRequestError(error, req, 'render');
|