@timber-js/app 0.2.0-alpha.37 → 0.2.0-alpha.39

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 (68) hide show
  1. package/dist/adapters/nitro.d.ts.map +1 -1
  2. package/dist/adapters/nitro.js +27 -4
  3. package/dist/adapters/nitro.js.map +1 -1
  4. package/dist/cache/index.d.ts +5 -2
  5. package/dist/cache/index.d.ts.map +1 -1
  6. package/dist/cache/index.js +37 -8
  7. package/dist/cache/index.js.map +1 -1
  8. package/dist/cache/singleflight.d.ts +18 -1
  9. package/dist/cache/singleflight.d.ts.map +1 -1
  10. package/dist/cache/timber-cache.d.ts.map +1 -1
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +53 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/plugins/dev-error-overlay.d.ts +26 -1
  16. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  17. package/dist/plugins/entries.d.ts.map +1 -1
  18. package/dist/server/action-handler.d.ts.map +1 -1
  19. package/dist/server/default-logger.d.ts +22 -0
  20. package/dist/server/default-logger.d.ts.map +1 -0
  21. package/dist/server/flush.d.ts.map +1 -1
  22. package/dist/server/html-injectors.d.ts +2 -2
  23. package/dist/server/html-injectors.d.ts.map +1 -1
  24. package/dist/server/index.d.ts +2 -0
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/index.js +135 -24
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/logger.d.ts +24 -7
  29. package/dist/server/logger.d.ts.map +1 -1
  30. package/dist/server/node-stream-transforms.d.ts +13 -1
  31. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  32. package/dist/server/render-timeout.d.ts +51 -0
  33. package/dist/server/render-timeout.d.ts.map +1 -0
  34. package/dist/server/route-handler.d.ts.map +1 -1
  35. package/dist/server/rsc-entry/helpers.d.ts +46 -3
  36. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  37. package/dist/server/rsc-entry/index.d.ts +6 -1
  38. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  39. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  40. package/dist/server/rsc-entry/rsc-stream.d.ts +3 -0
  41. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  42. package/dist/server/ssr-entry.d.ts.map +1 -1
  43. package/dist/server/ssr-render.d.ts +2 -0
  44. package/dist/server/ssr-render.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/adapters/nitro.ts +27 -4
  47. package/src/cache/index.ts +5 -2
  48. package/src/cache/singleflight.ts +62 -4
  49. package/src/cache/timber-cache.ts +17 -16
  50. package/src/index.ts +12 -0
  51. package/src/plugins/dev-error-overlay.ts +70 -1
  52. package/src/plugins/dev-server.ts +38 -4
  53. package/src/plugins/entries.ts +1 -0
  54. package/src/server/action-handler.ts +3 -2
  55. package/src/server/default-logger.ts +95 -0
  56. package/src/server/flush.ts +2 -1
  57. package/src/server/html-injectors.ts +32 -7
  58. package/src/server/index.ts +4 -0
  59. package/src/server/logger.ts +38 -35
  60. package/src/server/node-stream-transforms.ts +51 -14
  61. package/src/server/render-timeout.ts +108 -0
  62. package/src/server/route-handler.ts +2 -1
  63. package/src/server/rsc-entry/helpers.ts +122 -3
  64. package/src/server/rsc-entry/index.ts +34 -4
  65. package/src/server/rsc-entry/rsc-payload.ts +11 -3
  66. package/src/server/rsc-entry/rsc-stream.ts +24 -3
  67. package/src/server/ssr-entry.ts +9 -2
  68. package/src/server/ssr-render.ts +105 -16
@@ -30,6 +30,8 @@ import {
30
30
  type FlightInjectionState,
31
31
  type FlightInjectionEvent,
32
32
  } from './flight-injection-state.js';
33
+ import { withTimeout, RenderTimeoutError } from './render-timeout.js';
34
+ import { logStreamingError } from './logger.js';
33
35
 
34
36
  // ─── Head Injection ──────────────────────────────────────────────────────────
35
37
 
@@ -108,8 +110,22 @@ export function createNodeHeadInjector(headHtml: string): Transform {
108
110
  * stream). We read from it using the Web API — this is the one bridge
109
111
  * point between Web Streams and Node.js streams in the pipeline.
110
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
+
111
126
  export function createNodeFlightInjector(
112
- rscStream: ReadableStream<Uint8Array> | undefined
127
+ rscStream: ReadableStream<Uint8Array> | undefined,
128
+ options?: NodeFlightInjectorOptions
113
129
  ): Transform {
114
130
  if (!rscStream) {
115
131
  return new Transform({
@@ -119,6 +135,7 @@ export function createNodeFlightInjector(
119
135
  });
120
136
  }
121
137
 
138
+ const timeoutMs = options?.renderTimeoutMs ?? 30_000;
122
139
  const suffix = '</body></html>';
123
140
  const suffixBuf = Buffer.from(suffix, 'utf-8');
124
141
  const rscReader = rscStream.getReader();
@@ -129,6 +146,11 @@ export function createNodeFlightInjector(
129
146
  transitions: flightInjectionTransitions,
130
147
  });
131
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
+
132
154
  // pullLoop reads RSC chunks and pushes them directly to the transform
133
155
  // output as <script> tags. This ensures RSC data is delivered to the
134
156
  // browser as soon as it's available — not deferred until the next HTML
@@ -141,7 +163,16 @@ export function createNodeFlightInjector(
141
163
  await new Promise<void>((r) => setImmediate(r));
142
164
  try {
143
165
  for (;;) {
144
- const { done, value } = await rscReader.read();
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;
145
176
  if (done) {
146
177
  machine.send({ type: 'PULL_DONE' });
147
178
  return;
@@ -160,6 +191,10 @@ export function createNodeFlightInjector(
160
191
  }
161
192
  }
162
193
  } catch (err) {
194
+ // On timeout, cancel the RSC reader to release resources.
195
+ if (err instanceof RenderTimeoutError) {
196
+ rscReader.cancel(err).catch(() => {});
197
+ }
163
198
  machine.send({ type: 'PULL_ERROR', error: err });
164
199
  }
165
200
  }
@@ -196,8 +231,9 @@ export function createNodeFlightInjector(
196
231
  // Start the pull loop on the first HTML chunk to stream RSC
197
232
  // data chunks alongside the HTML. The __timber_f init script is
198
233
  // already in <head> (via flightInitScript), so no bootstrap needed.
234
+ // Store the promise so flush() can await it instead of polling.
199
235
  if (isFirst) {
200
- pullLoop(transform);
236
+ pullPromise = pullLoop(transform);
201
237
  }
202
238
  callback();
203
239
  },
@@ -226,16 +262,17 @@ export function createNodeFlightInjector(
226
262
  finish();
227
263
  return;
228
264
  }
229
- // Wait for the RSC stream to finish before closing.
230
- // pullLoop is already running and pushing directly.
231
- const waitForPull = () => {
232
- if (isPullDone(machine.state)) {
233
- finish();
234
- } else {
235
- setImmediate(waitForPull);
236
- }
237
- };
238
- waitForPull();
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
+ });
239
276
  },
240
277
  });
241
278
 
@@ -272,7 +309,7 @@ export function createNodeErrorHandler(signal?: AbortSignal): Transform {
272
309
  return;
273
310
  }
274
311
 
275
- console.error('[timber] SSR streaming error (post-shell):', error.message || error);
312
+ logStreamingError({ error });
276
313
  transform.push(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
277
314
  transform.end();
278
315
  });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Render timeout utilities for SSR streaming pipeline.
3
+ *
4
+ * Provides a RenderTimeoutError class and a helper to create
5
+ * timeout-guarded AbortSignals. Used to defend against hung RSC
6
+ * streams and infinite SSR renders.
7
+ *
8
+ * Design doc: 02-rendering-pipeline.md §"Streaming Constraints"
9
+ */
10
+
11
+ /**
12
+ * Error thrown when an SSR render or RSC stream read exceeds the
13
+ * configured timeout. Callers can check `instanceof RenderTimeoutError`
14
+ * to distinguish timeout from other errors and return a 504 or close
15
+ * the connection cleanly.
16
+ */
17
+ export class RenderTimeoutError extends Error {
18
+ readonly timeoutMs: number;
19
+
20
+ constructor(timeoutMs: number, context?: string) {
21
+ const message = context
22
+ ? `Render timeout after ${timeoutMs}ms: ${context}`
23
+ : `Render timeout after ${timeoutMs}ms`;
24
+ super(message);
25
+ this.name = 'RenderTimeoutError';
26
+ this.timeoutMs = timeoutMs;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Result of createRenderTimeout — an AbortSignal that fires after
32
+ * the given duration, plus a cancel function to clear the timer
33
+ * when the render completes normally.
34
+ */
35
+ export interface RenderTimeout {
36
+ /** AbortSignal that aborts after timeoutMs. */
37
+ signal: AbortSignal;
38
+ /** Cancel the timeout timer. Call this when the render completes. */
39
+ cancel: () => void;
40
+ }
41
+
42
+ /**
43
+ * Create a render timeout that aborts after the given duration.
44
+ *
45
+ * Returns an AbortSignal and a cancel function. The signal fires
46
+ * with a RenderTimeoutError as the abort reason after `timeoutMs`.
47
+ * Call `cancel()` when the render completes to prevent the timeout
48
+ * from firing.
49
+ *
50
+ * If an existing `parentSignal` is provided, the returned signal
51
+ * aborts when either the parent signal or the timeout fires —
52
+ * whichever comes first.
53
+ */
54
+ export function createRenderTimeout(timeoutMs: number, parentSignal?: AbortSignal): RenderTimeout {
55
+ const controller = new AbortController();
56
+ const reason = new RenderTimeoutError(timeoutMs, 'RSC stream read timed out');
57
+
58
+ const timer = setTimeout(() => {
59
+ controller.abort(reason);
60
+ }, timeoutMs);
61
+
62
+ // If there's a parent signal (e.g. request abort), chain it
63
+ if (parentSignal) {
64
+ if (parentSignal.aborted) {
65
+ clearTimeout(timer);
66
+ controller.abort(parentSignal.reason);
67
+ } else {
68
+ parentSignal.addEventListener(
69
+ 'abort',
70
+ () => {
71
+ clearTimeout(timer);
72
+ controller.abort(parentSignal.reason);
73
+ },
74
+ { once: true }
75
+ );
76
+ }
77
+ }
78
+
79
+ return {
80
+ signal: controller.signal,
81
+ cancel: () => {
82
+ clearTimeout(timer);
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Race a promise against a timeout. Rejects with RenderTimeoutError
89
+ * if the promise does not resolve within `timeoutMs`.
90
+ *
91
+ * Used to guard individual `rscReader.read()` calls inside pullLoop.
92
+ */
93
+ export function withTimeout<T>(
94
+ promise: Promise<T>,
95
+ timeoutMs: number,
96
+ context?: string
97
+ ): Promise<T> {
98
+ let timer: ReturnType<typeof setTimeout>;
99
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
100
+ timer = setTimeout(() => {
101
+ reject(new RenderTimeoutError(timeoutMs, context));
102
+ }, timeoutMs);
103
+ });
104
+
105
+ return Promise.race([promise, timeoutPromise]).finally(() => {
106
+ clearTimeout(timer!);
107
+ });
108
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { RouteContext } from './types.js';
12
+ import { logRouteError } from './logger.js';
12
13
 
13
14
  // ─── Types ───────────────────────────────────────────────────────────────
14
15
 
@@ -122,7 +123,7 @@ async function runHandler(handler: RouteHandler, ctx: RouteContext): Promise<Res
122
123
  const res = await handler(ctx);
123
124
  return mergeResponseHeaders(res, ctx.headers);
124
125
  } catch (error) {
125
- console.error('[timber] Uncaught error in route.ts handler:', error);
126
+ logRouteError({ method: ctx.req.method, path: new URL(ctx.req.url).pathname, error });
126
127
  return new Response(null, { status: 500 });
127
128
  }
128
129
  }
@@ -20,9 +20,6 @@ export const RSC_CONTENT_TYPE = 'text/x-component';
20
20
  * stream that we drain and discard.
21
21
  *
22
22
  * See design/13-security.md §"Server component source leak"
23
- *
24
- * TODO: In the future, expose this debug data to the browser in dev mode
25
- * for inline error overlays (e.g. component stack traces).
26
23
  */
27
24
  export function createDebugChannelSink(): { readable: ReadableStream; writable: WritableStream } {
28
25
  const sink = new TransformStream();
@@ -34,6 +31,128 @@ export function createDebugChannelSink(): { readable: ReadableStream; writable:
34
31
  };
35
32
  }
36
33
 
34
+ // ─── Debug Channel Collector (dev mode only) ────────────────────────────
35
+
36
+ /**
37
+ * Parsed component debug info extracted from the Flight debug channel.
38
+ *
39
+ * Contains only component names, environment labels, and stack frames —
40
+ * never source code or props. See design/13-security.md §"Server source
41
+ * never reaches the client".
42
+ */
43
+ export interface DebugComponentEntry {
44
+ name: string;
45
+ env: string | null;
46
+ key: string | null;
47
+ stack: unknown[] | null;
48
+ }
49
+
50
+ /**
51
+ * A debug channel that collects Flight debug rows instead of discarding them.
52
+ *
53
+ * Used in dev mode to capture server component tree information for the
54
+ * Vite error overlay. The collector provides the same `{ readable, writable }`
55
+ * shape as the discard sink, plus methods to retrieve collected data.
56
+ *
57
+ * Security: only component names, environments, and stack frames are
58
+ * extracted — props and source code are stripped. In production builds,
59
+ * use `createDebugChannelSink()` instead (this function is never called).
60
+ */
61
+ export interface DebugChannelCollector {
62
+ readable: ReadableStream;
63
+ writable: WritableStream;
64
+ /** Get the raw collected text from the debug channel. */
65
+ getCollectedText(): string;
66
+ /** Get parsed component entries (names, stacks — no props or source). */
67
+ getComponents(): DebugComponentEntry[];
68
+ }
69
+
70
+ export function createDebugChannelCollector(): DebugChannelCollector {
71
+ const chunks: string[] = [];
72
+ const decoder = new TextDecoder();
73
+
74
+ const sink = new TransformStream();
75
+
76
+ // Collect chunks from the readable side instead of discarding them.
77
+ sink.readable
78
+ .pipeTo(
79
+ new WritableStream({
80
+ write(chunk: Uint8Array) {
81
+ chunks.push(decoder.decode(chunk, { stream: true }));
82
+ },
83
+ close() {
84
+ // Flush any remaining bytes in the decoder
85
+ const remaining = decoder.decode();
86
+ if (remaining) chunks.push(remaining);
87
+ },
88
+ })
89
+ )
90
+ .catch(() => {
91
+ // Stream abort — request cancelled. Not an error.
92
+ });
93
+
94
+ return {
95
+ readable: new ReadableStream(), // no commands to send to Flight
96
+ writable: sink.writable,
97
+ getCollectedText() {
98
+ return chunks.join('');
99
+ },
100
+ getComponents() {
101
+ return parseDebugRows(chunks.join(''));
102
+ },
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Parse React Flight debug rows into component entries.
108
+ *
109
+ * The Flight debug channel writes rows in `hexId:json\n` format. Each row
110
+ * with a JSON object containing a `name` field is a component debug info
111
+ * entry. Rows without `name` (timing rows, reference rows like `D"$id"`)
112
+ * are skipped.
113
+ *
114
+ * Security: `props` are explicitly stripped from parsed entries — they may
115
+ * contain rendered output or user data. Only `name`, `env`, `key`, and
116
+ * `stack` are retained.
117
+ */
118
+ export function parseDebugRows(text: string): DebugComponentEntry[] {
119
+ if (!text) return [];
120
+
121
+ const entries: DebugComponentEntry[] = [];
122
+ const lines = text.split('\n');
123
+
124
+ for (const line of lines) {
125
+ if (!line) continue;
126
+
127
+ // Flight row format: hexId:payload
128
+ const colonIdx = line.indexOf(':');
129
+ if (colonIdx === -1) continue;
130
+
131
+ const payload = line.slice(colonIdx + 1);
132
+ // Skip non-JSON payloads (e.g., D"$a" reference rows)
133
+ if (!payload.startsWith('{')) continue;
134
+
135
+ try {
136
+ const parsed = JSON.parse(payload);
137
+ if (typeof parsed !== 'object' || parsed === null) continue;
138
+ if (typeof parsed.name !== 'string') continue;
139
+
140
+ // Strip props — may contain source-derived data or user data.
141
+ // Only retain: name, env, key, stack.
142
+ entries.push({
143
+ name: parsed.name,
144
+ env: parsed.env ?? null,
145
+ key: parsed.key ?? null,
146
+ stack: Array.isArray(parsed.stack) ? parsed.stack : null,
147
+ });
148
+ } catch {
149
+ // Malformed JSON — skip this row
150
+ }
151
+ }
152
+
153
+ return entries;
154
+ }
155
+
37
156
  /**
38
157
  * Build segment metadata for the X-Timber-Segments response header.
39
158
  * Describes the rendered segment chain with async status, enabling
@@ -65,6 +65,7 @@ import {
65
65
  createDebugChannelSink,
66
66
  escapeHtml,
67
67
  isRscPayloadRequest,
68
+ type DebugComponentEntry,
68
69
  } from './helpers.js';
69
70
  import { parseClientStateTree } from '#/server/state-tree-diff.js';
70
71
  import { buildRscPayloadResponse } from './rsc-payload.js';
@@ -91,18 +92,35 @@ function resolveServerTimingMode(
91
92
 
92
93
  // Dev-only pipeline error handler, set by the dev server after import.
93
94
  // In production this is always undefined — no overhead.
94
- let _devPipelineErrorHandler: ((error: Error, phase: string) => void) | undefined;
95
+ // The third argument provides RSC debug component data (from the Flight
96
+ // debug channel) when available — used by the error overlay to show the
97
+ // server component tree context for render errors.
98
+ let _devPipelineErrorHandler:
99
+ | ((error: Error, phase: string, debugComponents?: DebugComponentEntry[]) => void)
100
+ | undefined;
95
101
 
96
102
  /**
97
103
  * Set the dev pipeline error handler.
98
104
  *
99
105
  * Called by the dev server after importing this module to wire pipeline
100
106
  * errors into the Vite browser error overlay. No-op in production.
107
+ *
108
+ * The handler receives an optional third argument with RSC debug component
109
+ * info — component names, environments, and stack frames from the Flight
110
+ * debug channel. This is only populated for render-phase errors.
101
111
  */
102
- export function setDevPipelineErrorHandler(handler: (error: Error, phase: string) => void): void {
112
+ export function setDevPipelineErrorHandler(
113
+ handler: (error: Error, phase: string, debugComponents?: DebugComponentEntry[]) => void
114
+ ): void {
103
115
  _devPipelineErrorHandler = handler;
104
116
  }
105
117
 
118
+ // Dev-only: holds a getter for the current request's RSC debug components.
119
+ // Updated on each renderRscStream call so the onPipelineError callback can
120
+ // include component tree context for render-phase errors. This is request-
121
+ // scoped by convention — each renderRoute call sets it before returning.
122
+ let _lastDebugComponentsGetter: (() => DebugComponentEntry[]) | undefined;
123
+
106
124
  /**
107
125
  * Create the RSC request handler from the route manifest.
108
126
  *
@@ -231,7 +249,15 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
231
249
  serverTiming: resolveServerTimingMode(runtimeConfig, isDev),
232
250
  onPipelineError: isDev
233
251
  ? (error: Error, phase: string) => {
234
- if (_devPipelineErrorHandler) _devPipelineErrorHandler(error, phase);
252
+ if (_devPipelineErrorHandler) {
253
+ // For render-phase errors, include RSC debug component data
254
+ // from the Flight debug channel (if available from the current request).
255
+ const debugComponents =
256
+ phase === 'render' && _lastDebugComponentsGetter
257
+ ? _lastDebugComponentsGetter()
258
+ : undefined;
259
+ _devPipelineErrorHandler(error, phase, debugComponents);
260
+ }
235
261
  }
236
262
  : undefined,
237
263
  renderFallbackError: (error, req, responseHeaders) =>
@@ -441,7 +467,11 @@ async function renderRoute(
441
467
 
442
468
  // Render to RSC Flight stream with signal tracking.
443
469
  const _rscStart = performance.now();
444
- const { rscStream, signals } = renderRscStream(element, _req);
470
+ const { rscStream, signals, getDebugComponents } = renderRscStream(element, _req);
471
+
472
+ // Store the debug components getter so onPipelineError can include
473
+ // component tree context for render-phase errors (dev mode only).
474
+ _lastDebugComponentsGetter = getDebugComponents;
445
475
  recordTiming({
446
476
  name: 'rsc-init',
447
477
  dur: Math.round(performance.now() - _rscStart),
@@ -103,9 +103,17 @@ export async function buildRscPayloadResponse(
103
103
  }
104
104
 
105
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();
106
+ // If the signal won the race but neither redirect nor deny was detected
107
+ // (edge case), cancel the reader immediately rather than issuing a bare
108
+ // read() that could hang forever if the RSC stream has stalled.
109
+ // See TIM-519.
110
+ let firstRead: ReadableStreamReadResult<Uint8Array>;
111
+ if (first.type === 'data') {
112
+ firstRead = first.chunk;
113
+ } else {
114
+ await reader.cancel();
115
+ firstRead = { done: true, value: undefined };
116
+ }
109
117
 
110
118
  // Reconstruct the stream: prepend the buffered first chunk,
111
119
  // then continue piping from the original reader.
@@ -16,8 +16,14 @@ import { logRenderError } from '#/server/logger.js';
16
16
  import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js';
17
17
  import { checkAndWarnRscPropError } from '#/server/rsc-prop-warnings.js';
18
18
 
19
- import { createDebugChannelSink, isAbortError } from './helpers.js';
19
+ import {
20
+ createDebugChannelSink,
21
+ createDebugChannelCollector,
22
+ isAbortError,
23
+ type DebugComponentEntry,
24
+ } from './helpers.js';
20
25
  import { isDebug } from '#/server/debug.js';
26
+ import { isDevMode } from '#/server/debug.js';
21
27
 
22
28
  /**
23
29
  * Mutable signal state captured during RSC rendering.
@@ -40,6 +46,8 @@ export interface RenderSignals {
40
46
  export interface RscStreamResult {
41
47
  rscStream: ReadableStream<Uint8Array> | undefined;
42
48
  signals: RenderSignals;
49
+ /** Dev-only: server component debug info from the Flight debug channel. */
50
+ getDebugComponents?: () => DebugComponentEntry[];
43
51
  }
44
52
 
45
53
  /**
@@ -62,6 +70,10 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
62
70
 
63
71
  let rscStream: ReadableStream<Uint8Array> | undefined;
64
72
 
73
+ // In dev mode, collect debug channel data for the error overlay.
74
+ // In production, use the discard sink (no overhead).
75
+ const debugChannel = isDevMode() ? createDebugChannelCollector() : createDebugChannelSink();
76
+
65
77
  try {
66
78
  rscStream = renderToReadableStream(
67
79
  element,
@@ -132,7 +144,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
132
144
  }
133
145
  logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
134
146
  },
135
- debugChannel: createDebugChannelSink(),
147
+ debugChannel,
136
148
  },
137
149
  {
138
150
  onClientReference(info: { id: string; name: string; deps: unknown }) {
@@ -160,5 +172,14 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
160
172
  }
161
173
  }
162
174
 
163
- return { rscStream, signals };
175
+ return {
176
+ rscStream,
177
+ signals,
178
+ // Expose the debug channel collector's getComponents in dev mode.
179
+ // The caller can retrieve component tree info when handling errors.
180
+ getDebugComponents:
181
+ 'getComponents' in debugChannel
182
+ ? (debugChannel as { getComponents: () => DebugComponentEntry[] }).getComponents
183
+ : undefined,
184
+ };
164
185
  }
@@ -243,12 +243,15 @@ export async function handleSsr(
243
243
  PassThrough,
244
244
  } = _nodeStreamImports;
245
245
 
246
+ const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
247
+
246
248
  let nodeHtmlStream: import('node:stream').Readable;
247
249
  try {
248
250
  nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
249
251
  bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
250
252
  deferSuspenseFor: navContext.deferSuspenseFor,
251
253
  signal: navContext.signal,
254
+ renderTimeoutMs,
252
255
  });
253
256
  } catch (renderError) {
254
257
  console.error(
@@ -266,7 +269,9 @@ export async function handleSsr(
266
269
  // element is <html>, so no framework-level doctype prepend needed.
267
270
  const errorHandler = createNodeErrorHandler(navContext.signal);
268
271
  const headInjector = createNodeHeadInjector(navContext.headHtml);
269
- const flightInjector = createNodeFlightInjector(navContext.rscStream);
272
+ const flightInjector = createNodeFlightInjector(navContext.rscStream, {
273
+ renderTimeoutMs,
274
+ });
270
275
 
271
276
  const output = new PassThrough();
272
277
  pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
@@ -289,12 +294,14 @@ export async function handleSsr(
289
294
  }
290
295
 
291
296
  // Web Streams path (CF Workers / fallback)
297
+ const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
292
298
  let htmlStream: ReadableStream<Uint8Array>;
293
299
  try {
294
300
  htmlStream = await renderSsrStream(wrappedElement, {
295
301
  bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
296
302
  deferSuspenseFor: navContext.deferSuspenseFor,
297
303
  signal: navContext.signal,
304
+ renderTimeoutMs,
298
305
  });
299
306
  } catch (renderError) {
300
307
  console.error(
@@ -312,7 +319,7 @@ export async function handleSsr(
312
319
  // Inject metadata into <head>, then interleave RSC payload chunks
313
320
  // into the body as they arrive from the tee'd RSC stream.
314
321
  let outputStream = injectHead(htmlStream, navContext.headHtml);
315
- outputStream = injectRscPayload(outputStream, navContext.rscStream);
322
+ outputStream = injectRscPayload(outputStream, navContext.rscStream, renderTimeoutMs);
316
323
  const _pipelineEnd = performance.now();
317
324
 
318
325
  navContext._ssrTimings = {