@timber-js/app 0.2.0-alpha.200 → 0.2.0-alpha.201

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 (135) hide show
  1. package/bin/timber.mjs +15 -1
  2. package/dist/_chunks/{actions-d1hCqnU3.js → actions-HUdJADAD.js} +3 -3
  3. package/dist/_chunks/{actions-d1hCqnU3.js.map → actions-HUdJADAD.js.map} +1 -1
  4. package/dist/_chunks/{build-manifest-DTmSGLRz.js → build-manifest-DWppEdLB.js} +2 -51
  5. package/dist/_chunks/build-manifest-DWppEdLB.js.map +1 -0
  6. package/dist/_chunks/{cache-api-ByagcC-J.js → cache-api-CAPbZTga.js} +2 -2
  7. package/dist/_chunks/{cache-api-ByagcC-J.js.map → cache-api-CAPbZTga.js.map} +1 -1
  8. package/dist/_chunks/{chains-Bpb0W4ax.js → chains-CBNA0Ozj.js} +3 -3
  9. package/dist/_chunks/{chains-Bpb0W4ax.js.map → chains-CBNA0Ozj.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-D6VolrDV.js → cli-check-BzMGuIH6.js} +3 -3
  11. package/dist/_chunks/{cli-check-D6VolrDV.js.map → cli-check-BzMGuIH6.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js → cli-schema-sync-DnXqcIIj.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js.map → cli-schema-sync-DnXqcIIj.js.map} +1 -1
  14. package/dist/_chunks/{cloudflare-BFb__LYG.js → cloudflare-DxX1SU0g.js} +3 -3
  15. package/dist/_chunks/{cloudflare-BFb__LYG.js.map → cloudflare-DxX1SU0g.js.map} +1 -1
  16. package/dist/_chunks/{convention-lint-fRkwVwEH.js → convention-lint-DHOFvX5s.js} +5 -95
  17. package/dist/_chunks/convention-lint-DHOFvX5s.js.map +1 -0
  18. package/dist/_chunks/csp-nonce-hOGniaG4.js +227 -0
  19. package/dist/_chunks/csp-nonce-hOGniaG4.js.map +1 -0
  20. package/dist/_chunks/dev-server-DioP7tkQ.js +2288 -0
  21. package/dist/_chunks/dev-server-DioP7tkQ.js.map +1 -0
  22. package/dist/_chunks/{error-boundary-BfPHZjm0.js → error-boundary-BQKxl6EX.js} +11 -17
  23. package/dist/_chunks/{error-boundary-BfPHZjm0.js.map → error-boundary-BQKxl6EX.js.map} +1 -1
  24. package/dist/_chunks/{graph-cache-CP4GEmf9.js → graph-cache-Cv3njEH8.js} +2 -2
  25. package/dist/_chunks/{graph-cache-CP4GEmf9.js.map → graph-cache-Cv3njEH8.js.map} +1 -1
  26. package/dist/_chunks/{live-graph-D_2D32Ad.js → live-graph-VjHFF5EV.js} +3 -3
  27. package/dist/_chunks/{live-graph-D_2D32Ad.js.map → live-graph-VjHFF5EV.js.map} +1 -1
  28. package/dist/_chunks/{logger-uLBuGKDI.js → logger-DqJ2VoAY.js} +450 -458
  29. package/dist/_chunks/logger-DqJ2VoAY.js.map +1 -0
  30. package/dist/_chunks/{segment-keys-lqtdookO.js → metadata-routes-DSDjM_hJ.js} +2 -61
  31. package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -0
  32. package/dist/_chunks/{poison-scan-Bm9Yyqk9.js → poison-scan-lEbz4pQE.js} +2 -2
  33. package/dist/_chunks/{poison-scan-Bm9Yyqk9.js.map → poison-scan-lEbz4pQE.js.map} +1 -1
  34. package/dist/_chunks/{scanner-AiazgH_f.js → scanner-B_tnqFcF.js} +37 -137
  35. package/dist/_chunks/scanner-B_tnqFcF.js.map +1 -0
  36. package/dist/_chunks/segment-keys-D5hu1hz4.js +62 -0
  37. package/dist/_chunks/segment-keys-D5hu1hz4.js.map +1 -0
  38. package/dist/_chunks/tree-match-CdbvYTBz.js +122 -0
  39. package/dist/_chunks/tree-match-CdbvYTBz.js.map +1 -0
  40. package/dist/_chunks/{walkers-B6XUtmqK.js → walkers-Cm3PC5JT.js} +2 -2
  41. package/dist/_chunks/{walkers-B6XUtmqK.js.map → walkers-Cm3PC5JT.js.map} +1 -1
  42. package/dist/adapters/cloudflare-dev.js +1 -1
  43. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  44. package/dist/adapters/cloudflare.js +1 -1
  45. package/dist/analyze/crawl-entry.js +2 -2
  46. package/dist/analyze/graph-command.js +3 -3
  47. package/dist/cache/index.js +1 -1
  48. package/dist/cli.d.ts +7 -2
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +44 -7
  51. package/dist/cli.js.map +1 -1
  52. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  53. package/dist/client/browser-entry/action-queue.d.ts +20 -1
  54. package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
  55. package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -1
  56. package/dist/client/error-boundary.d.ts +3 -15
  57. package/dist/client/error-boundary.d.ts.map +1 -1
  58. package/dist/client/error-boundary.js +1 -1
  59. package/dist/client/error-reconstituter.d.ts +4 -4
  60. package/dist/client/error-reconstituter.d.ts.map +1 -1
  61. package/dist/client/internal.js +1 -1
  62. package/dist/dev-tools/debug-channel.d.ts +55 -0
  63. package/dist/dev-tools/debug-channel.d.ts.map +1 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +17 -2119
  66. package/dist/index.js.map +1 -1
  67. package/dist/plugins/dev-server.d.ts +7 -0
  68. package/dist/plugins/dev-server.d.ts.map +1 -1
  69. package/dist/plugins/mdx.d.ts.map +1 -1
  70. package/dist/routing/convention-lint.d.ts.map +1 -1
  71. package/dist/routing/index.js +2 -2
  72. package/dist/routing/scanner.d.ts +4 -4
  73. package/dist/routing/scanner.d.ts.map +1 -1
  74. package/dist/server/access-gate.d.ts +2 -2
  75. package/dist/server/deny-boundary.d.ts +3 -5
  76. package/dist/server/deny-boundary.d.ts.map +1 -1
  77. package/dist/server/deny-renderer.d.ts +0 -1
  78. package/dist/server/deny-renderer.d.ts.map +1 -1
  79. package/dist/server/error-boundary-wrapper.d.ts +5 -12
  80. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  81. package/dist/server/index.js +2 -2
  82. package/dist/server/internal.js +20 -195
  83. package/dist/server/internal.js.map +1 -1
  84. package/dist/server/pipeline.d.ts +8 -0
  85. package/dist/server/pipeline.d.ts.map +1 -1
  86. package/dist/server/primitives.d.ts +15 -13
  87. package/dist/server/primitives.d.ts.map +1 -1
  88. package/dist/server/rsc-entry/error-renderer.d.ts +3 -7
  89. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  90. package/dist/server/rsc-entry/helpers.d.ts +6 -27
  91. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  92. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  93. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  94. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  95. package/dist/server/rsc-entry/rsc-stream.d.ts +4 -1
  96. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  97. package/dist/server/rsc-entry/ssr-renderer.d.ts +1 -0
  98. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  99. package/docs/api/30-api-server.mdx +5 -5
  100. package/docs/api/34-api-config.mdx +2 -2
  101. package/docs/api/36-cli.mdx +1 -1
  102. package/docs/learn/12-error-handling.mdx +7 -8
  103. package/package.json +1 -2
  104. package/src/cli.ts +62 -8
  105. package/src/client/browser-entry/action-dispatch.ts +12 -0
  106. package/src/client/browser-entry/action-queue.ts +70 -8
  107. package/src/client/browser-entry/rsc-stream.ts +78 -22
  108. package/src/client/error-boundary.tsx +13 -39
  109. package/src/client/error-reconstituter.tsx +5 -5
  110. package/src/dev-tools/debug-channel.ts +151 -0
  111. package/src/index.ts +8 -12
  112. package/src/plugins/dev-server.ts +58 -7
  113. package/src/plugins/mdx.ts +2 -1
  114. package/src/routing/convention-lint.ts +4 -111
  115. package/src/routing/scanner.ts +33 -22
  116. package/src/server/access-gate.tsx +3 -3
  117. package/src/server/deny-boundary.ts +5 -23
  118. package/src/server/deny-renderer.ts +4 -7
  119. package/src/server/error-boundary-wrapper.ts +11 -35
  120. package/src/server/pipeline.ts +9 -0
  121. package/src/server/primitives.ts +20 -26
  122. package/src/server/rsc-entry/error-renderer.ts +15 -43
  123. package/src/server/rsc-entry/helpers.ts +10 -67
  124. package/src/server/rsc-entry/index.ts +1 -0
  125. package/src/server/rsc-entry/render-route.ts +12 -1
  126. package/src/server/rsc-entry/rsc-stream.ts +27 -24
  127. package/src/server/rsc-entry/ssr-renderer.ts +7 -1
  128. package/dist/_chunks/build-manifest-DTmSGLRz.js.map +0 -1
  129. package/dist/_chunks/convention-lint-fRkwVwEH.js.map +0 -1
  130. package/dist/_chunks/logger-uLBuGKDI.js.map +0 -1
  131. package/dist/_chunks/scanner-AiazgH_f.js.map +0 -1
  132. package/dist/_chunks/segment-keys-lqtdookO.js.map +0 -1
  133. package/dist/server/utils/mdx-file.d.ts +0 -17
  134. package/dist/server/utils/mdx-file.d.ts.map +0 -1
  135. package/src/server/utils/mdx-file.ts +0 -22
package/dist/index.js CHANGED
@@ -1,38 +1,30 @@
1
- import { l as registerDevDiscovery } from "./_chunks/graph-cache-CP4GEmf9.js";
2
- import { n as isScannableSourceFile, t as buildSourceExtensions } from "./_chunks/scan-BoP17W3w.js";
3
- import { t as RSC_CONTENT_TYPE } from "./_chunks/rsc-media-type-DDc7duTD.js";
4
- import { M as isSsrStreamError, h as swallow } from "./_chunks/logger-uLBuGKDI.js";
5
- import { c as isMetadataRouteServePath, s as isDynamicMetadataExtension } from "./_chunks/segment-keys-lqtdookO.js";
6
- import { r as canonicalize } from "./_chunks/canonicalize-CgHoscYO.js";
7
- import { i as matchUrlParts, n as scanRoutes, r as collectInterceptionRewrites } from "./_chunks/scanner-AiazgH_f.js";
8
- import { c as writeRouteCodegen, i as getPrerenderExport, l as buildDirToRouteMap, n as formatConventionWarnings, r as lintConventions, s as flushCodegenWrites, t as checkAppDirExists, u as MDX_EXTENSIONS } from "./_chunks/convention-lint-fRkwVwEH.js";
1
+ import { a as formatMissingPeers, i as formatConfigErrors, n as timberDevServer, o as validateConfig, r as checkPeerDependencies } from "./_chunks/dev-server-DioP7tkQ.js";
2
+ import { h as swallow } from "./_chunks/logger-DqJ2VoAY.js";
3
+ import "./_chunks/csp-nonce-hOGniaG4.js";
4
+ import { a as isDynamicMetadataExtension } from "./_chunks/metadata-routes-DSDjM_hJ.js";
5
+ import { n as scanRoutes, r as collectInterceptionRewrites } from "./_chunks/scanner-B_tnqFcF.js";
6
+ import { c as writeRouteCodegen, i as getPrerenderExport, l as buildDirToRouteMap, n as formatConventionWarnings, r as lintConventions, s as flushCodegenWrites, t as checkAppDirExists } from "./_chunks/convention-lint-DHOFvX5s.js";
9
7
  import { c as resolveClientJavascript, i as mergeFileConfig, l as createNoopTimer, o as resolveAppDir, r as loadTimberConfigFile, s as resolveBuildDir, t as createPluginContext } from "./_chunks/plugin-context-8idfR3UZ.js";
10
- import { r as validateSchemaAgainstRoutes, t as collectLeafRoutes } from "./_chunks/walkers-B6XUtmqK.js";
8
+ import { r as validateSchemaAgainstRoutes, t as collectLeafRoutes } from "./_chunks/walkers-Cm3PC5JT.js";
11
9
  import { i as poisonPillSource, r as markerFor, t as EXPLICIT_MARKERS } from "./_chunks/poison-rules-DoEhbqaY.js";
12
10
  import { i as withBasePath, n as normalizeBasePath, t as isRelativeBase } from "./_chunks/base-path-DaQrzbez.js";
13
- import { t as EMPTY_BUILD_MANIFEST } from "./_chunks/build-manifest-DTmSGLRz.js";
14
11
  import { t as rscPayloadPath } from "./_chunks/rsc-payload-path-B_LBodc2.js";
15
12
  import { a as REDIRECTS_FILE, n as detectFsIdentity, o as RESERVED_OUTPUT_PATHS, r as fsKey, t as EXACT_FS_IDENTITY } from "./_chunks/fs-identity-D7vb6GdI.js";
13
+ import { t as EMPTY_BUILD_MANIFEST } from "./_chunks/build-manifest-DWppEdLB.js";
16
14
  import { t as fnv1aHash } from "./_chunks/fast-hash-C-tVYrLb.js";
17
15
  import { t as formatSize } from "./_chunks/format-BKclgVbk.js";
18
- import { createLogger, normalizePath, parseAst } from "vite";
16
+ import { createLogger, parseAst } from "vite";
19
17
  import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, win32 } from "node:path";
20
18
  import { builtinModules, createRequire } from "node:module";
21
19
  import react from "@vitejs/plugin-react";
22
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
20
+ import { existsSync, readFileSync } from "node:fs";
23
21
  import nodeHTTP from "node:http";
24
- import { Readable, pipeline } from "node:stream";
25
- import { pipeline as pipeline$1 } from "node:stream/promises";
26
- import "node:https";
27
- import "node:http2";
28
22
  import { fileURLToPath, pathToFileURL } from "node:url";
29
- import { styleText } from "node:util";
30
23
  import { createHash, randomUUID } from "node:crypto";
31
- import { copyFile, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
32
- import { isIP } from "node:net";
33
- import { constants, createGzip, gzipSync } from "node:zlib";
24
+ import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
25
+ import { gzipSync } from "node:zlib";
34
26
  //#region src/plugins/content.ts
35
- var CONFIG_FILE_NAMES$1 = [
27
+ var CONFIG_FILE_NAMES = [
36
28
  "content-collections.ts",
37
29
  "content-collections.js",
38
30
  "content-collections.mts",
@@ -43,7 +35,7 @@ var CONFIG_FILE_NAMES$1 = [
43
35
  * Returns the filename (not full path) if found, otherwise undefined.
44
36
  */
45
37
  function findConfigFile(root) {
46
- for (const name of CONFIG_FILE_NAMES$1) if (existsSync(join(root, name))) return name;
38
+ for (const name of CONFIG_FILE_NAMES) if (existsSync(join(root, name))) return name;
47
39
  }
48
40
  /**
49
41
  * Create the timber-content Vite plugin.
@@ -117,2096 +109,6 @@ function timberContent(ctx) {
117
109
  };
118
110
  }
119
111
  //#endregion
120
- //#region ../../node_modules/.pnpm/srvx@1.0.5/node_modules/srvx/dist/adapters/node.mjs
121
- function sendNodeResponse(nodeRes, webRes) {
122
- try {
123
- return _sendNodeResponse(nodeRes, webRes, false) || Promise.resolve();
124
- } catch (error) {
125
- return Promise.reject(error);
126
- }
127
- }
128
- function _sendNodeResponse(nodeRes, webRes, detached) {
129
- if (!webRes) {
130
- nodeRes.statusCode = 500;
131
- return endNodeResponse(nodeRes, detached);
132
- }
133
- if (webRes._toNodeResponse) {
134
- const res = webRes._toNodeResponse();
135
- if (res.body) {
136
- if (res.body instanceof ReadableStream) {
137
- writeHead(nodeRes, res.status, res.statusText, res.headers);
138
- return streamBody(res.body, nodeRes);
139
- } else if (typeof res.body?.pipe === "function") return pipeBody(res.body, nodeRes, res.status, res.statusText, res.headers);
140
- writeHead(nodeRes, res.status, res.statusText, res.headers);
141
- nodeRes.write(res.body);
142
- } else writeHead(nodeRes, res.status, res.statusText, res.headers);
143
- return endNodeResponse(nodeRes, detached);
144
- }
145
- const rawHeaders = [];
146
- for (const [key, value] of webRes.headers) rawHeaders.push(key, value);
147
- writeHead(nodeRes, webRes.status, webRes.statusText, rawHeaders);
148
- return webRes.body ? streamBody(webRes.body, nodeRes) : endNodeResponse(nodeRes, detached);
149
- }
150
- function writeHead(nodeRes, status, statusText, rawHeaders) {
151
- if (!nodeRes.headersSent) {
152
- if (nodeRes.req?.httpVersion === "2.0") nodeRes.writeHead(status, rawHeaders);
153
- else nodeRes.writeHead(status, safeStatusText(statusText), rawHeaders);
154
- }
155
- }
156
- var INVALID_REASON_PHRASE_RE = /[^\t\u0020-\u007E\u0080-\u00FF]/g;
157
- function safeStatusText(statusText) {
158
- return typeof statusText === "string" && statusText ? statusText.replace(INVALID_REASON_PHRASE_RE, "") : statusText;
159
- }
160
- function endNodeResponse(nodeRes, detached) {
161
- if (detached) {
162
- nodeRes.end();
163
- return;
164
- }
165
- return new Promise((resolve) => nodeRes.end(resolve));
166
- }
167
- function pipeBody(stream, nodeRes, status, statusText, headers) {
168
- if (nodeRes.destroyed) {
169
- stream.destroy?.();
170
- return;
171
- }
172
- if (nodeRes.req?.method === "HEAD") {
173
- if (typeof stream.destroy === "function") stream.destroy();
174
- else stream.abort?.();
175
- writeHead(nodeRes, status, statusText, headers);
176
- return endNodeResponse(nodeRes);
177
- }
178
- if (typeof stream.on !== "function" || typeof stream.destroy !== "function") {
179
- writeHead(nodeRes, status, statusText, headers);
180
- stream.pipe(nodeRes);
181
- return new Promise((resolve) => nodeRes.on("close", resolve));
182
- }
183
- if (stream.destroyed) {
184
- writeHead(nodeRes, 500, "Internal Server Error", []);
185
- return endNodeResponse(nodeRes);
186
- }
187
- return new Promise((resolve) => {
188
- function cleanup() {
189
- stream.off("error", onEarlyError);
190
- stream.off("readable", onReadable);
191
- nodeRes.off("close", onResClose);
192
- }
193
- function onEarlyError() {
194
- cleanup();
195
- stream.destroy();
196
- writeHead(nodeRes, 500, "Internal Server Error", []);
197
- endNodeResponse(nodeRes).then(resolve);
198
- }
199
- function onReadable() {
200
- cleanup();
201
- if (nodeRes.destroyed) {
202
- stream.destroy();
203
- return resolve();
204
- }
205
- writeHead(nodeRes, status, statusText, headers);
206
- pipeline$1(stream, nodeRes).catch(() => {}).then(() => resolve());
207
- }
208
- function onResClose() {
209
- cleanup();
210
- stream.destroy();
211
- resolve();
212
- }
213
- stream.once("error", onEarlyError);
214
- stream.once("readable", onReadable);
215
- nodeRes.once("close", onResClose);
216
- });
217
- }
218
- function streamBody(stream, nodeRes) {
219
- if (nodeRes.destroyed) {
220
- stream.cancel().catch(() => {});
221
- return;
222
- }
223
- if (nodeRes.req?.method === "HEAD") {
224
- stream.cancel().catch(() => {});
225
- return endNodeResponse(nodeRes);
226
- }
227
- const reader = stream.getReader();
228
- function streamCancel(error) {
229
- reader.cancel(error).catch(() => {});
230
- if (error) nodeRes.destroy(error);
231
- }
232
- function streamHandle({ done, value }) {
233
- try {
234
- if (done) nodeRes.end();
235
- else if (nodeRes.write(value)) reader.read().then(streamHandle, streamCancel);
236
- else nodeRes.once("drain", () => reader.read().then(streamHandle, streamCancel));
237
- } catch (error) {
238
- streamCancel(error instanceof Error ? error : void 0);
239
- }
240
- }
241
- nodeRes.on("close", streamCancel);
242
- nodeRes.on("error", streamCancel);
243
- reader.read().then(streamHandle, streamCancel);
244
- return reader.closed.catch(streamCancel).finally(() => {
245
- nodeRes.off("close", streamCancel);
246
- nodeRes.off("error", streamCancel);
247
- });
248
- }
249
- //#endregion
250
- //#region src/dev-tools/stack-classifier.ts
251
- /** Parse file/line/col from a stack frame line. */
252
- function parseFrame(frameLine) {
253
- const parenMatch = /\(([^)]+):(\d+):(\d+)\)/.exec(frameLine);
254
- if (parenMatch) return {
255
- file: parenMatch[1],
256
- line: Number(parenMatch[2]),
257
- col: Number(parenMatch[3])
258
- };
259
- const bareMatch = /at (\/[^:]+):(\d+):(\d+)/.exec(frameLine);
260
- if (bareMatch) return {
261
- file: bareMatch[1],
262
- line: Number(bareMatch[2]),
263
- col: Number(bareMatch[3])
264
- };
265
- return {};
266
- }
267
- /**
268
- * Classify a single stack frame line by origin.
269
- *
270
- * - 'app': user application code (in project root, not node_modules)
271
- * - 'framework': timber-app internal code
272
- * - 'internal': node_modules, Node.js internals
273
- */
274
- function classifyFrame(frameLine, projectRoot) {
275
- const trimmed = frameLine.trim();
276
- if (trimmed.includes("packages/timber-app/")) return "framework";
277
- if (trimmed.includes("node_modules/")) return "internal";
278
- if (trimmed.startsWith("at node:") || trimmed.includes("(node:")) return "internal";
279
- if (trimmed.includes(projectRoot)) return "app";
280
- return "internal";
281
- }
282
- /**
283
- * Classify all frames in a full stack trace string.
284
- *
285
- * Parses the stack, skips the first line (error message), filters to
286
- * lines starting with "at ", and returns classified frames with optional
287
- * file/line/col metadata.
288
- */
289
- function classifyStack(stack, projectRoot) {
290
- return stack.split("\n").slice(1).filter((line) => line.trim().startsWith("at ")).map((raw) => {
291
- const type = classifyFrame(raw, projectRoot);
292
- const { file, line, col } = parseFrame(raw);
293
- return {
294
- raw,
295
- type,
296
- file,
297
- line,
298
- col
299
- };
300
- });
301
- }
302
- //#endregion
303
- //#region src/dev-tools/terminal.ts
304
- /**
305
- * Terminal error formatting — boxed, color-coded error output for dev mode.
306
- *
307
- * Produces a visually scannable error block with:
308
- * - Unicode box-drawing border around the error
309
- * - Phase badge and error message
310
- * - First app frame highlighted as the primary action item
311
- * - OSC 8 clickable file:line links (VSCode terminal, iTerm2, etc.)
312
- * - Internal/framework frames collapsed with a count
313
- * - Component stack (for React render errors)
314
- *
315
- * Dev-only: this module is only imported by dev-error-overlay.ts.
316
- *
317
- * Design doc: 21-dev-server.md §"Error Overlay"
318
- */
319
- var noValidate = { validateStream: false };
320
- var style = (fmt, text) => styleText(fmt, text, noValidate);
321
- /**
322
- * Wrap text in an OSC 8 hyperlink escape sequence.
323
- *
324
- * Terminals that support OSC 8 (VSCode, iTerm2, Windows Terminal, etc.)
325
- * render this as a clickable link. Others ignore the escape sequences
326
- * and show the text normally.
327
- *
328
- * Format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07
329
- */
330
- function hyperlink(text, url) {
331
- return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
332
- }
333
- /**
334
- * Format a file:line:col reference as a clickable terminal link.
335
- *
336
- * Uses file:// URLs so terminals open the file in the configured editor.
337
- * The link text is styled with cyan + underline for visibility.
338
- */
339
- function fileLink(filePath, line, col) {
340
- const display = line ? `${filePath}:${line}${col ? `:${col}` : ""}` : filePath;
341
- const base = pathToFileURL(filePath).href;
342
- return style(["cyan", "underline"], hyperlink(display, line ? `${base}:${line}${col ? `:${col}` : ""}` : base));
343
- }
344
- var BOX = {
345
- topLeft: "╭",
346
- topRight: "╮",
347
- bottomLeft: "╰",
348
- bottomRight: "╯",
349
- horizontal: "─",
350
- vertical: "│"
351
- };
352
- /**
353
- * Wrap lines of text in a Unicode box with a colored left border.
354
- *
355
- * @param lines - Content lines (no ANSI length calculation — keeps it simple)
356
- * @param width - Box width (characters). Lines longer than this are not truncated.
357
- */
358
- function box(lines, borderFormat, width = 80) {
359
- const bar = BOX.horizontal.repeat(width - 2);
360
- const output = [];
361
- output.push(style(borderFormat, `${BOX.topLeft}${bar}${BOX.topRight}`));
362
- for (const line of lines) output.push(`${style(borderFormat, BOX.vertical)} ${line}`);
363
- output.push(style(borderFormat, `${BOX.bottomLeft}${bar}${BOX.bottomRight}`));
364
- return output.join("\n");
365
- }
366
- /**
367
- * Format an error for terminal output with a boxed layout.
368
- *
369
- * The output is designed to be scannable at a glance:
370
- * 1. Red box with phase badge and error message
371
- * 2. First app frame as a clickable link (the primary action item)
372
- * 3. App frames listed normally
373
- * 4. Internal/framework frames collapsed with count
374
- * 5. Component stack (if present)
375
- */
376
- function formatTerminalError$1(error, phase, projectRoot) {
377
- const sections = [];
378
- const componentStack = extractComponentStack(error);
379
- const loc = parseFirstAppFrame(error.stack ?? "", projectRoot);
380
- const frames = error.stack ? classifyStack(error.stack, projectRoot) : [];
381
- const appFrames = frames.filter((f) => f.type === "app");
382
- const internalCount = frames.filter((f) => f.type !== "app").length;
383
- const boxLines = [];
384
- boxLines.push(style(["red", "bold"], `${PHASE_LABELS$1[phase]} Error`));
385
- boxLines.push("");
386
- for (const msgLine of error.message.split("\n")) boxLines.push(style("red", msgLine));
387
- if (loc) {
388
- boxLines.push("");
389
- const relPath = loc.file.startsWith(projectRoot) ? loc.file.slice(projectRoot.length + 1) : loc.file;
390
- boxLines.push(`${style("bold", "→")} ${fileLink(loc.file, loc.line, loc.column)} ${style("dim", `(${relPath})`)}`);
391
- }
392
- sections.push(box(boxLines, "red"));
393
- if (componentStack) {
394
- sections.push("");
395
- sections.push(` ${style("bold", "Component Stack:")}`);
396
- for (const csLine of componentStack.trim().split("\n")) sections.push(` ${style("dim", csLine.trim())}`);
397
- }
398
- if (appFrames.length > 0) {
399
- sections.push("");
400
- sections.push(` ${style("bold", "Application Frames:")}`);
401
- for (let i = 0; i < appFrames.length; i++) {
402
- const f = appFrames[i];
403
- if (f.file && f.line) {
404
- const prefix = i === 0 ? style("bold", "▸") : " ";
405
- sections.push(` ${prefix} ${fileLink(f.file, f.line, f.col)} ${style("dim", extractFnName(f.raw))}`);
406
- } else sections.push(` ${f.raw}`);
407
- }
408
- }
409
- if (internalCount > 0) sections.push(` ${style("dim", `… ${internalCount} internal frame${internalCount !== 1 ? "s" : ""} hidden`)}`);
410
- sections.push("");
411
- return sections.join("\n");
412
- }
413
- /** Extract the function name from a stack frame line like " at fnName (/path:1:2)". */
414
- function extractFnName(frameLine) {
415
- const match = /at\s+(\S+)\s+\(/.exec(frameLine.trim());
416
- return match ? match[1] : "";
417
- }
418
- //#endregion
419
- //#region src/dev-tools/overlay.ts
420
- var _traceMapping = null;
421
- /**
422
- * Lazy-load @jridgewell/trace-mapping from Vite's dependency tree.
423
- * Vite bundles it internally; we resolve from Vite's package to avoid
424
- * adding a direct dependency.
425
- */
426
- function getTraceMapping() {
427
- if (_traceMapping) return _traceMapping;
428
- const vitePath = createRequire(import.meta.url).resolve("vite");
429
- _traceMapping = createRequire(vitePath)("@jridgewell/trace-mapping");
430
- return _traceMapping;
431
- }
432
- /** Labels for terminal output. */
433
- var PHASE_LABELS$1 = {
434
- "module-transform": "Module Transform",
435
- "proxy": "Proxy",
436
- "middleware": "Middleware",
437
- "access": "Access Check",
438
- "render": "RSC Render",
439
- "handler": "Route Handler"
440
- };
441
- /**
442
- * Extract the React component stack from an error, if present.
443
- * React attaches this as `componentStack` during renderToReadableStream errors.
444
- */
445
- function extractComponentStack(error) {
446
- if (error && typeof error === "object" && "componentStack" in error && typeof error.componentStack === "string") return error.componentStack;
447
- return null;
448
- }
449
- /**
450
- * Parse the first application frame from a stack trace.
451
- * Returns file/line/column for the overlay's `loc` field.
452
- */
453
- function parseFirstAppFrame(stack, projectRoot) {
454
- const lines = stack.split("\n");
455
- const parenRegex = /\(([^)]+):(\d+):(\d+)\)/;
456
- const bareRegex = /at (\/[^:]+):(\d+):(\d+)/;
457
- for (const line of lines) {
458
- if (classifyFrame(line, projectRoot) !== "app") continue;
459
- const match = parenRegex.exec(line) ?? bareRegex.exec(line);
460
- if (!match) continue;
461
- const [, file, lineNum, col] = match;
462
- if (file && lineNum && col) return {
463
- file,
464
- line: parseInt(lineNum, 10),
465
- column: parseInt(col, 10)
466
- };
467
- }
468
- return null;
469
- }
470
- /**
471
- * Classify the error phase by inspecting the error's stack trace.
472
- * Falls back to 'render' if no specific phase can be determined.
473
- */
474
- function classifyErrorPhase(error, projectRoot) {
475
- const stack = error.stack ?? "";
476
- if (extractComponentStack(error)) return "render";
477
- const appRoot = projectRoot.replace(/\/$/, "");
478
- if (stack.includes(`${appRoot}/app/`) || stack.includes("/app/")) {
479
- if (stack.includes("/middleware.ts") || stack.includes("/middleware.js")) return "middleware";
480
- if (stack.includes("/access.ts") || stack.includes("/access.js")) return "access";
481
- if (stack.includes("/route.ts") || stack.includes("/route.js")) return "handler";
482
- }
483
- return "render";
484
- }
485
- var formatTerminalError = formatTerminalError$1;
486
- /**
487
- * Format RSC debug component info into a readable string for the overlay.
488
- *
489
- * Renders the server component tree that was active when an error occurred,
490
- * including component names and source locations from stack frames. This
491
- * gives developers visibility into which server components were rendering
492
- * without exposing source code.
493
- *
494
- * Returns an empty string if no components are provided.
495
- */
496
- function formatRscDebugContext(components) {
497
- if (!components || components.length === 0) return "";
498
- const seen = /* @__PURE__ */ new Set();
499
- const unique = [];
500
- for (const c of components) if (!seen.has(c.name)) {
501
- seen.add(c.name);
502
- unique.push(c);
503
- }
504
- const lines = ["Server Component Tree:"];
505
- for (let i = 0; i < unique.length; i++) {
506
- const c = unique[i];
507
- const indent = " ".repeat(i + 1);
508
- const envLabel = c.env ? ` [${c.env}]` : "";
509
- let locStr = "";
510
- if (c.stack && c.stack.length > 0) {
511
- const frame = c.stack[0];
512
- if (Array.isArray(frame) && frame.length >= 3) locStr = ` (${frame[1]}:${frame[2]})`;
513
- }
514
- lines.push(`${indent}${c.name}${envLabel}${locStr}`);
515
- }
516
- return lines.join("\n");
517
- }
518
- /**
519
- * Dynamically compute the line offset that Vite's module runner adds
520
- * when wrapping modules in an async function.
521
- *
522
- * Vite's `calculateOffsetOnce()` uses the same technique: create a new
523
- * AsyncFunction, throw from line 1, and check where the engine reports
524
- * the error. The difference between the reported line and 1 is the offset.
525
- *
526
- * This is engine-dependent (currently 2 on Node 18-22) and could change
527
- * in future Node.js or V8 versions. Computing it at runtime ensures we
528
- * always match the actual behavior.
529
- */
530
- var _cachedOffset = null;
531
- function calculateModuleRunnerOffset() {
532
- if (_cachedOffset !== null) return _cachedOffset;
533
- try {
534
- const AsyncFunction = async function() {}.constructor;
535
- const src = new AsyncFunction("BODY").toString();
536
- const bodyIndex = src.indexOf("BODY");
537
- if (bodyIndex === -1) {
538
- _cachedOffset = 2;
539
- return _cachedOffset;
540
- }
541
- _cachedOffset = (src.slice(0, bodyIndex).match(/\n/g) || []).length;
542
- } catch (e) {
543
- console.debug("[timber] overlay offset calculation failed:", e instanceof Error ? e.message : e);
544
- _cachedOffset = 2;
545
- }
546
- return _cachedOffset;
547
- }
548
- /**
549
- * Phases where the error originated in the RSC environment.
550
- * These use `server.environments.rsc.moduleGraph` for source-mapping.
551
- */
552
- var RSC_PHASES = /* @__PURE__ */ new Set([
553
- "render",
554
- "access",
555
- "middleware",
556
- "handler"
557
- ]);
558
- /**
559
- * Rewrite an error's stack trace using the correct Vite environment module graph.
560
- *
561
- * `server.ssrFixStacktrace()` hardcodes `server.environments.ssr.moduleGraph`,
562
- * but RSC errors have stack frames pointing to modules loaded in the RSC
563
- * environment — a separate Vite module graph with separate transform results
564
- * and source maps. Using the SSR module graph silently fails to find the
565
- * modules, leaving transpiled/bundled line numbers in the stack trace.
566
- *
567
- * This function picks the RSC module graph for render-phase errors and falls
568
- * back to SSR for module-transform/proxy errors. If the first pass doesn't
569
- * rewrite any frames (e.g., mixed RSC+SSR stack), it tries the other graph.
570
- */
571
- function fixStacktraceForEnvironment(server, error, phase) {
572
- if (!error.stack) return;
573
- const primaryEnvName = RSC_PHASES.has(phase) ? "rsc" : "ssr";
574
- const fallbackEnvName = primaryEnvName === "rsc" ? "ssr" : "rsc";
575
- const primaryEnv = server.environments[primaryEnvName];
576
- const fallbackEnv = server.environments[fallbackEnvName];
577
- if (primaryEnv?.moduleGraph) {
578
- const rewritten = rewriteStacktrace(error.stack, primaryEnv.moduleGraph);
579
- if (rewritten.changed) {
580
- error.stack = rewritten.stack;
581
- return;
582
- }
583
- }
584
- if (fallbackEnv?.moduleGraph) {
585
- const rewritten = rewriteStacktrace(error.stack, fallbackEnv.moduleGraph);
586
- if (rewritten.changed) {
587
- error.stack = rewritten.stack;
588
- return;
589
- }
590
- }
591
- }
592
- /**
593
- * Rewrite stack trace frames using source maps from an environment's module graph.
594
- *
595
- * Mirrors Vite's internal `ssrRewriteStacktrace` logic but works with any
596
- * `EnvironmentModuleGraph`, not just the SSR one.
597
- *
598
- * Returns the rewritten stack and whether any frames were actually changed.
599
- */
600
- function rewriteStacktrace(stack, moduleGraph) {
601
- let changed = false;
602
- return {
603
- stack: stack.split("\n").map((line) => {
604
- return line.replace(/^ {4}at (?:(\S.*?)\s\()?(.+?):(\d+)(?::(\d+))?\)?/, (input, varName, id, lineStr, colStr) => {
605
- if (!id) return input;
606
- const rawSourceMap = moduleGraph.getModuleById(id)?.transformResult?.map;
607
- if (!rawSourceMap) return input;
608
- const OFFSET = calculateModuleRunnerOffset();
609
- const origLine = Number(lineStr) - OFFSET;
610
- const origCol = Number(colStr) - 1;
611
- if (origLine <= 0 || origCol < 0) return input;
612
- let pos;
613
- try {
614
- const { TraceMap: TM, originalPositionFor: opf } = getTraceMapping();
615
- pos = opf(new TM(rawSourceMap), {
616
- line: origLine,
617
- column: origCol
618
- });
619
- } catch (e) {
620
- console.debug("[timber] source map tracing failed:", e instanceof Error ? e.message : e);
621
- return input;
622
- }
623
- if (!pos.source || pos.line == null) return input;
624
- changed = true;
625
- const source = `${resolve(dirname(id), pos.source)}:${pos.line}:${(pos.column ?? 0) + 1}`;
626
- const trimmedVarName = varName?.trim();
627
- if (!trimmedVarName || trimmedVarName === "eval") return ` at ${source}`;
628
- return ` at ${trimmedVarName} (${source})`;
629
- });
630
- }).join("\n"),
631
- changed
632
- };
633
- }
634
- /**
635
- * Source-map an error's stack trace using the Vite dev server's module graph.
636
- *
637
- * Exported so that the fallback error renderer (fallback-error.ts) can
638
- * source-map errors before rendering the dev error page. Without this,
639
- * the dev error page shows transpiled positions (e.g. page.tsx:1:1 instead
640
- * of page.tsx:4:9).
641
- *
642
- * See TIM-811.
643
- */
644
- function fixErrorStacktrace(server, error, phase) {
645
- fixStacktraceForEnvironment(server, error, phase ?? classifyErrorPhase(error, server.config.root));
646
- }
647
- /**
648
- * Send an error to Vite's browser overlay and log it to stderr.
649
- *
650
- * Uses `server.ssrFixStacktrace()` to map stack traces back to source,
651
- * then sends the error via `server.hot.send()` for the browser overlay.
652
- *
653
- * When `rscDebugComponents` is provided (dev mode only), the server
654
- * component tree context is appended to the error message. This helps
655
- * developers identify which server component caused the error without
656
- * exposing source code.
657
- *
658
- * The dev server remains running — errors are handled, not fatal.
659
- */
660
- function sendErrorToOverlay(server, error, phase, projectRoot, rscDebugComponents) {
661
- fixStacktraceForEnvironment(server, error, phase);
662
- const formatted = formatTerminalError(error, phase, projectRoot);
663
- process.stderr.write(`${formatted}\n`);
664
- const loc = parseFirstAppFrame(error.stack ?? "", projectRoot);
665
- const componentStack = extractComponentStack(error);
666
- let message = error.message;
667
- if (componentStack) message = `${error.message}\n\nComponent Stack:\n${componentStack.trim()}`;
668
- const debugContext = formatRscDebugContext(rscDebugComponents ?? []);
669
- if (debugContext) message = `${message}\n\n${debugContext}`;
670
- try {
671
- server.hot.send({
672
- type: "error",
673
- err: {
674
- message,
675
- stack: error.stack ?? "",
676
- id: loc?.file,
677
- plugin: `timber (${PHASE_LABELS$1[phase]})`,
678
- loc: loc ? {
679
- file: loc.file,
680
- line: loc.line,
681
- column: loc.column
682
- } : void 0
683
- }
684
- });
685
- } catch (e) {
686
- console.debug("[timber] overlay error send failed:", e instanceof Error ? e.message : e);
687
- }
688
- }
689
- //#endregion
690
- //#region src/server/utils/escape-html.ts
691
- /**
692
- * Shared HTML escaping utility.
693
- *
694
- * Used by both dev-only paths (error pages, 404 page) and production
695
- * paths (deny-renderer, RSC helpers). Lives in server/utils/ because
696
- * it's not dev-only.
697
- */
698
- /**
699
- * Escape a string for safe embedding in HTML content or attributes.
700
- *
701
- * Replaces `&`, `<`, `>`, and `"` with their HTML entity equivalents.
702
- */
703
- function escapeHtml(str) {
704
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
705
- }
706
- //#endregion
707
- //#region src/dev-tools/dev-page-shell.ts
708
- /**
709
- * Shared scaffold for dev-mode HTML pages (dev error page, dev 404 page).
710
- *
711
- * Both pages are self-contained HTML documents served when the normal
712
- * render pipeline can't produce a page. They share:
713
- * - The document scaffold (head, container, footer)
714
- * - Base CSS (layout, light/dark variables, common components)
715
- * - The HMR auto-reload script that watches Vite's WebSocket and reloads
716
- * when the developer fixes the error / adds the missing route
717
- *
718
- * Dev-only: this module is only imported by dev-mode code paths. It is
719
- * never included in production builds.
720
- *
721
- * Design doc: 21-dev-server.md §"Error Overlay", §"Dev 404 Page"
722
- */
723
- /**
724
- * Extract HMR connection options from a Vite resolved config.
725
- * Used by the dev server to pass HMR config to the dev page generators
726
- * so the auto-reload WebSocket connects to the correct endpoint.
727
- */
728
- function extractHmrOptions(config) {
729
- const hmr = config.server?.hmr;
730
- if (hmr === false) return void 0;
731
- const hmrOpts = typeof hmr === "object" ? hmr : {};
732
- const opts = {
733
- protocol: hmrOpts.protocol,
734
- host: hmrOpts.host,
735
- port: hmrOpts.clientPort || hmrOpts.port,
736
- path: hmrOpts.path,
737
- token: config.webSocketToken
738
- };
739
- if (!opts.protocol && !opts.host && !opts.port && !opts.path && !opts.token) return;
740
- return opts;
741
- }
742
- /**
743
- * Build the inline auto-reload script for dev pages.
744
- *
745
- * The page is static HTML, not a Vite-managed module, so the normal HMR
746
- * update flow can't reach it. Instead we connect to Vite's HMR WebSocket
747
- * directly and reload on any update event.
748
- *
749
- * URL construction mirrors Vite's own client.mjs: explicitly configured
750
- * values win, and the browser's location fills any gaps at runtime. This
751
- * matters because Vite 8 requires a `?token=` on browser WS upgrades in
752
- * every config — a config with only a token still needs the host and port
753
- * from `location` (TIM-1067).
754
- */
755
- function buildReloadScript(hmrOptions) {
756
- return ` (function() {
757
- try {
758
- var o = ${JSON.stringify(hmrOptions ?? {}).replace(/</g, "\\u003c")};
759
- var protocol = o.protocol || (location.protocol === 'https:' ? 'wss' : 'ws');
760
- var host = o.host || location.hostname;
761
- var port = o.port || location.port;
762
- var url = protocol + '://' + host + (port ? ':' + port : '') + (o.path || '')
763
- + (o.token ? '?token=' + encodeURIComponent(o.token) : '');
764
- var ws = new WebSocket(url, 'vite-hmr');
765
- ws.addEventListener('message', function(e) {
766
- try {
767
- var data = JSON.parse(e.data);
768
- if (data.type === 'full-reload' || data.type === 'update') {
769
- location.reload();
770
- }
771
- } catch (ex) {}
772
- });
773
- } catch (ex) {}
774
- })();`;
775
- }
776
- /**
777
- * Render a self-contained dev page: scaffold + shared CSS + footer +
778
- * HMR auto-reload script.
779
- */
780
- function renderDevPageShell(opts) {
781
- return `<!DOCTYPE html>
782
- <html lang="en">
783
- <head>
784
- <meta charset="utf-8">
785
- <meta name="viewport" content="width=device-width, initial-scale=1">
786
- <title>${escapeHtml(opts.title)}</title>
787
- <style>${SHARED_CSS}${opts.extraCss ?? ""}</style>
788
- </head>
789
- <body>
790
- <div class="container">
791
- ${opts.bodyHtml}
792
-
793
- <footer class="footer">
794
- <span class="timber-logo">🪵 timber.js</span>
795
- <span class="footer-hint">${opts.footerHint}</span>
796
- </footer>
797
- </div>
798
-
799
- <script>
800
- ${opts.extraScript ?? ""}
801
- // Auto-reload when Vite HMR signals an update (file fixed, route added).
802
- ${buildReloadScript(opts.hmrOptions)}
803
- <\/script>
804
- </body>
805
- </html>`;
806
- }
807
- /**
808
- * Base CSS shared by all dev pages. Pages override the --badge-* variables
809
- * (and add their own component styles) via `extraCss`.
810
- */
811
- var SHARED_CSS = `
812
- :root {
813
- --bg: #fff;
814
- --fg: #1a1a1a;
815
- --fg-dim: #6b7280;
816
- --border: #e5e7eb;
817
- --source-bg: #fafafa;
818
- --link: #2563eb;
819
- --code-bg: #f3f4f6;
820
- --btn-bg: #f3f4f6;
821
- --btn-fg: #374151;
822
- --btn-border: #d1d5db;
823
- --code-font: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
824
- }
825
-
826
- @media (prefers-color-scheme: dark) {
827
- :root {
828
- --bg: #0a0a0a;
829
- --fg: #f5f5f5;
830
- --fg-dim: #9ca3af;
831
- --border: #27272a;
832
- --source-bg: #18181b;
833
- --link: #60a5fa;
834
- --code-bg: #27272a;
835
- --btn-bg: #27272a;
836
- --btn-fg: #e5e7eb;
837
- --btn-border: #3f3f46;
838
- }
839
- }
840
-
841
- * { margin: 0; padding: 0; box-sizing: border-box; }
842
-
843
- body {
844
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
845
- background: var(--bg);
846
- color: var(--fg);
847
- line-height: 1.6;
848
- padding: 2rem;
849
- }
850
-
851
- .container { max-width: 56rem; margin: 0 auto; }
852
- .header { margin-bottom: 1.5rem; }
853
-
854
- .badge {
855
- display: inline-block;
856
- font-size: 0.75rem;
857
- font-weight: 600;
858
- text-transform: uppercase;
859
- letter-spacing: 0.05em;
860
- padding: 0.25rem 0.625rem;
861
- border-radius: 0.375rem;
862
- background: var(--badge-bg);
863
- color: var(--badge-fg);
864
- border: 1px solid var(--badge-border);
865
- margin-bottom: 0.75rem;
866
- }
867
-
868
- .message {
869
- font-size: 1.375rem;
870
- font-weight: 600;
871
- line-height: 1.3;
872
- word-break: break-word;
873
- }
874
-
875
- .hint {
876
- margin-top: 0.5rem;
877
- color: var(--fg-dim);
878
- font-size: 0.875rem;
879
- }
880
-
881
- .section {
882
- margin-bottom: 1rem;
883
- border: 1px solid var(--border);
884
- border-radius: 0.5rem;
885
- overflow: hidden;
886
- }
887
-
888
- .section-header {
889
- font-size: 0.75rem;
890
- font-weight: 600;
891
- text-transform: uppercase;
892
- letter-spacing: 0.05em;
893
- padding: 0.5rem 0.75rem;
894
- background: var(--source-bg);
895
- border-bottom: 1px solid var(--border);
896
- color: var(--fg-dim);
897
- }
898
-
899
- .footer {
900
- display: flex;
901
- align-items: center;
902
- gap: 0.75rem;
903
- padding-top: 1rem;
904
- border-top: 1px solid var(--border);
905
- font-size: 0.75rem;
906
- color: var(--fg-dim);
907
- }
908
-
909
- .timber-logo { font-weight: 600; white-space: nowrap; }
910
- `;
911
- //#endregion
912
- //#region src/dev-tools/error-page.ts
913
- /**
914
- * Dev error page — self-contained HTML error page for dev server 500s.
915
- *
916
- * Generates a styled, self-contained HTML page when the RSC pipeline fails
917
- * and the Vite error overlay can't fire (e.g., first page load before HMR
918
- * WebSocket connects, RSC entry module crash, early pipeline errors).
919
- *
920
- * This is NOT a replacement for Vite's error overlay — it's the fallback
921
- * for when the overlay's transport (WebSocket) isn't available yet.
922
- *
923
- * Dev-only: this module is only imported by dev-server.ts (apply: 'serve')
924
- * and via dynamic import from fallback-error.ts in dev mode. It is never
925
- * included in production builds.
926
- *
927
- * Design doc: 21-dev-server.md §"Error Overlay"
928
- */
929
- var PHASE_LABELS = {
930
- "module-transform": "Module Transform",
931
- "proxy": "Proxy",
932
- "middleware": "Middleware",
933
- "access": "Access Check",
934
- "render": "RSC Render",
935
- "handler": "Route Handler"
936
- };
937
- var PHASE_HINTS = {
938
- "module-transform": "This error occurred while Vite was transforming a module. Check for syntax errors or missing imports.",
939
- "proxy": "This error occurred in proxy.ts. Check your proxy configuration.",
940
- "middleware": "This error occurred in a middleware.ts file. Check the middleware function for unhandled exceptions.",
941
- "access": "This error occurred in an access.ts file. Check your access control logic.",
942
- "render": "This error occurred while rendering a server component. Check the component for runtime errors.",
943
- "handler": "This error occurred in a route handler (route.ts). Check the handler function."
944
- };
945
- /**
946
- * Try to read a few lines of source code around the error location.
947
- * Returns null if the file can't be read (e.g., virtual modules).
948
- *
949
- * Uses a top-level `node:fs` import — this module is dev-server-only and
950
- * Node-only. A bare `require()` here would throw in the ESM build and the
951
- * code frame would silently never render (TIM-1067, B48).
952
- */
953
- function readSourceContext(filePath, line, contextLines = 3) {
954
- try {
955
- const allLines = readFileSync(filePath, "utf-8").split("\n");
956
- const start = Math.max(0, line - 1 - contextLines);
957
- const end = Math.min(allLines.length, line + contextLines);
958
- return {
959
- startLine: start + 1,
960
- lines: allLines.slice(start, end).map((text, i) => ({
961
- num: start + 1 + i,
962
- text,
963
- highlight: start + 1 + i === line
964
- }))
965
- };
966
- } catch (e) {
967
- console.debug("[timber] source context extraction failed:", e instanceof Error ? e.message : e);
968
- return null;
969
- }
970
- }
971
- var esc = escapeHtml;
972
- /**
973
- * Escape a JSON string for safe embedding in an HTML `<script>` block.
974
- *
975
- * `JSON.stringify` does not escape `<\/script>`, so error text containing
976
- * that sequence breaks out of the script block — an XSS vector.
977
- * We replace all `<` with the equivalent unicode escape, which is valid in
978
- * JSON strings and prevents the HTML parser from seeing a closing
979
- * `<\/script>` tag.
980
- *
981
- * Security: TIM-788
982
- */
983
- function escJsonForScript(json) {
984
- return json.replace(/</g, "\\u003c");
985
- }
986
- /**
987
- * Generate a self-contained HTML error page for dev server 500 responses.
988
- *
989
- * The page includes:
990
- * - Error message and phase label
991
- * - Source code context around the first app frame (if readable)
992
- * - Component stack (for React render errors)
993
- * - Classified stack trace (app frames highlighted, internals collapsed)
994
- * - Copy button for the full error
995
- * - Dark/light mode via prefers-color-scheme
996
- * - Auto-reconnect script that watches for Vite HMR and reloads
997
- */
998
- function generateDevErrorPage(error, phase, projectRoot, hmrOptions) {
999
- const message = error.message || "Unknown error";
1000
- const phaseLabel = PHASE_LABELS[phase];
1001
- const phaseHint = PHASE_HINTS[phase];
1002
- const componentStack = extractComponentStack(error);
1003
- const loc = parseFirstAppFrame(error.stack ?? "", projectRoot);
1004
- const frames = error.stack ? classifyStack(error.stack, projectRoot) : [];
1005
- const appFrames = frames.filter((f) => f.type === "app");
1006
- const internalFrameCount = frames.filter((f) => f.type !== "app").length;
1007
- let sourceContext = null;
1008
- if (loc) sourceContext = readSourceContext(loc.file, loc.line);
1009
- const relPath = loc?.file.startsWith(projectRoot) ? loc.file.slice(projectRoot.length + 1) : loc?.file;
1010
- const bodyHtml = `<header class="header">
1011
- <div class="badge">${esc(phaseLabel)} Error</div>
1012
- <h1 class="message">${esc(message)}</h1>
1013
- ${phaseHint ? `<p class="hint">${esc(phaseHint)}</p>` : ""}
1014
- </header>
1015
-
1016
- ${relPath ? `<div class="location">${esc(relPath)}${loc ? `:${loc.line}:${loc.column}` : ""}</div>` : ""}
1017
-
1018
- ${sourceContext ? `<div class="source-context">
1019
- <div class="section-header">Source</div>
1020
- <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>
1021
- </div>` : ""}
1022
-
1023
- ${componentStack ? `<div class="section">
1024
- <div class="section-header">Component Stack</div>
1025
- <pre class="component-stack">${esc(componentStack.trim())}</pre>
1026
- </div>` : ""}
1027
-
1028
- ${appFrames.length > 0 ? `<div class="section">
1029
- <div class="section-header">Application Frames</div>
1030
- <pre class="stack">${appFrames.map((f) => esc(f.raw)).join("\n")}</pre>
1031
- </div>` : ""}
1032
-
1033
- ${internalFrameCount > 0 ? `<details class="section internal-frames">
1034
- <summary class="section-header clickable">${internalFrameCount} internal frame${internalFrameCount !== 1 ? "s" : ""}</summary>
1035
- <pre class="stack dimmed">${frames.filter((f) => f.type !== "app").map((f) => esc(f.raw)).join("\n")}</pre>
1036
- </details>` : ""}
1037
-
1038
- <div class="actions">
1039
- <button class="btn" onclick="copyError()">Copy Error</button>
1040
- </div>`;
1041
- const copyScript = ` function copyError() {
1042
- 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 ?? ""}`))};
1043
- navigator.clipboard.writeText(text).then(function() {
1044
- var btn = document.querySelector('.btn');
1045
- if (btn) { btn.textContent = 'Copied!'; setTimeout(function() { btn.textContent = 'Copy Error'; }, 1500); }
1046
- });
1047
- }
1048
- `;
1049
- return renderDevPageShell({
1050
- title: `Error — ${phaseLabel} | timber.js`,
1051
- extraCss: ERROR_CSS,
1052
- bodyHtml,
1053
- footerHint: "Fix the error and save — the page will reload automatically via HMR.",
1054
- extraScript: copyScript,
1055
- hmrOptions
1056
- });
1057
- }
1058
- var ERROR_CSS = `
1059
- :root {
1060
- --badge-bg: #fef2f2;
1061
- --badge-fg: #991b1b;
1062
- --badge-border: #fecaca;
1063
- --highlight-bg: #fef2f2;
1064
- --highlight-border: #ef4444;
1065
- --line-num: #9ca3af;
1066
- }
1067
-
1068
- @media (prefers-color-scheme: dark) {
1069
- :root {
1070
- --badge-bg: #450a0a;
1071
- --badge-fg: #fca5a5;
1072
- --badge-border: #7f1d1d;
1073
- --highlight-bg: #450a0a;
1074
- --highlight-border: #dc2626;
1075
- --line-num: #6b7280;
1076
- }
1077
- }
1078
-
1079
- .location {
1080
- font-family: var(--code-font);
1081
- font-size: 0.8125rem;
1082
- color: var(--link);
1083
- margin-bottom: 1rem;
1084
- padding: 0.5rem 0.75rem;
1085
- background: var(--source-bg);
1086
- border-radius: 0.375rem;
1087
- border: 1px solid var(--border);
1088
- }
1089
-
1090
- .source-context {
1091
- margin-bottom: 1rem;
1092
- border: 1px solid var(--border);
1093
- border-radius: 0.5rem;
1094
- overflow: hidden;
1095
- }
1096
-
1097
- .source-code {
1098
- overflow-x: auto;
1099
- font-family: var(--code-font);
1100
- font-size: 0.8125rem;
1101
- line-height: 1.7;
1102
- padding: 0;
1103
- margin: 0;
1104
- }
1105
-
1106
- .source-code code {
1107
- display: block;
1108
- }
1109
-
1110
- .source-line {
1111
- display: block;
1112
- padding: 0 0.75rem;
1113
- border-left: 3px solid transparent;
1114
- }
1115
-
1116
- .source-line-highlight {
1117
- background: var(--highlight-bg);
1118
- border-left-color: var(--highlight-border);
1119
- }
1120
-
1121
- .line-num {
1122
- display: inline-block;
1123
- width: 3rem;
1124
- text-align: right;
1125
- margin-right: 1rem;
1126
- color: var(--line-num);
1127
- user-select: none;
1128
- }
1129
-
1130
- .clickable { cursor: pointer; }
1131
- .clickable:hover { background: var(--btn-bg); }
1132
-
1133
- .component-stack, .stack {
1134
- font-family: var(--code-font);
1135
- font-size: 0.8125rem;
1136
- line-height: 1.7;
1137
- padding: 0.75rem;
1138
- overflow-x: auto;
1139
- white-space: pre;
1140
- margin: 0;
1141
- }
1142
-
1143
- .dimmed { color: var(--fg-dim); }
1144
-
1145
- .actions {
1146
- margin: 1.5rem 0;
1147
- }
1148
-
1149
- .btn {
1150
- font-family: inherit;
1151
- font-size: 0.8125rem;
1152
- font-weight: 500;
1153
- padding: 0.5rem 1rem;
1154
- border-radius: 0.375rem;
1155
- border: 1px solid var(--btn-border);
1156
- background: var(--btn-bg);
1157
- color: var(--btn-fg);
1158
- cursor: pointer;
1159
- transition: background 0.15s;
1160
- }
1161
-
1162
- .btn:hover { opacity: 0.85; }
1163
- `;
1164
- //#endregion
1165
- //#region src/config-validation.ts
1166
- /**
1167
- * Config validation — validates timber.config.ts at startup.
1168
- *
1169
- * Runs in the plugin's configResolved hook (once, at startup/build).
1170
- * Each check produces a clear error message with the invalid value,
1171
- * what's expected, and how to fix it.
1172
- *
1173
- * Design doc: 18-build-system.md
1174
- */
1175
- var knownKeys = new Set(Object.keys({
1176
- output: true,
1177
- debug: true,
1178
- clientJavascript: true,
1179
- adapter: true,
1180
- allowedOrigins: true,
1181
- csrf: true,
1182
- limits: true,
1183
- forms: true,
1184
- pageExtensions: true,
1185
- slowRequestMs: true,
1186
- renderTimeoutMs: true,
1187
- devBrowserLogs: true,
1188
- dev: true,
1189
- serverTiming: true,
1190
- appDir: true,
1191
- mdx: true,
1192
- actionEncryption: true,
1193
- reactCompiler: true,
1194
- sitemap: true,
1195
- buildDir: true,
1196
- cache: true,
1197
- clientSegmentCache: true,
1198
- topLoader: true,
1199
- budget: true
1200
- }));
1201
- /**
1202
- * Validate a TimberUserConfig object.
1203
- *
1204
- * Returns an array of errors. Empty array means the config is valid.
1205
- * Does not throw — the caller decides how to surface errors.
1206
- */
1207
- function validateConfig(config) {
1208
- const errors = [];
1209
- if (config.output !== void 0 && config.output !== "server" && config.output !== "static") errors.push({
1210
- field: "output",
1211
- message: `Invalid output mode: "${String(config.output)}". Must be "server" or "static".`,
1212
- value: config.output,
1213
- suggestion: "Use output: \"server\" (default) or output: \"static\" for static site generation."
1214
- });
1215
- if (config.pageExtensions !== void 0) {
1216
- if (!Array.isArray(config.pageExtensions)) errors.push({
1217
- field: "pageExtensions",
1218
- message: "pageExtensions must be an array of strings.",
1219
- value: config.pageExtensions,
1220
- suggestion: "Example: pageExtensions: [\"tsx\", \"ts\", \"jsx\", \"js\", \"mdx\"]"
1221
- });
1222
- else for (const ext of config.pageExtensions) {
1223
- if (typeof ext !== "string") {
1224
- errors.push({
1225
- field: "pageExtensions",
1226
- message: `pageExtensions contains a non-string value: ${JSON.stringify(ext)}`,
1227
- value: ext
1228
- });
1229
- break;
1230
- }
1231
- if (ext.startsWith(".")) {
1232
- errors.push({
1233
- field: "pageExtensions",
1234
- message: `pageExtensions should not include the leading dot: "${ext}"`,
1235
- value: ext,
1236
- suggestion: `Use "${ext.slice(1)}" instead of "${ext}".`
1237
- });
1238
- break;
1239
- }
1240
- }
1241
- }
1242
- if (config.slowRequestMs !== void 0) {
1243
- if (typeof config.slowRequestMs !== "number" || config.slowRequestMs < 0) errors.push({
1244
- field: "slowRequestMs",
1245
- message: `slowRequestMs must be a non-negative number (got ${JSON.stringify(config.slowRequestMs)}).`,
1246
- value: config.slowRequestMs,
1247
- suggestion: "Use slowRequestMs: 3000 (default) or slowRequestMs: 0 to disable."
1248
- });
1249
- }
1250
- if (config.renderTimeoutMs !== void 0) {
1251
- if (typeof config.renderTimeoutMs !== "number" || config.renderTimeoutMs < 0) errors.push({
1252
- field: "renderTimeoutMs",
1253
- message: `renderTimeoutMs must be a non-negative number (got ${JSON.stringify(config.renderTimeoutMs)}).`,
1254
- value: config.renderTimeoutMs,
1255
- suggestion: "Use renderTimeoutMs: 30000 (default). Setting 0 uses a 120s safety ceiling."
1256
- });
1257
- }
1258
- if (config.serverTiming !== void 0) {
1259
- if (config.serverTiming !== "detailed" && config.serverTiming !== "total" && config.serverTiming !== false) errors.push({
1260
- field: "serverTiming",
1261
- message: `Invalid serverTiming value: ${JSON.stringify(config.serverTiming)}.`,
1262
- value: config.serverTiming,
1263
- suggestion: "Use \"detailed\", \"total\", or false."
1264
- });
1265
- }
1266
- if (config.devBrowserLogs !== void 0) {
1267
- const valid = [
1268
- "error",
1269
- "warn",
1270
- "info",
1271
- "none"
1272
- ];
1273
- if (!valid.includes(config.devBrowserLogs)) errors.push({
1274
- field: "devBrowserLogs",
1275
- message: `Invalid devBrowserLogs value: ${JSON.stringify(config.devBrowserLogs)}.`,
1276
- value: config.devBrowserLogs,
1277
- suggestion: `Use one of: ${valid.map((v) => `"${v}"`).join(", ")}.`
1278
- });
1279
- }
1280
- if (config.sitemap != null && typeof config.sitemap === "object") {
1281
- if (config.sitemap.enabled && !config.sitemap.baseUrl) errors.push({
1282
- field: "sitemap.baseUrl",
1283
- message: "sitemap.baseUrl is required when sitemap is enabled.",
1284
- suggestion: "Add sitemap: { enabled: true, baseUrl: \"https://example.com\" }."
1285
- });
1286
- if (config.sitemap.defaultPriority !== void 0 && (config.sitemap.defaultPriority < 0 || config.sitemap.defaultPriority > 1)) errors.push({
1287
- field: "sitemap.defaultPriority",
1288
- message: `sitemap.defaultPriority must be between 0.0 and 1.0 (got ${config.sitemap.defaultPriority}).`,
1289
- value: config.sitemap.defaultPriority
1290
- });
1291
- }
1292
- if (config.budget != null && typeof config.budget === "object") {
1293
- if (config.budget.firstLoadJs !== void 0 && (typeof config.budget.firstLoadJs !== "number" || config.budget.firstLoadJs <= 0)) errors.push({
1294
- field: "budget.firstLoadJs",
1295
- message: `budget.firstLoadJs must be a positive number in bytes (got ${JSON.stringify(config.budget.firstLoadJs)}).`,
1296
- value: config.budget.firstLoadJs,
1297
- suggestion: "Example: budget: { firstLoadJs: 150_000 } for a ~150 kB limit."
1298
- });
1299
- }
1300
- if (config.cache != null && typeof config.cache === "object") {
1301
- if (config.cache.strictKeyDiscipline !== void 0 && typeof config.cache.strictKeyDiscipline !== "boolean") errors.push({
1302
- field: "cache.strictKeyDiscipline",
1303
- message: `cache.strictKeyDiscipline must be a boolean (got ${JSON.stringify(config.cache.strictKeyDiscipline)}).`,
1304
- value: config.cache.strictKeyDiscipline,
1305
- suggestion: "Use cache: { strictKeyDiscipline: true } to promote key-discipline warnings to errors."
1306
- });
1307
- }
1308
- const MOVED_TO_CACHE_FILE = /* @__PURE__ */ new Set(["cacheHandler", "cdnPurge"]);
1309
- for (const key of Object.keys(config)) if (!knownKeys.has(key)) errors.push({
1310
- field: key,
1311
- message: `Unknown config option: "${key}".`,
1312
- suggestion: MOVED_TO_CACHE_FILE.has(key) ? `"${key}" has moved to timber.cache.ts. Export cacheHandler as default, cdnPurge as a named export.` : `Check for typos. Known options: ${[...knownKeys].sort().join(", ")}.`
1313
- });
1314
- return errors;
1315
- }
1316
- var RED = "\x1B[31m";
1317
- var BOLD = "\x1B[1m";
1318
- var DIM = "\x1B[2m";
1319
- var RESET = "\x1B[0m";
1320
- /**
1321
- * Format config errors for terminal output.
1322
- */
1323
- function formatConfigErrors(errors) {
1324
- if (errors.length === 0) return "";
1325
- const lines = [];
1326
- lines.push(`${RED}${BOLD}[timber]${RESET} ${RED}${errors.length} config error${errors.length !== 1 ? "s" : ""} in timber.config.ts:${RESET}`);
1327
- for (const err of errors) {
1328
- lines.push("");
1329
- lines.push(` ${RED}✗${RESET} ${BOLD}${err.field}${RESET}: ${err.message}`);
1330
- if (err.suggestion) lines.push(` ${DIM}${err.suggestion}${RESET}`);
1331
- }
1332
- return lines.join("\n");
1333
- }
1334
- var VIRTUAL_MODULE_NAMES = {
1335
- "virtual:timber-rsc-entry": "RSC entry (server component handler)",
1336
- "virtual:timber-ssr-entry": "SSR entry (HTML renderer)",
1337
- "virtual:timber-browser-entry": "Browser entry (client hydration)",
1338
- "virtual:timber-config": "Runtime config (timber.config.ts)",
1339
- "virtual:timber-route-manifest": "Route manifest (app/ file tree)",
1340
- "virtual:timber-instrumentation": "Instrumentation (instrumentation.ts)",
1341
- "virtual:timber-cache-handler": "Cache handler (timber.cache.ts)",
1342
- "virtual:timber-build-manifest": "Build manifest (asset mapping)"
1343
- };
1344
- /**
1345
- * Add timber-specific context to an error message that references virtual modules.
1346
- *
1347
- * If the error message contains a `virtual:timber-*` ID, appends a
1348
- * human-readable explanation. Does not replace the original message.
1349
- */
1350
- function addVirtualModuleContext(errorMessage) {
1351
- 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.`;
1352
- return errorMessage;
1353
- }
1354
- /**
1355
- * Required (non-optional) peer dependencies that must be installed.
1356
- */
1357
- var REQUIRED_PEERS = [
1358
- "react",
1359
- "react-dom",
1360
- "@vitejs/plugin-react",
1361
- "@vitejs/plugin-rsc"
1362
- ];
1363
- /**
1364
- * Check that required peer dependencies are installed.
1365
- *
1366
- * Uses require.resolve with a try/catch — no fs scanning.
1367
- * Returns only the missing packages.
1368
- */
1369
- function checkPeerDependencies(projectRoot) {
1370
- const results = [];
1371
- const userRequire = createRequire(`${projectRoot}/package.json`);
1372
- for (const name of REQUIRED_PEERS) try {
1373
- userRequire.resolve(name);
1374
- results.push({
1375
- name,
1376
- status: "ok"
1377
- });
1378
- } catch {
1379
- results.push({
1380
- name,
1381
- status: "missing"
1382
- });
1383
- }
1384
- return results;
1385
- }
1386
- /**
1387
- * Format missing peer dependencies as an actionable warning.
1388
- */
1389
- function formatMissingPeers(results) {
1390
- const missing = results.filter((r) => r.status === "missing");
1391
- if (missing.length === 0) return "";
1392
- const names = missing.map((r) => r.name);
1393
- const lines = [];
1394
- lines.push(`${RED}${BOLD}[timber]${RESET} ${RED}Missing required dependencies:${RESET}`);
1395
- lines.push("");
1396
- for (const name of names) lines.push(` ${RED}✗${RESET} ${name}`);
1397
- lines.push("");
1398
- lines.push(` ${DIM}Install with:${RESET}`);
1399
- lines.push(` ${BOLD}pnpm add ${names.join(" ")}${RESET}`);
1400
- return lines.join("\n");
1401
- }
1402
- /** HMR custom event name for graph-affecting file changes. */
1403
- var GRAPH_UPDATE_EVENT = "timber:graph-update";
1404
- /**
1405
- * Host-header allowlist check, ported branch-for-branch from Vite's
1406
- * host-validation middleware (`isHostAllowedWithoutCache`) so this
1407
- * endpoint's reachability matches the rest of the dev server:
1408
- *
1409
- * - `allowedHosts: true` allows everything
1410
- * - a MISSING Host header is allowed (Vite parity — HTTP/1.0 probes)
1411
- * - any IP-literal host is allowed (`vite --host` LAN access sends
1412
- * `Host: 192.168.x.x:port`; rejecting it would 403 this endpoint
1413
- * while every other dev route works)
1414
- * - `localhost` and `*.localhost` are always allowed
1415
- * - allowedHosts entries: exact match, or `.suffix` subdomain entries
1416
- */
1417
- function isAllowedGraphHost(hostHeader, allowedHosts) {
1418
- if (allowedHosts === true) return true;
1419
- if (hostHeader === void 0) return true;
1420
- let host = hostHeader;
1421
- const bracket = host.match(/^\[([^\]]+)\](?::\d+)?$/);
1422
- if (bracket) return isIP(bracket[1]) === 6;
1423
- const colon = host.lastIndexOf(":");
1424
- if (colon !== -1) host = host.slice(0, colon);
1425
- host = host.toLowerCase();
1426
- if (isIP(host) !== 0) return true;
1427
- if (host === "localhost" || host.endsWith(".localhost")) return true;
1428
- for (const entry of allowedHosts ?? []) {
1429
- const allowed = entry.toLowerCase();
1430
- if (allowed.startsWith(".")) {
1431
- if (host === allowed.slice(1) || host.endsWith(allowed)) return true;
1432
- } else if (host === allowed) return true;
1433
- }
1434
- return false;
1435
- }
1436
- /**
1437
- * Non-http(s) origins admitted without a host check: Vite's own
1438
- * `file:` / `*-extension:` set (its host-validation regex), plus
1439
- * `vscode-webview:` — VS Code webviews fetch with that Origin, and the
1440
- * extension is this endpoint's primary consumer. Vite itself never
1441
- * origin-checks plain HTTP GETs (only WebSocket upgrades), so this
1442
- * layer is strictly additional; the additions keep it from rejecting
1443
- * consumers Vite would serve.
1444
- *
1445
- * NOT admitted: `Origin: null`. Browsers serialize file:// pages'
1446
- * opaque origin as the literal string `null` — but so do sandboxed
1447
- * iframes and data: documents, exactly the untrusted embedders the
1448
- * check exists to stop, and the header offers nothing to tell them
1449
- * apart. file:// pages therefore cannot use this endpoint from a
1450
- * browser; the `file:` scheme entry covers non-browser clients that
1451
- * send a real scheme-form Origin (Vite admits the same set).
1452
- */
1453
- var TRUSTED_ORIGIN_SCHEMES = /^(?:file|vscode-webview|.+-extension):/i;
1454
- /** Origin check: absent is fine (CLI probe); present must pass the host allowlist. */
1455
- function isAllowedGraphOrigin(originHeader, allowedHosts) {
1456
- if (originHeader === void 0) return true;
1457
- if (TRUSTED_ORIGIN_SCHEMES.test(originHeader)) return true;
1458
- let originUrl;
1459
- try {
1460
- originUrl = new URL(originHeader);
1461
- } catch {
1462
- return false;
1463
- }
1464
- if (originUrl.host === "") return false;
1465
- const bareHost = originUrl.hostname.replace(/^\[|\]$/g, "");
1466
- if (isIP(bareHost) !== 0) {
1467
- if (bareHost === "127.0.0.1" || bareHost === "::1") return true;
1468
- if (allowedHosts === true) return true;
1469
- return (allowedHosts ?? []).some((entry) => entry.toLowerCase() === bareHost);
1470
- }
1471
- return isAllowedGraphHost(originUrl.host, allowedHosts);
1472
- }
1473
- function sendJson(res, status, body) {
1474
- res.statusCode = status;
1475
- res.setHeader("content-type", "application/json; charset=utf-8");
1476
- res.end(JSON.stringify(body));
1477
- }
1478
- /**
1479
- * Echo an ADMITTED origin back as CORS headers. A webview fetch from
1480
- * `vscode-webview://…` passes the origin check but the browser rejects
1481
- * the response body without `Access-Control-Allow-Origin` — the exact
1482
- * transport design/47 specifies for the extension. Only the validated
1483
- * origin is echoed (never `*`), with `Vary: Origin` for any cache in
1484
- * between; unadmitted origins were already 403'd and get nothing.
1485
- */
1486
- function reflectCors(res, origin) {
1487
- if (!origin) return;
1488
- res.setHeader("Access-Control-Allow-Origin", origin);
1489
- res.setHeader("Vary", "Origin");
1490
- }
1491
- /**
1492
- * Create the Connect middleware handling GET /__timber/graph.
1493
- *
1494
- * Query surface: `?file=<path>` (absolute or project-root-relative) or
1495
- * `?all=1`. Anything else is a 400 — fail loudly, like the CLI.
1496
- */
1497
- function createGraphEndpointMiddleware(server, ctx) {
1498
- const sourceExtensions = buildSourceExtensions(ctx.config.pageExtensions);
1499
- return async (req, res, next) => {
1500
- const rawUrl = req.url;
1501
- if (!rawUrl || !rawUrl.startsWith("/__timber/")) {
1502
- next();
1503
- return;
1504
- }
1505
- const url = new URL(rawUrl, "http://placeholder.invalid");
1506
- if (url.pathname !== "/__timber/graph") {
1507
- sendJson(res, 404, { error: `unknown /__timber/ endpoint: ${url.pathname}` });
1508
- return;
1509
- }
1510
- if (req.method !== "GET") {
1511
- res.setHeader("Allow", "GET");
1512
- sendJson(res, 405, { error: "GET only" });
1513
- return;
1514
- }
1515
- const origin = req.headers.origin;
1516
- const allowedHosts = server.config.server.allowedHosts;
1517
- if (!server.config.server.https && !isAllowedGraphHost(req.headers.host, allowedHosts)) {
1518
- sendJson(res, 403, { error: "host not allowed" });
1519
- return;
1520
- }
1521
- if (!isAllowedGraphOrigin(origin, allowedHosts)) {
1522
- sendJson(res, 403, { error: "origin not allowed" });
1523
- return;
1524
- }
1525
- reflectCors(res, origin);
1526
- try {
1527
- const { classifyLiveFile, classifyLiveProject } = await import("./_chunks/live-graph-D_2D32Ad.js");
1528
- const preserveSymlinks = server.config.resolve.preserveSymlinks;
1529
- const root = normalizePath(preserveSymlinks ? ctx.root : await realpath(ctx.root));
1530
- const appDir = normalizePath(preserveSymlinks ? ctx.appDir : await realpath(ctx.appDir).catch(() => join(root, relative(ctx.root, ctx.appDir))));
1531
- const liveCtx = {
1532
- rsc: server.environments.rsc.moduleGraph,
1533
- ssr: server.environments.ssr.moduleGraph,
1534
- client: server.environments.client.moduleGraph,
1535
- root,
1536
- appDir,
1537
- pageExtensions: ctx.config.pageExtensions
1538
- };
1539
- const allParam = url.searchParams.get("all");
1540
- if (allParam !== null && allParam !== "1") {
1541
- sendJson(res, 400, { error: `unsupported all value: ${allParam} (use ?all=1)` });
1542
- return;
1543
- }
1544
- if (allParam === "1") {
1545
- sendJson(res, 200, {
1546
- version: 1,
1547
- source: "dev-server",
1548
- cached: false,
1549
- ...await classifyLiveProject(liveCtx)
1550
- });
1551
- return;
1552
- }
1553
- const fileParam = url.searchParams.get("file");
1554
- if (!fileParam) {
1555
- sendJson(res, 400, { error: "missing ?file=<path> (or ?all=1)" });
1556
- return;
1557
- }
1558
- const absolute = normalizePath(isAbsolute(fileParam) ? resolve(fileParam) : resolve(root, fileParam));
1559
- let target;
1560
- try {
1561
- const physical = normalizePath(await realpath(absolute));
1562
- const rootPhysical = preserveSymlinks ? normalizePath(await realpath(root)) : root;
1563
- if (physical !== rootPhysical && !physical.startsWith(rootPhysical + "/") && !physical.startsWith(rootPhysical + "\\")) {
1564
- sendJson(res, 404, { error: `not a project file under ${root}` });
1565
- return;
1566
- }
1567
- if (preserveSymlinks) {
1568
- if (physical !== rootPhysical + absolute.slice(root.length)) {
1569
- sendJson(res, 404, { error: `not a project source file: ${absolute}` });
1570
- return;
1571
- }
1572
- }
1573
- target = normalizePath(preserveSymlinks ? absolute : physical);
1574
- } catch {
1575
- sendJson(res, 404, { error: `file not found: ${absolute}` });
1576
- return;
1577
- }
1578
- if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\")) {
1579
- sendJson(res, 404, { error: `not a project file under ${root}` });
1580
- return;
1581
- }
1582
- if (!(await stat(target).catch(() => null))?.isFile() || !isScannableSourceFile(normalizePath(target), normalizePath(root), sourceExtensions)) {
1583
- sendJson(res, 404, { error: `not a project source file: ${target}` });
1584
- return;
1585
- }
1586
- const errors = [];
1587
- const module = await classifyLiveFile(target, liveCtx, void 0, errors);
1588
- sendJson(res, 200, {
1589
- version: 1,
1590
- source: "dev-server",
1591
- cached: false,
1592
- target: {
1593
- input: fileParam,
1594
- resolved: target
1595
- },
1596
- kind: module.kind,
1597
- pending: module.pending,
1598
- subtreeUnknown: module.subtreeUnknown,
1599
- envs: module.envs,
1600
- boundary: module.boundary,
1601
- chains: module.chains,
1602
- poisonings: module.poisonings,
1603
- errors
1604
- });
1605
- } catch (error) {
1606
- sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1607
- }
1608
- };
1609
- }
1610
- /**
1611
- * Build the push channel: `timber:graph-update` custom HMR events per
1612
- * project-source change. Consumers (the VS Code extension) re-query
1613
- * and debounce on their side; the channel just emits. Non-source noise
1614
- * (build output, dotdirs) is filtered by the same extension set the
1615
- * scanners use.
1616
- *
1617
- * Two phases per edit, because one is not enough (codex review, PR
1618
- * #1057): the `invalidated` event fires from the plugin's `hotUpdate`
1619
- * hook — after Vite clears the module's transform result, but its
1620
- * importer EDGES are only rebuilt when the module is next transformed.
1621
- * The `transformed` event fires for exactly the files with a pending
1622
- * invalidation, and only once the node's `transformResult` is set
1623
- * again — the plugin transform hook itself runs BEFORE Vite's trailing
1624
- * `vite:import-analysis` (which rebuilds the edges), so the hook only
1625
- * marks the file "settling" and a short bounded timer confirms
1626
- * completion via `isTransformed` before emitting. (A module nothing
1627
- * re-requests never re-transforms — its `transformed` event waits for
1628
- * the next render, which is also when the graph has new information.)
1629
- */
1630
- function makeGraphUpdateChannel(ctx, send, isTransformed, canonicalize = (file) => file) {
1631
- /** Files invalidated but not yet re-transformed, per environment. */
1632
- const pending = /* @__PURE__ */ new Set();
1633
- const key = (file, environment) => `${environment}\0${file}`;
1634
- const root = normalizePath(canonicalize(ctx.root));
1635
- const extensions = buildSourceExtensions(ctx.config.pageExtensions);
1636
- const emitTransformed = (file, environment, structural) => {
1637
- send({
1638
- type: "custom",
1639
- event: GRAPH_UPDATE_EVENT,
1640
- data: structural ? {
1641
- file,
1642
- event: "update",
1643
- environment,
1644
- phase: "transformed",
1645
- structural: true
1646
- } : {
1647
- file,
1648
- event: "update",
1649
- environment,
1650
- phase: "transformed"
1651
- }
1652
- });
1653
- };
1654
- /**
1655
- * Confirm the transform request finished (transformResult set again)
1656
- * before emitting phase `transformed`. If confirmation never arrives
1657
- * within the bounded schedule below, the pending key is RE-ARMED
1658
- * rather than emitted — a `transformed` event over a still-stale
1659
- * graph would be a false all-clear with no corrective event.
1660
- */
1661
- /**
1662
- * Delays between confirmation re-checks: dense while a normal
1663
- * transform request finishes, then exponential backoff for slow
1664
- * import-analysis passes (large graphs, cold dep optimizer) — a
1665
- * completed slow transform is CACHED, so no later transform hook may
1666
- * ever fire for it and the tail checks are the only chance to emit.
1667
- * Total window ≈ 16 s, then give up (bounded — the async-safety
1668
- * rules ban open-ended polling) and re-arm the pending key.
1669
- */
1670
- const CONFIRM_DELAYS_MS = [
1671
- ...Array.from({ length: 25 }, () => 20),
1672
- 500,
1673
- 1e3,
1674
- 2e3,
1675
- 4e3,
1676
- 8e3
1677
- ];
1678
- const confirmAndEmit = (file, environment, attempt = 0, structural = false) => {
1679
- if (!isTransformed || isTransformed(file, environment)) {
1680
- emitTransformed(file, environment, structural);
1681
- return;
1682
- }
1683
- if (attempt >= CONFIRM_DELAYS_MS.length) {
1684
- if (!structural) pending.add(key(file, environment));
1685
- return;
1686
- }
1687
- setTimeout(() => confirmAndEmit(file, environment, attempt + 1, structural), CONFIRM_DELAYS_MS[attempt]);
1688
- };
1689
- return {
1690
- onHotUpdate(update) {
1691
- const file = normalizePath(canonicalize(update.file));
1692
- if (!isScannableSourceFile(file, root, extensions)) return;
1693
- if (update.type === "delete") pending.delete(key(file, update.environment));
1694
- else pending.add(key(file, update.environment));
1695
- send({
1696
- type: "custom",
1697
- event: GRAPH_UPDATE_EVENT,
1698
- data: {
1699
- file,
1700
- event: update.type,
1701
- environment: update.environment,
1702
- phase: "invalidated"
1703
- }
1704
- });
1705
- },
1706
- onTransform(id, environment) {
1707
- const file = normalizePath(id.split("?")[0]);
1708
- if (file.includes("virtual:timber-route-manifest")) {
1709
- confirmAndEmit(file, environment, 25, true);
1710
- return;
1711
- }
1712
- const pendingKey = key(file, environment);
1713
- if (!pending.has(pendingKey)) return;
1714
- pending.delete(pendingKey);
1715
- confirmAndEmit(file, environment);
1716
- }
1717
- };
1718
- }
1719
- //#endregion
1720
- //#region src/server/compress.ts
1721
- /**
1722
- * MIME types that benefit from compression.
1723
- * text/* is handled via prefix matching; these are the specific
1724
- * application/* and image/* types that are compressible.
1725
- */
1726
- var COMPRESSIBLE_TYPES = /* @__PURE__ */ new Set([
1727
- "text/html",
1728
- "text/css",
1729
- "text/plain",
1730
- "text/xml",
1731
- "text/javascript",
1732
- RSC_CONTENT_TYPE,
1733
- "application/json",
1734
- "application/javascript",
1735
- "application/xml",
1736
- "application/xhtml+xml",
1737
- "application/rss+xml",
1738
- "application/atom+xml",
1739
- "image/svg+xml"
1740
- ]);
1741
- /**
1742
- * Status codes that should never be compressed (no body or special semantics).
1743
- */
1744
- var NO_COMPRESS_STATUSES = /* @__PURE__ */ new Set([204, 304]);
1745
- /**
1746
- * Parse Accept-Encoding and return the best supported encoding.
1747
- * Returns 'gzip' if the client accepts it, null otherwise.
1748
- *
1749
- * Brotli (br) is intentionally not handled at the application level.
1750
- * At the streaming-friendly quality levels (0–4), brotli's compression
1751
- * ratio advantage over gzip is marginal, and node:zlib's brotli transform
1752
- * buffers output internally — turning smooth streaming responses into
1753
- * large infrequent bursts. Brotli's real wins come from offline/static
1754
- * compression at higher quality levels (5–11), which CDNs and reverse
1755
- * proxies (Cloudflare, nginx, Caddy) apply on cached responses.
1756
- *
1757
- * See design/25-production-deployments.md.
1758
- */
1759
- function negotiateEncoding(acceptEncoding) {
1760
- if (!acceptEncoding) return null;
1761
- const parts = acceptEncoding.split(",");
1762
- for (const part of parts) {
1763
- const [token, ...params] = part.split(";");
1764
- if (token.trim().toLowerCase() !== "gzip") continue;
1765
- let qValue = 1;
1766
- for (const param of params) {
1767
- const trimmed = param.trim().toLowerCase();
1768
- if (trimmed.startsWith("q=")) {
1769
- qValue = parseFloat(trimmed.slice(2));
1770
- if (Number.isNaN(qValue)) qValue = 1;
1771
- break;
1772
- }
1773
- }
1774
- if (qValue > 0) return "gzip";
1775
- }
1776
- return null;
1777
- }
1778
- /**
1779
- * Determine if a response should be compressed.
1780
- *
1781
- * Returns false for:
1782
- * - Responses without a body (204, 304, null body)
1783
- * - Already-encoded responses (Content-Encoding set)
1784
- * - Non-compressible content types (images, binary)
1785
- * - SSE streams (text/event-stream — must not be buffered)
1786
- */
1787
- function shouldCompress(response) {
1788
- if (!response.body) return false;
1789
- if (NO_COMPRESS_STATUSES.has(response.status)) return false;
1790
- if (response.headers.has("Content-Encoding")) return false;
1791
- const contentType = response.headers.get("Content-Type");
1792
- if (!contentType) return false;
1793
- const mimeType = contentType.split(";")[0].trim().toLowerCase();
1794
- if (mimeType === "text/event-stream") return false;
1795
- return COMPRESSIBLE_TYPES.has(mimeType);
1796
- }
1797
- /**
1798
- * Compress a Web Response if the client supports it and the content is compressible.
1799
- *
1800
- * Returns the original response unchanged if compression is not applicable.
1801
- * Returns a new Response with the compressed body, Content-Encoding, and Vary headers.
1802
- *
1803
- * The body is piped through a compression stream — no buffering of the full response.
1804
- * This preserves streaming behavior for HTML shell + deferred Suspense chunks.
1805
- */
1806
- function compressResponse(request, response) {
1807
- if (!shouldCompress(response)) return response;
1808
- const encoding = negotiateEncoding(request.headers.get("Accept-Encoding") ?? "");
1809
- if (!encoding) return response;
1810
- const compressedBody = compressWithGzip(response.body);
1811
- const headers = new Headers(response.headers);
1812
- headers.set("Content-Encoding", encoding);
1813
- headers.delete("Content-Length");
1814
- const existingVary = headers.get("Vary");
1815
- if (existingVary) {
1816
- if (!existingVary.toLowerCase().includes("accept-encoding")) headers.set("Vary", `${existingVary}, Accept-Encoding`);
1817
- } else headers.set("Vary", "Accept-Encoding");
1818
- return new Response(compressedBody, {
1819
- status: response.status,
1820
- statusText: response.statusText,
1821
- headers
1822
- });
1823
- }
1824
- /**
1825
- * Compress a ReadableStream with gzip, flushing each chunk immediately.
1826
- *
1827
- * Uses node:zlib's createGzip with Z_SYNC_FLUSH to ensure each HTML chunk
1828
- * (shell, Suspense resolution, RSC payload) is delivered to the browser
1829
- * as soon as it's available — preserving streaming semantics.
1830
- */
1831
- function compressWithGzip(body) {
1832
- const gzip = createGzip({ flush: constants.Z_SYNC_FLUSH });
1833
- const nodeInput = Readable.fromWeb(body);
1834
- pipeline(nodeInput, gzip, () => {});
1835
- return Readable.toWeb(gzip);
1836
- }
1837
- //#endregion
1838
- //#region src/plugins/dev-server.ts
1839
- var RSC_ENTRY_ID = "virtual:timber-rsc-entry";
1840
- /**
1841
- * Config file names that trigger a full dev server restart when changed.
1842
- * See 21-dev-server.md §HMR Wiring — config is loaded once at startup.
1843
- */
1844
- var CONFIG_FILE_NAMES = [
1845
- "timber.config.ts",
1846
- "timber.config.js",
1847
- "timber.config.mjs"
1848
- ];
1849
- /**
1850
- * URL prefixes that are Vite-internal and should never be intercepted.
1851
- * These are passed through to Vite's built-in middleware.
1852
- */
1853
- var VITE_INTERNAL_PREFIXES = [
1854
- "/@",
1855
- "/__vite",
1856
- "/node_modules/"
1857
- ];
1858
- /**
1859
- * File extensions that indicate static asset requests.
1860
- * These are passed through to Vite's static file serving.
1861
- */
1862
- var ASSET_EXTENSIONS = /\.(?:js|ts|tsx|jsx|css|map|json|svg|png|jpg|jpeg|gif|webp|avif|ico|woff|woff2|ttf|eot|mp4|webm|ogg|mp3|wav)(?:\?.*)?$/;
1863
- /**
1864
- * Create the timber-dev-server Vite plugin.
1865
- *
1866
- * Hook: configureServer (returns post-hook to register after Vite's middleware)
1867
- */
1868
- function timberDevServer(ctx) {
1869
- let graphChannel = null;
1870
- return {
1871
- name: "timber-dev-server",
1872
- apply: "serve",
1873
- /**
1874
- * Push channel for the graph endpoint, phase 1: `invalidated`, one
1875
- * per (file, environment). Emitted from hotUpdate — which runs
1876
- * AFTER Vite invalidates the environment module graphs — never
1877
- * from a raw watcher listener, which would race consumers into
1878
- * querying pre-invalidation memberships (design/47 §"Dev-server
1879
- * endpoint").
1880
- */
1881
- hotUpdate({ file, type }) {
1882
- graphChannel?.onHotUpdate({
1883
- file,
1884
- type,
1885
- environment: this.environment.name
1886
- });
1887
- },
1888
- /**
1889
- * Push channel phase 2: `transformed`, when a pending-invalidated
1890
- * module's import edges are actually rebuilt. A Set lookup for
1891
- * everything without a pending invalidation — free on the hot
1892
- * path. Returning nothing leaves the code untouched.
1893
- */
1894
- transform(_code, id) {
1895
- graphChannel?.onTransform(id, this.environment.name);
1896
- },
1897
- /**
1898
- * Register the dev server middleware and config file watcher.
1899
- *
1900
- * Registers as a pre-hook (no return value) so our middleware runs
1901
- * before Vite's built-in SPA fallback / historyApiFallback. This
1902
- * ensures we see the original URL (e.g. /blog) rather than a
1903
- * rewritten /index.html. Vite-internal and asset requests are
1904
- * filtered out explicitly and passed through to Vite.
1905
- */
1906
- async configureServer(server) {
1907
- const configPaths = CONFIG_FILE_NAMES.map((name) => join(ctx.root, name));
1908
- server.watcher.add(configPaths);
1909
- listenForClientErrors(server, ctx.root);
1910
- server.middlewares.use(createGraphEndpointMiddleware(server, ctx));
1911
- graphChannel = makeGraphUpdateChannel(ctx, (payload) => server.hot.send(payload), (file, environment) => {
1912
- const graph = server.environments[environment]?.moduleGraph;
1913
- if (!graph) return false;
1914
- const nodes = graph.getModulesByFile(file);
1915
- if (nodes) {
1916
- for (const node of nodes) if (node.transformResult != null) return true;
1917
- return false;
1918
- }
1919
- return graph.getModuleById(file)?.transformResult != null;
1920
- }, (file) => {
1921
- if (server.config?.resolve?.preserveSymlinks || file.includes("virtual:")) return file;
1922
- try {
1923
- return realpathSync(file);
1924
- } catch {
1925
- try {
1926
- return join(realpathSync(dirname(file)), basename(file));
1927
- } catch {
1928
- return file;
1929
- }
1930
- }
1931
- });
1932
- server.middlewares.use(createTimberMiddleware(server, ctx));
1933
- registerDevDiscovery(server.httpServer, ctx.root, server.config);
1934
- if (ctx.holdingServer) {
1935
- await ctx.holdingServer.close().catch(() => {});
1936
- ctx.holdingServer = null;
1937
- }
1938
- ctx.timer.end("dev-server-setup");
1939
- const summary = ctx.timer.formatSummary();
1940
- if (summary) console.log(summary);
1941
- }
1942
- };
1943
- }
1944
- /**
1945
- * Create the Connect middleware that routes requests through the timber pipeline.
1946
- *
1947
- * For route requests (HTML pages, API endpoints), the middleware:
1948
- * 1. Loads the RSC entry via ssrLoadModule
1949
- * 2. Converts the Node request to a Web Request
1950
- * 3. Passes it through the RSC handler (which runs the full pipeline)
1951
- * 4. Converts the Web Response back to a Node response
1952
- *
1953
- * For non-route requests (assets, Vite internals, HMR), the middleware
1954
- * calls next() to let Vite handle them.
1955
- */
1956
- function createTimberMiddleware(server, ctx) {
1957
- const projectRoot = ctx.root;
1958
- const hmrOptions = extractHmrOptions(server.config);
1959
- return async (req, res, next) => {
1960
- const url = req.url;
1961
- if (!url) {
1962
- next();
1963
- return;
1964
- }
1965
- if (isViteInternal(url)) {
1966
- next();
1967
- return;
1968
- }
1969
- if (isPublicFile(server, url)) {
1970
- next();
1971
- return;
1972
- }
1973
- if (isAssetRequest(url) && !isMetadataRouteServePath(url) && !isRouteOwnedAsset(ctx.routeTree, server, url)) {
1974
- next();
1975
- return;
1976
- }
1977
- let handler;
1978
- try {
1979
- const rscEnv = server.environments.rsc;
1980
- if (!rscEnv?.runner?.import) throw new Error("[timber] RSC environment is not runnable");
1981
- const rscModule = await rscEnv.runner.import(RSC_ENTRY_ID);
1982
- handler = rscModule.default;
1983
- const config = rscModule.pipelineConfig;
1984
- if (config) {
1985
- config.devPipelineErrorHandler = (error, _phase, debugComponents) => {
1986
- sendErrorToOverlay(server, error, classifyErrorPhase(error, projectRoot), projectRoot, debugComponents);
1987
- };
1988
- config.devHmrOptions = hmrOptions;
1989
- }
1990
- const setSourceMap = rscModule.setDevSourceMapHandler;
1991
- if (typeof setSourceMap === "function") setSourceMap((error) => {
1992
- fixErrorStacktrace(server, error);
1993
- });
1994
- } catch (error) {
1995
- if (error instanceof Error) {
1996
- addTimberContext(error);
1997
- sendErrorToOverlay(server, error, "module-transform", projectRoot);
1998
- }
1999
- respond500(res, error, "module-transform", projectRoot, hmrOptions);
2000
- return;
2001
- }
2002
- if (typeof handler !== "function") {
2003
- console.error("[timber] RSC entry module does not export a default function");
2004
- next();
2005
- return;
2006
- }
2007
- try {
2008
- const webRequest = toWebRequest(req);
2009
- const wrapper = server[Symbol.for("timber:dev-request-wrapper")];
2010
- const finalResponse = compressResponse(webRequest, wrapper ? await wrapper(() => handler(webRequest)) : await handler(webRequest));
2011
- res.writeHead(finalResponse.status, [...finalResponse.headers].flat());
2012
- res.flushHeaders();
2013
- await sendNodeResponse(res, finalResponse);
2014
- } catch (error) {
2015
- if (error instanceof Error) {
2016
- const displayError = isSsrStreamError(error) && error.cause instanceof Error ? error.cause : error;
2017
- const phase = classifyErrorPhase(displayError, projectRoot);
2018
- sendErrorToOverlay(server, displayError, phase, projectRoot);
2019
- respond500(res, displayError, phase, projectRoot, hmrOptions);
2020
- } else {
2021
- process.stderr.write(`\x1b[31m[timber] Dev server error:\x1b[0m ${String(error)}\n`);
2022
- respond500(res, error, "render", projectRoot, hmrOptions);
2023
- }
2024
- }
2025
- };
2026
- }
2027
- /**
2028
- * Send a 500 response without crashing the dev server.
2029
- *
2030
- * In dev mode, renders a styled HTML error page with source context,
2031
- * classified stack trace, and auto-reload on HMR. Falls back to
2032
- * text/plain if the HTML generator fails (must never crash).
2033
- */
2034
- function respond500(res, error, phase, projectRoot, hmrOptions) {
2035
- if (res.headersSent) return;
2036
- if (error instanceof Error) try {
2037
- const html = generateDevErrorPage(error, phase, projectRoot, hmrOptions);
2038
- res.statusCode = 500;
2039
- res.setHeader("content-type", "text/html; charset=utf-8");
2040
- res.end(html);
2041
- return;
2042
- } catch (e) {
2043
- console.debug("[timber] dev error page generation failed, falling back to text/plain:", e instanceof Error ? e.message : e);
2044
- }
2045
- res.statusCode = 500;
2046
- res.setHeader("content-type", "text/plain");
2047
- res.end(`[timber] Internal server error\n\n${error instanceof Error ? error.stack ?? error.message : String(error)}`);
2048
- }
2049
- /**
2050
- * Add timber-specific context to an error's message if it references
2051
- * internal virtual modules (virtual:timber-*). Mutates the error in place.
2052
- */
2053
- function addTimberContext(error) {
2054
- const enriched = addVirtualModuleContext(error.message);
2055
- if (enriched !== error.message) error.message = enriched;
2056
- }
2057
- /**
2058
- * Convert a Node IncomingMessage to a Web Request.
2059
- *
2060
- * Constructs the full URL from the Host header and request URL,
2061
- * and forwards the method, headers, and body.
2062
- *
2063
- * The scheme comes from the socket's TLS state — with `server.https` the
2064
- * browser sends `Origin: https://host`, and a hardcoded `http://` would
2065
- * fail CSRF origin comparison on every POST. X-Forwarded-Proto is NOT
2066
- * consulted: dev connections are direct, so the header would be
2067
- * attacker-controlled. (TIM-1067, B32)
2068
- *
2069
- * Exported for tests.
2070
- */
2071
- function toWebRequest(nodeReq) {
2072
- const protocol = nodeReq.socket?.encrypted ? "https" : "http";
2073
- const host = nodeReq.headers.host ?? nodeReq.headers[":authority"] ?? "localhost";
2074
- const url = `${protocol}://${host}${nodeReq.url}`;
2075
- const headers = new Headers();
2076
- for (const [key, value] of Object.entries(nodeReq.headers)) {
2077
- if (value === void 0) continue;
2078
- if (key.startsWith(":")) continue;
2079
- if (Array.isArray(value)) for (const v of value) headers.append(key, v);
2080
- else headers.set(key, value);
2081
- }
2082
- if (!headers.has("host")) headers.set("host", host);
2083
- const method = nodeReq.method ?? "GET";
2084
- const hasBody = method !== "GET" && method !== "HEAD";
2085
- return new Request(url, {
2086
- method,
2087
- headers,
2088
- body: hasBody ? nodeReadableToWebStream(nodeReq) : void 0,
2089
- duplex: hasBody ? "half" : void 0
2090
- });
2091
- }
2092
- /**
2093
- * Convert a Node Readable stream to a Web ReadableStream.
2094
- */
2095
- function nodeReadableToWebStream(nodeStream) {
2096
- return new ReadableStream({ start(controller) {
2097
- nodeStream.on("data", (chunk) => {
2098
- controller.enqueue(new Uint8Array(chunk));
2099
- });
2100
- nodeStream.on("end", () => {
2101
- controller.close();
2102
- });
2103
- nodeStream.on("error", (err) => {
2104
- controller.error(err);
2105
- });
2106
- } });
2107
- }
2108
- /**
2109
- * Check if a URL is a Vite-internal request that should be passed through.
2110
- */
2111
- function isViteInternal(url) {
2112
- return VITE_INTERNAL_PREFIXES.some((prefix) => url.startsWith(prefix));
2113
- }
2114
- /**
2115
- * Check if a URL looks like a static asset request.
2116
- */
2117
- function isAssetRequest(url) {
2118
- return ASSET_EXTENSIONS.test(url);
2119
- }
2120
- /**
2121
- * Check if a URL maps to a real file in the project's public/ directory.
2122
- * Vite serves public/ files as static assets — if a file exists there it
2123
- * must be passed through to Vite regardless of extension.
2124
- */
2125
- function isPublicFile(server, url) {
2126
- const { publicDir } = server.config;
2127
- if (!publicDir) return false;
2128
- const pathname = url.split("?")[0];
2129
- const relativePath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
2130
- if (!relativePath) return false;
2131
- const fullPath = join(publicDir, relativePath);
2132
- try {
2133
- return statSync(fullPath).isFile();
2134
- } catch {
2135
- return false;
2136
- }
2137
- }
2138
- /**
2139
- * Check whether an asset-extension URL belongs to a scanned route rather
2140
- * than a static file.
2141
- *
2142
- * Route handlers may live at paths ending in asset-like extensions
2143
- * (app/feed.json/route.ts → /feed.json), which the asset bypass would
2144
- * otherwise hand to Vite — 404ing in dev while working in production.
2145
- * Uses the same canonicalization and tree walker as the runtime matcher
2146
- * so dev classification agrees with what the pipeline would match.
2147
- *
2148
- * A file that actually exists on disk wins over the route match: the
2149
- * production server serves public/ statics before the RSC handler (see
2150
- * adapters/nitro.ts), and Vite serves root-relative source files (e.g.
2151
- * /app/global.css) in dev. Without this, a broad catch-all route like
2152
- * app/[...slug] would shadow every static asset. Joining the canonical
2153
- * pathname is traversal-safe — canonicalize() resolves `..` segments and
2154
- * rejects root escapes and encoded separators.
2155
- *
2156
- * Reads ctx.routeTree per request — the routing plugin rescans it on
2157
- * file add/unlink/change, so newly created routes are picked up live.
2158
- */
2159
- function isRouteOwnedAsset(routeTree, server, url) {
2160
- if (!routeTree) return false;
2161
- const rawPathname = url.split("?")[0];
2162
- const result = canonicalize(rawPathname);
2163
- if (!result.ok) return false;
2164
- const parts = result.pathname === "/" ? [] : result.pathname.slice(1).split("/");
2165
- if (matchUrlParts(routeTree.root, parts) === null) return false;
2166
- const relativePath = result.pathname.slice(1);
2167
- const { publicDir, root } = server.config;
2168
- if (publicDir && existsSync(join(publicDir, relativePath))) return false;
2169
- if (existsSync(join(root, relativePath))) return false;
2170
- return true;
2171
- }
2172
- /**
2173
- * Listen for client-side errors forwarded from the browser via HMR.
2174
- *
2175
- * The browser entry catches uncaught errors and unhandled rejections,
2176
- * then sends them as 'timber:client-error' custom events. We parse
2177
- * the first app frame for the overlay's loc field and forward the
2178
- * error to Vite's overlay protocol.
2179
- */
2180
- function listenForClientErrors(server, projectRoot) {
2181
- server.hot.on("timber:client-error", (data) => {
2182
- const loc = parseFirstAppFrame(data.stack, projectRoot);
2183
- let message = data.message;
2184
- if (data.componentStack) message = `${data.message}\n\nComponent Stack:\n${data.componentStack.trim()}`;
2185
- const RED = "\x1B[31m";
2186
- const BOLD = "\x1B[1m";
2187
- const RESET = "\x1B[0m";
2188
- process.stderr.write(`${RED}${BOLD}[timber] Client Error${RESET}\n${RED}${data.message}${RESET}\n\n`);
2189
- try {
2190
- server.hot.send({
2191
- type: "error",
2192
- err: {
2193
- message,
2194
- stack: data.stack,
2195
- id: loc?.file,
2196
- plugin: "timber (Client)",
2197
- loc: loc ? {
2198
- file: loc.file,
2199
- line: loc.line,
2200
- column: loc.column
2201
- } : void 0
2202
- }
2203
- });
2204
- } catch (e) {
2205
- console.debug("[timber] overlay send failed:", e instanceof Error ? e.message : e);
2206
- }
2207
- });
2208
- }
2209
- //#endregion
2210
112
  //#region src/plugins/entries.ts
2211
113
  var __dirname$1 = dirname(fileURLToPath(import.meta.url));
2212
114
  var SRC_DIR = existsSync(resolve(__dirname$1, "..", "server", "rsc-entry", "index.ts")) ? resolve(__dirname$1, "..") : resolve(__dirname$1, "..", "src");
@@ -2465,7 +367,7 @@ function timberEntries(ctx) {
2465
367
  }
2466
368
  //#endregion
2467
369
  //#region src/plugins/mdx.ts
2468
- var MDX_EXTENSIONS_UNDOTTED = [...MDX_EXTENSIONS].map((ext) => ext.slice(1));
370
+ var MDX_EXTENSIONS_UNDOTTED = [.../* @__PURE__ */ new Set([".mdx", ".md"])].map((ext) => ext.slice(1));
2469
371
  /**
2470
372
  * Check if mdx-components.tsx (or .ts, .jsx, .js) exists at the project root
2471
373
  * or in src/. Root takes precedence, matching Next.js behavior.
@@ -11313,17 +9215,13 @@ function timber(config) {
11313
9215
  ctx.appDir = resolveAppDir(resolved.root, ctx.config.appDir);
11314
9216
  ctx.dev = resolved.command === "serve";
11315
9217
  ctx.base = resolved.base;
11316
- for (const name of [
11317
- "timber.config.ts",
11318
- "timber.config.js",
11319
- "timber.config.mjs"
11320
- ]) resolved.configFileDependencies.push(resolve(resolved.root, name));
11321
9218
  ctx.buildDir = resolve(resolved.root, resolved.build.outDir);
11322
9219
  if (!ctx.dev) ctx.timer = createNoopTimer();
11323
9220
  else ctx.timer.start("dev-server-setup");
11324
9221
  const configErrors = validateConfig(ctx.config);
11325
9222
  if (configErrors.length > 0) process.stderr.write(`${formatConfigErrors(configErrors)}\n\n`);
11326
- const peerWarning = formatMissingPeers(checkPeerDependencies(ctx.root));
9223
+ const peerResults = checkPeerDependencies(ctx.root);
9224
+ const peerWarning = formatMissingPeers(peerResults);
11327
9225
  if (peerWarning) process.stderr.write(`${peerWarning}\n\n`);
11328
9226
  }
11329
9227
  };