@timber-js/app 0.2.0-alpha.83 → 0.2.0-alpha.85

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 (66) hide show
  1. package/dist/_chunks/{actions-Dg-ANYHb.js → actions-DLnUaR65.js} +2 -2
  2. package/dist/_chunks/{actions-Dg-ANYHb.js.map → actions-DLnUaR65.js.map} +1 -1
  3. package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
  4. package/dist/_chunks/{define-CZqDwhSu.js → define-Itxvcd7F.js} +2 -2
  5. package/dist/_chunks/{define-CZqDwhSu.js.map → define-Itxvcd7F.js.map} +1 -1
  6. package/dist/_chunks/{define-cookie-C2IkoFGN.js → define-cookie-BowvzoP0.js} +4 -4
  7. package/dist/_chunks/{define-cookie-C2IkoFGN.js.map → define-cookie-BowvzoP0.js.map} +1 -1
  8. package/dist/_chunks/{request-context-qMsWgy9C.js → request-context-CK5tZqIP.js} +3 -3
  9. package/dist/_chunks/{request-context-qMsWgy9C.js.map → request-context-CK5tZqIP.js.map} +1 -1
  10. package/dist/_chunks/{use-query-states-Lo_s_pw2.js → use-query-states-BiV5GJgm.js} +4 -1
  11. package/dist/_chunks/{use-query-states-Lo_s_pw2.js.map → use-query-states-BiV5GJgm.js.map} +1 -1
  12. package/dist/client/form.d.ts +4 -1
  13. package/dist/client/form.d.ts.map +1 -1
  14. package/dist/client/index.js +3 -3
  15. package/dist/client/index.js.map +1 -1
  16. package/dist/client/internal.js +1 -1
  17. package/dist/client/use-query-states.d.ts.map +1 -1
  18. package/dist/config-validation.d.ts +51 -0
  19. package/dist/config-validation.d.ts.map +1 -0
  20. package/dist/cookies/index.js +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1169 -51
  23. package/dist/index.js.map +1 -1
  24. package/dist/plugins/dev-404-page.d.ts +56 -0
  25. package/dist/plugins/dev-404-page.d.ts.map +1 -0
  26. package/dist/plugins/dev-error-overlay.d.ts +14 -11
  27. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  28. package/dist/plugins/dev-error-page.d.ts +58 -0
  29. package/dist/plugins/dev-error-page.d.ts.map +1 -0
  30. package/dist/plugins/dev-server.d.ts.map +1 -1
  31. package/dist/plugins/dev-terminal-error.d.ts +28 -0
  32. package/dist/plugins/dev-terminal-error.d.ts.map +1 -0
  33. package/dist/plugins/entries.d.ts.map +1 -1
  34. package/dist/plugins/fonts.d.ts +4 -0
  35. package/dist/plugins/fonts.d.ts.map +1 -1
  36. package/dist/plugins/routing.d.ts.map +1 -1
  37. package/dist/plugins/shims.d.ts.map +1 -1
  38. package/dist/routing/convention-lint.d.ts +41 -0
  39. package/dist/routing/convention-lint.d.ts.map +1 -0
  40. package/dist/search-params/index.js +2 -2
  41. package/dist/server/action-client.d.ts +13 -5
  42. package/dist/server/action-client.d.ts.map +1 -1
  43. package/dist/server/fallback-error.d.ts +9 -5
  44. package/dist/server/fallback-error.d.ts.map +1 -1
  45. package/dist/server/index.js +2 -2
  46. package/dist/server/index.js.map +1 -1
  47. package/dist/server/internal.js +2 -2
  48. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/client/form.tsx +10 -5
  51. package/src/client/use-query-states.ts +6 -0
  52. package/src/config-validation.ts +299 -0
  53. package/src/index.ts +17 -0
  54. package/src/plugins/dev-404-page.ts +418 -0
  55. package/src/plugins/dev-error-overlay.ts +165 -54
  56. package/src/plugins/dev-error-page.ts +536 -0
  57. package/src/plugins/dev-server.ts +63 -10
  58. package/src/plugins/dev-terminal-error.ts +217 -0
  59. package/src/plugins/entries.ts +3 -0
  60. package/src/plugins/fonts.ts +3 -2
  61. package/src/plugins/routing.ts +37 -5
  62. package/src/plugins/shims.ts +1 -0
  63. package/src/routing/convention-lint.ts +356 -0
  64. package/src/server/action-client.ts +17 -9
  65. package/src/server/fallback-error.ts +39 -88
  66. package/src/server/rsc-entry/index.ts +34 -2
@@ -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,18 @@ 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
+ parseFirstAppFrame,
24
+ type ErrorPhase,
25
+ } from './dev-error-overlay.js';
26
+ import {
27
+ generateDevErrorPage,
28
+ extractHmrOptions,
29
+ type DevErrorHmrOptions,
30
+ } from './dev-error-page.js';
31
+ import { addVirtualModuleContext } from '../config-validation.js';
21
32
  import { compressResponse } from '../server/compress.js';
22
33
 
23
34
  // ─── Constants ────────────────────────────────────────────────────────────
@@ -137,6 +148,10 @@ export function timberDevServer(ctx: PluginContext): Plugin {
137
148
  * calls next() to let Vite handle them.
138
149
  */
139
150
  function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
151
+ // Extract HMR connection options once so the error page can construct
152
+ // a correct WebSocket URL (TIM-789).
153
+ const hmrOptions = extractHmrOptions(server.config);
154
+
140
155
  return async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void> => {
141
156
  const url = req.url;
142
157
  if (!url) {
@@ -203,9 +218,11 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
203
218
  // Vite may already show its own overlay for these, but we still
204
219
  // log to stderr with frame dimming for the terminal.
205
220
  if (error instanceof Error) {
221
+ // Add context for virtual:timber-* module errors
222
+ addTimberContext(error);
206
223
  sendErrorToOverlay(server, error, 'module-transform', projectRoot);
207
224
  }
208
- respond500(res, error);
225
+ respond500(res, error, 'module-transform', projectRoot, hmrOptions);
209
226
  return;
210
227
  }
211
228
 
@@ -248,24 +265,60 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
248
265
  if (error instanceof Error) {
249
266
  const phase = classifyErrorPhase(error, projectRoot);
250
267
  sendErrorToOverlay(server, error, phase, projectRoot);
268
+ respond500(res, error, phase, projectRoot, hmrOptions);
251
269
  } else {
252
270
  process.stderr.write(`\x1b[31m[timber] Dev server error:\x1b[0m ${String(error)}\n`);
271
+ respond500(res, error, 'render', projectRoot, hmrOptions);
253
272
  }
254
- respond500(res, error);
255
273
  }
256
274
  };
257
275
  }
258
276
 
259
277
  /**
260
278
  * Send a 500 response without crashing the dev server.
279
+ *
280
+ * In dev mode, renders a styled HTML error page with source context,
281
+ * classified stack trace, and auto-reload on HMR. Falls back to
282
+ * text/plain if the HTML generator fails (must never crash).
261
283
  */
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
- );
284
+ function respond500(
285
+ res: ServerResponse,
286
+ error: unknown,
287
+ phase: ErrorPhase,
288
+ projectRoot: string,
289
+ hmrOptions?: DevErrorHmrOptions
290
+ ): void {
291
+ if (res.headersSent) return;
292
+
293
+ // Try to render the rich HTML error page
294
+ if (error instanceof Error) {
295
+ try {
296
+ const html = generateDevErrorPage(error, phase, projectRoot, hmrOptions);
297
+ res.statusCode = 500;
298
+ res.setHeader('content-type', 'text/html; charset=utf-8');
299
+ res.end(html);
300
+ return;
301
+ } catch {
302
+ // Fall through to text/plain if the HTML generator fails
303
+ }
304
+ }
305
+
306
+ // Fallback: plain text (same as before)
307
+ res.statusCode = 500;
308
+ res.setHeader('content-type', 'text/plain');
309
+ res.end(
310
+ `[timber] Internal server error\n\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Add timber-specific context to an error's message if it references
316
+ * internal virtual modules (virtual:timber-*). Mutates the error in place.
317
+ */
318
+ function addTimberContext(error: Error): void {
319
+ const enriched = addVirtualModuleContext(error.message);
320
+ if (enriched !== error.message) {
321
+ error.message = enriched;
269
322
  }
270
323
  }
271
324