@timber-js/app 0.2.0-alpha.84 → 0.2.0-alpha.86

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 (73) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-YHRCboUO.js → actions-DLnUaR65.js} +2 -2
  3. package/dist/_chunks/{actions-YHRCboUO.js.map → actions-DLnUaR65.js.map} +1 -1
  4. package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
  5. package/dist/_chunks/{define-cookie-C9pquwOg.js → define-cookie-BowvzoP0.js} +4 -4
  6. package/dist/_chunks/{define-cookie-C9pquwOg.js.map → define-cookie-BowvzoP0.js.map} +1 -1
  7. package/dist/_chunks/{request-context-Dl0hXED3.js → request-context-CK5tZqIP.js} +2 -2
  8. package/dist/_chunks/{request-context-Dl0hXED3.js.map → request-context-CK5tZqIP.js.map} +1 -1
  9. package/dist/client/form.d.ts +4 -1
  10. package/dist/client/form.d.ts.map +1 -1
  11. package/dist/client/index.js +2 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/config-validation.d.ts +51 -0
  14. package/dist/config-validation.d.ts.map +1 -0
  15. package/dist/cookies/index.js +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1185 -51
  18. package/dist/index.js.map +1 -1
  19. package/dist/plugins/dev-404-page.d.ts +56 -0
  20. package/dist/plugins/dev-404-page.d.ts.map +1 -0
  21. package/dist/plugins/dev-error-overlay.d.ts +25 -11
  22. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  23. package/dist/plugins/dev-error-page.d.ts +58 -0
  24. package/dist/plugins/dev-error-page.d.ts.map +1 -0
  25. package/dist/plugins/dev-server.d.ts.map +1 -1
  26. package/dist/plugins/dev-terminal-error.d.ts +28 -0
  27. package/dist/plugins/dev-terminal-error.d.ts.map +1 -0
  28. package/dist/plugins/entries.d.ts.map +1 -1
  29. package/dist/plugins/fonts.d.ts +4 -0
  30. package/dist/plugins/fonts.d.ts.map +1 -1
  31. package/dist/plugins/routing.d.ts.map +1 -1
  32. package/dist/routing/convention-lint.d.ts +41 -0
  33. package/dist/routing/convention-lint.d.ts.map +1 -0
  34. package/dist/server/action-client.d.ts +13 -5
  35. package/dist/server/action-client.d.ts.map +1 -1
  36. package/dist/server/dev-source-map.d.ts +22 -0
  37. package/dist/server/dev-source-map.d.ts.map +1 -0
  38. package/dist/server/fallback-error.d.ts +9 -5
  39. package/dist/server/fallback-error.d.ts.map +1 -1
  40. package/dist/server/index.js +2 -2
  41. package/dist/server/index.js.map +1 -1
  42. package/dist/server/internal.js +21 -4
  43. package/dist/server/internal.js.map +1 -1
  44. package/dist/server/pipeline.d.ts +10 -0
  45. package/dist/server/pipeline.d.ts.map +1 -1
  46. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  47. package/dist/server/rsc-entry/index.d.ts +11 -0
  48. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  49. package/dist/server/rsc-entry/rsc-stream.d.ts +10 -0
  50. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  52. package/package.json +6 -7
  53. package/src/cli.ts +0 -0
  54. package/src/client/form.tsx +10 -5
  55. package/src/config-validation.ts +299 -0
  56. package/src/index.ts +17 -0
  57. package/src/plugins/dev-404-page.ts +418 -0
  58. package/src/plugins/dev-error-overlay.ts +185 -54
  59. package/src/plugins/dev-error-page.ts +536 -0
  60. package/src/plugins/dev-server.ts +76 -10
  61. package/src/plugins/dev-terminal-error.ts +217 -0
  62. package/src/plugins/entries.ts +3 -0
  63. package/src/plugins/fonts.ts +3 -2
  64. package/src/plugins/routing.ts +37 -5
  65. package/src/routing/convention-lint.ts +356 -0
  66. package/src/server/action-client.ts +17 -9
  67. package/src/server/dev-source-map.ts +31 -0
  68. package/src/server/fallback-error.ts +44 -88
  69. package/src/server/pipeline.ts +34 -4
  70. package/src/server/rsc-entry/error-renderer.ts +5 -0
  71. package/src/server/rsc-entry/index.ts +88 -2
  72. package/src/server/rsc-entry/rsc-stream.ts +16 -0
  73. package/src/server/rsc-entry/ssr-renderer.ts +6 -3
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Dev error page — self-contained HTML error page for dev server 500s.
3
+ *
4
+ * Generates a styled, self-contained HTML page when the RSC pipeline fails
5
+ * and the Vite error overlay can't fire (e.g., first page load before HMR
6
+ * WebSocket connects, RSC entry module crash, early pipeline errors).
7
+ *
8
+ * This is NOT a replacement for Vite's error overlay — it's the fallback
9
+ * for when the overlay's transport (WebSocket) isn't available yet.
10
+ *
11
+ * Dev-only: this module is only imported by dev-server.ts (apply: 'serve').
12
+ * It is never included in production builds.
13
+ *
14
+ * Design doc: 21-dev-server.md §"Error Overlay"
15
+ */
16
+
17
+ import {
18
+ classifyFrame,
19
+ extractComponentStack,
20
+ parseFirstAppFrame,
21
+ type ErrorPhase,
22
+ type FrameType,
23
+ } from './dev-error-overlay.js';
24
+
25
+ // ─── Types ──────────────────────────────────────────────────────────────────
26
+
27
+ interface ClassifiedFrame {
28
+ raw: string;
29
+ type: FrameType;
30
+ }
31
+
32
+ // ─── Phase Labels ───────────────────────────────────────────────────────────
33
+
34
+ const PHASE_LABELS: Record<ErrorPhase, string> = {
35
+ 'module-transform': 'Module Transform',
36
+ 'proxy': 'Proxy',
37
+ 'middleware': 'Middleware',
38
+ 'access': 'Access Check',
39
+ 'render': 'RSC Render',
40
+ 'handler': 'Route Handler',
41
+ };
42
+
43
+ const PHASE_HINTS: Record<ErrorPhase, string> = {
44
+ 'module-transform':
45
+ 'This error occurred while Vite was transforming a module. Check for syntax errors or missing imports.',
46
+ 'proxy': 'This error occurred in proxy.ts. Check your proxy configuration.',
47
+ 'middleware':
48
+ 'This error occurred in a middleware.ts file. Check the middleware function for unhandled exceptions.',
49
+ 'access': 'This error occurred in an access.ts file. Check your access control logic.',
50
+ 'render':
51
+ 'This error occurred while rendering a server component. Check the component for runtime errors.',
52
+ 'handler': 'This error occurred in a route handler (route.ts). Check the handler function.',
53
+ };
54
+
55
+ // ─── Stack Trace Processing ─────────────────────────────────────────────────
56
+
57
+ function classifyStack(stack: string, projectRoot: string): ClassifiedFrame[] {
58
+ return stack
59
+ .split('\n')
60
+ .slice(1) // skip first line (error message)
61
+ .filter((line) => line.trim().startsWith('at '))
62
+ .map((line) => ({
63
+ raw: line,
64
+ type: classifyFrame(line, projectRoot),
65
+ }));
66
+ }
67
+
68
+ // ─── Source Context ─────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Try to read a few lines of source code around the error location.
72
+ * Returns null if the file can't be read (e.g., virtual modules).
73
+ */
74
+ function readSourceContext(
75
+ filePath: string,
76
+ line: number,
77
+ contextLines = 3
78
+ ): { lines: Array<{ num: number; text: string; highlight: boolean }>; startLine: number } | null {
79
+ try {
80
+ const { readFileSync } = require('node:fs');
81
+ const content = readFileSync(filePath, 'utf-8');
82
+ const allLines = content.split('\n');
83
+
84
+ const start = Math.max(0, line - 1 - contextLines);
85
+ const end = Math.min(allLines.length, line + contextLines);
86
+
87
+ return {
88
+ startLine: start + 1,
89
+ lines: allLines.slice(start, end).map((text: string, i: number) => ({
90
+ num: start + 1 + i,
91
+ text,
92
+ highlight: start + 1 + i === line,
93
+ })),
94
+ };
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ // ─── HTML Escaping ──────────────────────────────────────────────────────────
101
+
102
+ function esc(str: string): string {
103
+ return str
104
+ .replace(/&/g, '&amp;')
105
+ .replace(/</g, '&lt;')
106
+ .replace(/>/g, '&gt;')
107
+ .replace(/"/g, '&quot;');
108
+ }
109
+
110
+ /**
111
+ * Escape a JSON string for safe embedding in an HTML `<script>` block.
112
+ *
113
+ * `JSON.stringify` does not escape `</script>`, so error text containing
114
+ * that sequence breaks out of the script block — an XSS vector.
115
+ * We replace all `<` with `\u003c` which is valid in JSON strings and
116
+ * prevents the HTML parser from seeing a closing `</script>` tag.
117
+ *
118
+ * Security: TIM-788
119
+ */
120
+ function escJsonForScript(json: string): string {
121
+ return json.replace(/</g, '\\u003c');
122
+ }
123
+
124
+ // ─── HMR Options ────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * HMR connection options for the dev error page auto-reload WebSocket.
128
+ * Derived from Vite's resolved server config so the error page can
129
+ * connect to the correct HMR endpoint (TIM-789).
130
+ */
131
+ export interface DevErrorHmrOptions {
132
+ protocol?: string;
133
+ host?: string;
134
+ port?: number;
135
+ path?: string;
136
+ token?: string;
137
+ }
138
+
139
+ /**
140
+ * Build a WebSocket URL from Vite HMR options.
141
+ * Mirrors the logic in Vite's client.mjs for constructing the WS connection URL.
142
+ */
143
+ function buildWsUrl(opts: DevErrorHmrOptions): string {
144
+ const protocol = opts.protocol ?? 'ws';
145
+ const host = opts.host ?? 'localhost';
146
+ const port = opts.port ? `:${opts.port}` : '';
147
+ const path = opts.path ?? '';
148
+ const token = opts.token ? `?token=${opts.token}` : '';
149
+ return `${protocol}://${host}${port}${path}${token}`;
150
+ }
151
+
152
+ /**
153
+ * Extract HMR connection options from a Vite resolved config.
154
+ * Used by the dev server to pass HMR config to the error page generator
155
+ * so the auto-reload WebSocket connects to the correct endpoint.
156
+ */
157
+ export function extractHmrOptions(config: {
158
+ server?: { hmr?: { protocol?: string; host?: string; port?: number; path?: string } | boolean };
159
+ webSocketToken?: string;
160
+ }): DevErrorHmrOptions | undefined {
161
+ const hmr = config.server?.hmr;
162
+ if (hmr === false) return undefined;
163
+ const hmrOpts = typeof hmr === 'object' ? hmr : {};
164
+ const opts: DevErrorHmrOptions = {
165
+ protocol: hmrOpts.protocol,
166
+ host: hmrOpts.host,
167
+ port: hmrOpts.port,
168
+ path: hmrOpts.path,
169
+ token: config.webSocketToken,
170
+ };
171
+ // If no HMR fields were explicitly configured, return undefined so the
172
+ // error page falls back to `'ws://' + location.host` which correctly
173
+ // includes the browser's current port (TIM-805).
174
+ if (!opts.protocol && !opts.host && !opts.port && !opts.path && !opts.token) {
175
+ return undefined;
176
+ }
177
+ return opts;
178
+ }
179
+
180
+ // ─── HTML Generation ────────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Generate a self-contained HTML error page for dev server 500 responses.
184
+ *
185
+ * The page includes:
186
+ * - Error message and phase label
187
+ * - Source code context around the first app frame (if readable)
188
+ * - Component stack (for React render errors)
189
+ * - Classified stack trace (app frames highlighted, internals collapsed)
190
+ * - Copy button for the full error
191
+ * - Dark/light mode via prefers-color-scheme
192
+ * - Auto-reconnect script that watches for Vite HMR and reloads
193
+ */
194
+ export function generateDevErrorPage(
195
+ error: Error,
196
+ phase: ErrorPhase,
197
+ projectRoot: string,
198
+ hmrOptions?: DevErrorHmrOptions
199
+ ): string {
200
+ const message = error.message || 'Unknown error';
201
+ const phaseLabel = PHASE_LABELS[phase];
202
+ const phaseHint = PHASE_HINTS[phase];
203
+ const componentStack = extractComponentStack(error);
204
+ const loc = parseFirstAppFrame(error.stack ?? '', projectRoot);
205
+ const frames = error.stack ? classifyStack(error.stack, projectRoot) : [];
206
+ const appFrames = frames.filter((f) => f.type === 'app');
207
+ const internalFrameCount = frames.filter((f) => f.type !== 'app').length;
208
+
209
+ // Try to read source code context
210
+ let sourceContext: ReturnType<typeof readSourceContext> = null;
211
+ if (loc) {
212
+ sourceContext = readSourceContext(loc.file, loc.line);
213
+ }
214
+
215
+ // Relative path for display
216
+ const relPath = loc?.file.startsWith(projectRoot)
217
+ ? loc.file.slice(projectRoot.length + 1)
218
+ : loc?.file;
219
+
220
+ return `<!DOCTYPE html>
221
+ <html lang="en">
222
+ <head>
223
+ <meta charset="utf-8">
224
+ <meta name="viewport" content="width=device-width, initial-scale=1">
225
+ <title>Error — ${esc(phaseLabel)} | timber.js</title>
226
+ <style>${CSS}</style>
227
+ </head>
228
+ <body>
229
+ <div class="container">
230
+ <header class="header">
231
+ <div class="badge">${esc(phaseLabel)} Error</div>
232
+ <h1 class="message">${esc(message)}</h1>
233
+ ${phaseHint ? `<p class="hint">${esc(phaseHint)}</p>` : ''}
234
+ </header>
235
+
236
+ ${relPath ? `<div class="location">${esc(relPath)}${loc ? `:${loc.line}:${loc.column}` : ''}</div>` : ''}
237
+
238
+ ${
239
+ sourceContext
240
+ ? `<div class="source-context">
241
+ <div class="source-header">Source</div>
242
+ <pre class="source-code"><code>${sourceContext.lines
243
+ .map(
244
+ (l) =>
245
+ `<span class="source-line${l.highlight ? ' source-line-highlight' : ''}"` +
246
+ `><span class="line-num">${l.num}</span>${esc(l.text)}</span>`
247
+ )
248
+ .join('\n')}</code></pre>
249
+ </div>`
250
+ : ''
251
+ }
252
+
253
+ ${
254
+ componentStack
255
+ ? `<div class="section">
256
+ <div class="section-header">Component Stack</div>
257
+ <pre class="component-stack">${esc(componentStack.trim())}</pre>
258
+ </div>`
259
+ : ''
260
+ }
261
+
262
+ ${
263
+ appFrames.length > 0
264
+ ? `<div class="section">
265
+ <div class="section-header">Application Frames</div>
266
+ <pre class="stack">${appFrames.map((f) => esc(f.raw)).join('\n')}</pre>
267
+ </div>`
268
+ : ''
269
+ }
270
+
271
+ ${
272
+ internalFrameCount > 0
273
+ ? `<details class="section internal-frames">
274
+ <summary class="section-header clickable">${internalFrameCount} internal frame${internalFrameCount !== 1 ? 's' : ''}</summary>
275
+ <pre class="stack dimmed">${frames
276
+ .filter((f) => f.type !== 'app')
277
+ .map((f) => esc(f.raw))
278
+ .join('\n')}</pre>
279
+ </details>`
280
+ : ''
281
+ }
282
+
283
+ <div class="actions">
284
+ <button class="btn" onclick="copyError()">Copy Error</button>
285
+ </div>
286
+
287
+ <footer class="footer">
288
+ <span class="timber-logo">🪵 timber.js</span>
289
+ <span class="footer-hint">Fix the error and save — the page will reload automatically via HMR.</span>
290
+ </footer>
291
+ </div>
292
+
293
+ <script>
294
+ function copyError() {
295
+ var text = ${escJsonForScript(
296
+ JSON.stringify(
297
+ `${phaseLabel} Error: ${message}\n\n` +
298
+ (relPath ? `File: ${relPath}${loc ? `:${loc.line}:${loc.column}` : ''}\n\n` : '') +
299
+ (componentStack ? `Component Stack:\n${componentStack.trim()}\n\n` : '') +
300
+ `Stack Trace:\n${error.stack ?? ''}`
301
+ )
302
+ )};
303
+ navigator.clipboard.writeText(text).then(function() {
304
+ var btn = document.querySelector('.btn');
305
+ if (btn) { btn.textContent = 'Copied!'; setTimeout(function() { btn.textContent = 'Copy Error'; }, 1500); }
306
+ });
307
+ }
308
+
309
+ // Auto-reload when Vite HMR reconnects.
310
+ // When the developer fixes the error and saves, Vite's module graph
311
+ // invalidates. We can't rely on the normal HMR update flow (the page
312
+ // is a static error page, not a Vite-managed module), so we connect
313
+ // to Vite's WebSocket and reload on any update event.
314
+ (function() {
315
+ try {
316
+ var wsUrl = ${hmrOptions ? escJsonForScript(JSON.stringify(buildWsUrl(hmrOptions))) : "'ws://' + location.host"};
317
+ var ws = new WebSocket(wsUrl, 'vite-hmr');
318
+ ws.addEventListener('message', function(e) {
319
+ try {
320
+ var data = JSON.parse(e.data);
321
+ if (data.type === 'full-reload' || data.type === 'update' || data.type === 'connected') {
322
+ if (data.type !== 'connected') location.reload();
323
+ }
324
+ } catch(ex) {}
325
+ });
326
+ } catch(ex) {}
327
+ })();
328
+ </script>
329
+ </body>
330
+ </html>`;
331
+ }
332
+
333
+ // ─── CSS ────────────────────────────────────────────────────────────────────
334
+
335
+ const CSS = `
336
+ :root {
337
+ --bg: #fff;
338
+ --fg: #1a1a1a;
339
+ --fg-dim: #6b7280;
340
+ --border: #e5e7eb;
341
+ --badge-bg: #fef2f2;
342
+ --badge-fg: #991b1b;
343
+ --badge-border: #fecaca;
344
+ --source-bg: #fafafa;
345
+ --highlight-bg: #fef2f2;
346
+ --highlight-border: #ef4444;
347
+ --line-num: #9ca3af;
348
+ --btn-bg: #f3f4f6;
349
+ --btn-fg: #374151;
350
+ --btn-border: #d1d5db;
351
+ --link: #2563eb;
352
+ --code-font: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
353
+ }
354
+
355
+ @media (prefers-color-scheme: dark) {
356
+ :root {
357
+ --bg: #0a0a0a;
358
+ --fg: #f5f5f5;
359
+ --fg-dim: #9ca3af;
360
+ --border: #27272a;
361
+ --badge-bg: #450a0a;
362
+ --badge-fg: #fca5a5;
363
+ --badge-border: #7f1d1d;
364
+ --source-bg: #18181b;
365
+ --highlight-bg: #450a0a;
366
+ --highlight-border: #dc2626;
367
+ --line-num: #6b7280;
368
+ --btn-bg: #27272a;
369
+ --btn-fg: #e5e7eb;
370
+ --btn-border: #3f3f46;
371
+ --link: #60a5fa;
372
+ }
373
+ }
374
+
375
+ * { margin: 0; padding: 0; box-sizing: border-box; }
376
+
377
+ body {
378
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
379
+ background: var(--bg);
380
+ color: var(--fg);
381
+ line-height: 1.6;
382
+ padding: 2rem;
383
+ }
384
+
385
+ .container {
386
+ max-width: 56rem;
387
+ margin: 0 auto;
388
+ }
389
+
390
+ .header { margin-bottom: 1.5rem; }
391
+
392
+ .badge {
393
+ display: inline-block;
394
+ font-size: 0.75rem;
395
+ font-weight: 600;
396
+ text-transform: uppercase;
397
+ letter-spacing: 0.05em;
398
+ padding: 0.25rem 0.625rem;
399
+ border-radius: 0.375rem;
400
+ background: var(--badge-bg);
401
+ color: var(--badge-fg);
402
+ border: 1px solid var(--badge-border);
403
+ margin-bottom: 0.75rem;
404
+ }
405
+
406
+ .message {
407
+ font-size: 1.375rem;
408
+ font-weight: 600;
409
+ line-height: 1.3;
410
+ word-break: break-word;
411
+ }
412
+
413
+ .hint {
414
+ margin-top: 0.5rem;
415
+ color: var(--fg-dim);
416
+ font-size: 0.875rem;
417
+ }
418
+
419
+ .location {
420
+ font-family: var(--code-font);
421
+ font-size: 0.8125rem;
422
+ color: var(--link);
423
+ margin-bottom: 1rem;
424
+ padding: 0.5rem 0.75rem;
425
+ background: var(--source-bg);
426
+ border-radius: 0.375rem;
427
+ border: 1px solid var(--border);
428
+ }
429
+
430
+ .source-context {
431
+ margin-bottom: 1rem;
432
+ border: 1px solid var(--border);
433
+ border-radius: 0.5rem;
434
+ overflow: hidden;
435
+ }
436
+
437
+ .source-header, .section-header {
438
+ font-size: 0.75rem;
439
+ font-weight: 600;
440
+ text-transform: uppercase;
441
+ letter-spacing: 0.05em;
442
+ padding: 0.5rem 0.75rem;
443
+ background: var(--source-bg);
444
+ border-bottom: 1px solid var(--border);
445
+ color: var(--fg-dim);
446
+ }
447
+
448
+ .source-code {
449
+ overflow-x: auto;
450
+ font-family: var(--code-font);
451
+ font-size: 0.8125rem;
452
+ line-height: 1.7;
453
+ padding: 0;
454
+ margin: 0;
455
+ }
456
+
457
+ .source-code code {
458
+ display: block;
459
+ }
460
+
461
+ .source-line {
462
+ display: block;
463
+ padding: 0 0.75rem;
464
+ border-left: 3px solid transparent;
465
+ }
466
+
467
+ .source-line-highlight {
468
+ background: var(--highlight-bg);
469
+ border-left-color: var(--highlight-border);
470
+ }
471
+
472
+ .line-num {
473
+ display: inline-block;
474
+ width: 3rem;
475
+ text-align: right;
476
+ margin-right: 1rem;
477
+ color: var(--line-num);
478
+ user-select: none;
479
+ }
480
+
481
+ .section {
482
+ margin-bottom: 1rem;
483
+ border: 1px solid var(--border);
484
+ border-radius: 0.5rem;
485
+ overflow: hidden;
486
+ }
487
+
488
+ .clickable { cursor: pointer; }
489
+ .clickable:hover { background: var(--btn-bg); }
490
+
491
+ .component-stack, .stack {
492
+ font-family: var(--code-font);
493
+ font-size: 0.8125rem;
494
+ line-height: 1.7;
495
+ padding: 0.75rem;
496
+ overflow-x: auto;
497
+ white-space: pre;
498
+ margin: 0;
499
+ }
500
+
501
+ .dimmed { color: var(--fg-dim); }
502
+
503
+ .actions {
504
+ margin: 1.5rem 0;
505
+ }
506
+
507
+ .btn {
508
+ font-family: inherit;
509
+ font-size: 0.8125rem;
510
+ font-weight: 500;
511
+ padding: 0.5rem 1rem;
512
+ border-radius: 0.375rem;
513
+ border: 1px solid var(--btn-border);
514
+ background: var(--btn-bg);
515
+ color: var(--btn-fg);
516
+ cursor: pointer;
517
+ transition: background 0.15s;
518
+ }
519
+
520
+ .btn:hover { opacity: 0.85; }
521
+
522
+ .footer {
523
+ display: flex;
524
+ align-items: center;
525
+ gap: 0.75rem;
526
+ padding-top: 1rem;
527
+ border-top: 1px solid var(--border);
528
+ font-size: 0.75rem;
529
+ color: var(--fg-dim);
530
+ }
531
+
532
+ .timber-logo {
533
+ font-weight: 600;
534
+ white-space: nowrap;
535
+ }
536
+ `;
@@ -17,7 +17,19 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
17
17
  import { join } from 'node:path';
18
18
  import type { PluginContext } from '../plugin-context.js';
19
19
  import { setViteServer } from '../server/dev-warnings.js';
20
- import { sendErrorToOverlay, classifyErrorPhase, parseFirstAppFrame } from './dev-error-overlay.js';
20
+ import {
21
+ sendErrorToOverlay,
22
+ classifyErrorPhase,
23
+ fixErrorStacktrace,
24
+ parseFirstAppFrame,
25
+ type ErrorPhase,
26
+ } from './dev-error-overlay.js';
27
+ import {
28
+ generateDevErrorPage,
29
+ extractHmrOptions,
30
+ type DevErrorHmrOptions,
31
+ } from './dev-error-page.js';
32
+ import { addVirtualModuleContext } from '../config-validation.js';
21
33
  import { compressResponse } from '../server/compress.js';
22
34
 
23
35
  // ─── Constants ────────────────────────────────────────────────────────────
@@ -137,6 +149,10 @@ export function timberDevServer(ctx: PluginContext): Plugin {
137
149
  * calls next() to let Vite handle them.
138
150
  */
139
151
  function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
152
+ // Extract HMR connection options once so the error page can construct
153
+ // a correct WebSocket URL (TIM-789).
154
+ const hmrOptions = extractHmrOptions(server.config);
155
+
140
156
  return async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void> => {
141
157
  const url = req.url;
142
158
  if (!url) {
@@ -198,14 +214,28 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
198
214
  );
199
215
  });
200
216
  }
217
+
218
+ // Wire source-map handler so error pages show original positions.
219
+ // fixErrorStacktrace rewrites the error's stack trace in-place using
220
+ // the Vite dev server's module graph. See TIM-811.
221
+ const setSourceMap = rscModule.setDevSourceMapHandler as
222
+ | ((fn: (error: Error) => void) => void)
223
+ | undefined;
224
+ if (typeof setSourceMap === 'function') {
225
+ setSourceMap((error: Error) => {
226
+ fixErrorStacktrace(server, error);
227
+ });
228
+ }
201
229
  } catch (error) {
202
230
  // Module transform error — syntax error, missing import, etc.
203
231
  // Vite may already show its own overlay for these, but we still
204
232
  // log to stderr with frame dimming for the terminal.
205
233
  if (error instanceof Error) {
234
+ // Add context for virtual:timber-* module errors
235
+ addTimberContext(error);
206
236
  sendErrorToOverlay(server, error, 'module-transform', projectRoot);
207
237
  }
208
- respond500(res, error);
238
+ respond500(res, error, 'module-transform', projectRoot, hmrOptions);
209
239
  return;
210
240
  }
211
241
 
@@ -248,24 +278,60 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
248
278
  if (error instanceof Error) {
249
279
  const phase = classifyErrorPhase(error, projectRoot);
250
280
  sendErrorToOverlay(server, error, phase, projectRoot);
281
+ respond500(res, error, phase, projectRoot, hmrOptions);
251
282
  } else {
252
283
  process.stderr.write(`\x1b[31m[timber] Dev server error:\x1b[0m ${String(error)}\n`);
284
+ respond500(res, error, 'render', projectRoot, hmrOptions);
253
285
  }
254
- respond500(res, error);
255
286
  }
256
287
  };
257
288
  }
258
289
 
259
290
  /**
260
291
  * Send a 500 response without crashing the dev server.
292
+ *
293
+ * In dev mode, renders a styled HTML error page with source context,
294
+ * classified stack trace, and auto-reload on HMR. Falls back to
295
+ * text/plain if the HTML generator fails (must never crash).
261
296
  */
262
- function respond500(res: ServerResponse, error: unknown): void {
263
- if (!res.headersSent) {
264
- res.statusCode = 500;
265
- res.setHeader('content-type', 'text/plain');
266
- res.end(
267
- `[timber] Internal server error\n\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
268
- );
297
+ function respond500(
298
+ res: ServerResponse,
299
+ error: unknown,
300
+ phase: ErrorPhase,
301
+ projectRoot: string,
302
+ hmrOptions?: DevErrorHmrOptions
303
+ ): void {
304
+ if (res.headersSent) return;
305
+
306
+ // Try to render the rich HTML error page
307
+ if (error instanceof Error) {
308
+ try {
309
+ const html = generateDevErrorPage(error, phase, projectRoot, hmrOptions);
310
+ res.statusCode = 500;
311
+ res.setHeader('content-type', 'text/html; charset=utf-8');
312
+ res.end(html);
313
+ return;
314
+ } catch {
315
+ // Fall through to text/plain if the HTML generator fails
316
+ }
317
+ }
318
+
319
+ // Fallback: plain text (same as before)
320
+ res.statusCode = 500;
321
+ res.setHeader('content-type', 'text/plain');
322
+ res.end(
323
+ `[timber] Internal server error\n\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Add timber-specific context to an error's message if it references
329
+ * internal virtual modules (virtual:timber-*). Mutates the error in place.
330
+ */
331
+ function addTimberContext(error: Error): void {
332
+ const enriched = addVirtualModuleContext(error.message);
333
+ if (enriched !== error.message) {
334
+ error.message = enriched;
269
335
  }
270
336
  }
271
337