@timber-js/app 0.2.0-alpha.84 → 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.
- 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 +1168 -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 +14 -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/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 +2 -2
- package/dist/server/rsc-entry/index.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 +165 -54
- package/src/plugins/dev-error-page.ts +536 -0
- package/src/plugins/dev-server.ts +63 -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/fallback-error.ts +39 -88
- package/src/server/rsc-entry/index.ts +34 -2
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev 404 page — self-contained HTML page for dev-mode route misses.
|
|
3
|
+
*
|
|
4
|
+
* When no route matches and the user has no 404.tsx, this page shows:
|
|
5
|
+
* - The requested path
|
|
6
|
+
* - All registered routes in the app
|
|
7
|
+
* - "Did you mean?" suggestions based on string similarity
|
|
8
|
+
* - Setup instructions for new projects with no routes
|
|
9
|
+
*
|
|
10
|
+
* Dev-only: this module is only imported in dev mode. It is never
|
|
11
|
+
* included in production builds.
|
|
12
|
+
*
|
|
13
|
+
* Design doc: 21-dev-server.md, 07-routing.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
interface RouteInfo {
|
|
19
|
+
/** URL path pattern (e.g., "/dashboard/[id]") */
|
|
20
|
+
path: string;
|
|
21
|
+
/** Whether this is a page route or API route handler */
|
|
22
|
+
type: 'page' | 'route';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal segment node shape — matches ManifestSegmentNode. */
|
|
26
|
+
interface SegmentNode {
|
|
27
|
+
segmentName: string;
|
|
28
|
+
segmentType: string;
|
|
29
|
+
urlPath: string;
|
|
30
|
+
page?: { filePath: string };
|
|
31
|
+
route?: { filePath: string };
|
|
32
|
+
children: SegmentNode[];
|
|
33
|
+
slots: Record<string, SegmentNode> | Map<string, SegmentNode>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Route Collection ───────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collect all routable paths from the manifest tree.
|
|
40
|
+
*
|
|
41
|
+
* Walks the segment tree and collects paths for segments that have
|
|
42
|
+
* a page or route handler.
|
|
43
|
+
*/
|
|
44
|
+
export function collectRoutes(root: SegmentNode): RouteInfo[] {
|
|
45
|
+
const routes: RouteInfo[] = [];
|
|
46
|
+
walkRoutes(root, routes);
|
|
47
|
+
return routes.sort((a, b) => a.path.localeCompare(b.path));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function walkRoutes(node: SegmentNode, routes: RouteInfo[]): void {
|
|
51
|
+
if (node.page) {
|
|
52
|
+
routes.push({ path: node.urlPath || '/', type: 'page' });
|
|
53
|
+
}
|
|
54
|
+
if (node.route) {
|
|
55
|
+
routes.push({ path: node.urlPath || '/', type: 'route' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const child of node.children) {
|
|
59
|
+
walkRoutes(child, routes);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Handle both Map and plain object for slots
|
|
63
|
+
const slots = node.slots;
|
|
64
|
+
if (slots instanceof Map) {
|
|
65
|
+
for (const [, slotNode] of slots) {
|
|
66
|
+
walkRoutes(slotNode, routes);
|
|
67
|
+
}
|
|
68
|
+
} else if (slots && typeof slots === 'object') {
|
|
69
|
+
for (const key of Object.keys(slots)) {
|
|
70
|
+
walkRoutes((slots as Record<string, SegmentNode>)[key]!, routes);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── String Similarity ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compute Levenshtein distance between two strings.
|
|
79
|
+
*
|
|
80
|
+
* Used for "did you mean?" suggestions. Simple O(n*m) implementation
|
|
81
|
+
* — fine for short URL paths.
|
|
82
|
+
*/
|
|
83
|
+
function levenshtein(a: string, b: string): number {
|
|
84
|
+
const m = a.length;
|
|
85
|
+
const n = b.length;
|
|
86
|
+
|
|
87
|
+
// Optimization: use single-row DP
|
|
88
|
+
const row = Array.from({ length: n + 1 }, (_, i) => i);
|
|
89
|
+
|
|
90
|
+
for (let i = 1; i <= m; i++) {
|
|
91
|
+
let prev = i;
|
|
92
|
+
for (let j = 1; j <= n; j++) {
|
|
93
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
94
|
+
const val = Math.min(
|
|
95
|
+
row[j]! + 1, // deletion
|
|
96
|
+
prev + 1, // insertion
|
|
97
|
+
row[j - 1]! + cost // substitution
|
|
98
|
+
);
|
|
99
|
+
row[j - 1] = prev;
|
|
100
|
+
prev = val;
|
|
101
|
+
}
|
|
102
|
+
row[n] = prev;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return row[n]!;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Find routes similar to the requested path.
|
|
110
|
+
*
|
|
111
|
+
* Returns up to 3 suggestions, sorted by similarity.
|
|
112
|
+
* Only includes routes with distance ≤ 40% of the longer string.
|
|
113
|
+
*/
|
|
114
|
+
export function findSimilarRoutes(requestedPath: string, routes: RouteInfo[]): RouteInfo[] {
|
|
115
|
+
if (routes.length === 0) return [];
|
|
116
|
+
|
|
117
|
+
const scored = routes
|
|
118
|
+
.map((route) => ({
|
|
119
|
+
route,
|
|
120
|
+
distance: levenshtein(requestedPath.toLowerCase(), route.path.toLowerCase()),
|
|
121
|
+
}))
|
|
122
|
+
.filter((s) => {
|
|
123
|
+
const maxLen = Math.max(requestedPath.length, s.route.path.length);
|
|
124
|
+
return s.distance <= maxLen * 0.4;
|
|
125
|
+
})
|
|
126
|
+
.sort((a, b) => a.distance - b.distance);
|
|
127
|
+
|
|
128
|
+
return scored.slice(0, 3).map((s) => s.route);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── HTML Escaping ──────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function esc(str: string): string {
|
|
134
|
+
return str
|
|
135
|
+
.replace(/&/g, '&')
|
|
136
|
+
.replace(/</g, '<')
|
|
137
|
+
.replace(/>/g, '>')
|
|
138
|
+
.replace(/"/g, '"');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── HTML Generation ────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Generate a dev-mode 404 page with route listing and suggestions.
|
|
145
|
+
*
|
|
146
|
+
* Returns an HTML string for a self-contained error page.
|
|
147
|
+
*/
|
|
148
|
+
export function generateDev404Page(requestedPath: string, routes: RouteInfo[]): string {
|
|
149
|
+
const suggestions = findSimilarRoutes(requestedPath, routes);
|
|
150
|
+
const isEmpty = routes.length === 0;
|
|
151
|
+
|
|
152
|
+
return `<!DOCTYPE html>
|
|
153
|
+
<html lang="en">
|
|
154
|
+
<head>
|
|
155
|
+
<meta charset="utf-8">
|
|
156
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
157
|
+
<title>404 — ${esc(requestedPath)} | timber.js</title>
|
|
158
|
+
<style>${CSS}</style>
|
|
159
|
+
</head>
|
|
160
|
+
<body>
|
|
161
|
+
<div class="container">
|
|
162
|
+
<header class="header">
|
|
163
|
+
<div class="badge">404 Not Found</div>
|
|
164
|
+
<h1 class="message">No route matches <code>${esc(requestedPath)}</code></h1>
|
|
165
|
+
</header>
|
|
166
|
+
|
|
167
|
+
${
|
|
168
|
+
isEmpty
|
|
169
|
+
? `<div class="welcome">
|
|
170
|
+
<h2>Welcome to timber.js</h2>
|
|
171
|
+
<p>Your app has no pages yet. Create your first page to get started:</p>
|
|
172
|
+
<div class="code-block">
|
|
173
|
+
<div class="code-header">app/page.tsx</div>
|
|
174
|
+
<pre><code>export default function Home() {
|
|
175
|
+
return <h1>Hello, timber.js!</h1>;
|
|
176
|
+
}</code></pre>
|
|
177
|
+
</div>
|
|
178
|
+
<p class="hint">Save the file and this page will reload automatically.</p>
|
|
179
|
+
</div>`
|
|
180
|
+
: ''
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
${
|
|
184
|
+
suggestions.length > 0
|
|
185
|
+
? `<div class="section">
|
|
186
|
+
<div class="section-header">Did you mean?</div>
|
|
187
|
+
<ul class="route-list suggestions">
|
|
188
|
+
${suggestions.map((r) => `<li><a href="${esc(r.path)}">${esc(r.path)}</a> <span class="route-type">${r.type}</span></li>`).join('\n ')}
|
|
189
|
+
</ul>
|
|
190
|
+
</div>`
|
|
191
|
+
: ''
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
${
|
|
195
|
+
routes.length > 0
|
|
196
|
+
? `<div class="section">
|
|
197
|
+
<div class="section-header">Available Routes (${routes.length})</div>
|
|
198
|
+
<ul class="route-list">
|
|
199
|
+
${routes.map((r) => `<li><a href="${esc(r.path)}">${esc(r.path)}</a> <span class="route-type">${r.type}</span></li>`).join('\n ')}
|
|
200
|
+
</ul>
|
|
201
|
+
</div>`
|
|
202
|
+
: ''
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
<footer class="footer">
|
|
206
|
+
<span class="timber-logo">🪵 timber.js</span>
|
|
207
|
+
<span class="footer-hint">Add or edit routes and this page will reload automatically.</span>
|
|
208
|
+
</footer>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<script>
|
|
212
|
+
// Auto-reload when Vite HMR fires (route added, file changed, etc.)
|
|
213
|
+
(function() {
|
|
214
|
+
try {
|
|
215
|
+
const ws = new WebSocket('ws://' + location.host, 'vite-hmr');
|
|
216
|
+
ws.addEventListener('message', function(e) {
|
|
217
|
+
try {
|
|
218
|
+
const data = JSON.parse(e.data);
|
|
219
|
+
if (data.type === 'full-reload' || data.type === 'update') {
|
|
220
|
+
location.reload();
|
|
221
|
+
}
|
|
222
|
+
} catch {}
|
|
223
|
+
});
|
|
224
|
+
} catch {}
|
|
225
|
+
})();
|
|
226
|
+
</script>
|
|
227
|
+
</body>
|
|
228
|
+
</html>`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── CSS ────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
const CSS = `
|
|
234
|
+
:root {
|
|
235
|
+
--bg: #fff;
|
|
236
|
+
--fg: #1a1a1a;
|
|
237
|
+
--fg-dim: #6b7280;
|
|
238
|
+
--border: #e5e7eb;
|
|
239
|
+
--badge-bg: #fefce8;
|
|
240
|
+
--badge-fg: #854d0e;
|
|
241
|
+
--badge-border: #fde68a;
|
|
242
|
+
--source-bg: #fafafa;
|
|
243
|
+
--link: #2563eb;
|
|
244
|
+
--code-bg: #f3f4f6;
|
|
245
|
+
--btn-bg: #f3f4f6;
|
|
246
|
+
--route-type-bg: #e0f2fe;
|
|
247
|
+
--route-type-fg: #0369a1;
|
|
248
|
+
--code-font: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
@media (prefers-color-scheme: dark) {
|
|
252
|
+
:root {
|
|
253
|
+
--bg: #0a0a0a;
|
|
254
|
+
--fg: #f5f5f5;
|
|
255
|
+
--fg-dim: #9ca3af;
|
|
256
|
+
--border: #27272a;
|
|
257
|
+
--badge-bg: #422006;
|
|
258
|
+
--badge-fg: #fde68a;
|
|
259
|
+
--badge-border: #854d0e;
|
|
260
|
+
--source-bg: #18181b;
|
|
261
|
+
--link: #60a5fa;
|
|
262
|
+
--code-bg: #27272a;
|
|
263
|
+
--btn-bg: #27272a;
|
|
264
|
+
--route-type-bg: #0c4a6e;
|
|
265
|
+
--route-type-fg: #bae6fd;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
270
|
+
|
|
271
|
+
body {
|
|
272
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
273
|
+
background: var(--bg);
|
|
274
|
+
color: var(--fg);
|
|
275
|
+
line-height: 1.6;
|
|
276
|
+
padding: 2rem;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.container { max-width: 56rem; margin: 0 auto; }
|
|
280
|
+
.header { margin-bottom: 1.5rem; }
|
|
281
|
+
|
|
282
|
+
.badge {
|
|
283
|
+
display: inline-block;
|
|
284
|
+
font-size: 0.75rem;
|
|
285
|
+
font-weight: 600;
|
|
286
|
+
text-transform: uppercase;
|
|
287
|
+
letter-spacing: 0.05em;
|
|
288
|
+
padding: 0.25rem 0.625rem;
|
|
289
|
+
border-radius: 0.375rem;
|
|
290
|
+
background: var(--badge-bg);
|
|
291
|
+
color: var(--badge-fg);
|
|
292
|
+
border: 1px solid var(--badge-border);
|
|
293
|
+
margin-bottom: 0.75rem;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.message {
|
|
297
|
+
font-size: 1.375rem;
|
|
298
|
+
font-weight: 600;
|
|
299
|
+
line-height: 1.3;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.message code {
|
|
303
|
+
font-family: var(--code-font);
|
|
304
|
+
background: var(--code-bg);
|
|
305
|
+
padding: 0.125rem 0.375rem;
|
|
306
|
+
border-radius: 0.25rem;
|
|
307
|
+
font-size: 1.125rem;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.welcome {
|
|
311
|
+
margin-bottom: 1.5rem;
|
|
312
|
+
padding: 1.5rem;
|
|
313
|
+
border: 1px solid var(--border);
|
|
314
|
+
border-radius: 0.5rem;
|
|
315
|
+
background: var(--source-bg);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.welcome h2 {
|
|
319
|
+
font-size: 1.125rem;
|
|
320
|
+
margin-bottom: 0.5rem;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.welcome p { color: var(--fg-dim); margin-bottom: 1rem; }
|
|
324
|
+
|
|
325
|
+
.code-block {
|
|
326
|
+
border: 1px solid var(--border);
|
|
327
|
+
border-radius: 0.5rem;
|
|
328
|
+
overflow: hidden;
|
|
329
|
+
margin-bottom: 1rem;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.code-header {
|
|
333
|
+
font-size: 0.75rem;
|
|
334
|
+
font-weight: 600;
|
|
335
|
+
padding: 0.5rem 0.75rem;
|
|
336
|
+
background: var(--code-bg);
|
|
337
|
+
border-bottom: 1px solid var(--border);
|
|
338
|
+
color: var(--fg-dim);
|
|
339
|
+
font-family: var(--code-font);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.code-block pre {
|
|
343
|
+
padding: 0.75rem;
|
|
344
|
+
font-family: var(--code-font);
|
|
345
|
+
font-size: 0.8125rem;
|
|
346
|
+
line-height: 1.7;
|
|
347
|
+
overflow-x: auto;
|
|
348
|
+
margin: 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.section {
|
|
352
|
+
margin-bottom: 1rem;
|
|
353
|
+
border: 1px solid var(--border);
|
|
354
|
+
border-radius: 0.5rem;
|
|
355
|
+
overflow: hidden;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.section-header {
|
|
359
|
+
font-size: 0.75rem;
|
|
360
|
+
font-weight: 600;
|
|
361
|
+
text-transform: uppercase;
|
|
362
|
+
letter-spacing: 0.05em;
|
|
363
|
+
padding: 0.5rem 0.75rem;
|
|
364
|
+
background: var(--source-bg);
|
|
365
|
+
border-bottom: 1px solid var(--border);
|
|
366
|
+
color: var(--fg-dim);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.route-list {
|
|
370
|
+
list-style: none;
|
|
371
|
+
padding: 0.5rem 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.route-list li {
|
|
375
|
+
padding: 0.375rem 0.75rem;
|
|
376
|
+
font-family: var(--code-font);
|
|
377
|
+
font-size: 0.8125rem;
|
|
378
|
+
display: flex;
|
|
379
|
+
align-items: center;
|
|
380
|
+
gap: 0.5rem;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.route-list li:hover { background: var(--source-bg); }
|
|
384
|
+
|
|
385
|
+
.route-list a {
|
|
386
|
+
color: var(--link);
|
|
387
|
+
text-decoration: none;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.route-list a:hover { text-decoration: underline; }
|
|
391
|
+
|
|
392
|
+
.route-type {
|
|
393
|
+
font-size: 0.625rem;
|
|
394
|
+
font-weight: 600;
|
|
395
|
+
text-transform: uppercase;
|
|
396
|
+
letter-spacing: 0.05em;
|
|
397
|
+
padding: 0.125rem 0.375rem;
|
|
398
|
+
border-radius: 0.25rem;
|
|
399
|
+
background: var(--route-type-bg);
|
|
400
|
+
color: var(--route-type-fg);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.suggestions { background: var(--source-bg); }
|
|
404
|
+
|
|
405
|
+
.hint { font-size: 0.875rem; color: var(--fg-dim); }
|
|
406
|
+
|
|
407
|
+
.footer {
|
|
408
|
+
display: flex;
|
|
409
|
+
align-items: center;
|
|
410
|
+
gap: 0.75rem;
|
|
411
|
+
padding-top: 1rem;
|
|
412
|
+
border-top: 1px solid var(--border);
|
|
413
|
+
font-size: 0.75rem;
|
|
414
|
+
color: var(--fg-dim);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.timber-logo { font-weight: 600; white-space: nowrap; }
|
|
418
|
+
`;
|
|
@@ -1,13 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dev error overlay — formats and sends errors to Vite's browser overlay and stderr.
|
|
3
3
|
*
|
|
4
|
-
* Integrates with Vite's built-in error overlay (`server.
|
|
5
|
-
*
|
|
4
|
+
* Integrates with Vite's built-in error overlay (`server.hot.send`) rather
|
|
5
|
+
* than implementing a custom overlay.
|
|
6
|
+
*
|
|
7
|
+
* Stack trace source-mapping uses the correct Vite environment module graph:
|
|
8
|
+
* RSC errors use `server.environments.rsc.moduleGraph`, SSR/other errors use
|
|
9
|
+
* `server.environments.ssr.moduleGraph`. `server.ssrFixStacktrace()` is NOT
|
|
10
|
+
* used because it hardcodes the SSR module graph, which doesn't contain
|
|
11
|
+
* source maps for RSC modules (separate Vite environment with its own
|
|
12
|
+
* module graph). This caused RSC errors to show transpiled line numbers
|
|
13
|
+
* instead of original source positions.
|
|
6
14
|
*
|
|
7
15
|
* Design doc: 21-dev-server.md §"Error Overlay"
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
|
-
import type { ViteDevServer } from 'vite';
|
|
18
|
+
import type { ViteDevServer, DevEnvironment } from 'vite';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { resolve, dirname } from 'node:path';
|
|
21
|
+
|
|
22
|
+
// ─── Trace Mapping (lazy-loaded) ─────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
interface TraceMappingModule {
|
|
25
|
+
TraceMap: new (map: unknown) => unknown;
|
|
26
|
+
originalPositionFor: (
|
|
27
|
+
map: unknown,
|
|
28
|
+
needle: { line: number; column: number }
|
|
29
|
+
) => { source: string | null; line: number | null; column: number | null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let _traceMapping: TraceMappingModule | null = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lazy-load @jridgewell/trace-mapping from Vite's dependency tree.
|
|
36
|
+
* Vite bundles it internally; we resolve from Vite's package to avoid
|
|
37
|
+
* adding a direct dependency.
|
|
38
|
+
*/
|
|
39
|
+
function getTraceMapping(): TraceMappingModule {
|
|
40
|
+
if (_traceMapping) return _traceMapping;
|
|
41
|
+
// Resolve from Vite's package location so we pick up its transitive
|
|
42
|
+
// @jridgewell/trace-mapping without declaring it as a direct dependency.
|
|
43
|
+
// createRequire(import.meta.url) gives us ESM-compatible require (TIM-796),
|
|
44
|
+
// then we hop to Vite's path to reach its dependency tree (TIM-804).
|
|
45
|
+
const esmRequire = createRequire(import.meta.url);
|
|
46
|
+
const vitePath = esmRequire.resolve('vite');
|
|
47
|
+
const viteRequire = createRequire(vitePath);
|
|
48
|
+
_traceMapping = viteRequire('@jridgewell/trace-mapping') as TraceMappingModule;
|
|
49
|
+
return _traceMapping;
|
|
50
|
+
}
|
|
11
51
|
|
|
12
52
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
13
53
|
|
|
@@ -21,7 +61,7 @@ export type ErrorPhase =
|
|
|
21
61
|
| 'handler';
|
|
22
62
|
|
|
23
63
|
/** Labels for terminal output. */
|
|
24
|
-
const PHASE_LABELS: Record<ErrorPhase, string> = {
|
|
64
|
+
export const PHASE_LABELS: Record<ErrorPhase, string> = {
|
|
25
65
|
'module-transform': 'Module Transform',
|
|
26
66
|
'proxy': 'Proxy',
|
|
27
67
|
'middleware': 'Middleware',
|
|
@@ -131,54 +171,11 @@ export function classifyErrorPhase(error: Error, projectRoot: string): ErrorPhas
|
|
|
131
171
|
|
|
132
172
|
// ─── Terminal Formatting ────────────────────────────────────────────────────
|
|
133
173
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Format an error for terminal output.
|
|
142
|
-
*
|
|
143
|
-
* - Red for the error message and phase label
|
|
144
|
-
* - Dim for framework-internal frames
|
|
145
|
-
* - Normal for application frames
|
|
146
|
-
* - Separate section for component stack (if present)
|
|
147
|
-
*/
|
|
148
|
-
export function formatTerminalError(error: Error, phase: ErrorPhase, projectRoot: string): string {
|
|
149
|
-
const lines: string[] = [];
|
|
150
|
-
|
|
151
|
-
// Phase header + error message
|
|
152
|
-
lines.push(`${RED}${BOLD}[timber] ${PHASE_LABELS[phase]} Error${RESET}`);
|
|
153
|
-
lines.push(`${RED}${error.message}${RESET}`);
|
|
154
|
-
lines.push('');
|
|
155
|
-
|
|
156
|
-
// Component stack (if present)
|
|
157
|
-
const componentStack = extractComponentStack(error);
|
|
158
|
-
if (componentStack) {
|
|
159
|
-
lines.push(`${BOLD}Component Stack:${RESET}`);
|
|
160
|
-
for (const csLine of componentStack.trim().split('\n')) {
|
|
161
|
-
lines.push(` ${csLine.trim()}`);
|
|
162
|
-
}
|
|
163
|
-
lines.push('');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Stack trace with frame dimming
|
|
167
|
-
if (error.stack) {
|
|
168
|
-
lines.push(`${BOLD}Stack Trace:${RESET}`);
|
|
169
|
-
const stackLines = error.stack.split('\n').slice(1); // Skip the first line (message)
|
|
170
|
-
for (const stackLine of stackLines) {
|
|
171
|
-
const frameType = classifyFrame(stackLine, projectRoot);
|
|
172
|
-
if (frameType === 'app') {
|
|
173
|
-
lines.push(stackLine);
|
|
174
|
-
} else {
|
|
175
|
-
lines.push(`${DIM}${stackLine}${RESET}`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return lines.join('\n');
|
|
181
|
-
}
|
|
174
|
+
// formatTerminalError is implemented in the dedicated terminal formatter module
|
|
175
|
+
// (dev-terminal-error.ts) to keep this file under 500 lines.
|
|
176
|
+
// Import for use in sendErrorToOverlay, and re-export for external consumers.
|
|
177
|
+
import { formatTerminalError as _formatTerminalError } from './dev-terminal-error.js';
|
|
178
|
+
export const formatTerminalError = _formatTerminalError;
|
|
182
179
|
|
|
183
180
|
// ─── RSC Debug Context ──────────────────────────────────────────────────────
|
|
184
181
|
|
|
@@ -237,6 +234,116 @@ export function formatRscDebugContext(components: RscDebugComponentInfo[]): stri
|
|
|
237
234
|
return lines.join('\n');
|
|
238
235
|
}
|
|
239
236
|
|
|
237
|
+
// ─── Stack Trace Source-Mapping ──────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Phases where the error originated in the RSC environment.
|
|
241
|
+
* These use `server.environments.rsc.moduleGraph` for source-mapping.
|
|
242
|
+
*/
|
|
243
|
+
const RSC_PHASES: ReadonlySet<ErrorPhase> = new Set(['render', 'access', 'middleware', 'handler']);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Rewrite an error's stack trace using the correct Vite environment module graph.
|
|
247
|
+
*
|
|
248
|
+
* `server.ssrFixStacktrace()` hardcodes `server.environments.ssr.moduleGraph`,
|
|
249
|
+
* but RSC errors have stack frames pointing to modules loaded in the RSC
|
|
250
|
+
* environment — a separate Vite module graph with separate transform results
|
|
251
|
+
* and source maps. Using the SSR module graph silently fails to find the
|
|
252
|
+
* modules, leaving transpiled/bundled line numbers in the stack trace.
|
|
253
|
+
*
|
|
254
|
+
* This function picks the RSC module graph for render-phase errors and falls
|
|
255
|
+
* back to SSR for module-transform/proxy errors. If the first pass doesn't
|
|
256
|
+
* rewrite any frames (e.g., mixed RSC+SSR stack), it tries the other graph.
|
|
257
|
+
*/
|
|
258
|
+
function fixStacktraceForEnvironment(server: ViteDevServer, error: Error, phase: ErrorPhase): void {
|
|
259
|
+
if (!error.stack) return;
|
|
260
|
+
|
|
261
|
+
// Pick the primary environment based on error phase
|
|
262
|
+
const primaryEnvName = RSC_PHASES.has(phase) ? 'rsc' : 'ssr';
|
|
263
|
+
const fallbackEnvName = primaryEnvName === 'rsc' ? 'ssr' : 'rsc';
|
|
264
|
+
|
|
265
|
+
const primaryEnv = server.environments[primaryEnvName] as DevEnvironment | undefined;
|
|
266
|
+
const fallbackEnv = server.environments[fallbackEnvName] as DevEnvironment | undefined;
|
|
267
|
+
|
|
268
|
+
// Try primary environment first
|
|
269
|
+
if (primaryEnv?.moduleGraph) {
|
|
270
|
+
const rewritten = rewriteStacktrace(error.stack, primaryEnv.moduleGraph);
|
|
271
|
+
if (rewritten.changed) {
|
|
272
|
+
error.stack = rewritten.stack;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Fall back to the other environment if primary didn't rewrite anything
|
|
278
|
+
if (fallbackEnv?.moduleGraph) {
|
|
279
|
+
const rewritten = rewriteStacktrace(error.stack, fallbackEnv.moduleGraph);
|
|
280
|
+
if (rewritten.changed) {
|
|
281
|
+
error.stack = rewritten.stack;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Rewrite stack trace frames using source maps from an environment's module graph.
|
|
289
|
+
*
|
|
290
|
+
* Mirrors Vite's internal `ssrRewriteStacktrace` logic but works with any
|
|
291
|
+
* `EnvironmentModuleGraph`, not just the SSR one.
|
|
292
|
+
*
|
|
293
|
+
* Returns the rewritten stack and whether any frames were actually changed.
|
|
294
|
+
*/
|
|
295
|
+
function rewriteStacktrace(
|
|
296
|
+
stack: string,
|
|
297
|
+
moduleGraph: DevEnvironment['moduleGraph']
|
|
298
|
+
): { stack: string; changed: boolean } {
|
|
299
|
+
let changed = false;
|
|
300
|
+
|
|
301
|
+
const result = stack
|
|
302
|
+
.split('\n')
|
|
303
|
+
.map((line) => {
|
|
304
|
+
return line.replace(
|
|
305
|
+
/^ {4}at (?:(\S.*?)\s\()?(.+?):(\d+)(?::(\d+))?\)?/,
|
|
306
|
+
(input, varName, id, lineStr, colStr) => {
|
|
307
|
+
if (!id) return input;
|
|
308
|
+
|
|
309
|
+
const mod = moduleGraph.getModuleById(id);
|
|
310
|
+
const rawSourceMap = mod?.transformResult?.map;
|
|
311
|
+
if (!rawSourceMap) return input;
|
|
312
|
+
|
|
313
|
+
// Vite's module runner adds a 2-line offset for the async wrapper.
|
|
314
|
+
// This matches Vite's internal `calculateOffsetOnce()` behavior.
|
|
315
|
+
const OFFSET = 2;
|
|
316
|
+
const origLine = Number(lineStr) - OFFSET;
|
|
317
|
+
const origCol = Number(colStr) - 1;
|
|
318
|
+
if (origLine <= 0 || origCol < 0) return input;
|
|
319
|
+
|
|
320
|
+
// Use @jridgewell/trace-mapping resolved from Vite's dependency tree.
|
|
321
|
+
// Vite bundles it internally; we resolve from Vite's package location
|
|
322
|
+
// to avoid adding a direct dependency.
|
|
323
|
+
let pos: { source: string | null; line: number | null; column: number | null };
|
|
324
|
+
try {
|
|
325
|
+
const { TraceMap: TM, originalPositionFor: opf } = getTraceMapping();
|
|
326
|
+
const traced = new TM(rawSourceMap);
|
|
327
|
+
pos = opf(traced, { line: origLine, column: origCol });
|
|
328
|
+
} catch {
|
|
329
|
+
return input;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!pos.source || pos.line == null) return input;
|
|
333
|
+
|
|
334
|
+
changed = true;
|
|
335
|
+
const source = `${resolve(dirname(id), pos.source)}:${pos.line}:${(pos.column ?? 0) + 1}`;
|
|
336
|
+
const trimmedVarName = varName?.trim();
|
|
337
|
+
if (!trimmedVarName || trimmedVarName === 'eval') return ` at ${source}`;
|
|
338
|
+
return ` at ${trimmedVarName} (${source})`;
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
})
|
|
342
|
+
.join('\n');
|
|
343
|
+
|
|
344
|
+
return { stack: result, changed };
|
|
345
|
+
}
|
|
346
|
+
|
|
240
347
|
// ─── Overlay Integration ────────────────────────────────────────────────────
|
|
241
348
|
|
|
242
349
|
/**
|
|
@@ -259,8 +366,12 @@ export function sendErrorToOverlay(
|
|
|
259
366
|
projectRoot: string,
|
|
260
367
|
rscDebugComponents?: RscDebugComponentInfo[]
|
|
261
368
|
): void {
|
|
262
|
-
// Fix stack trace to use source-mapped positions
|
|
263
|
-
|
|
369
|
+
// Fix stack trace to use source-mapped positions.
|
|
370
|
+
// Use the RSC environment's module graph for render/access/middleware errors
|
|
371
|
+
// (which originate in the RSC environment), and fall back to SSR for others.
|
|
372
|
+
// server.ssrFixStacktrace() is NOT used — it hardcodes the SSR module graph
|
|
373
|
+
// which doesn't contain source maps for RSC-loaded modules.
|
|
374
|
+
fixStacktraceForEnvironment(server, error, phase);
|
|
264
375
|
|
|
265
376
|
// Log to stderr with frame dimming
|
|
266
377
|
const formatted = formatTerminalError(error, phase, projectRoot);
|