@timber-js/app 0.2.0-alpha.37 → 0.2.0-alpha.38
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/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +27 -4
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.d.ts +5 -2
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +32 -8
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/singleflight.d.ts +18 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +53 -4
- package/dist/index.js.map +1 -1
- 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.map +1 -1
- package/dist/server/html-injectors.d.ts +2 -2
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +27 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +13 -1
- package/dist/server/node-stream-transforms.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/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 +3 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +2 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/nitro.ts +27 -4
- package/src/cache/index.ts +5 -2
- package/src/cache/singleflight.ts +54 -4
- package/src/cache/timber-cache.ts +17 -16
- package/src/index.ts +12 -0
- package/src/plugins/dev-error-overlay.ts +70 -1
- package/src/plugins/dev-server.ts +38 -4
- package/src/plugins/entries.ts +1 -0
- package/src/server/html-injectors.ts +32 -7
- package/src/server/index.ts +4 -0
- package/src/server/node-stream-transforms.ts +49 -13
- package/src/server/render-timeout.ts +108 -0
- package/src/server/rsc-entry/helpers.ts +122 -3
- package/src/server/rsc-entry/index.ts +34 -4
- package/src/server/rsc-entry/rsc-payload.ts +11 -3
- package/src/server/rsc-entry/rsc-stream.ts +24 -3
- package/src/server/ssr-entry.ts +9 -2
- package/src/server/ssr-render.ts +94 -13
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type FlightInjectionState,
|
|
18
18
|
type FlightInjectionEvent,
|
|
19
19
|
} from './flight-injection-state.js';
|
|
20
|
+
import { withTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Inject HTML content before a closing tag in the stream.
|
|
@@ -118,11 +119,13 @@ export function injectScripts(
|
|
|
118
119
|
* flightInitScript() — see flight-scripts.ts.
|
|
119
120
|
*/
|
|
120
121
|
export function createInlinedRscStream(
|
|
121
|
-
rscStream: ReadableStream<Uint8Array
|
|
122
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
123
|
+
renderTimeoutMs?: number
|
|
122
124
|
): ReadableStream<Uint8Array> {
|
|
123
125
|
const encoder = new TextEncoder();
|
|
124
126
|
const rscReader = rscStream.getReader();
|
|
125
127
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
128
|
+
const timeoutMs = renderTimeoutMs ?? 30_000;
|
|
126
129
|
|
|
127
130
|
return new ReadableStream<Uint8Array>({
|
|
128
131
|
// No bootstrap signal here — the init script is in <head> via
|
|
@@ -130,7 +133,11 @@ export function createInlinedRscStream(
|
|
|
130
133
|
// __timber_f array exists before any chunk scripts execute.
|
|
131
134
|
async pull(controller) {
|
|
132
135
|
try {
|
|
133
|
-
const
|
|
136
|
+
const readPromise = rscReader.read();
|
|
137
|
+
const { done, value } =
|
|
138
|
+
timeoutMs > 0
|
|
139
|
+
? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
|
|
140
|
+
: await readPromise;
|
|
134
141
|
if (done) {
|
|
135
142
|
controller.close();
|
|
136
143
|
return;
|
|
@@ -140,6 +147,9 @@ export function createInlinedRscStream(
|
|
|
140
147
|
controller.enqueue(encoder.encode(flightChunkScript(decoded)));
|
|
141
148
|
}
|
|
142
149
|
} catch (error) {
|
|
150
|
+
if (error instanceof RenderTimeoutError) {
|
|
151
|
+
rscReader.cancel(error).catch(() => {});
|
|
152
|
+
}
|
|
143
153
|
controller.error(error);
|
|
144
154
|
}
|
|
145
155
|
},
|
|
@@ -173,7 +183,8 @@ export function createInlinedRscStream(
|
|
|
173
183
|
* Inspired by Next.js createFlightDataInjectionTransformStream.
|
|
174
184
|
*/
|
|
175
185
|
function createFlightInjectionTransform(
|
|
176
|
-
rscScriptStream: ReadableStream<Uint8Array
|
|
186
|
+
rscScriptStream: ReadableStream<Uint8Array>,
|
|
187
|
+
renderTimeoutMs?: number
|
|
177
188
|
): TransformStream<Uint8Array, Uint8Array> {
|
|
178
189
|
const encoder = new TextEncoder();
|
|
179
190
|
const decoder = new TextDecoder();
|
|
@@ -181,6 +192,7 @@ function createFlightInjectionTransform(
|
|
|
181
192
|
const suffixBytes = encoder.encode(suffix);
|
|
182
193
|
|
|
183
194
|
const rscReader = rscScriptStream.getReader();
|
|
195
|
+
const timeoutMs = renderTimeoutMs ?? 30_000;
|
|
184
196
|
|
|
185
197
|
const machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
|
|
186
198
|
initial: { phase: 'init' },
|
|
@@ -205,7 +217,15 @@ function createFlightInjectionTransform(
|
|
|
205
217
|
|
|
206
218
|
try {
|
|
207
219
|
for (;;) {
|
|
208
|
-
|
|
220
|
+
// Guard each RSC read with a timeout so a permanently hung
|
|
221
|
+
// RSC stream eventually aborts. When timeoutMs <= 0, the
|
|
222
|
+
// guard is disabled. See design/02-rendering-pipeline.md
|
|
223
|
+
// §"Streaming Constraints".
|
|
224
|
+
const readPromise = rscReader.read();
|
|
225
|
+
const { done, value } =
|
|
226
|
+
timeoutMs > 0
|
|
227
|
+
? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
|
|
228
|
+
: await readPromise;
|
|
209
229
|
if (done) {
|
|
210
230
|
machine.send({ type: 'PULL_DONE' });
|
|
211
231
|
return;
|
|
@@ -220,6 +240,10 @@ function createFlightInjectionTransform(
|
|
|
220
240
|
}
|
|
221
241
|
}
|
|
222
242
|
} catch (err) {
|
|
243
|
+
// On timeout, cancel the RSC reader to release resources.
|
|
244
|
+
if (err instanceof RenderTimeoutError) {
|
|
245
|
+
rscReader.cancel(err).catch(() => {});
|
|
246
|
+
}
|
|
223
247
|
machine.send({ type: 'PULL_ERROR', error: err });
|
|
224
248
|
}
|
|
225
249
|
}
|
|
@@ -326,16 +350,17 @@ function createFlightInjectionTransform(
|
|
|
326
350
|
*/
|
|
327
351
|
export function injectRscPayload(
|
|
328
352
|
htmlStream: ReadableStream<Uint8Array>,
|
|
329
|
-
rscStream: ReadableStream<Uint8Array> | undefined
|
|
353
|
+
rscStream: ReadableStream<Uint8Array> | undefined,
|
|
354
|
+
renderTimeoutMs?: number
|
|
330
355
|
): ReadableStream<Uint8Array> {
|
|
331
356
|
if (!rscStream) return htmlStream;
|
|
332
357
|
|
|
333
358
|
// Transform RSC binary stream → stream of <script> tags
|
|
334
|
-
const rscScriptStream = createInlinedRscStream(rscStream);
|
|
359
|
+
const rscScriptStream = createInlinedRscStream(rscStream, renderTimeoutMs);
|
|
335
360
|
|
|
336
361
|
// Single transform: strip </body></html>, inject RSC scripts at
|
|
337
362
|
// body level, re-emit suffix at the very end.
|
|
338
|
-
return htmlStream.pipeThrough(createFlightInjectionTransform(rscScriptStream));
|
|
363
|
+
return htmlStream.pipeThrough(createFlightInjectionTransform(rscScriptStream, renderTimeoutMs));
|
|
339
364
|
}
|
|
340
365
|
|
|
341
366
|
/**
|
package/src/server/index.ts
CHANGED
|
@@ -222,3 +222,7 @@ export type { DevWarningConfig } from './dev-warnings';
|
|
|
222
222
|
// Design doc: design/07-routing.md §"route.ts — API Endpoints"
|
|
223
223
|
export { handleRouteRequest, resolveAllowedMethods } from './route-handler';
|
|
224
224
|
export type { RouteModule, RouteHandler, HttpMethod } from './route-handler';
|
|
225
|
+
|
|
226
|
+
// Render timeout — design doc: 02-rendering-pipeline.md §"Streaming Constraints"
|
|
227
|
+
export { RenderTimeoutError } from './render-timeout';
|
|
228
|
+
export type { RenderTimeout } from './render-timeout';
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type FlightInjectionState,
|
|
31
31
|
type FlightInjectionEvent,
|
|
32
32
|
} from './flight-injection-state.js';
|
|
33
|
+
import { withTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
33
34
|
|
|
34
35
|
// ─── Head Injection ──────────────────────────────────────────────────────────
|
|
35
36
|
|
|
@@ -108,8 +109,22 @@ export function createNodeHeadInjector(headHtml: string): Transform {
|
|
|
108
109
|
* stream). We read from it using the Web API — this is the one bridge
|
|
109
110
|
* point between Web Streams and Node.js streams in the pipeline.
|
|
110
111
|
*/
|
|
112
|
+
/**
|
|
113
|
+
* Options for the Node.js flight injector.
|
|
114
|
+
*/
|
|
115
|
+
export interface NodeFlightInjectorOptions {
|
|
116
|
+
/**
|
|
117
|
+
* Timeout in milliseconds for individual RSC stream reads.
|
|
118
|
+
* If a single `rscReader.read()` call does not resolve within
|
|
119
|
+
* this duration, the read is aborted and the stream errors with
|
|
120
|
+
* a RenderTimeoutError. Default: 30000 (30s).
|
|
121
|
+
*/
|
|
122
|
+
renderTimeoutMs?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
export function createNodeFlightInjector(
|
|
112
|
-
rscStream: ReadableStream<Uint8Array> | undefined
|
|
126
|
+
rscStream: ReadableStream<Uint8Array> | undefined,
|
|
127
|
+
options?: NodeFlightInjectorOptions
|
|
113
128
|
): Transform {
|
|
114
129
|
if (!rscStream) {
|
|
115
130
|
return new Transform({
|
|
@@ -119,6 +134,7 @@ export function createNodeFlightInjector(
|
|
|
119
134
|
});
|
|
120
135
|
}
|
|
121
136
|
|
|
137
|
+
const timeoutMs = options?.renderTimeoutMs ?? 30_000;
|
|
122
138
|
const suffix = '</body></html>';
|
|
123
139
|
const suffixBuf = Buffer.from(suffix, 'utf-8');
|
|
124
140
|
const rscReader = rscStream.getReader();
|
|
@@ -129,6 +145,11 @@ export function createNodeFlightInjector(
|
|
|
129
145
|
transitions: flightInjectionTransitions,
|
|
130
146
|
});
|
|
131
147
|
|
|
148
|
+
// Stored promise from pullLoop — awaited in flush() via .then()
|
|
149
|
+
// instead of polling. Matches the Web Streams pattern in
|
|
150
|
+
// html-injectors.ts (pullPromise.then(finish)).
|
|
151
|
+
let pullPromise: Promise<void> | null = null;
|
|
152
|
+
|
|
132
153
|
// pullLoop reads RSC chunks and pushes them directly to the transform
|
|
133
154
|
// output as <script> tags. This ensures RSC data is delivered to the
|
|
134
155
|
// browser as soon as it's available — not deferred until the next HTML
|
|
@@ -141,7 +162,16 @@ export function createNodeFlightInjector(
|
|
|
141
162
|
await new Promise<void>((r) => setImmediate(r));
|
|
142
163
|
try {
|
|
143
164
|
for (;;) {
|
|
144
|
-
|
|
165
|
+
// Guard each RSC read with a timeout so a permanently hung
|
|
166
|
+
// RSC stream (e.g. a Suspense component with a fetch that
|
|
167
|
+
// never resolves) eventually aborts instead of blocking
|
|
168
|
+
// forever. When timeoutMs <= 0, the guard is disabled.
|
|
169
|
+
// See design/02-rendering-pipeline.md §"Streaming Constraints".
|
|
170
|
+
const readPromise = rscReader.read();
|
|
171
|
+
const { done, value } =
|
|
172
|
+
timeoutMs > 0
|
|
173
|
+
? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
|
|
174
|
+
: await readPromise;
|
|
145
175
|
if (done) {
|
|
146
176
|
machine.send({ type: 'PULL_DONE' });
|
|
147
177
|
return;
|
|
@@ -160,6 +190,10 @@ export function createNodeFlightInjector(
|
|
|
160
190
|
}
|
|
161
191
|
}
|
|
162
192
|
} catch (err) {
|
|
193
|
+
// On timeout, cancel the RSC reader to release resources.
|
|
194
|
+
if (err instanceof RenderTimeoutError) {
|
|
195
|
+
rscReader.cancel(err).catch(() => {});
|
|
196
|
+
}
|
|
163
197
|
machine.send({ type: 'PULL_ERROR', error: err });
|
|
164
198
|
}
|
|
165
199
|
}
|
|
@@ -196,8 +230,9 @@ export function createNodeFlightInjector(
|
|
|
196
230
|
// Start the pull loop on the first HTML chunk to stream RSC
|
|
197
231
|
// data chunks alongside the HTML. The __timber_f init script is
|
|
198
232
|
// already in <head> (via flightInitScript), so no bootstrap needed.
|
|
233
|
+
// Store the promise so flush() can await it instead of polling.
|
|
199
234
|
if (isFirst) {
|
|
200
|
-
pullLoop(transform);
|
|
235
|
+
pullPromise = pullLoop(transform);
|
|
201
236
|
}
|
|
202
237
|
callback();
|
|
203
238
|
},
|
|
@@ -226,16 +261,17 @@ export function createNodeFlightInjector(
|
|
|
226
261
|
finish();
|
|
227
262
|
return;
|
|
228
263
|
}
|
|
229
|
-
// Wait for the RSC
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
264
|
+
// Wait for the RSC pull loop promise to resolve instead of
|
|
265
|
+
// polling with setImmediate. This matches the Web Streams
|
|
266
|
+
// pattern in html-injectors.ts: `pullPromise.then(finish)`.
|
|
267
|
+
// No CPU spin, no busy-poll — just a Promise chain.
|
|
268
|
+
if (!pullPromise) {
|
|
269
|
+
pullPromise = pullLoop(transform);
|
|
270
|
+
}
|
|
271
|
+
pullPromise.then(finish, (err) => {
|
|
272
|
+
machine.send({ type: 'PULL_ERROR', error: err });
|
|
273
|
+
finish();
|
|
274
|
+
});
|
|
239
275
|
},
|
|
240
276
|
});
|
|
241
277
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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(
|
|
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)
|
|
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
|
|
107
|
-
//
|
|
108
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
}
|