@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,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, '&amp;')
136
+ .replace(/</g, '&lt;')
137
+ .replace(/>/g, '&gt;')
138
+ .replace(/"/g, '&quot;');
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 &lt;h1&gt;Hello, timber.js!&lt;/h1&gt;;
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.ssrFixStacktrace` +
5
- * `server.hot.send`) rather than implementing a custom overlay.
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
- // ANSI codes
135
- const RED = '\x1b[31m';
136
- const DIM = '\x1b[2m';
137
- const RESET = '\x1b[0m';
138
- const BOLD = '\x1b[1m';
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
- server.ssrFixStacktrace(error);
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);