@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
@@ -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
- | ((fn: (error: Error, phase: string) => void) => void)
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(server, error, classifyErrorPhase(error, projectRoot), projectRoot);
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.end();
362
+ if (!nodeRes.writableEnded) {
363
+ nodeRes.end();
364
+ }
331
365
  }
332
366
  }
333
367
 
@@ -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
- console.error('[timber] server action error:', error);
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
- console.error('[timber] server action error:', error);
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
+ }
@@ -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
- console.error('[timber] Unhandled render-phase error:', error);
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 { done, value } = await rscReader.read();
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
- const { done, value } = await rscReader.read();
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
  /**
@@ -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';
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * Logger — structured logging with environment-aware formatting.
3
3
  *
4
- * timber.js does not ship a logger. Users export any object with
5
- * info/warn/error/debug methods from instrumentation.ts and the framework
6
- * picks it up. Silent if no logger export is present.
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 { formatSsrError } from './error-formatter.js';
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
- let _logger: TimberLogger | null = null;
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, or null if none configured.
39
- * Framework-internal used at framework event points to emit structured logs.
40
+ * Get the current logger. Always non-null returns DefaultLogger when
41
+ * no custom logger is configured.
40
42
  */
41
- export function getLogger(): TimberLogger | null {
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?.info('request completed', withTraceContext(data));
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?.debug('request received', withTraceContext(data));
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?.warn('slow request exceeded threshold', withTraceContext(data));
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?.debug('middleware short-circuited', withTraceContext(data));
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
- if (_logger) {
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
- if (_logger) {
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
- if (_logger) {
126
- _logger.error('proxy.ts threw uncaught error', withTraceContext(data));
127
- } else if (isDebug()) {
128
- console.error('[timber] proxy error', data.error);
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?.warn('adapter does not support waitUntil()');
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?.warn('waitUntil() promise rejected', withTraceContext(data));
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?.warn('staleWhileRevalidate refetch failed', withTraceContext(data));
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?.debug('timber.cache MISS', withTraceContext(data));
152
+ _logger.debug('timber.cache MISS', withTraceContext(data));
150
153
  }