@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.
- package/LICENSE +8 -0
- package/dist/_chunks/{actions-YHRCboUO.js → actions-DLnUaR65.js} +2 -2
- package/dist/_chunks/{actions-YHRCboUO.js.map → actions-DLnUaR65.js.map} +1 -1
- package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
- package/dist/_chunks/{define-cookie-C9pquwOg.js → define-cookie-BowvzoP0.js} +4 -4
- package/dist/_chunks/{define-cookie-C9pquwOg.js.map → define-cookie-BowvzoP0.js.map} +1 -1
- package/dist/_chunks/{request-context-Dl0hXED3.js → request-context-CK5tZqIP.js} +2 -2
- package/dist/_chunks/{request-context-Dl0hXED3.js.map → request-context-CK5tZqIP.js.map} +1 -1
- package/dist/client/form.d.ts +4 -1
- package/dist/client/form.d.ts.map +1 -1
- package/dist/client/index.js +2 -2
- package/dist/client/index.js.map +1 -1
- package/dist/config-validation.d.ts +51 -0
- package/dist/config-validation.d.ts.map +1 -0
- package/dist/cookies/index.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1185 -51
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-404-page.d.ts +56 -0
- package/dist/plugins/dev-404-page.d.ts.map +1 -0
- package/dist/plugins/dev-error-overlay.d.ts +25 -11
- package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
- package/dist/plugins/dev-error-page.d.ts +58 -0
- package/dist/plugins/dev-error-page.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/dev-terminal-error.d.ts +28 -0
- package/dist/plugins/dev-terminal-error.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +4 -0
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts +41 -0
- package/dist/routing/convention-lint.d.ts.map +1 -0
- package/dist/server/action-client.d.ts +13 -5
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/dev-source-map.d.ts +22 -0
- package/dist/server/dev-source-map.d.ts.map +1 -0
- package/dist/server/fallback-error.d.ts +9 -5
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +21 -4
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline.d.ts +10 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +11 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +10 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/form.tsx +10 -5
- package/src/config-validation.ts +299 -0
- package/src/index.ts +17 -0
- package/src/plugins/dev-404-page.ts +418 -0
- package/src/plugins/dev-error-overlay.ts +185 -54
- package/src/plugins/dev-error-page.ts +536 -0
- package/src/plugins/dev-server.ts +76 -10
- package/src/plugins/dev-terminal-error.ts +217 -0
- package/src/plugins/entries.ts +3 -0
- package/src/plugins/fonts.ts +3 -2
- package/src/plugins/routing.ts +37 -5
- package/src/routing/convention-lint.ts +356 -0
- package/src/server/action-client.ts +17 -9
- package/src/server/dev-source-map.ts +31 -0
- package/src/server/fallback-error.ts +44 -88
- package/src/server/pipeline.ts +34 -4
- package/src/server/rsc-entry/error-renderer.ts +5 -0
- package/src/server/rsc-entry/index.ts +88 -2
- package/src/server/rsc-entry/rsc-stream.ts +16 -0
- 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, '&')
|
|
105
|
+
.replace(/</g, '<')
|
|
106
|
+
.replace(/>/g, '>')
|
|
107
|
+
.replace(/"/g, '"');
|
|
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 {
|
|
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(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|