@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.
- package/dist/_chunks/{actions-Dg-ANYHb.js → actions-DLnUaR65.js} +2 -2
- package/dist/_chunks/{actions-Dg-ANYHb.js.map → actions-DLnUaR65.js.map} +1 -1
- package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
- package/dist/_chunks/{define-CZqDwhSu.js → define-Itxvcd7F.js} +2 -2
- package/dist/_chunks/{define-CZqDwhSu.js.map → define-Itxvcd7F.js.map} +1 -1
- package/dist/_chunks/{define-cookie-C2IkoFGN.js → define-cookie-BowvzoP0.js} +4 -4
- package/dist/_chunks/{define-cookie-C2IkoFGN.js.map → define-cookie-BowvzoP0.js.map} +1 -1
- package/dist/_chunks/{request-context-qMsWgy9C.js → request-context-CK5tZqIP.js} +3 -3
- package/dist/_chunks/{request-context-qMsWgy9C.js.map → request-context-CK5tZqIP.js.map} +1 -1
- package/dist/_chunks/{use-query-states-Lo_s_pw2.js → use-query-states-BiV5GJgm.js} +4 -1
- package/dist/_chunks/{use-query-states-Lo_s_pw2.js.map → use-query-states-BiV5GJgm.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 +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +1 -1
- package/dist/client/use-query-states.d.ts.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 +1169 -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/plugins/shims.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/search-params/index.js +2 -2
- 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 +1 -1
- package/src/client/form.tsx +10 -5
- package/src/client/use-query-states.ts +6 -0
- 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/plugins/shims.ts +1 -0
- 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
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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)
|
|
487
|
-
|
|
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)
|
|
501
|
-
|
|
502
|
-
|
|
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 (
|
|
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/
|
|
513
|
-
res.end(
|
|
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
|
|
11976
|
-
if (
|
|
11977
|
-
for (const w of
|
|
11978
|
-
console.warn(formatStatusFileLintWarnings(
|
|
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 {
|
|
@@ -12210,6 +13321,7 @@ var SUBPATH_SRC_MAP = {
|
|
|
12210
13321
|
"cache": "cache/index.ts",
|
|
12211
13322
|
"cookies": "cookies/index.ts",
|
|
12212
13323
|
"search-params": "search-params/index.ts",
|
|
13324
|
+
"segment-params": "segment-params/index.ts",
|
|
12213
13325
|
"adapters/cloudflare": "adapters/cloudflare.ts",
|
|
12214
13326
|
"adapters/cloudflare/dev": "adapters/cloudflare-dev.ts",
|
|
12215
13327
|
"adapters/nitro": "adapters/nitro.ts"
|
|
@@ -13230,7 +14342,8 @@ function generateFontId(family, config) {
|
|
|
13230
14342
|
const weights = normalizeToArray(config.weight);
|
|
13231
14343
|
const styles = normalizeToArray(config.style);
|
|
13232
14344
|
const subsets = config.subsets ?? ["latin"];
|
|
13233
|
-
|
|
14345
|
+
const display = config.display ?? "swap";
|
|
14346
|
+
return `${family.toLowerCase()}-${weights.join(",")}-${styles.join(",")}-${subsets.join(",")}-${display}`;
|
|
13234
14347
|
}
|
|
13235
14348
|
/**
|
|
13236
14349
|
* Normalize a string or string array to an array.
|
|
@@ -15450,6 +16563,11 @@ function timber(config) {
|
|
|
15450
16563
|
ctx.buildDir = resolve(resolved.root, resolved.build.outDir);
|
|
15451
16564
|
if (!ctx.dev) ctx.timer = createNoopTimer();
|
|
15452
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`);
|
|
15453
16571
|
}
|
|
15454
16572
|
};
|
|
15455
16573
|
const reactCompilerPlugins = [];
|