@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.
- 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 +37 -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/action-handler.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/flush.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 +135 -24
- 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 +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/route-handler.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 +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 +62 -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/action-handler.ts +3 -2
- package/src/server/default-logger.ts +95 -0
- package/src/server/flush.ts +2 -1
- package/src/server/html-injectors.ts +32 -7
- package/src/server/index.ts +4 -0
- package/src/server/logger.ts +38 -35
- package/src/server/node-stream-transforms.ts +51 -14
- package/src/server/render-timeout.ts +108 -0
- package/src/server/route-handler.ts +2 -1
- 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 +105 -16
|
@@ -180,6 +180,63 @@ export function formatTerminalError(error: Error, phase: ErrorPhase, projectRoot
|
|
|
180
180
|
return lines.join('\n');
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// ─── RSC Debug Context ──────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Component info extracted from the RSC debug channel.
|
|
187
|
+
* Contains only names, environments, and stack frames — never source code.
|
|
188
|
+
*/
|
|
189
|
+
export interface RscDebugComponentInfo {
|
|
190
|
+
name: string;
|
|
191
|
+
env: string | null;
|
|
192
|
+
stack: unknown[] | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Format RSC debug component info into a readable string for the overlay.
|
|
197
|
+
*
|
|
198
|
+
* Renders the server component tree that was active when an error occurred,
|
|
199
|
+
* including component names and source locations from stack frames. This
|
|
200
|
+
* gives developers visibility into which server components were rendering
|
|
201
|
+
* without exposing source code.
|
|
202
|
+
*
|
|
203
|
+
* Returns an empty string if no components are provided.
|
|
204
|
+
*/
|
|
205
|
+
export function formatRscDebugContext(components: RscDebugComponentInfo[]): string {
|
|
206
|
+
if (!components || components.length === 0) return '';
|
|
207
|
+
|
|
208
|
+
// Deduplicate by name — the debug channel may emit the same component
|
|
209
|
+
// multiple times (e.g., when re-rendered or when multiple instances exist).
|
|
210
|
+
const seen = new Set<string>();
|
|
211
|
+
const unique: RscDebugComponentInfo[] = [];
|
|
212
|
+
for (const c of components) {
|
|
213
|
+
if (!seen.has(c.name)) {
|
|
214
|
+
seen.add(c.name);
|
|
215
|
+
unique.push(c);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lines: string[] = ['Server Component Tree:'];
|
|
220
|
+
for (let i = 0; i < unique.length; i++) {
|
|
221
|
+
const c = unique[i]!;
|
|
222
|
+
const indent = ' '.repeat(i + 1);
|
|
223
|
+
const envLabel = c.env ? ` [${c.env}]` : '';
|
|
224
|
+
|
|
225
|
+
// Extract file location from stack frames if available
|
|
226
|
+
let locStr = '';
|
|
227
|
+
if (c.stack && c.stack.length > 0) {
|
|
228
|
+
const frame = c.stack[0] as [string, string, number, number] | undefined;
|
|
229
|
+
if (Array.isArray(frame) && frame.length >= 3) {
|
|
230
|
+
locStr = ` (${frame[1]}:${frame[2]})`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
lines.push(`${indent}${c.name}${envLabel}${locStr}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return lines.join('\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
183
240
|
// ─── Overlay Integration ────────────────────────────────────────────────────
|
|
184
241
|
|
|
185
242
|
/**
|
|
@@ -188,13 +245,19 @@ export function formatTerminalError(error: Error, phase: ErrorPhase, projectRoot
|
|
|
188
245
|
* Uses `server.ssrFixStacktrace()` to map stack traces back to source,
|
|
189
246
|
* then sends the error via `server.hot.send()` for the browser overlay.
|
|
190
247
|
*
|
|
248
|
+
* When `rscDebugComponents` is provided (dev mode only), the server
|
|
249
|
+
* component tree context is appended to the error message. This helps
|
|
250
|
+
* developers identify which server component caused the error without
|
|
251
|
+
* exposing source code.
|
|
252
|
+
*
|
|
191
253
|
* The dev server remains running — errors are handled, not fatal.
|
|
192
254
|
*/
|
|
193
255
|
export function sendErrorToOverlay(
|
|
194
256
|
server: ViteDevServer,
|
|
195
257
|
error: Error,
|
|
196
258
|
phase: ErrorPhase,
|
|
197
|
-
projectRoot: string
|
|
259
|
+
projectRoot: string,
|
|
260
|
+
rscDebugComponents?: RscDebugComponentInfo[]
|
|
198
261
|
): void {
|
|
199
262
|
// Fix stack trace to use source-mapped positions
|
|
200
263
|
server.ssrFixStacktrace(error);
|
|
@@ -212,6 +275,12 @@ export function sendErrorToOverlay(
|
|
|
212
275
|
message = `${error.message}\n\nComponent Stack:\n${componentStack.trim()}`;
|
|
213
276
|
}
|
|
214
277
|
|
|
278
|
+
// Append RSC debug context if available (dev mode only)
|
|
279
|
+
const debugContext = formatRscDebugContext(rscDebugComponents ?? []);
|
|
280
|
+
if (debugContext) {
|
|
281
|
+
message = `${message}\n\n${debugContext}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
215
284
|
// Send to browser via Vite's error overlay protocol
|
|
216
285
|
try {
|
|
217
286
|
server.hot.send({
|
|
@@ -161,12 +161,26 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
|
|
|
161
161
|
|
|
162
162
|
// Wire pipeline errors into the browser error overlay.
|
|
163
163
|
// setDevPipelineErrorHandler is only defined in dev (rsc-entry.ts exports it).
|
|
164
|
+
// The handler receives optional RSC debug component data (component names,
|
|
165
|
+
// environments, stack frames) from the Flight debug channel for render errors.
|
|
164
166
|
const setHandler = rscModule.setDevPipelineErrorHandler as
|
|
165
|
-
| ((
|
|
167
|
+
| ((
|
|
168
|
+
fn: (
|
|
169
|
+
error: Error,
|
|
170
|
+
phase: string,
|
|
171
|
+
debugComponents?: Array<{ name: string; env: string | null; stack: unknown[] | null }>
|
|
172
|
+
) => void
|
|
173
|
+
) => void)
|
|
166
174
|
| undefined;
|
|
167
175
|
if (typeof setHandler === 'function') {
|
|
168
|
-
setHandler((error) => {
|
|
169
|
-
sendErrorToOverlay(
|
|
176
|
+
setHandler((error, _phase, debugComponents) => {
|
|
177
|
+
sendErrorToOverlay(
|
|
178
|
+
server,
|
|
179
|
+
error,
|
|
180
|
+
classifyErrorPhase(error, projectRoot),
|
|
181
|
+
projectRoot,
|
|
182
|
+
debugComponents
|
|
183
|
+
);
|
|
170
184
|
});
|
|
171
185
|
}
|
|
172
186
|
} catch (error) {
|
|
@@ -317,6 +331,17 @@ async function sendWebResponse(nodeRes: ServerResponse, webResponse: Response):
|
|
|
317
331
|
nodeRes.flushHeaders();
|
|
318
332
|
|
|
319
333
|
const reader = webResponse.body.getReader();
|
|
334
|
+
|
|
335
|
+
// Cancel the reader when the client disconnects. This causes any pending
|
|
336
|
+
// reader.read() to reject, breaking the pump loop. Critical for SSE and
|
|
337
|
+
// other infinite streams — without this, disconnected clients leak readers.
|
|
338
|
+
let clientDisconnected = false;
|
|
339
|
+
const onClose = () => {
|
|
340
|
+
clientDisconnected = true;
|
|
341
|
+
reader.cancel('Client disconnected').catch(() => {});
|
|
342
|
+
};
|
|
343
|
+
nodeRes.on('close', onClose);
|
|
344
|
+
|
|
320
345
|
try {
|
|
321
346
|
while (true) {
|
|
322
347
|
const { done, value } = await reader.read();
|
|
@@ -325,9 +350,18 @@ async function sendWebResponse(nodeRes: ServerResponse, webResponse: Response):
|
|
|
325
350
|
// don't need back-pressure here — just keep pushing chunks.
|
|
326
351
|
nodeRes.write(value);
|
|
327
352
|
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
// reader.cancel() from the close handler causes read() to reject.
|
|
355
|
+
// This is expected on client disconnect — not an error.
|
|
356
|
+
if (!clientDisconnected) {
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
328
359
|
} finally {
|
|
360
|
+
nodeRes.off('close', onClose);
|
|
329
361
|
reader.releaseLock();
|
|
330
|
-
nodeRes.
|
|
362
|
+
if (!nodeRes.writableEnded) {
|
|
363
|
+
nodeRes.end();
|
|
364
|
+
}
|
|
331
365
|
}
|
|
332
366
|
}
|
|
333
367
|
|
package/src/plugins/entries.ts
CHANGED
|
@@ -106,6 +106,7 @@ function generateConfigModule(ctx: PluginContext): string {
|
|
|
106
106
|
topLoader: ctx.config.topLoader,
|
|
107
107
|
debug: ctx.config.debug ?? false,
|
|
108
108
|
serverTiming: ctx.config.serverTiming,
|
|
109
|
+
renderTimeoutMs: ctx.config.renderTimeoutMs ?? 30_000,
|
|
109
110
|
// Per-build deployment ID for version skew detection (TIM-446).
|
|
110
111
|
// Null in dev mode — HMR handles code updates without full reloads.
|
|
111
112
|
deploymentId: ctx.deploymentId ?? null,
|
|
@@ -32,6 +32,7 @@ import { enforceBodyLimits, enforceFieldLimit, type BodyLimitsConfig } from './b
|
|
|
32
32
|
import { parseFormData } from './form-data.js';
|
|
33
33
|
import type { FormFlashData } from './form-flash.js';
|
|
34
34
|
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
35
|
+
import { logActionError } from './logger.js';
|
|
35
36
|
|
|
36
37
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
37
38
|
|
|
@@ -193,7 +194,7 @@ async function handleRscAction(
|
|
|
193
194
|
});
|
|
194
195
|
} catch (error) {
|
|
195
196
|
// Log full error server-side for debugging
|
|
196
|
-
|
|
197
|
+
logActionError({ method: req.method, path: new URL(req.url).pathname, error });
|
|
197
198
|
|
|
198
199
|
// Return structured error response — ActionError gets its code/data,
|
|
199
200
|
// unexpected errors get sanitized { code: 'INTERNAL_ERROR' }
|
|
@@ -309,7 +310,7 @@ async function handleFormAction(
|
|
|
309
310
|
renderer: config.revalidateRenderer,
|
|
310
311
|
});
|
|
311
312
|
} catch (error) {
|
|
312
|
-
|
|
313
|
+
logActionError({ method: req.method, path: new URL(req.url).pathname, error });
|
|
313
314
|
|
|
314
315
|
// Return the error as flash data for re-render.
|
|
315
316
|
// handleActionError produces { serverError } for ActionErrors
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DefaultLogger — human-readable stderr logging when no custom logger is configured.
|
|
3
|
+
*
|
|
4
|
+
* Ships as the fallback so production deployments always have error visibility,
|
|
5
|
+
* even without an `instrumentation.ts` logger export. Output is one line per
|
|
6
|
+
* event, designed for `fly logs`, `kubectl logs`, Cloudflare dashboard tails, etc.
|
|
7
|
+
*
|
|
8
|
+
* Format:
|
|
9
|
+
* [timber] ERROR message key=value key=value trace_id=4bf92f35
|
|
10
|
+
* [timber] WARN message key=value key=value trace_id=4bf92f35
|
|
11
|
+
* [timber] INFO message method=GET path=/dashboard status=200 durationMs=43 trace_id=4bf92f35
|
|
12
|
+
*
|
|
13
|
+
* Behavior:
|
|
14
|
+
* - Suppressed entirely in dev mode (dev logging handles all output)
|
|
15
|
+
* - `debug` suppressed unless TIMBER_DEBUG is set
|
|
16
|
+
* - Replaced entirely when a custom logger is set via `setLogger()`
|
|
17
|
+
*
|
|
18
|
+
* See design/17-logging.md §"DefaultLogger"
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isDevMode, isDebug } from './debug.js';
|
|
22
|
+
import { formatSsrError } from './error-formatter.js';
|
|
23
|
+
import type { TimberLogger } from './logger.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Format data fields as `key=value` pairs for human-readable output.
|
|
27
|
+
* - `error` key is serialized via formatSsrError for stack trace cleanup
|
|
28
|
+
* - `trace_id` is truncated to 8 chars for readability (full ID in OTEL)
|
|
29
|
+
* - Other values are stringified inline
|
|
30
|
+
*/
|
|
31
|
+
function formatDataFields(data?: Record<string, unknown>): string {
|
|
32
|
+
if (!data) return '';
|
|
33
|
+
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
let traceId: string | undefined;
|
|
36
|
+
|
|
37
|
+
for (const [key, value] of Object.entries(data)) {
|
|
38
|
+
if (key === 'trace_id') {
|
|
39
|
+
// Defer trace_id to the end
|
|
40
|
+
traceId = typeof value === 'string' ? value : String(value);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (key === 'error') {
|
|
44
|
+
// Serialize errors with formatSsrError for clean output
|
|
45
|
+
parts.push(`error=${formatSsrError(value)}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (value === undefined || value === null) continue;
|
|
49
|
+
parts.push(`${key}=${value}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// trace_id always last, truncated to 8 chars for readability
|
|
53
|
+
if (traceId) {
|
|
54
|
+
parts.push(`trace_id=${traceId.slice(0, 8)}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return parts.length > 0 ? ' ' + parts.join(' ') : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Pad level string to fixed width for alignment. */
|
|
61
|
+
function padLevel(level: string): string {
|
|
62
|
+
return level.padEnd(5);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createDefaultLogger(): TimberLogger {
|
|
66
|
+
return {
|
|
67
|
+
error(msg: string, data?: Record<string, unknown>): void {
|
|
68
|
+
if (isDevMode()) return;
|
|
69
|
+
const fields = formatDataFields(data);
|
|
70
|
+
// Use process.stderr.write for consistent output (no extra newline handling)
|
|
71
|
+
process.stderr.write(`[timber] ${padLevel('ERROR')} ${msg}${fields}\n`);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
warn(msg: string, data?: Record<string, unknown>): void {
|
|
75
|
+
if (isDevMode()) return;
|
|
76
|
+
const fields = formatDataFields(data);
|
|
77
|
+
process.stderr.write(`[timber] ${padLevel('WARN')} ${msg}${fields}\n`);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
info(msg: string, data?: Record<string, unknown>): void {
|
|
81
|
+
if (isDevMode()) return;
|
|
82
|
+
const fields = formatDataFields(data);
|
|
83
|
+
process.stderr.write(`[timber] ${padLevel('INFO')} ${msg}${fields}\n`);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
debug(msg: string, data?: Record<string, unknown>): void {
|
|
87
|
+
// debug is suppressed in dev (dev logger handles it) and in
|
|
88
|
+
// production unless TIMBER_DEBUG is explicitly set.
|
|
89
|
+
if (isDevMode()) return;
|
|
90
|
+
if (!isDebug()) return;
|
|
91
|
+
const fields = formatDataFields(data);
|
|
92
|
+
process.stderr.write(`[timber] ${padLevel('DEBUG')} ${msg}${fields}\n`);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/server/flush.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { DenySignal, RedirectSignal, RenderError } from './primitives.js';
|
|
12
|
+
import { logRenderError } from './logger.js';
|
|
12
13
|
|
|
13
14
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -169,7 +170,7 @@ function handleSignal(error: unknown, responseHeaders: Headers): FlushResult {
|
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
// Unknown error → HTTP 500
|
|
172
|
-
|
|
173
|
+
logRenderError({ method: '', path: '', error });
|
|
173
174
|
return {
|
|
174
175
|
response: new Response(null, {
|
|
175
176
|
status: 500,
|
|
@@ -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';
|
package/src/server/logger.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Logger — structured logging with environment-aware formatting.
|
|
3
3
|
*
|
|
4
|
-
* timber.js
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* timber.js ships a DefaultLogger that writes human-readable lines to stderr
|
|
5
|
+
* in production. Users can export a custom logger from instrumentation.ts to
|
|
6
|
+
* replace it with pino, winston, or any TimberLogger-compatible object.
|
|
7
7
|
*
|
|
8
8
|
* See design/17-logging.md §"Production Logging"
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { getTraceStore } from './tracing.js';
|
|
12
|
-
import {
|
|
13
|
-
import { isDebug } from './debug.js';
|
|
12
|
+
import { createDefaultLogger } from './default-logger.js';
|
|
14
13
|
|
|
15
14
|
// ─── Logger Interface ─────────────────────────────────────────────────────
|
|
16
15
|
|
|
@@ -24,21 +23,24 @@ export interface TimberLogger {
|
|
|
24
23
|
|
|
25
24
|
// ─── Logger Registry ──────────────────────────────────────────────────────
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
// Initialize with DefaultLogger so production errors are never silent.
|
|
27
|
+
// Replaced when setLogger() is called from instrumentation.ts.
|
|
28
|
+
let _logger: TimberLogger = createDefaultLogger();
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Set the user-provided logger. Called by the instrumentation loader
|
|
31
|
-
* when it finds a `logger` export in instrumentation.ts.
|
|
32
|
+
* when it finds a `logger` export in instrumentation.ts. Replaces
|
|
33
|
+
* the DefaultLogger entirely.
|
|
32
34
|
*/
|
|
33
35
|
export function setLogger(logger: TimberLogger): void {
|
|
34
36
|
_logger = logger;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
* Get the current logger
|
|
39
|
-
*
|
|
40
|
+
* Get the current logger. Always non-null — returns DefaultLogger when
|
|
41
|
+
* no custom logger is configured.
|
|
40
42
|
*/
|
|
41
|
-
export function getLogger(): TimberLogger
|
|
43
|
+
export function getLogger(): TimberLogger {
|
|
42
44
|
return _logger;
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -71,12 +73,12 @@ export function logRequestCompleted(data: {
|
|
|
71
73
|
/** Number of concurrent in-flight requests (including this one) at completion time. */
|
|
72
74
|
concurrency?: number;
|
|
73
75
|
}): void {
|
|
74
|
-
_logger
|
|
76
|
+
_logger.info('request completed', withTraceContext(data));
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
/** Log request received. Level: debug. */
|
|
78
80
|
export function logRequestReceived(data: { method: string; path: string }): void {
|
|
79
|
-
_logger
|
|
81
|
+
_logger.debug('request received', withTraceContext(data));
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/** Log a slow request warning. Level: warn. */
|
|
@@ -88,7 +90,7 @@ export function logSlowRequest(data: {
|
|
|
88
90
|
/** Number of concurrent in-flight requests at the time the slow request completed. */
|
|
89
91
|
concurrency?: number;
|
|
90
92
|
}): void {
|
|
91
|
-
_logger
|
|
93
|
+
_logger.warn('slow request exceeded threshold', withTraceContext(data));
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
/** Log middleware short-circuit. Level: debug. */
|
|
@@ -97,54 +99,55 @@ export function logMiddlewareShortCircuit(data: {
|
|
|
97
99
|
path: string;
|
|
98
100
|
status: number;
|
|
99
101
|
}): void {
|
|
100
|
-
_logger
|
|
102
|
+
_logger.debug('middleware short-circuited', withTraceContext(data));
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
/** Log unhandled error in middleware phase. Level: error. */
|
|
104
106
|
export function logMiddlewareError(data: { method: string; path: string; error: unknown }): void {
|
|
105
|
-
|
|
106
|
-
_logger.error('unhandled error in middleware phase', withTraceContext(data));
|
|
107
|
-
} else if (isDebug()) {
|
|
108
|
-
console.error('[timber] middleware error', data.error);
|
|
109
|
-
}
|
|
107
|
+
_logger.error('unhandled error in middleware phase', withTraceContext(data));
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
/** Log unhandled render-phase error. Level: error. */
|
|
113
111
|
export function logRenderError(data: { method: string; path: string; error: unknown }): void {
|
|
114
|
-
|
|
115
|
-
_logger.error('unhandled render-phase error', withTraceContext(data));
|
|
116
|
-
} else if (isDebug()) {
|
|
117
|
-
// No logger configured — fall back to console.error in dev with
|
|
118
|
-
// cleaned-up error messages (vendor paths rewritten, hints added).
|
|
119
|
-
console.error('[timber] render error:', formatSsrError(data.error));
|
|
120
|
-
}
|
|
112
|
+
_logger.error('unhandled render-phase error', withTraceContext(data));
|
|
121
113
|
}
|
|
122
114
|
|
|
123
115
|
/** Log proxy.ts uncaught error. Level: error. */
|
|
124
116
|
export function logProxyError(data: { error: unknown }): void {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
117
|
+
_logger.error('proxy.ts threw uncaught error', withTraceContext(data));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Log unhandled error in server action. Level: error. */
|
|
121
|
+
export function logActionError(data: { method: string; path: string; error: unknown }): void {
|
|
122
|
+
_logger.error('unhandled server action error', withTraceContext(data));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Log unhandled error in route handler. Level: error. */
|
|
126
|
+
export function logRouteError(data: { method: string; path: string; error: unknown }): void {
|
|
127
|
+
_logger.error('unhandled route handler error', withTraceContext(data));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Log SSR streaming error (post-shell). Level: error. */
|
|
131
|
+
export function logStreamingError(data: { error: unknown }): void {
|
|
132
|
+
_logger.error('SSR streaming error (post-shell)', withTraceContext(data));
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
/** Log waitUntil() adapter missing (once at startup). Level: warn. */
|
|
133
136
|
export function logWaitUntilUnsupported(): void {
|
|
134
|
-
_logger
|
|
137
|
+
_logger.warn('adapter does not support waitUntil()');
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
/** Log waitUntil() promise rejection. Level: warn. */
|
|
138
141
|
export function logWaitUntilRejected(data: { error: unknown }): void {
|
|
139
|
-
_logger
|
|
142
|
+
_logger.warn('waitUntil() promise rejected', withTraceContext(data));
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
/** Log staleWhileRevalidate refetch failure. Level: warn. */
|
|
143
146
|
export function logSwrRefetchFailed(data: { cacheKey: string; error: unknown }): void {
|
|
144
|
-
_logger
|
|
147
|
+
_logger.warn('staleWhileRevalidate refetch failed', withTraceContext(data));
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
/** Log cache miss. Level: debug. */
|
|
148
151
|
export function logCacheMiss(data: { cacheKey: string }): void {
|
|
149
|
-
_logger
|
|
152
|
+
_logger.debug('timber.cache MISS', withTraceContext(data));
|
|
150
153
|
}
|