@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.
Files changed (58) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-YHRCboUO.js → actions-DLnUaR65.js} +2 -2
  3. package/dist/_chunks/{actions-YHRCboUO.js.map → actions-DLnUaR65.js.map} +1 -1
  4. package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
  5. package/dist/_chunks/{define-cookie-C9pquwOg.js → define-cookie-BowvzoP0.js} +4 -4
  6. package/dist/_chunks/{define-cookie-C9pquwOg.js.map → define-cookie-BowvzoP0.js.map} +1 -1
  7. package/dist/_chunks/{request-context-Dl0hXED3.js → request-context-CK5tZqIP.js} +2 -2
  8. package/dist/_chunks/{request-context-Dl0hXED3.js.map → request-context-CK5tZqIP.js.map} +1 -1
  9. package/dist/client/form.d.ts +4 -1
  10. package/dist/client/form.d.ts.map +1 -1
  11. package/dist/client/index.js +2 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/config-validation.d.ts +51 -0
  14. package/dist/config-validation.d.ts.map +1 -0
  15. package/dist/cookies/index.js +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1168 -51
  18. package/dist/index.js.map +1 -1
  19. package/dist/plugins/dev-404-page.d.ts +56 -0
  20. package/dist/plugins/dev-404-page.d.ts.map +1 -0
  21. package/dist/plugins/dev-error-overlay.d.ts +14 -11
  22. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  23. package/dist/plugins/dev-error-page.d.ts +58 -0
  24. package/dist/plugins/dev-error-page.d.ts.map +1 -0
  25. package/dist/plugins/dev-server.d.ts.map +1 -1
  26. package/dist/plugins/dev-terminal-error.d.ts +28 -0
  27. package/dist/plugins/dev-terminal-error.d.ts.map +1 -0
  28. package/dist/plugins/entries.d.ts.map +1 -1
  29. package/dist/plugins/fonts.d.ts +4 -0
  30. package/dist/plugins/fonts.d.ts.map +1 -1
  31. package/dist/plugins/routing.d.ts.map +1 -1
  32. package/dist/routing/convention-lint.d.ts +41 -0
  33. package/dist/routing/convention-lint.d.ts.map +1 -0
  34. package/dist/server/action-client.d.ts +13 -5
  35. package/dist/server/action-client.d.ts.map +1 -1
  36. package/dist/server/fallback-error.d.ts +9 -5
  37. package/dist/server/fallback-error.d.ts.map +1 -1
  38. package/dist/server/index.js +2 -2
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/internal.js +2 -2
  41. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  42. package/package.json +6 -7
  43. package/src/cli.ts +0 -0
  44. package/src/client/form.tsx +10 -5
  45. package/src/config-validation.ts +299 -0
  46. package/src/index.ts +17 -0
  47. package/src/plugins/dev-404-page.ts +418 -0
  48. package/src/plugins/dev-error-overlay.ts +165 -54
  49. package/src/plugins/dev-error-page.ts +536 -0
  50. package/src/plugins/dev-server.ts +63 -10
  51. package/src/plugins/dev-terminal-error.ts +217 -0
  52. package/src/plugins/entries.ts +3 -0
  53. package/src/plugins/fonts.ts +3 -2
  54. package/src/plugins/routing.ts +37 -5
  55. package/src/routing/convention-lint.ts +356 -0
  56. package/src/server/action-client.ts +17 -9
  57. package/src/server/fallback-error.ts +39 -88
  58. package/src/server/rsc-entry/index.ts +34 -2
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { r as __toESM, t as __commonJSMin } from "./_chunks/chunk-DYhsFzuS.js";
1
+ import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM, r as __exportAll, t as __commonJSMin } from "./_chunks/chunk-BYIpzuS7.js";
2
2
  import { n as setViteServer } from "./_chunks/dev-warnings-DpGRGoDi.js";
3
3
  import { i as scanRoutes, n as generateRouteMap, t as collectInterceptionRewrites } from "./_chunks/interception-Dpn_UfAD.js";
4
4
  import { t as formatSize } from "./_chunks/format-CYBGxKtc.js";
@@ -6,9 +6,9 @@ import { dirname, extname, join, normalize, resolve } from "node:path";
6
6
  import { createRequire } from "node:module";
7
7
  import react, { reactCompilerPreset } from "@vitejs/plugin-react";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
10
  import { constants, createGzip, gzipSync } from "node:zlib";
10
11
  import { Readable } from "node:stream";
11
- import { fileURLToPath } from "node:url";
12
12
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
13
13
  import { createHash, randomUUID } from "node:crypto";
14
14
  import { performance as performance$1 } from "node:perf_hooks";
@@ -98,9 +98,169 @@ function timberContent(ctx) {
98
98
  };
99
99
  }
100
100
  //#endregion
101
+ //#region src/plugins/dev-terminal-error.ts
102
+ /**
103
+ * Terminal error formatting — boxed, color-coded error output for dev mode.
104
+ *
105
+ * Produces a visually scannable error block with:
106
+ * - Unicode box-drawing border around the error
107
+ * - Phase badge and error message
108
+ * - First app frame highlighted as the primary action item
109
+ * - OSC 8 clickable file:line links (VSCode terminal, iTerm2, etc.)
110
+ * - Internal/framework frames collapsed with a count
111
+ * - Component stack (for React render errors)
112
+ *
113
+ * Dev-only: this module is only imported by dev-error-overlay.ts.
114
+ *
115
+ * Design doc: 21-dev-server.md §"Error Overlay"
116
+ */
117
+ var RED$2 = "\x1B[31m";
118
+ var CYAN = "\x1B[36m";
119
+ var DIM$2 = "\x1B[2m";
120
+ var RESET$2 = "\x1B[0m";
121
+ var BOLD$2 = "\x1B[1m";
122
+ var UNDERLINE = "\x1B[4m";
123
+ /**
124
+ * Wrap text in an OSC 8 hyperlink escape sequence.
125
+ *
126
+ * Terminals that support OSC 8 (VSCode, iTerm2, Windows Terminal, etc.)
127
+ * render this as a clickable link. Others ignore the escape sequences
128
+ * and show the text normally.
129
+ *
130
+ * Format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07
131
+ */
132
+ function hyperlink(text, url) {
133
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
134
+ }
135
+ /**
136
+ * Format a file:line:col reference as a clickable terminal link.
137
+ *
138
+ * Uses file:// URLs so terminals open the file in the configured editor.
139
+ * The link text is styled with cyan + underline for visibility.
140
+ */
141
+ function fileLink(filePath, line, col) {
142
+ const display = line ? `${filePath}:${line}${col ? `:${col}` : ""}` : filePath;
143
+ const base = pathToFileURL(filePath).href;
144
+ return `${CYAN}${UNDERLINE}${hyperlink(display, line ? `${base}:${line}${col ? `:${col}` : ""}` : base)}${RESET$2}`;
145
+ }
146
+ var BOX = {
147
+ topLeft: "╭",
148
+ topRight: "╮",
149
+ bottomLeft: "╰",
150
+ bottomRight: "╯",
151
+ horizontal: "─",
152
+ vertical: "│"
153
+ };
154
+ /**
155
+ * Wrap lines of text in a Unicode box with a colored left border.
156
+ *
157
+ * @param lines - Content lines (no ANSI length calculation — keeps it simple)
158
+ * @param width - Box width (characters). Lines longer than this are not truncated.
159
+ */
160
+ function box(lines, borderColor, width = 80) {
161
+ const bar = BOX.horizontal.repeat(width - 2);
162
+ const output = [];
163
+ output.push(`${borderColor}${BOX.topLeft}${bar}${BOX.topRight}${RESET$2}`);
164
+ for (const line of lines) output.push(`${borderColor}${BOX.vertical}${RESET$2} ${line}`);
165
+ output.push(`${borderColor}${BOX.bottomLeft}${bar}${BOX.bottomRight}${RESET$2}`);
166
+ return output.join("\n");
167
+ }
168
+ /** Parse file/line/col from a stack frame line. */
169
+ function parseFrame(frameLine) {
170
+ const parenMatch = /\(([^)]+):(\d+):(\d+)\)/.exec(frameLine);
171
+ if (parenMatch) return {
172
+ file: parenMatch[1],
173
+ line: Number(parenMatch[2]),
174
+ col: Number(parenMatch[3])
175
+ };
176
+ const bareMatch = /at (\/[^:]+):(\d+):(\d+)/.exec(frameLine);
177
+ if (bareMatch) return {
178
+ file: bareMatch[1],
179
+ line: Number(bareMatch[2]),
180
+ col: Number(bareMatch[3])
181
+ };
182
+ return {};
183
+ }
184
+ function classifyFrames(stack, projectRoot) {
185
+ return stack.split("\n").slice(1).filter((l) => l.trim().startsWith("at ")).map((raw) => {
186
+ const type = classifyFrame(raw, projectRoot);
187
+ const { file, line, col } = parseFrame(raw);
188
+ return {
189
+ raw,
190
+ type,
191
+ file,
192
+ line,
193
+ col
194
+ };
195
+ });
196
+ }
197
+ /**
198
+ * Format an error for terminal output with a boxed layout.
199
+ *
200
+ * The output is designed to be scannable at a glance:
201
+ * 1. Red box with phase badge and error message
202
+ * 2. First app frame as a clickable link (the primary action item)
203
+ * 3. App frames listed normally
204
+ * 4. Internal/framework frames collapsed with count
205
+ * 5. Component stack (if present)
206
+ */
207
+ function formatTerminalError$1(error, phase, projectRoot) {
208
+ const sections = [];
209
+ const componentStack = extractComponentStack(error);
210
+ const loc = parseFirstAppFrame(error.stack ?? "", projectRoot);
211
+ const frames = error.stack ? classifyFrames(error.stack, projectRoot) : [];
212
+ const appFrames = frames.filter((f) => f.type === "app");
213
+ const internalCount = frames.filter((f) => f.type !== "app").length;
214
+ const boxLines = [];
215
+ boxLines.push(`${RED$2}${BOLD$2}${PHASE_LABELS$1[phase]} Error${RESET$2}`);
216
+ boxLines.push("");
217
+ for (const msgLine of error.message.split("\n")) boxLines.push(`${RED$2}${msgLine}${RESET$2}`);
218
+ if (loc) {
219
+ boxLines.push("");
220
+ const relPath = loc.file.startsWith(projectRoot) ? loc.file.slice(projectRoot.length + 1) : loc.file;
221
+ boxLines.push(`${BOLD$2}→${RESET$2} ${fileLink(loc.file, loc.line, loc.column)} ${DIM$2}(${relPath})${RESET$2}`);
222
+ }
223
+ sections.push(box(boxLines, RED$2));
224
+ if (componentStack) {
225
+ sections.push("");
226
+ sections.push(` ${BOLD$2}Component Stack:${RESET$2}`);
227
+ for (const csLine of componentStack.trim().split("\n")) sections.push(` ${DIM$2}${csLine.trim()}${RESET$2}`);
228
+ }
229
+ if (appFrames.length > 0) {
230
+ sections.push("");
231
+ sections.push(` ${BOLD$2}Application Frames:${RESET$2}`);
232
+ for (let i = 0; i < appFrames.length; i++) {
233
+ const f = appFrames[i];
234
+ if (f.file && f.line) {
235
+ const prefix = i === 0 ? `${BOLD$2}▸${RESET$2}` : " ";
236
+ sections.push(` ${prefix} ${fileLink(f.file, f.line, f.col)} ${DIM$2}${extractFnName(f.raw)}${RESET$2}`);
237
+ } else sections.push(` ${f.raw}`);
238
+ }
239
+ }
240
+ if (internalCount > 0) sections.push(` ${DIM$2}… ${internalCount} internal frame${internalCount !== 1 ? "s" : ""} hidden${RESET$2}`);
241
+ sections.push("");
242
+ return sections.join("\n");
243
+ }
244
+ /** Extract the function name from a stack frame line like " at fnName (/path:1:2)". */
245
+ function extractFnName(frameLine) {
246
+ const match = /at\s+(\S+)\s+\(/.exec(frameLine.trim());
247
+ return match ? match[1] : "";
248
+ }
249
+ //#endregion
101
250
  //#region src/plugins/dev-error-overlay.ts
251
+ var _traceMapping = null;
252
+ /**
253
+ * Lazy-load @jridgewell/trace-mapping from Vite's dependency tree.
254
+ * Vite bundles it internally; we resolve from Vite's package to avoid
255
+ * adding a direct dependency.
256
+ */
257
+ function getTraceMapping() {
258
+ if (_traceMapping) return _traceMapping;
259
+ _traceMapping = createRequire(createRequire(import.meta.url).resolve("vite"))("@jridgewell/trace-mapping");
260
+ return _traceMapping;
261
+ }
102
262
  /** Labels for terminal output. */
103
- var PHASE_LABELS = {
263
+ var PHASE_LABELS$1 = {
104
264
  "module-transform": "Module Transform",
105
265
  "proxy": "Proxy",
106
266
  "middleware": "Middleware",
@@ -167,37 +327,7 @@ function classifyErrorPhase(error, projectRoot) {
167
327
  }
168
328
  return "render";
169
329
  }
170
- var RED = "\x1B[31m";
171
- var DIM = "\x1B[2m";
172
- var RESET = "\x1B[0m";
173
- var BOLD = "\x1B[1m";
174
- /**
175
- * Format an error for terminal output.
176
- *
177
- * - Red for the error message and phase label
178
- * - Dim for framework-internal frames
179
- * - Normal for application frames
180
- * - Separate section for component stack (if present)
181
- */
182
- function formatTerminalError(error, phase, projectRoot) {
183
- const lines = [];
184
- lines.push(`${RED}${BOLD}[timber] ${PHASE_LABELS[phase]} Error${RESET}`);
185
- lines.push(`${RED}${error.message}${RESET}`);
186
- lines.push("");
187
- const componentStack = extractComponentStack(error);
188
- if (componentStack) {
189
- lines.push(`${BOLD}Component Stack:${RESET}`);
190
- for (const csLine of componentStack.trim().split("\n")) lines.push(` ${csLine.trim()}`);
191
- lines.push("");
192
- }
193
- if (error.stack) {
194
- lines.push(`${BOLD}Stack Trace:${RESET}`);
195
- const stackLines = error.stack.split("\n").slice(1);
196
- for (const stackLine of stackLines) if (classifyFrame(stackLine, projectRoot) === "app") lines.push(stackLine);
197
- else lines.push(`${DIM}${stackLine}${RESET}`);
198
- }
199
- return lines.join("\n");
200
- }
330
+ var formatTerminalError = formatTerminalError$1;
201
331
  /**
202
332
  * Format RSC debug component info into a readable string for the overlay.
203
333
  *
@@ -231,6 +361,90 @@ function formatRscDebugContext(components) {
231
361
  return lines.join("\n");
232
362
  }
233
363
  /**
364
+ * Phases where the error originated in the RSC environment.
365
+ * These use `server.environments.rsc.moduleGraph` for source-mapping.
366
+ */
367
+ var RSC_PHASES = new Set([
368
+ "render",
369
+ "access",
370
+ "middleware",
371
+ "handler"
372
+ ]);
373
+ /**
374
+ * Rewrite an error's stack trace using the correct Vite environment module graph.
375
+ *
376
+ * `server.ssrFixStacktrace()` hardcodes `server.environments.ssr.moduleGraph`,
377
+ * but RSC errors have stack frames pointing to modules loaded in the RSC
378
+ * environment — a separate Vite module graph with separate transform results
379
+ * and source maps. Using the SSR module graph silently fails to find the
380
+ * modules, leaving transpiled/bundled line numbers in the stack trace.
381
+ *
382
+ * This function picks the RSC module graph for render-phase errors and falls
383
+ * back to SSR for module-transform/proxy errors. If the first pass doesn't
384
+ * rewrite any frames (e.g., mixed RSC+SSR stack), it tries the other graph.
385
+ */
386
+ function fixStacktraceForEnvironment(server, error, phase) {
387
+ if (!error.stack) return;
388
+ const primaryEnvName = RSC_PHASES.has(phase) ? "rsc" : "ssr";
389
+ const fallbackEnvName = primaryEnvName === "rsc" ? "ssr" : "rsc";
390
+ const primaryEnv = server.environments[primaryEnvName];
391
+ const fallbackEnv = server.environments[fallbackEnvName];
392
+ if (primaryEnv?.moduleGraph) {
393
+ const rewritten = rewriteStacktrace(error.stack, primaryEnv.moduleGraph);
394
+ if (rewritten.changed) {
395
+ error.stack = rewritten.stack;
396
+ return;
397
+ }
398
+ }
399
+ if (fallbackEnv?.moduleGraph) {
400
+ const rewritten = rewriteStacktrace(error.stack, fallbackEnv.moduleGraph);
401
+ if (rewritten.changed) {
402
+ error.stack = rewritten.stack;
403
+ return;
404
+ }
405
+ }
406
+ }
407
+ /**
408
+ * Rewrite stack trace frames using source maps from an environment's module graph.
409
+ *
410
+ * Mirrors Vite's internal `ssrRewriteStacktrace` logic but works with any
411
+ * `EnvironmentModuleGraph`, not just the SSR one.
412
+ *
413
+ * Returns the rewritten stack and whether any frames were actually changed.
414
+ */
415
+ function rewriteStacktrace(stack, moduleGraph) {
416
+ let changed = false;
417
+ return {
418
+ stack: stack.split("\n").map((line) => {
419
+ return line.replace(/^ {4}at (?:(\S.*?)\s\()?(.+?):(\d+)(?::(\d+))?\)?/, (input, varName, id, lineStr, colStr) => {
420
+ if (!id) return input;
421
+ const rawSourceMap = moduleGraph.getModuleById(id)?.transformResult?.map;
422
+ if (!rawSourceMap) return input;
423
+ const origLine = Number(lineStr) - 2;
424
+ const origCol = Number(colStr) - 1;
425
+ if (origLine <= 0 || origCol < 0) return input;
426
+ let pos;
427
+ try {
428
+ const { TraceMap: TM, originalPositionFor: opf } = getTraceMapping();
429
+ pos = opf(new TM(rawSourceMap), {
430
+ line: origLine,
431
+ column: origCol
432
+ });
433
+ } catch {
434
+ return input;
435
+ }
436
+ if (!pos.source || pos.line == null) return input;
437
+ changed = true;
438
+ const source = `${resolve(dirname(id), pos.source)}:${pos.line}:${(pos.column ?? 0) + 1}`;
439
+ const trimmedVarName = varName?.trim();
440
+ if (!trimmedVarName || trimmedVarName === "eval") return ` at ${source}`;
441
+ return ` at ${trimmedVarName} (${source})`;
442
+ });
443
+ }).join("\n"),
444
+ changed
445
+ };
446
+ }
447
+ /**
234
448
  * Send an error to Vite's browser overlay and log it to stderr.
235
449
  *
236
450
  * Uses `server.ssrFixStacktrace()` to map stack traces back to source,
@@ -244,7 +458,7 @@ function formatRscDebugContext(components) {
244
458
  * The dev server remains running — errors are handled, not fatal.
245
459
  */
246
460
  function sendErrorToOverlay(server, error, phase, projectRoot, rscDebugComponents) {
247
- server.ssrFixStacktrace(error);
461
+ fixStacktraceForEnvironment(server, error, phase);
248
462
  const formatted = formatTerminalError(error, phase, projectRoot);
249
463
  process.stderr.write(`${formatted}\n`);
250
464
  const loc = parseFirstAppFrame(error.stack ?? "", projectRoot);
@@ -260,7 +474,7 @@ function sendErrorToOverlay(server, error, phase, projectRoot, rscDebugComponent
260
474
  message,
261
475
  stack: error.stack ?? "",
262
476
  id: loc?.file,
263
- plugin: `timber (${PHASE_LABELS[phase]})`,
477
+ plugin: `timber (${PHASE_LABELS$1[phase]})`,
264
478
  loc: loc ? {
265
479
  file: loc.file,
266
480
  line: loc.line,
@@ -271,7 +485,631 @@ function sendErrorToOverlay(server, error, phase, projectRoot, rscDebugComponent
271
485
  } catch {}
272
486
  }
273
487
  //#endregion
488
+ //#region src/plugins/dev-error-page.ts
489
+ /**
490
+ * Dev error page — self-contained HTML error page for dev server 500s.
491
+ *
492
+ * Generates a styled, self-contained HTML page when the RSC pipeline fails
493
+ * and the Vite error overlay can't fire (e.g., first page load before HMR
494
+ * WebSocket connects, RSC entry module crash, early pipeline errors).
495
+ *
496
+ * This is NOT a replacement for Vite's error overlay — it's the fallback
497
+ * for when the overlay's transport (WebSocket) isn't available yet.
498
+ *
499
+ * Dev-only: this module is only imported by dev-server.ts (apply: 'serve').
500
+ * It is never included in production builds.
501
+ *
502
+ * Design doc: 21-dev-server.md §"Error Overlay"
503
+ */
504
+ var PHASE_LABELS = {
505
+ "module-transform": "Module Transform",
506
+ "proxy": "Proxy",
507
+ "middleware": "Middleware",
508
+ "access": "Access Check",
509
+ "render": "RSC Render",
510
+ "handler": "Route Handler"
511
+ };
512
+ var PHASE_HINTS = {
513
+ "module-transform": "This error occurred while Vite was transforming a module. Check for syntax errors or missing imports.",
514
+ "proxy": "This error occurred in proxy.ts. Check your proxy configuration.",
515
+ "middleware": "This error occurred in a middleware.ts file. Check the middleware function for unhandled exceptions.",
516
+ "access": "This error occurred in an access.ts file. Check your access control logic.",
517
+ "render": "This error occurred while rendering a server component. Check the component for runtime errors.",
518
+ "handler": "This error occurred in a route handler (route.ts). Check the handler function."
519
+ };
520
+ function classifyStack(stack, projectRoot) {
521
+ return stack.split("\n").slice(1).filter((line) => line.trim().startsWith("at ")).map((line) => ({
522
+ raw: line,
523
+ type: classifyFrame(line, projectRoot)
524
+ }));
525
+ }
526
+ /**
527
+ * Try to read a few lines of source code around the error location.
528
+ * Returns null if the file can't be read (e.g., virtual modules).
529
+ */
530
+ function readSourceContext(filePath, line, contextLines = 3) {
531
+ try {
532
+ const { readFileSync } = __require("node:fs");
533
+ const allLines = readFileSync(filePath, "utf-8").split("\n");
534
+ const start = Math.max(0, line - 1 - contextLines);
535
+ const end = Math.min(allLines.length, line + contextLines);
536
+ return {
537
+ startLine: start + 1,
538
+ lines: allLines.slice(start, end).map((text, i) => ({
539
+ num: start + 1 + i,
540
+ text,
541
+ highlight: start + 1 + i === line
542
+ }))
543
+ };
544
+ } catch {
545
+ return null;
546
+ }
547
+ }
548
+ function esc(str) {
549
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
550
+ }
551
+ /**
552
+ * Escape a JSON string for safe embedding in an HTML `<script>` block.
553
+ *
554
+ * `JSON.stringify` does not escape `<\/script>`, so error text containing
555
+ * that sequence breaks out of the script block — an XSS vector.
556
+ * We replace all `<` with `\u003c` which is valid in JSON strings and
557
+ * prevents the HTML parser from seeing a closing `<\/script>` tag.
558
+ *
559
+ * Security: TIM-788
560
+ */
561
+ function escJsonForScript(json) {
562
+ return json.replace(/</g, "\\u003c");
563
+ }
564
+ /**
565
+ * Build a WebSocket URL from Vite HMR options.
566
+ * Mirrors the logic in Vite's client.mjs for constructing the WS connection URL.
567
+ */
568
+ function buildWsUrl(opts) {
569
+ return `${opts.protocol ?? "ws"}://${opts.host ?? "localhost"}${opts.port ? `:${opts.port}` : ""}${opts.path ?? ""}${opts.token ? `?token=${opts.token}` : ""}`;
570
+ }
571
+ /**
572
+ * Extract HMR connection options from a Vite resolved config.
573
+ * Used by the dev server to pass HMR config to the error page generator
574
+ * so the auto-reload WebSocket connects to the correct endpoint.
575
+ */
576
+ function extractHmrOptions(config) {
577
+ const hmr = config.server?.hmr;
578
+ if (hmr === false) return void 0;
579
+ const hmrOpts = typeof hmr === "object" ? hmr : {};
580
+ const opts = {
581
+ protocol: hmrOpts.protocol,
582
+ host: hmrOpts.host,
583
+ port: hmrOpts.port,
584
+ path: hmrOpts.path,
585
+ token: config.webSocketToken
586
+ };
587
+ if (!opts.protocol && !opts.host && !opts.port && !opts.path && !opts.token) return;
588
+ return opts;
589
+ }
590
+ /**
591
+ * Generate a self-contained HTML error page for dev server 500 responses.
592
+ *
593
+ * The page includes:
594
+ * - Error message and phase label
595
+ * - Source code context around the first app frame (if readable)
596
+ * - Component stack (for React render errors)
597
+ * - Classified stack trace (app frames highlighted, internals collapsed)
598
+ * - Copy button for the full error
599
+ * - Dark/light mode via prefers-color-scheme
600
+ * - Auto-reconnect script that watches for Vite HMR and reloads
601
+ */
602
+ function generateDevErrorPage(error, phase, projectRoot, hmrOptions) {
603
+ const message = error.message || "Unknown error";
604
+ const phaseLabel = PHASE_LABELS[phase];
605
+ const phaseHint = PHASE_HINTS[phase];
606
+ const componentStack = extractComponentStack(error);
607
+ const loc = parseFirstAppFrame(error.stack ?? "", projectRoot);
608
+ const frames = error.stack ? classifyStack(error.stack, projectRoot) : [];
609
+ const appFrames = frames.filter((f) => f.type === "app");
610
+ const internalFrameCount = frames.filter((f) => f.type !== "app").length;
611
+ let sourceContext = null;
612
+ if (loc) sourceContext = readSourceContext(loc.file, loc.line);
613
+ const relPath = loc?.file.startsWith(projectRoot) ? loc.file.slice(projectRoot.length + 1) : loc?.file;
614
+ return `<!DOCTYPE html>
615
+ <html lang="en">
616
+ <head>
617
+ <meta charset="utf-8">
618
+ <meta name="viewport" content="width=device-width, initial-scale=1">
619
+ <title>Error — ${esc(phaseLabel)} | timber.js</title>
620
+ <style>${CSS}</style>
621
+ </head>
622
+ <body>
623
+ <div class="container">
624
+ <header class="header">
625
+ <div class="badge">${esc(phaseLabel)} Error</div>
626
+ <h1 class="message">${esc(message)}</h1>
627
+ ${phaseHint ? `<p class="hint">${esc(phaseHint)}</p>` : ""}
628
+ </header>
629
+
630
+ ${relPath ? `<div class="location">${esc(relPath)}${loc ? `:${loc.line}:${loc.column}` : ""}</div>` : ""}
631
+
632
+ ${sourceContext ? `<div class="source-context">
633
+ <div class="source-header">Source</div>
634
+ <pre class="source-code"><code>${sourceContext.lines.map((l) => `<span class="source-line${l.highlight ? " source-line-highlight" : ""}"><span class="line-num">${l.num}</span>${esc(l.text)}</span>`).join("\n")}</code></pre>
635
+ </div>` : ""}
636
+
637
+ ${componentStack ? `<div class="section">
638
+ <div class="section-header">Component Stack</div>
639
+ <pre class="component-stack">${esc(componentStack.trim())}</pre>
640
+ </div>` : ""}
641
+
642
+ ${appFrames.length > 0 ? `<div class="section">
643
+ <div class="section-header">Application Frames</div>
644
+ <pre class="stack">${appFrames.map((f) => esc(f.raw)).join("\n")}</pre>
645
+ </div>` : ""}
646
+
647
+ ${internalFrameCount > 0 ? `<details class="section internal-frames">
648
+ <summary class="section-header clickable">${internalFrameCount} internal frame${internalFrameCount !== 1 ? "s" : ""}</summary>
649
+ <pre class="stack dimmed">${frames.filter((f) => f.type !== "app").map((f) => esc(f.raw)).join("\n")}</pre>
650
+ </details>` : ""}
651
+
652
+ <div class="actions">
653
+ <button class="btn" onclick="copyError()">Copy Error</button>
654
+ </div>
655
+
656
+ <footer class="footer">
657
+ <span class="timber-logo">🪵 timber.js</span>
658
+ <span class="footer-hint">Fix the error and save — the page will reload automatically via HMR.</span>
659
+ </footer>
660
+ </div>
661
+
662
+ <script>
663
+ function copyError() {
664
+ var text = ${escJsonForScript(JSON.stringify(`${phaseLabel} Error: ${message}\n\n` + (relPath ? `File: ${relPath}${loc ? `:${loc.line}:${loc.column}` : ""}\n\n` : "") + (componentStack ? `Component Stack:\n${componentStack.trim()}\n\n` : "") + `Stack Trace:\n${error.stack ?? ""}`))};
665
+ navigator.clipboard.writeText(text).then(function() {
666
+ var btn = document.querySelector('.btn');
667
+ if (btn) { btn.textContent = 'Copied!'; setTimeout(function() { btn.textContent = 'Copy Error'; }, 1500); }
668
+ });
669
+ }
670
+
671
+ // Auto-reload when Vite HMR reconnects.
672
+ // When the developer fixes the error and saves, Vite's module graph
673
+ // invalidates. We can't rely on the normal HMR update flow (the page
674
+ // is a static error page, not a Vite-managed module), so we connect
675
+ // to Vite's WebSocket and reload on any update event.
676
+ (function() {
677
+ try {
678
+ var wsUrl = ${hmrOptions ? escJsonForScript(JSON.stringify(buildWsUrl(hmrOptions))) : "'ws://' + location.host"};
679
+ var ws = new WebSocket(wsUrl, 'vite-hmr');
680
+ ws.addEventListener('message', function(e) {
681
+ try {
682
+ var data = JSON.parse(e.data);
683
+ if (data.type === 'full-reload' || data.type === 'update' || data.type === 'connected') {
684
+ if (data.type !== 'connected') location.reload();
685
+ }
686
+ } catch(ex) {}
687
+ });
688
+ } catch(ex) {}
689
+ })();
690
+ <\/script>
691
+ </body>
692
+ </html>`;
693
+ }
694
+ var CSS = `
695
+ :root {
696
+ --bg: #fff;
697
+ --fg: #1a1a1a;
698
+ --fg-dim: #6b7280;
699
+ --border: #e5e7eb;
700
+ --badge-bg: #fef2f2;
701
+ --badge-fg: #991b1b;
702
+ --badge-border: #fecaca;
703
+ --source-bg: #fafafa;
704
+ --highlight-bg: #fef2f2;
705
+ --highlight-border: #ef4444;
706
+ --line-num: #9ca3af;
707
+ --btn-bg: #f3f4f6;
708
+ --btn-fg: #374151;
709
+ --btn-border: #d1d5db;
710
+ --link: #2563eb;
711
+ --code-font: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
712
+ }
713
+
714
+ @media (prefers-color-scheme: dark) {
715
+ :root {
716
+ --bg: #0a0a0a;
717
+ --fg: #f5f5f5;
718
+ --fg-dim: #9ca3af;
719
+ --border: #27272a;
720
+ --badge-bg: #450a0a;
721
+ --badge-fg: #fca5a5;
722
+ --badge-border: #7f1d1d;
723
+ --source-bg: #18181b;
724
+ --highlight-bg: #450a0a;
725
+ --highlight-border: #dc2626;
726
+ --line-num: #6b7280;
727
+ --btn-bg: #27272a;
728
+ --btn-fg: #e5e7eb;
729
+ --btn-border: #3f3f46;
730
+ --link: #60a5fa;
731
+ }
732
+ }
733
+
734
+ * { margin: 0; padding: 0; box-sizing: border-box; }
735
+
736
+ body {
737
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
738
+ background: var(--bg);
739
+ color: var(--fg);
740
+ line-height: 1.6;
741
+ padding: 2rem;
742
+ }
743
+
744
+ .container {
745
+ max-width: 56rem;
746
+ margin: 0 auto;
747
+ }
748
+
749
+ .header { margin-bottom: 1.5rem; }
750
+
751
+ .badge {
752
+ display: inline-block;
753
+ font-size: 0.75rem;
754
+ font-weight: 600;
755
+ text-transform: uppercase;
756
+ letter-spacing: 0.05em;
757
+ padding: 0.25rem 0.625rem;
758
+ border-radius: 0.375rem;
759
+ background: var(--badge-bg);
760
+ color: var(--badge-fg);
761
+ border: 1px solid var(--badge-border);
762
+ margin-bottom: 0.75rem;
763
+ }
764
+
765
+ .message {
766
+ font-size: 1.375rem;
767
+ font-weight: 600;
768
+ line-height: 1.3;
769
+ word-break: break-word;
770
+ }
771
+
772
+ .hint {
773
+ margin-top: 0.5rem;
774
+ color: var(--fg-dim);
775
+ font-size: 0.875rem;
776
+ }
777
+
778
+ .location {
779
+ font-family: var(--code-font);
780
+ font-size: 0.8125rem;
781
+ color: var(--link);
782
+ margin-bottom: 1rem;
783
+ padding: 0.5rem 0.75rem;
784
+ background: var(--source-bg);
785
+ border-radius: 0.375rem;
786
+ border: 1px solid var(--border);
787
+ }
788
+
789
+ .source-context {
790
+ margin-bottom: 1rem;
791
+ border: 1px solid var(--border);
792
+ border-radius: 0.5rem;
793
+ overflow: hidden;
794
+ }
795
+
796
+ .source-header, .section-header {
797
+ font-size: 0.75rem;
798
+ font-weight: 600;
799
+ text-transform: uppercase;
800
+ letter-spacing: 0.05em;
801
+ padding: 0.5rem 0.75rem;
802
+ background: var(--source-bg);
803
+ border-bottom: 1px solid var(--border);
804
+ color: var(--fg-dim);
805
+ }
806
+
807
+ .source-code {
808
+ overflow-x: auto;
809
+ font-family: var(--code-font);
810
+ font-size: 0.8125rem;
811
+ line-height: 1.7;
812
+ padding: 0;
813
+ margin: 0;
814
+ }
815
+
816
+ .source-code code {
817
+ display: block;
818
+ }
819
+
820
+ .source-line {
821
+ display: block;
822
+ padding: 0 0.75rem;
823
+ border-left: 3px solid transparent;
824
+ }
825
+
826
+ .source-line-highlight {
827
+ background: var(--highlight-bg);
828
+ border-left-color: var(--highlight-border);
829
+ }
830
+
831
+ .line-num {
832
+ display: inline-block;
833
+ width: 3rem;
834
+ text-align: right;
835
+ margin-right: 1rem;
836
+ color: var(--line-num);
837
+ user-select: none;
838
+ }
839
+
840
+ .section {
841
+ margin-bottom: 1rem;
842
+ border: 1px solid var(--border);
843
+ border-radius: 0.5rem;
844
+ overflow: hidden;
845
+ }
846
+
847
+ .clickable { cursor: pointer; }
848
+ .clickable:hover { background: var(--btn-bg); }
849
+
850
+ .component-stack, .stack {
851
+ font-family: var(--code-font);
852
+ font-size: 0.8125rem;
853
+ line-height: 1.7;
854
+ padding: 0.75rem;
855
+ overflow-x: auto;
856
+ white-space: pre;
857
+ margin: 0;
858
+ }
859
+
860
+ .dimmed { color: var(--fg-dim); }
861
+
862
+ .actions {
863
+ margin: 1.5rem 0;
864
+ }
865
+
866
+ .btn {
867
+ font-family: inherit;
868
+ font-size: 0.8125rem;
869
+ font-weight: 500;
870
+ padding: 0.5rem 1rem;
871
+ border-radius: 0.375rem;
872
+ border: 1px solid var(--btn-border);
873
+ background: var(--btn-bg);
874
+ color: var(--btn-fg);
875
+ cursor: pointer;
876
+ transition: background 0.15s;
877
+ }
878
+
879
+ .btn:hover { opacity: 0.85; }
880
+
881
+ .footer {
882
+ display: flex;
883
+ align-items: center;
884
+ gap: 0.75rem;
885
+ padding-top: 1rem;
886
+ border-top: 1px solid var(--border);
887
+ font-size: 0.75rem;
888
+ color: var(--fg-dim);
889
+ }
890
+
891
+ .timber-logo {
892
+ font-weight: 600;
893
+ white-space: nowrap;
894
+ }
895
+ `;
896
+ //#endregion
897
+ //#region src/config-validation.ts
898
+ var config_validation_exports = /* @__PURE__ */ __exportAll({
899
+ addVirtualModuleContext: () => addVirtualModuleContext,
900
+ checkPeerDependencies: () => checkPeerDependencies,
901
+ formatConfigErrors: () => formatConfigErrors,
902
+ formatMissingPeers: () => formatMissingPeers,
903
+ validateConfig: () => validateConfig
904
+ });
905
+ /**
906
+ * Validate a TimberUserConfig object.
907
+ *
908
+ * Returns an array of errors. Empty array means the config is valid.
909
+ * Does not throw — the caller decides how to surface errors.
910
+ */
911
+ function validateConfig(config) {
912
+ const errors = [];
913
+ if (config.output !== void 0 && config.output !== "server" && config.output !== "static") errors.push({
914
+ field: "output",
915
+ message: `Invalid output mode: "${String(config.output)}". Must be "server" or "static".`,
916
+ value: config.output,
917
+ suggestion: "Use output: \"server\" (default) or output: \"static\" for static site generation."
918
+ });
919
+ if (config.pageExtensions !== void 0) if (!Array.isArray(config.pageExtensions)) errors.push({
920
+ field: "pageExtensions",
921
+ message: "pageExtensions must be an array of strings.",
922
+ value: config.pageExtensions,
923
+ suggestion: "Example: pageExtensions: [\"tsx\", \"ts\", \"jsx\", \"js\", \"mdx\"]"
924
+ });
925
+ else for (const ext of config.pageExtensions) {
926
+ if (typeof ext !== "string") {
927
+ errors.push({
928
+ field: "pageExtensions",
929
+ message: `pageExtensions contains a non-string value: ${JSON.stringify(ext)}`,
930
+ value: ext
931
+ });
932
+ break;
933
+ }
934
+ if (ext.startsWith(".")) {
935
+ errors.push({
936
+ field: "pageExtensions",
937
+ message: `pageExtensions should not include the leading dot: "${ext}"`,
938
+ value: ext,
939
+ suggestion: `Use "${ext.slice(1)}" instead of "${ext}".`
940
+ });
941
+ break;
942
+ }
943
+ }
944
+ if (config.slowRequestMs !== void 0) {
945
+ if (typeof config.slowRequestMs !== "number" || config.slowRequestMs < 0) errors.push({
946
+ field: "slowRequestMs",
947
+ message: `slowRequestMs must be a non-negative number (got ${JSON.stringify(config.slowRequestMs)}).`,
948
+ value: config.slowRequestMs,
949
+ suggestion: "Use slowRequestMs: 3000 (default) or slowRequestMs: 0 to disable."
950
+ });
951
+ }
952
+ if (config.renderTimeoutMs !== void 0) {
953
+ if (typeof config.renderTimeoutMs !== "number" || config.renderTimeoutMs < 0) errors.push({
954
+ field: "renderTimeoutMs",
955
+ message: `renderTimeoutMs must be a non-negative number (got ${JSON.stringify(config.renderTimeoutMs)}).`,
956
+ value: config.renderTimeoutMs,
957
+ suggestion: "Use renderTimeoutMs: 30000 (default) or renderTimeoutMs: 0 to disable."
958
+ });
959
+ }
960
+ if (config.serverTiming !== void 0) {
961
+ if (config.serverTiming !== "detailed" && config.serverTiming !== "total" && config.serverTiming !== false) errors.push({
962
+ field: "serverTiming",
963
+ message: `Invalid serverTiming value: ${JSON.stringify(config.serverTiming)}.`,
964
+ value: config.serverTiming,
965
+ suggestion: "Use \"detailed\", \"total\", or false."
966
+ });
967
+ }
968
+ if (config.devBrowserLogs !== void 0) {
969
+ const valid = [
970
+ "error",
971
+ "warn",
972
+ "info",
973
+ "none"
974
+ ];
975
+ if (!valid.includes(config.devBrowserLogs)) errors.push({
976
+ field: "devBrowserLogs",
977
+ message: `Invalid devBrowserLogs value: ${JSON.stringify(config.devBrowserLogs)}.`,
978
+ value: config.devBrowserLogs,
979
+ suggestion: `Use one of: ${valid.map((v) => `"${v}"`).join(", ")}.`
980
+ });
981
+ }
982
+ if (config.sitemap != null && typeof config.sitemap === "object") {
983
+ if (config.sitemap.enabled && !config.sitemap.baseUrl) errors.push({
984
+ field: "sitemap.baseUrl",
985
+ message: "sitemap.baseUrl is required when sitemap is enabled.",
986
+ suggestion: "Add sitemap: { enabled: true, baseUrl: \"https://example.com\" }."
987
+ });
988
+ if (config.sitemap.defaultPriority !== void 0 && (config.sitemap.defaultPriority < 0 || config.sitemap.defaultPriority > 1)) errors.push({
989
+ field: "sitemap.defaultPriority",
990
+ message: `sitemap.defaultPriority must be between 0.0 and 1.0 (got ${config.sitemap.defaultPriority}).`,
991
+ value: config.sitemap.defaultPriority
992
+ });
993
+ }
994
+ const knownKeys = new Set([
995
+ "output",
996
+ "debug",
997
+ "clientJavascript",
998
+ "adapter",
999
+ "cacheHandler",
1000
+ "allowedOrigins",
1001
+ "csrf",
1002
+ "limits",
1003
+ "pageExtensions",
1004
+ "slowRequestMs",
1005
+ "renderTimeoutMs",
1006
+ "devBrowserLogs",
1007
+ "dev",
1008
+ "serverTiming",
1009
+ "appDir",
1010
+ "mdx",
1011
+ "actionEncryption",
1012
+ "reactCompiler",
1013
+ "sitemap",
1014
+ "buildDir",
1015
+ "topLoader"
1016
+ ]);
1017
+ for (const key of Object.keys(config)) if (!knownKeys.has(key)) errors.push({
1018
+ field: key,
1019
+ message: `Unknown config option: "${key}".`,
1020
+ suggestion: `Check for typos. Known options: ${[...knownKeys].sort().join(", ")}.`
1021
+ });
1022
+ return errors;
1023
+ }
1024
+ /**
1025
+ * Format config errors for terminal output.
1026
+ */
1027
+ function formatConfigErrors(errors) {
1028
+ if (errors.length === 0) return "";
1029
+ const lines = [];
1030
+ lines.push(`${RED$1}${BOLD$1}[timber]${RESET$1} ${RED$1}${errors.length} config error${errors.length !== 1 ? "s" : ""} in timber.config.ts:${RESET$1}`);
1031
+ for (const err of errors) {
1032
+ lines.push("");
1033
+ lines.push(` ${RED$1}✗${RESET$1} ${BOLD$1}${err.field}${RESET$1}: ${err.message}`);
1034
+ if (err.suggestion) lines.push(` ${DIM$1}${err.suggestion}${RESET$1}`);
1035
+ }
1036
+ return lines.join("\n");
1037
+ }
1038
+ /**
1039
+ * Add timber-specific context to an error message that references virtual modules.
1040
+ *
1041
+ * If the error message contains a `virtual:timber-*` ID, appends a
1042
+ * human-readable explanation. Does not replace the original message.
1043
+ */
1044
+ function addVirtualModuleContext(errorMessage) {
1045
+ for (const [id, name] of Object.entries(VIRTUAL_MODULE_NAMES)) if (errorMessage.includes(id)) return `${errorMessage}\n\n [timber] This error references "${id}" — timber's ${name}.\n This is an internal module. The issue is likely in your app code or timber configuration.`;
1046
+ return errorMessage;
1047
+ }
1048
+ /**
1049
+ * Check that required peer dependencies are installed.
1050
+ *
1051
+ * Uses require.resolve with a try/catch — no fs scanning.
1052
+ * Returns only the missing packages.
1053
+ */
1054
+ function checkPeerDependencies(projectRoot) {
1055
+ const results = [];
1056
+ for (const name of REQUIRED_PEERS) try {
1057
+ const { createRequire } = __require("node:module");
1058
+ createRequire(`${projectRoot}/package.json`).resolve(name);
1059
+ results.push({
1060
+ name,
1061
+ status: "ok"
1062
+ });
1063
+ } catch {
1064
+ results.push({
1065
+ name,
1066
+ status: "missing"
1067
+ });
1068
+ }
1069
+ return results;
1070
+ }
1071
+ /**
1072
+ * Format missing peer dependencies as an actionable warning.
1073
+ */
1074
+ function formatMissingPeers(results) {
1075
+ const missing = results.filter((r) => r.status === "missing");
1076
+ if (missing.length === 0) return "";
1077
+ const names = missing.map((r) => r.name);
1078
+ const lines = [];
1079
+ lines.push(`${RED$1}${BOLD$1}[timber]${RESET$1} ${RED$1}Missing required dependencies:${RESET$1}`);
1080
+ lines.push("");
1081
+ for (const name of names) lines.push(` ${RED$1}✗${RESET$1} ${name}`);
1082
+ lines.push("");
1083
+ lines.push(` ${DIM$1}Install with:${RESET$1}`);
1084
+ lines.push(` ${BOLD$1}pnpm add ${names.join(" ")}${RESET$1}`);
1085
+ return lines.join("\n");
1086
+ }
1087
+ var RED$1, BOLD$1, DIM$1, RESET$1, VIRTUAL_MODULE_NAMES, REQUIRED_PEERS;
1088
+ var init_config_validation = __esmMin((() => {
1089
+ RED$1 = "\x1B[31m";
1090
+ BOLD$1 = "\x1B[1m";
1091
+ DIM$1 = "\x1B[2m";
1092
+ RESET$1 = "\x1B[0m";
1093
+ VIRTUAL_MODULE_NAMES = {
1094
+ "virtual:timber-rsc-entry": "RSC entry (server component handler)",
1095
+ "virtual:timber-ssr-entry": "SSR entry (HTML renderer)",
1096
+ "virtual:timber-browser-entry": "Browser entry (client hydration)",
1097
+ "virtual:timber-config": "Runtime config (timber.config.ts)",
1098
+ "virtual:timber-route-manifest": "Route manifest (app/ file tree)",
1099
+ "virtual:timber-instrumentation": "Instrumentation (instrumentation.ts)",
1100
+ "virtual:timber-cache-handler": "Cache handler (cacheHandler config)",
1101
+ "virtual:timber-build-manifest": "Build manifest (asset mapping)"
1102
+ };
1103
+ REQUIRED_PEERS = [
1104
+ "react",
1105
+ "react-dom",
1106
+ "@vitejs/plugin-react",
1107
+ "@vitejs/plugin-rsc"
1108
+ ];
1109
+ }));
1110
+ //#endregion
274
1111
  //#region src/server/compress.ts
1112
+ init_config_validation();
275
1113
  /**
276
1114
  * MIME types that benefit from compression.
277
1115
  * text/* is handled via prefix matching; these are the specific
@@ -458,6 +1296,7 @@ function timberDevServer(ctx) {
458
1296
  * calls next() to let Vite handle them.
459
1297
  */
460
1298
  function createTimberMiddleware(server, projectRoot) {
1299
+ const hmrOptions = extractHmrOptions(server.config);
461
1300
  return async (req, res, next) => {
462
1301
  const url = req.url;
463
1302
  if (!url) {
@@ -483,8 +1322,11 @@ function createTimberMiddleware(server, projectRoot) {
483
1322
  sendErrorToOverlay(server, error, classifyErrorPhase(error, projectRoot), projectRoot, debugComponents);
484
1323
  });
485
1324
  } catch (error) {
486
- if (error instanceof Error) sendErrorToOverlay(server, error, "module-transform", projectRoot);
487
- respond500(res, error);
1325
+ if (error instanceof Error) {
1326
+ addTimberContext(error);
1327
+ sendErrorToOverlay(server, error, "module-transform", projectRoot);
1328
+ }
1329
+ respond500(res, error, "module-transform", projectRoot, hmrOptions);
488
1330
  return;
489
1331
  }
490
1332
  if (typeof handler !== "function") {
@@ -497,21 +1339,44 @@ function createTimberMiddleware(server, projectRoot) {
497
1339
  const wrapper = server[Symbol.for("timber:dev-request-wrapper")];
498
1340
  await sendWebResponse(res, compressResponse(webRequest, wrapper ? await wrapper(() => handler(webRequest)) : await handler(webRequest)));
499
1341
  } catch (error) {
500
- if (error instanceof Error) sendErrorToOverlay(server, error, classifyErrorPhase(error, projectRoot), projectRoot);
501
- else process.stderr.write(`\x1b[31m[timber] Dev server error:\x1b[0m ${String(error)}\n`);
502
- respond500(res, error);
1342
+ if (error instanceof Error) {
1343
+ const phase = classifyErrorPhase(error, projectRoot);
1344
+ sendErrorToOverlay(server, error, phase, projectRoot);
1345
+ respond500(res, error, phase, projectRoot, hmrOptions);
1346
+ } else {
1347
+ process.stderr.write(`\x1b[31m[timber] Dev server error:\x1b[0m ${String(error)}\n`);
1348
+ respond500(res, error, "render", projectRoot, hmrOptions);
1349
+ }
503
1350
  }
504
1351
  };
505
1352
  }
506
1353
  /**
507
1354
  * Send a 500 response without crashing the dev server.
1355
+ *
1356
+ * In dev mode, renders a styled HTML error page with source context,
1357
+ * classified stack trace, and auto-reload on HMR. Falls back to
1358
+ * text/plain if the HTML generator fails (must never crash).
508
1359
  */
509
- function respond500(res, error) {
510
- if (!res.headersSent) {
1360
+ function respond500(res, error, phase, projectRoot, hmrOptions) {
1361
+ if (res.headersSent) return;
1362
+ if (error instanceof Error) try {
1363
+ const html = generateDevErrorPage(error, phase, projectRoot, hmrOptions);
511
1364
  res.statusCode = 500;
512
- res.setHeader("content-type", "text/plain");
513
- res.end(`[timber] Internal server error\n\n${error instanceof Error ? error.stack ?? error.message : String(error)}`);
514
- }
1365
+ res.setHeader("content-type", "text/html; charset=utf-8");
1366
+ res.end(html);
1367
+ return;
1368
+ } catch {}
1369
+ res.statusCode = 500;
1370
+ res.setHeader("content-type", "text/plain");
1371
+ res.end(`[timber] Internal server error\n\n${error instanceof Error ? error.stack ?? error.message : String(error)}`);
1372
+ }
1373
+ /**
1374
+ * Add timber-specific context to an error's message if it references
1375
+ * internal virtual modules (virtual:timber-*). Mutates the error in place.
1376
+ */
1377
+ function addTimberContext(error) {
1378
+ const enriched = addVirtualModuleContext(error.message);
1379
+ if (enriched !== error.message) error.message = enriched;
515
1380
  }
516
1381
  /**
517
1382
  * Convert a Node IncomingMessage to a Web Request.
@@ -11917,6 +12782,233 @@ function formatStatusFileLintWarnings(warnings) {
11917
12782
  return lines.join("\n");
11918
12783
  }
11919
12784
  //#endregion
12785
+ //#region src/routing/convention-lint.ts
12786
+ /**
12787
+ * Convention linter — validates common misconfigurations in the route tree.
12788
+ *
12789
+ * Runs at scan time (build and dev startup). Each check produces a warning
12790
+ * with the file path, what's wrong, and what to do about it.
12791
+ *
12792
+ * These are warnings, not errors — they don't block the build. The goal is
12793
+ * to catch issues that would otherwise produce cryptic runtime behavior
12794
+ * (silent 404s, empty pages, confusing React errors).
12795
+ *
12796
+ * Design doc: 07-routing.md, 10-error-handling.md
12797
+ */
12798
+ /**
12799
+ * Run all convention lint checks on a route tree.
12800
+ *
12801
+ * Returns an array of warnings. Empty array means everything looks good.
12802
+ */
12803
+ function lintConventions(tree, appDir) {
12804
+ const warnings = [];
12805
+ checkEmptyApp(tree.root, appDir, warnings);
12806
+ checkRouteExports(tree.root, warnings);
12807
+ checkRootLayout(tree.root, warnings);
12808
+ checkDefaultExports(tree.root, warnings);
12809
+ return warnings;
12810
+ }
12811
+ /**
12812
+ * Warn when the app/ directory has no routable files at all.
12813
+ *
12814
+ * This catches the "I created app/ but didn't add any pages" case where
12815
+ * every request silently 404s with no guidance.
12816
+ */
12817
+ function checkEmptyApp(root, appDir, warnings) {
12818
+ if (hasAnyRoutable(root)) return;
12819
+ warnings.push({
12820
+ id: "EMPTY_APP",
12821
+ summary: "No pages or route handlers found in app/",
12822
+ details: ` Directory: ${appDir}\n\n Your app/ directory has no page.tsx or route.ts files.
12823
+ Every request will return 404.
12824
+
12825
+ To fix: Create app/page.tsx with a default export:
12826
+
12827
+ export default function Home() {
12828
+ return <h1>Hello</h1>;
12829
+ }
12830
+ `,
12831
+ level: "warn"
12832
+ });
12833
+ }
12834
+ /**
12835
+ * Check if a segment tree has any routable files (page or route) anywhere.
12836
+ */
12837
+ function hasAnyRoutable(node) {
12838
+ if (node.page || node.route) return true;
12839
+ for (const child of node.children) if (hasAnyRoutable(child)) return true;
12840
+ for (const [, slot] of node.slots) if (hasAnyRoutable(slot)) return true;
12841
+ return false;
12842
+ }
12843
+ /**
12844
+ * Check if a segment tree has any page files (not just route handlers).
12845
+ * Used by checkRootLayout to avoid false positives for API-only apps (TIM-794).
12846
+ * API-only apps (just route.ts handlers) don't need a root layout.
12847
+ */
12848
+ function hasAnyPage(node) {
12849
+ if (node.page) return true;
12850
+ for (const child of node.children) if (hasAnyPage(child)) return true;
12851
+ for (const [, slot] of node.slots) if (hasAnyPage(slot)) return true;
12852
+ return false;
12853
+ }
12854
+ /** HTTP methods that route.ts can export. */
12855
+ var HTTP_METHODS = [
12856
+ "GET",
12857
+ "POST",
12858
+ "PUT",
12859
+ "PATCH",
12860
+ "DELETE",
12861
+ "HEAD",
12862
+ "OPTIONS"
12863
+ ];
12864
+ /**
12865
+ * Pattern to detect named exports that look like HTTP method handlers.
12866
+ * Matches: export function GET, export const GET, export async function POST, etc.
12867
+ * Also matches: export { GET } or re-exports like export { GET } from './handler'.
12868
+ *
12869
+ * The re-export branch uses word boundaries (\b) around method names to avoid
12870
+ * matching substrings (e.g. TARGET should not match GET). See TIM-795.
12871
+ */
12872
+ var EXPORT_PATTERN = new RegExp(`export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+(${HTTP_METHODS.join("|")})\\b|export\\s*\\{[^}]*\\b(${HTTP_METHODS.join("|")})\\b[^}]*\\}`);
12873
+ /**
12874
+ * Warn when a route.ts file doesn't appear to export any recognized HTTP methods.
12875
+ *
12876
+ * Uses static analysis (regex on source text) — not module loading.
12877
+ * This is intentionally conservative: it may miss complex re-exports but
12878
+ * catches the common case of an empty route.ts or one with wrong export names.
12879
+ */
12880
+ function checkRouteExports(node, warnings) {
12881
+ if (node.route) {
12882
+ const filePath = node.route.filePath;
12883
+ try {
12884
+ const source = readFileSync(filePath, "utf-8");
12885
+ if (!EXPORT_PATTERN.test(source)) {
12886
+ const hint = /export\s+default\b/.test(source) ? " It looks like you have a default export. route.ts uses named exports\n for HTTP methods (GET, POST, etc.), not a default export.\n" : "";
12887
+ warnings.push({
12888
+ id: "ROUTE_NO_METHODS",
12889
+ summary: `route.ts has no HTTP method exports: ${filePath}`,
12890
+ details: ` File: ${filePath}\n\n route.ts files must export named HTTP method handlers.
12891
+ Without them, the route matches but returns 405 Method Not Allowed.
12892
+
12893
+ ` + hint + " Example:\n\n export function GET(ctx: RouteContext) {\n return Response.json({ hello: 'world' });\n }\n",
12894
+ level: "warn"
12895
+ });
12896
+ }
12897
+ } catch {}
12898
+ }
12899
+ for (const child of node.children) checkRouteExports(child, warnings);
12900
+ for (const [, slot] of node.slots) checkRouteExports(slot, warnings);
12901
+ }
12902
+ /**
12903
+ * Warn when the root segment has no layout.tsx.
12904
+ *
12905
+ * Without a root layout, there's no HTML shell (<html>, <body>).
12906
+ * The page will render but may have hydration issues or no proper document structure.
12907
+ */
12908
+ function checkRootLayout(root, warnings) {
12909
+ if (root.layout) return;
12910
+ if (!hasAnyPage(root)) return;
12911
+ warnings.push({
12912
+ id: "NO_ROOT_LAYOUT",
12913
+ summary: "No root layout.tsx found",
12914
+ details: " Your app has pages but no root layout.\n Without app/layout.tsx, pages have no <html> or <body> wrapper.\n\n To fix: Create app/layout.tsx:\n\n export default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>{children}</body>\n </html>\n );\n }\n",
12915
+ level: "warn"
12916
+ });
12917
+ }
12918
+ /**
12919
+ * Pattern to detect a default export in source code.
12920
+ * Matches: export default function, export default class, export default
12921
+ * Also matches: export { X as default }, export { default } from './Impl'
12922
+ *
12923
+ * The third alternative catches bare `default` re-exports (TIM-790).
12924
+ * The negative lookahead (?!\s+as\b) prevents matching `export { default as X }`
12925
+ * which is a named export, not a default export (TIM-806).
12926
+ */
12927
+ var DEFAULT_EXPORT_PATTERN = /export\s+default\b|export\s*\{[^}]*\bas\s+default\b|export\s*\{[^}]*\bdefault\b(?!\s+as\b)[^}]*\}/;
12928
+ /**
12929
+ * Warn when page.tsx or layout.tsx files don't have a default export.
12930
+ *
12931
+ * Without a default export:
12932
+ * - page.tsx: the page renders nothing (empty content)
12933
+ * - layout.tsx: the layout module loads but has no component to render
12934
+ *
12935
+ * Uses static analysis (regex on source text) — intentionally conservative.
12936
+ */
12937
+ function checkDefaultExports(node, warnings) {
12938
+ if (node.page && isScriptExtension(node.page.extension)) checkFileDefaultExport(node.page.filePath, "page", warnings);
12939
+ if (node.layout && isScriptExtension(node.layout.extension)) checkFileDefaultExport(node.layout.filePath, "layout", warnings);
12940
+ for (const child of node.children) checkDefaultExports(child, warnings);
12941
+ for (const [, slot] of node.slots) checkDefaultExports(slot, warnings);
12942
+ }
12943
+ function isScriptExtension(ext) {
12944
+ return ext === "tsx" || ext === "ts" || ext === "jsx" || ext === "js";
12945
+ }
12946
+ function checkFileDefaultExport(filePath, fileType, warnings) {
12947
+ try {
12948
+ const source = readFileSync(filePath, "utf-8");
12949
+ if (!DEFAULT_EXPORT_PATTERN.test(source)) warnings.push({
12950
+ id: `NO_DEFAULT_EXPORT:${filePath}`,
12951
+ summary: `${fileType}.tsx has no default export: ${filePath}`,
12952
+ details: ` File: ${filePath}\n\n ${fileType}.tsx files must export a default React component.\n Without a default export, the ${fileType === "page" ? "page renders nothing" : "layout has no component to wrap children"}.\n\n To fix: Add a default export:\n\n export default function My${fileType === "page" ? "Page" : "Layout"}(${fileType === "layout" ? "{ children }" : ""}) {\n return ${fileType === "layout" ? "<div>{children}</div>" : "<h1>Hello</h1>"};\n }\n`,
12953
+ level: "warn"
12954
+ });
12955
+ } catch {}
12956
+ }
12957
+ /**
12958
+ * Check if the app/ directory exists. Called before scanning.
12959
+ * Returns a warning if missing, or null if the directory exists.
12960
+ */
12961
+ function checkAppDirExists(appDir) {
12962
+ if (existsSync(appDir)) return null;
12963
+ return {
12964
+ id: "NO_APP_DIR",
12965
+ summary: "No app/ directory found",
12966
+ details: ` Expected: ${appDir}\n\n timber.js requires an app/ directory for file-system routing.
12967
+ Every request will return 404 until you create it.
12968
+
12969
+ To fix: Create the app/ directory with a page:
12970
+
12971
+ mkdir -p app
12972
+ # Create app/layout.tsx and app/page.tsx
12973
+ `,
12974
+ level: "error"
12975
+ };
12976
+ }
12977
+ var YELLOW = "\x1B[33m";
12978
+ var RED = "\x1B[31m";
12979
+ var BOLD = "\x1B[1m";
12980
+ var DIM = "\x1B[2m";
12981
+ var RESET = "\x1B[0m";
12982
+ /**
12983
+ * Format warnings for terminal output.
12984
+ *
12985
+ * Groups by severity, uses colors, and includes fix suggestions.
12986
+ */
12987
+ function formatConventionWarnings(warnings) {
12988
+ if (warnings.length === 0) return "";
12989
+ const errors = warnings.filter((w) => w.level === "error");
12990
+ const warns = warnings.filter((w) => w.level === "warn");
12991
+ const lines = [];
12992
+ if (errors.length > 0) {
12993
+ lines.push(`${RED}${BOLD}[timber]${RESET} ${RED}${errors.length} configuration error${errors.length !== 1 ? "s" : ""}:${RESET}`);
12994
+ for (const e of errors) {
12995
+ lines.push("");
12996
+ lines.push(` ${RED}✗${RESET} ${e.summary}`);
12997
+ lines.push(`${DIM}${e.details}${RESET}`);
12998
+ }
12999
+ }
13000
+ if (warns.length > 0) {
13001
+ if (errors.length > 0) lines.push("");
13002
+ lines.push(`${YELLOW}${BOLD}[timber]${RESET} ${YELLOW}${warns.length} configuration warning${warns.length !== 1 ? "s" : ""}:${RESET}`);
13003
+ for (const w of warns) {
13004
+ lines.push("");
13005
+ lines.push(` ${YELLOW}⚠${RESET} ${w.summary}`);
13006
+ lines.push(`${DIM}${w.details}${RESET}`);
13007
+ }
13008
+ }
13009
+ return lines.join("\n");
13010
+ }
13011
+ //#endregion
11920
13012
  //#region src/plugins/routing.ts
11921
13013
  var VIRTUAL_MODULE_ID$1 = "virtual:timber-route-manifest";
11922
13014
  var RESOLVED_VIRTUAL_ID$1 = `\0${VIRTUAL_MODULE_ID$1}`;
@@ -11968,14 +13060,33 @@ function timberRouting(ctx) {
11968
13060
  /** Track warned files to avoid repeating warnings on rescan. */
11969
13061
  const warnedFiles = /* @__PURE__ */ new Set();
11970
13062
  function rescan() {
13063
+ const appDirWarning = checkAppDirExists(ctx.appDir);
13064
+ if (appDirWarning) {
13065
+ const formatted = formatConventionWarnings([appDirWarning]);
13066
+ if (formatted) process.stderr.write(`${formatted}\n`);
13067
+ ctx.routeTree = { root: {
13068
+ segmentName: "",
13069
+ segmentType: "static",
13070
+ urlPath: "/",
13071
+ children: [],
13072
+ slots: /* @__PURE__ */ new Map()
13073
+ } };
13074
+ return;
13075
+ }
11971
13076
  ctx.timer.start("route-scan");
11972
13077
  ctx.routeTree = scanRoutes(ctx.appDir, { pageExtensions: ctx.config.pageExtensions });
11973
13078
  ctx.timer.end("route-scan");
11974
13079
  writeCodegen(ctx);
11975
- const newWarnings = lintStatusFileDirectives(ctx.routeTree).filter((w) => !warnedFiles.has(w.filePath));
11976
- if (newWarnings.length > 0) {
11977
- for (const w of newWarnings) warnedFiles.add(w.filePath);
11978
- console.warn(formatStatusFileLintWarnings(newWarnings));
13080
+ const newStatusWarnings = lintStatusFileDirectives(ctx.routeTree).filter((w) => !warnedFiles.has(w.filePath));
13081
+ if (newStatusWarnings.length > 0) {
13082
+ for (const w of newStatusWarnings) warnedFiles.add(w.filePath);
13083
+ console.warn(formatStatusFileLintWarnings(newStatusWarnings));
13084
+ }
13085
+ const newConventionWarnings = lintConventions(ctx.routeTree, ctx.appDir).filter((w) => !warnedFiles.has(w.id));
13086
+ if (newConventionWarnings.length > 0) {
13087
+ for (const w of newConventionWarnings) warnedFiles.add(w.id);
13088
+ const formatted = formatConventionWarnings(newConventionWarnings);
13089
+ if (formatted) process.stderr.write(`${formatted}\n`);
11979
13090
  }
11980
13091
  }
11981
13092
  return {
@@ -13231,7 +14342,8 @@ function generateFontId(family, config) {
13231
14342
  const weights = normalizeToArray(config.weight);
13232
14343
  const styles = normalizeToArray(config.style);
13233
14344
  const subsets = config.subsets ?? ["latin"];
13234
- return `${family.toLowerCase()}-${weights.join(",")}-${styles.join(",")}-${subsets.join(",")}`;
14345
+ const display = config.display ?? "swap";
14346
+ return `${family.toLowerCase()}-${weights.join(",")}-${styles.join(",")}-${subsets.join(",")}-${display}`;
13235
14347
  }
13236
14348
  /**
13237
14349
  * Normalize a string or string array to an array.
@@ -15451,6 +16563,11 @@ function timber(config) {
15451
16563
  ctx.buildDir = resolve(resolved.root, resolved.build.outDir);
15452
16564
  if (!ctx.dev) ctx.timer = createNoopTimer();
15453
16565
  else ctx.timer.start("dev-server-setup");
16566
+ const { validateConfig, formatConfigErrors, checkPeerDependencies, formatMissingPeers } = (init_config_validation(), __toCommonJS(config_validation_exports));
16567
+ const configErrors = validateConfig(ctx.config);
16568
+ if (configErrors.length > 0) process.stderr.write(`${formatConfigErrors(configErrors)}\n\n`);
16569
+ const peerWarning = formatMissingPeers(checkPeerDependencies(ctx.root));
16570
+ if (peerWarning) process.stderr.write(`${peerWarning}\n\n`);
15454
16571
  }
15455
16572
  };
15456
16573
  const reactCompilerPlugins = [];