@timber-js/app 0.2.0-alpha.3 → 0.2.0-alpha.30

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.
Files changed (140) hide show
  1. package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
  2. package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
  3. package/dist/_chunks/debug-B3Gypr3D.js +108 -0
  4. package/dist/_chunks/debug-B3Gypr3D.js.map +1 -0
  5. package/dist/_chunks/{format-DNt20Kt8.js → format-RyoGQL74.js} +3 -2
  6. package/dist/_chunks/format-RyoGQL74.js.map +1 -0
  7. package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
  8. package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
  9. package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
  10. package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
  11. package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-BQUC8PHn.js} +5 -4
  12. package/dist/_chunks/request-context-BQUC8PHn.js.map +1 -0
  13. package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
  14. package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
  15. package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
  16. package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
  17. package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
  18. package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
  19. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
  20. package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
  21. package/dist/adapters/nitro.d.ts +17 -1
  22. package/dist/adapters/nitro.d.ts.map +1 -1
  23. package/dist/adapters/nitro.js +5 -5
  24. package/dist/adapters/nitro.js.map +1 -1
  25. package/dist/cache/fast-hash.d.ts +22 -0
  26. package/dist/cache/fast-hash.d.ts.map +1 -0
  27. package/dist/cache/index.js +52 -10
  28. package/dist/cache/index.js.map +1 -1
  29. package/dist/cache/register-cached-function.d.ts.map +1 -1
  30. package/dist/cache/timber-cache.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +1 -1
  32. package/dist/client/index.js +3 -3
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/client/link.d.ts.map +1 -1
  35. package/dist/client/router.d.ts.map +1 -1
  36. package/dist/client/segment-context.d.ts +1 -1
  37. package/dist/client/segment-context.d.ts.map +1 -1
  38. package/dist/client/segment-merger.d.ts.map +1 -1
  39. package/dist/client/stale-reload.d.ts.map +1 -1
  40. package/dist/client/top-loader.d.ts.map +1 -1
  41. package/dist/client/transition-root.d.ts +1 -1
  42. package/dist/client/transition-root.d.ts.map +1 -1
  43. package/dist/cookies/index.js +4 -4
  44. package/dist/fonts/css.d.ts +1 -0
  45. package/dist/fonts/css.d.ts.map +1 -1
  46. package/dist/fonts/local.d.ts +4 -2
  47. package/dist/fonts/local.d.ts.map +1 -1
  48. package/dist/index.d.ts +28 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +249 -21
  51. package/dist/index.js.map +1 -1
  52. package/dist/plugins/build-report.d.ts +11 -1
  53. package/dist/plugins/build-report.d.ts.map +1 -1
  54. package/dist/plugins/entries.d.ts +7 -0
  55. package/dist/plugins/entries.d.ts.map +1 -1
  56. package/dist/plugins/fonts.d.ts +9 -1
  57. package/dist/plugins/fonts.d.ts.map +1 -1
  58. package/dist/plugins/mdx.d.ts +6 -0
  59. package/dist/plugins/mdx.d.ts.map +1 -1
  60. package/dist/plugins/server-bundle.d.ts.map +1 -1
  61. package/dist/routing/index.js +1 -1
  62. package/dist/rsc-runtime/ssr.d.ts +12 -0
  63. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  64. package/dist/search-params/index.js +1 -1
  65. package/dist/server/access-gate.d.ts.map +1 -1
  66. package/dist/server/action-client.d.ts.map +1 -1
  67. package/dist/server/debug.d.ts +82 -0
  68. package/dist/server/debug.d.ts.map +1 -0
  69. package/dist/server/deny-renderer.d.ts.map +1 -1
  70. package/dist/server/dev-warnings.d.ts.map +1 -1
  71. package/dist/server/html-injectors.d.ts.map +1 -1
  72. package/dist/server/index.js +32 -23
  73. package/dist/server/index.js.map +1 -1
  74. package/dist/server/logger.d.ts.map +1 -1
  75. package/dist/server/node-stream-transforms.d.ts +65 -0
  76. package/dist/server/node-stream-transforms.d.ts.map +1 -0
  77. package/dist/server/pipeline.d.ts +7 -4
  78. package/dist/server/pipeline.d.ts.map +1 -1
  79. package/dist/server/primitives.d.ts.map +1 -1
  80. package/dist/server/request-context.d.ts.map +1 -1
  81. package/dist/server/route-element-builder.d.ts.map +1 -1
  82. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  84. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  85. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  86. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  87. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  88. package/dist/server/ssr-entry.d.ts.map +1 -1
  89. package/dist/server/ssr-render.d.ts +34 -21
  90. package/dist/server/ssr-render.d.ts.map +1 -1
  91. package/dist/server/tracing.d.ts +10 -0
  92. package/dist/server/tracing.d.ts.map +1 -1
  93. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  94. package/dist/shims/image.d.ts +15 -15
  95. package/package.json +1 -1
  96. package/src/adapters/nitro.ts +31 -5
  97. package/src/cache/fast-hash.ts +34 -0
  98. package/src/cache/register-cached-function.ts +7 -3
  99. package/src/cache/timber-cache.ts +17 -10
  100. package/src/client/browser-entry.ts +10 -6
  101. package/src/client/link.tsx +14 -9
  102. package/src/client/router.ts +4 -6
  103. package/src/client/segment-context.ts +6 -1
  104. package/src/client/segment-merger.ts +2 -8
  105. package/src/client/stale-reload.ts +5 -7
  106. package/src/client/top-loader.tsx +8 -7
  107. package/src/client/transition-root.tsx +7 -1
  108. package/src/fonts/css.ts +2 -1
  109. package/src/fonts/local.ts +7 -3
  110. package/src/index.ts +35 -2
  111. package/src/plugins/build-report.ts +23 -3
  112. package/src/plugins/entries.ts +9 -4
  113. package/src/plugins/fonts.ts +171 -19
  114. package/src/plugins/mdx.ts +9 -5
  115. package/src/plugins/server-bundle.ts +4 -0
  116. package/src/rsc-runtime/ssr.ts +50 -0
  117. package/src/rsc-runtime/vendor-types.d.ts +7 -0
  118. package/src/server/access-gate.tsx +3 -2
  119. package/src/server/action-client.ts +15 -5
  120. package/src/server/debug.ts +137 -0
  121. package/src/server/deny-renderer.ts +3 -2
  122. package/src/server/dev-warnings.ts +2 -1
  123. package/src/server/html-injectors.ts +30 -10
  124. package/src/server/logger.ts +4 -3
  125. package/src/server/node-stream-transforms.ts +315 -0
  126. package/src/server/pipeline.ts +34 -20
  127. package/src/server/primitives.ts +2 -1
  128. package/src/server/request-context.ts +3 -2
  129. package/src/server/route-element-builder.ts +1 -6
  130. package/src/server/rsc-entry/index.ts +50 -7
  131. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  132. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  133. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  134. package/src/server/rsc-prop-warnings.ts +3 -1
  135. package/src/server/ssr-entry.ts +128 -8
  136. package/src/server/ssr-render.ts +168 -57
  137. package/src/server/tracing.ts +23 -0
  138. package/src/server/waituntil-bridge.ts +4 -1
  139. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  140. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
@@ -67,6 +67,22 @@ import { buildRscPayloadResponse } from './rsc-payload.js';
67
67
  import { renderRscStream } from './rsc-stream.js';
68
68
  import { renderSsrResponse } from './ssr-renderer.js';
69
69
  import { callSsr } from './ssr-bridge.js';
70
+ import { isDebug, isDevMode, setDebugFromConfig } from '#/server/debug.js';
71
+
72
+ /**
73
+ * Resolve the Server-Timing mode from timber.config.ts.
74
+ *
75
+ * If the user set `serverTiming` explicitly, use that value.
76
+ * Otherwise: `'detailed'` in dev, `'total'` in production.
77
+ */
78
+ function resolveServerTimingMode(
79
+ config: Record<string, unknown>,
80
+ isDev: boolean
81
+ ): 'detailed' | 'total' | false {
82
+ const userValue = config.serverTiming as 'detailed' | 'total' | false | undefined;
83
+ if (userValue !== undefined) return userValue;
84
+ return isDev ? 'detailed' : 'total';
85
+ }
70
86
 
71
87
  // Dev-only pipeline error handler, set by the dev server after import.
72
88
  // In production this is always undefined — no overhead.
@@ -121,13 +137,31 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
121
137
  buildManifest: buildManifest as BuildManifest,
122
138
  });
123
139
 
124
- // Dev logging initialize OTEL-based dev tracing once at handler creation.
125
- // In production, isDev is false no tracing, no overhead.
126
- // The DevSpanProcessor handles all formatting and stderr output.
127
- const isDev = process.env.NODE_ENV !== 'production';
140
+ // Initialize debug flag from config before anything else.
141
+ // This allows timber.config.ts `debug: true` to enable debug logging
142
+ // in production without the TIMBER_DEBUG env var.
143
+ if ((runtimeConfig as Record<string, unknown>).debug) {
144
+ setDebugFromConfig(true);
145
+ }
146
+
147
+ // Two separate flags for two different security levels:
148
+ //
149
+ // isDev (isDevMode) — gates client-visible behavior: dev error pages with
150
+ // stack traces, detailed Server-Timing headers, error messages in action
151
+ // payloads. Statically replaced in production → tree-shaken to zero.
152
+ // TIMBER_DEBUG cannot enable this.
153
+ //
154
+ // debugEnabled (isDebug) — gates server-side logging only: stderr warnings,
155
+ // OTEL dev tracing, console.error fallbacks. TIMBER_DEBUG enables this.
156
+ // Never affects what clients see.
157
+ const isDev = isDevMode();
158
+ const debugEnabled = isDebug();
128
159
  const slowPhaseMs = (runtimeConfig as Record<string, unknown>).slowPhaseMs as number | undefined;
129
160
 
130
- if (isDev) {
161
+ // Dev logging — initialize OTEL-based dev tracing once at handler creation.
162
+ // In production with TIMBER_DEBUG, this enables server-side tracing output
163
+ // without exposing anything to clients.
164
+ if (debugEnabled) {
131
165
  const devLogMode = resolveLogMode();
132
166
  if (devLogMode !== 'quiet') {
133
167
  await initDevTracing({ mode: devLogMode, slowPhaseMs });
@@ -186,7 +220,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
186
220
  // Slow request threshold from timber.config.ts. Default 3000ms, 0 to disable.
187
221
  // See design/17-logging.md §"slowRequestMs"
188
222
  slowRequestMs: (runtimeConfig as Record<string, unknown>).slowRequestMs as number | undefined,
189
- enableServerTiming: isDev,
223
+ serverTiming: resolveServerTimingMode(runtimeConfig, isDev),
190
224
  onPipelineError: isDev
191
225
  ? (error: Error, phase: string) => {
192
226
  if (_devPipelineErrorHandler) _devPipelineErrorHandler(error, phase);
@@ -330,7 +364,8 @@ async function renderRoute(
330
364
  throw error;
331
365
  }
332
366
 
333
- const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } = routeResult;
367
+ const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
368
+ routeResult;
334
369
 
335
370
  // Build head HTML for injection into the SSR output.
336
371
  // Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
@@ -347,6 +382,14 @@ async function renderRoute(
347
382
  headHtml += buildCssLinkTags(cssUrls);
348
383
  }
349
384
 
385
+ // Inline font CSS as a <style> tag — @font-face rules and scoped classes.
386
+ // The font CSS is set on globalThis by the transformed font file's
387
+ // side-effect import of virtual:timber-font-css-register.
388
+ const fontCss = (globalThis as Record<string, unknown>).__timber_font_css as string | undefined;
389
+ if (fontCss) {
390
+ headHtml += `<style data-timber-fonts>${fontCss}</style>`;
391
+ }
392
+
350
393
  const fontEntries = collectRouteFonts(segments, typedManifest);
351
394
  if (fontEntries.length > 0) {
352
395
  headHtml += buildFontPreloadTags(fontEntries);
@@ -45,15 +45,45 @@ export async function buildRscPayloadResponse(
45
45
  skippedSegments?: string[]
46
46
  ): Promise<Response> {
47
47
  // Read the first chunk from the RSC stream before committing headers.
48
+ // Race the first read against signal detection — if an async component
49
+ // throws a RedirectSignal or DenySignal, the onError callback fires
50
+ // signals.onSignal() and we can react immediately without waiting for
51
+ // the full macrotask queue.
52
+ //
53
+ // The rejection chain for an async-wrapped page component:
54
+ // 1. PageComponent throws RedirectSignal
55
+ // 2. withSpan catches and re-throws (microtask 1)
56
+ // 3. TracedPage promise rejects (microtask 2)
57
+ // 4. React Flight rejection handler → onError (microtask 3+)
58
+ //
59
+ // Promise.race reacts the instant onError fires, eliminating the
60
+ // per-request setTimeout(0) macrotask delay for the common case
61
+ // (no signal). A 50ms ceiling timeout guards against edge cases
62
+ // where onError never fires.
48
63
  const reader = rscStream.getReader();
49
- const firstRead = await reader.read();
64
+ const signalDetected = new Promise<void>((resolve) => {
65
+ signals.onSignal = resolve;
66
+ });
67
+
68
+ type RaceResult =
69
+ | { type: 'data'; chunk: ReadableStreamReadResult<Uint8Array> }
70
+ | { type: 'signal' };
71
+
72
+ const first: RaceResult = await Promise.race([
73
+ reader.read().then((chunk) => ({ type: 'data' as const, chunk })),
74
+ signalDetected.then(() => ({ type: 'signal' as const })),
75
+ ]);
50
76
 
51
- // Yield to the microtask queue so that async component rejections
52
- // (e.g. an async-wrapped page component that throws redirect())
53
- // propagate to the onError callback before we check the signals.
54
- // The rejected Promise from an async component resolves in the next
55
- // microtask after read(), so we need at least one tick.
56
- await new Promise<void>((r) => setTimeout(r, 0));
77
+ // If data arrived first, still check signals they may have fired
78
+ // concurrently. Also do a final ceiling timeout check for edge cases
79
+ // where the signal fires just after the first read resolves.
80
+ if (first.type === 'data' && !signals.redirectSignal && !signals.denySignal) {
81
+ // Brief yield to let any in-flight microtask rejections complete.
82
+ await new Promise<void>((r) => setTimeout(r, 0));
83
+ }
84
+
85
+ // Detach the callback — no longer needed after this point.
86
+ signals.onSignal = undefined;
57
87
 
58
88
  // Check for redirect/deny signals detected during initial rendering
59
89
  const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
@@ -72,6 +102,11 @@ export async function buildRscPayloadResponse(
72
102
  );
73
103
  }
74
104
 
105
+ // Extract the first chunk from the race result.
106
+ // If the signal won the race, read the first chunk now (the stream
107
+ // was already cancelled above, but we need a firstRead shape below).
108
+ const firstRead = first.type === 'data' ? first.chunk : await reader.read();
109
+
75
110
  // Reconstruct the stream: prepend the buffered first chunk,
76
111
  // then continue piping from the original reader.
77
112
  const patchedStream = new ReadableStream<Uint8Array>({
@@ -17,17 +17,24 @@ import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js'
17
17
  import { checkAndWarnRscPropError } from '#/server/rsc-prop-warnings.js';
18
18
 
19
19
  import { createDebugChannelSink, isAbortError } from './helpers.js';
20
+ import { isDebug } from '#/server/debug.js';
20
21
 
21
22
  /**
22
23
  * Mutable signal state captured during RSC rendering.
23
24
  *
24
25
  * Signals fire asynchronously via `onError` during stream consumption.
25
26
  * The first signal of each type wins — subsequent signals are ignored.
27
+ *
28
+ * `onSignal` is an optional callback fired when a DenySignal or
29
+ * RedirectSignal is captured. Consumers use it with Promise.race to
30
+ * react immediately instead of polling with setTimeout/queueMicrotask.
26
31
  */
27
32
  export interface RenderSignals {
28
33
  denySignal: DenySignal | null;
29
34
  redirectSignal: RedirectSignal | null;
30
35
  renderError: { error: unknown; status: number } | null;
36
+ /** Callback fired when a redirect or deny signal is captured in onError. */
37
+ onSignal?: () => void;
31
38
  }
32
39
 
33
40
  export interface RscStreamResult {
@@ -66,11 +73,13 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
66
73
  if (isAbortError(error) || req.signal?.aborted) return;
67
74
  if (error instanceof DenySignal) {
68
75
  signals.denySignal = error;
76
+ signals.onSignal?.();
69
77
  // Return structured digest for client-side error boundaries
70
78
  return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
71
79
  }
72
80
  if (error instanceof RedirectSignal) {
73
81
  signals.redirectSignal = error;
82
+ signals.onSignal?.();
74
83
  return JSON.stringify({
75
84
  type: 'redirect',
76
85
  location: error.location,
@@ -97,11 +106,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
97
106
  // directive isn't at the very top of the file, or the component is
98
107
  // re-exported through a barrel file without 'use client'.
99
108
  // See LOCAL-297.
100
- if (
101
- process.env.NODE_ENV !== 'production' &&
102
- error instanceof Error &&
103
- error.message.includes('Invalid hook call')
104
- ) {
109
+ if (isDebug() && error instanceof Error && error.message.includes('Invalid hook call')) {
105
110
  console.error(
106
111
  '[timber] A React hook was called during RSC rendering. This usually means a ' +
107
112
  "'use client' component is being executed as a server component instead of " +
@@ -156,12 +156,19 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
156
156
  try {
157
157
  const ssrResponse = await callSsr(ssrStream, navContext);
158
158
 
159
- // Signal promotion: yield one tick so async component rejections
160
- // propagate to the RSC onError callback, then check if any signals
161
- // were captured during rendering inside Suspense boundaries.
162
- // The Response hasn't been sent yet — it's an unconsumed stream.
159
+ // Signal promotion: check if any signals were captured during rendering
160
+ // inside Suspense boundaries. If no signals are present yet, yield one
161
+ // microtask so async component rejections propagate to the RSC onError
162
+ // callback before we commit the response.
163
+ //
164
+ // When signals are already captured (onSignal already fired), skip the
165
+ // yield entirely — react immediately. Uses queueMicrotask instead of
166
+ // setTimeout(0) for the fallback to avoid yielding to the full event
167
+ // loop (timers phase).
163
168
  // See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
164
- await new Promise<void>((r) => setTimeout(r, 0));
169
+ if (!signals.redirectSignal && !signals.denySignal && !signals.renderError) {
170
+ await new Promise<void>((r) => queueMicrotask(r));
171
+ }
165
172
 
166
173
  const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
167
174
  if (promoted) {
@@ -16,6 +16,8 @@
16
16
  * Task: TIM-358
17
17
  */
18
18
 
19
+ import { isDebug } from './debug.js';
20
+
19
21
  // ─── Types ────────────────────────────────────────────────────────────────
20
22
 
21
23
  export interface NonSerializableTypeInfo {
@@ -165,7 +167,7 @@ export function formatRscPropWarning(
165
167
  * @returns true if a warning was emitted
166
168
  */
167
169
  export function checkAndWarnRscPropError(error: unknown, requestPath: string): boolean {
168
- if (process.env.NODE_ENV === 'production') return false;
170
+ if (!isDebug()) return false;
169
171
  if (!(error instanceof Error)) return false;
170
172
 
171
173
  const info = detectNonSerializableType(error.message);
@@ -14,10 +14,20 @@
14
14
 
15
15
  // @ts-expect-error — virtual module provided by timber-entries plugin
16
16
  import config from 'virtual:timber-config';
17
- import { createFromReadableStream } from '#/rsc-runtime/ssr.js';
17
+ import {
18
+ createFromReadableStream,
19
+ createFromNodeStream,
20
+ hasNodeStreamDecode,
21
+ } from '#/rsc-runtime/ssr.js';
18
22
  import { AsyncLocalStorage } from 'node:async_hooks';
19
23
 
20
- import { renderSsrStream, buildSsrResponse } from './ssr-render.js';
24
+ import {
25
+ renderSsrStream,
26
+ renderSsrNodeStream,
27
+ nodeReadableToWeb,
28
+ useNodeStreams,
29
+ buildSsrResponse,
30
+ } from './ssr-render.js';
21
31
  import { formatSsrError } from './error-formatter.js';
22
32
  import { SsrStreamError } from './primitives.js';
23
33
  import { injectHead, injectRscPayload } from './html-injectors.js';
@@ -26,6 +36,41 @@ import { withSpan } from './tracing.js';
26
36
  import { setCurrentParams } from '#/client/use-params.js';
27
37
  import { registerSsrDataProvider, type SsrData } from '#/client/ssr-data.js';
28
38
 
39
+ // Pre-import Node.js stream modules at module load time — not per-request.
40
+ // Dynamic imports of node-stream-transforms and node:stream/promises were
41
+ // costing 3-17ms per request due to module resolution overhead.
42
+ let _nodeStreamImports: {
43
+ createNodeHeadInjector: typeof import('./node-stream-transforms.js').createNodeHeadInjector;
44
+ createNodeFlightInjector: typeof import('./node-stream-transforms.js').createNodeFlightInjector;
45
+ createNodeErrorHandler: typeof import('./node-stream-transforms.js').createNodeErrorHandler;
46
+ pipeline: typeof import('node:stream/promises').pipeline;
47
+ PassThrough: typeof import('node:stream').PassThrough;
48
+ ReadableFromWeb: (
49
+ webStream: import('stream/web').ReadableStream
50
+ ) => import('node:stream').Readable;
51
+ } | null = null;
52
+
53
+ if (useNodeStreams) {
54
+ try {
55
+ const [transforms, streamPromises, stream] = await Promise.all([
56
+ import('./node-stream-transforms.js'),
57
+ import('node:stream/promises'),
58
+ import('node:stream'),
59
+ ]);
60
+ _nodeStreamImports = {
61
+ createNodeHeadInjector: transforms.createNodeHeadInjector,
62
+ createNodeFlightInjector: transforms.createNodeFlightInjector,
63
+ createNodeErrorHandler: transforms.createNodeErrorHandler,
64
+ pipeline: streamPromises.pipeline,
65
+ PassThrough: stream.PassThrough,
66
+ ReadableFromWeb: (webStream: import('stream/web').ReadableStream) =>
67
+ stream.Readable.fromWeb(webStream) as import('node:stream').Readable,
68
+ };
69
+ } catch {
70
+ // Fall back to Web Streams path
71
+ }
72
+ }
73
+
29
74
  // ─── SSR Data ALS ─────────────────────────────────────────────────────────
30
75
  //
31
76
  // Per-request SSR data stored in AsyncLocalStorage, ensuring correct
@@ -137,7 +182,23 @@ export async function handleSsr(
137
182
  // createFromReadableStream resolves client component references
138
183
  // (from "use client" modules) using the SSR environment's module
139
184
  // map, importing the actual components for server-side rendering.
140
- const element = createFromReadableStream(rscStream) as React.ReactNode;
185
+ const _s0 = performance.now();
186
+ // eslint-disable-next-line no-console
187
+ console.log(`[diag] nodeImports=${!!_nodeStreamImports} nodeStreamDecode=${hasNodeStreamDecode} rscStream=${rscStream?.constructor?.name}`);
188
+ // Decode the RSC stream into a React element tree.
189
+ // On Node.js: convert Web ReadableStream → Node Readable → createFromNodeStream
190
+ // (eliminates Promise-per-chunk overhead from Web Streams reader)
191
+ // On Workers: createFromReadableStream (Web Streams are V8-native C++ there)
192
+ let element: React.ReactNode;
193
+ if (hasNodeStreamDecode && _nodeStreamImports) {
194
+ const nodeRscStream = _nodeStreamImports.ReadableFromWeb(
195
+ rscStream as import('stream/web').ReadableStream
196
+ );
197
+ element = createFromNodeStream(nodeRscStream) as React.ReactNode;
198
+ } else {
199
+ element = createFromReadableStream(rscStream) as React.ReactNode;
200
+ }
201
+ const _s1 = performance.now();
141
202
 
142
203
  // Wrap with a server-safe nuqs adapter so that 'use client' components
143
204
  // that call nuqs hooks (useQueryStates, useQueryState) can SSR correctly.
@@ -145,12 +206,76 @@ export async function handleSsr(
145
206
  // over after hydration. This provider supplies the request's search params
146
207
  // as a static snapshot so nuqs renders the right initial values on the server.
147
208
  const wrappedElement = withNuqsSsrAdapter(navContext.searchParams, element);
209
+ const _s2 = performance.now();
148
210
 
149
211
  // Render to HTML stream (waits for onShellReady).
150
212
  // Pass bootstrapScriptContent so React injects a non-deferred <script>
151
213
  // in the shell HTML. This executes immediately during parsing — even
152
214
  // while Suspense boundaries are still streaming — triggering module
153
215
  // loading via dynamic import() so hydration can start early.
216
+ //
217
+ // Two paths based on platform:
218
+ // - Node.js: renderToPipeableStream → Node Transform pipeline → Readable.toWeb() → Response
219
+ // Entire pipeline stays in C++ native streams until the Response boundary.
220
+ // - Workers: renderToReadableStream → Web TransformStream pipeline → Response
221
+ // Web Streams are V8-native C++ built-ins on Workers.
222
+ if (_nodeStreamImports) {
223
+ // Node.js fast path: full pipeline in native streams
224
+ const {
225
+ createNodeHeadInjector,
226
+ createNodeFlightInjector,
227
+ createNodeErrorHandler,
228
+ pipeline,
229
+ PassThrough,
230
+ } = _nodeStreamImports;
231
+
232
+ const _s3 = performance.now();
233
+ let nodeHtmlStream: import('node:stream').Readable;
234
+ try {
235
+ nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
236
+ bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
237
+ deferSuspenseFor: navContext.deferSuspenseFor,
238
+ signal: navContext.signal,
239
+ });
240
+ } catch (renderError) {
241
+ console.error(
242
+ '[timber] SSR shell failed from RSC stream error:',
243
+ formatSsrError(renderError)
244
+ );
245
+ throw new SsrStreamError(
246
+ 'SSR renderToReadableStream failed due to RSC stream error',
247
+ renderError
248
+ );
249
+ }
250
+
251
+ // Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector → gzip
252
+ const errorHandler = createNodeErrorHandler(navContext.signal);
253
+ const headInjector = createNodeHeadInjector(navContext.headHtml);
254
+ const flightInjector = createNodeFlightInjector(navContext.rscStream);
255
+
256
+ // Pipe through the chain. pipeline() handles backpressure and error propagation.
257
+ // The last stream in the chain is the output — convert to Web ReadableStream
258
+ // only at the Response boundary.
259
+ // Note: gzip compression is still handled by compressResponse() in the Nitro
260
+ // entry via Web Streams CompressionStream. Moving it into this Node.js pipeline
261
+ // requires the request headers (Accept-Encoding) which NavContext doesn't carry.
262
+ // TODO: pass request headers through NavContext to enable inline Node.js gzip.
263
+ const output = new PassThrough();
264
+ pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
265
+ // Pipeline errors are handled by errorHandler transform
266
+ });
267
+
268
+ const _s4 = performance.now();
269
+ // eslint-disable-next-line no-console
270
+ console.log(
271
+ `[ssr-perf] decode=${(_s1 - _s0).toFixed(1)}ms nuqs=${(_s2 - _s1).toFixed(1)}ms imports=${(_s3 - _s2).toFixed(1)}ms renderToPipeable=${(_s4 - _s3).toFixed(1)}ms pipeline=${(performance.now() - _s4).toFixed(1)}ms total=${(performance.now() - _s0).toFixed(1)}ms`
272
+ );
273
+
274
+ const webStream = nodeReadableToWeb(output);
275
+ return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);
276
+ }
277
+
278
+ // Web Streams path (CF Workers / fallback)
154
279
  let htmlStream: ReadableStream<Uint8Array>;
155
280
  try {
156
281
  htmlStream = await renderSsrStream(wrappedElement, {
@@ -159,11 +284,6 @@ export async function handleSsr(
159
284
  signal: navContext.signal,
160
285
  });
161
286
  } catch (renderError) {
162
- // SSR shell rendering failed — the RSC stream contained an error
163
- // that wasn't caught by any error boundary in the decoded tree.
164
- // Wrap in SsrStreamError so the RSC entry can handle it without
165
- // re-executing server components via renderDenyPage.
166
- // See LOCAL-293.
167
287
  console.error(
168
288
  '[timber] SSR shell failed from RSC stream error:',
169
289
  formatSsrError(renderError)