@timber-js/app 0.2.0-alpha.144 → 0.2.0-alpha.146

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 (149) hide show
  1. package/dist/_chunks/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
  2. package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
  3. package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
  4. package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
  5. package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
  6. package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
  7. package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
  8. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
  9. package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
  10. package/dist/adapters/cloudflare.d.ts +1 -15
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +10 -28
  13. package/dist/adapters/cloudflare.js.map +1 -1
  14. package/dist/adapters/compress-module.d.ts.map +1 -1
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +18 -5
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.js +39 -12
  19. package/dist/cache/index.js.map +1 -1
  20. package/dist/cache/redis-handler.d.ts +72 -8
  21. package/dist/cache/redis-handler.d.ts.map +1 -1
  22. package/dist/cache/stable-stringify.d.ts +9 -0
  23. package/dist/cache/stable-stringify.d.ts.map +1 -1
  24. package/dist/cache/timber-cache.d.ts +3 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  27. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  28. package/dist/client/browser-entry/scroll.d.ts +9 -0
  29. package/dist/client/browser-entry/scroll.d.ts.map +1 -1
  30. package/dist/client/error-boundary.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +11 -6
  32. package/dist/client/error-boundary.js.map +1 -1
  33. package/dist/client/index.js +3 -3
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/client/internal.js +99 -24
  36. package/dist/client/internal.js.map +1 -1
  37. package/dist/client/link.d.ts.map +1 -1
  38. package/dist/client/navigation-api.d.ts.map +1 -1
  39. package/dist/client/navigation-root.d.ts +13 -0
  40. package/dist/client/navigation-root.d.ts.map +1 -1
  41. package/dist/client/router.d.ts +7 -0
  42. package/dist/client/router.d.ts.map +1 -1
  43. package/dist/client/rsc-fetch.d.ts.map +1 -1
  44. package/dist/dev-tools/404-page.d.ts +6 -2
  45. package/dist/dev-tools/404-page.d.ts.map +1 -1
  46. package/dist/dev-tools/dev-page-shell.d.ts +77 -0
  47. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
  48. package/dist/dev-tools/error-page.d.ts +4 -30
  49. package/dist/dev-tools/error-page.d.ts.map +1 -1
  50. package/dist/dev-tools/index.d.ts +2 -1
  51. package/dist/dev-tools/index.d.ts.map +1 -1
  52. package/dist/dev-tools/instrumentation.d.ts +11 -1
  53. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +430 -253
  56. package/dist/index.js.map +1 -1
  57. package/dist/plugins/cache.d.ts +43 -9
  58. package/dist/plugins/cache.d.ts.map +1 -1
  59. package/dist/plugins/cloudflare-wasm.d.ts +4 -1
  60. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
  61. package/dist/plugins/dev-server.d.ts +16 -0
  62. package/dist/plugins/dev-server.d.ts.map +1 -1
  63. package/dist/plugins/server-bundle.d.ts.map +1 -1
  64. package/dist/plugins/shims.d.ts +23 -0
  65. package/dist/plugins/shims.d.ts.map +1 -1
  66. package/dist/server/action-handler.d.ts.map +1 -1
  67. package/dist/server/actions.d.ts +3 -2
  68. package/dist/server/actions.d.ts.map +1 -1
  69. package/dist/server/cookie-context.d.ts.map +1 -1
  70. package/dist/server/cookie-parsing.d.ts.map +1 -1
  71. package/dist/server/fallback-error.d.ts +2 -2
  72. package/dist/server/fallback-error.d.ts.map +1 -1
  73. package/dist/server/index.js +1 -1
  74. package/dist/server/internal.js +1 -1
  75. package/dist/server/internal.js.map +1 -1
  76. package/dist/server/node-stream-transforms.d.ts +24 -6
  77. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  78. package/dist/server/pipeline.d.ts +11 -0
  79. package/dist/server/pipeline.d.ts.map +1 -1
  80. package/dist/server/primitives.d.ts +1 -11
  81. package/dist/server/primitives.d.ts.map +1 -1
  82. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  84. package/dist/server/slot-resolver.d.ts +6 -4
  85. package/dist/server/slot-resolver.d.ts.map +1 -1
  86. package/dist/server/ssr-entry.d.ts.map +1 -1
  87. package/dist/server/tracing.d.ts +2 -1
  88. package/dist/server/tracing.d.ts.map +1 -1
  89. package/dist/shared/head-header.d.ts +54 -0
  90. package/dist/shared/head-header.d.ts.map +1 -0
  91. package/dist/shared/href-validation.d.ts +11 -0
  92. package/dist/shared/href-validation.d.ts.map +1 -1
  93. package/dist/shared/redirect-type.d.ts +17 -0
  94. package/dist/shared/redirect-type.d.ts.map +1 -0
  95. package/dist/shims/headers.d.ts +52 -1
  96. package/dist/shims/headers.d.ts.map +1 -1
  97. package/dist/shims/navigation-client.d.ts +33 -11
  98. package/dist/shims/navigation-client.d.ts.map +1 -1
  99. package/package.json +10 -10
  100. package/src/adapters/cloudflare.ts +13 -47
  101. package/src/adapters/compress-module.ts +9 -3
  102. package/src/adapters/nitro.ts +9 -2
  103. package/src/cache/redis-handler.ts +77 -13
  104. package/src/cache/stable-stringify.ts +65 -2
  105. package/src/cache/timber-cache.ts +25 -13
  106. package/src/cli.ts +0 -0
  107. package/src/client/browser-entry/action-dispatch.ts +7 -2
  108. package/src/client/browser-entry/router-init.ts +2 -1
  109. package/src/client/browser-entry/scroll.ts +23 -0
  110. package/src/client/error-boundary.tsx +19 -5
  111. package/src/client/link.tsx +4 -1
  112. package/src/client/navigation-api.ts +5 -1
  113. package/src/client/navigation-root.tsx +16 -0
  114. package/src/client/router.ts +107 -29
  115. package/src/client/rsc-fetch.ts +6 -6
  116. package/src/dev-tools/404-page.ts +22 -123
  117. package/src/dev-tools/dev-page-shell.ts +256 -0
  118. package/src/dev-tools/error-page.ts +27 -203
  119. package/src/dev-tools/index.ts +7 -1
  120. package/src/dev-tools/instrumentation.ts +39 -2
  121. package/src/index.ts +1 -0
  122. package/src/plugins/cache.ts +79 -21
  123. package/src/plugins/cloudflare-wasm.ts +48 -10
  124. package/src/plugins/dev-server.ts +32 -8
  125. package/src/plugins/server-bundle.ts +25 -9
  126. package/src/plugins/shims.ts +94 -52
  127. package/src/server/action-handler.ts +10 -2
  128. package/src/server/actions.ts +3 -2
  129. package/src/server/compress.ts +11 -2
  130. package/src/server/cookie-context.ts +11 -0
  131. package/src/server/cookie-parsing.ts +16 -4
  132. package/src/server/fallback-error.ts +9 -4
  133. package/src/server/node-stream-transforms.ts +61 -24
  134. package/src/server/pipeline.ts +12 -0
  135. package/src/server/primitives.ts +10 -31
  136. package/src/server/rsc-entry/index.ts +7 -2
  137. package/src/server/rsc-entry/rsc-payload.ts +4 -3
  138. package/src/server/slot-resolver.ts +27 -14
  139. package/src/server/ssr-entry.ts +17 -28
  140. package/src/server/tracing.ts +17 -1
  141. package/src/shared/head-header.ts +69 -0
  142. package/src/shared/href-validation.ts +24 -0
  143. package/src/shared/redirect-type.ts +17 -0
  144. package/src/shims/headers.ts +119 -2
  145. package/src/shims/navigation-client.ts +48 -27
  146. package/LICENSE +0 -8
  147. package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
  148. package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
  149. package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Shared scaffold for dev-mode HTML pages (dev error page, dev 404 page).
3
+ *
4
+ * Both pages are self-contained HTML documents served when the normal
5
+ * render pipeline can't produce a page. They share:
6
+ * - The document scaffold (head, container, footer)
7
+ * - Base CSS (layout, light/dark variables, common components)
8
+ * - The HMR auto-reload script that watches Vite's WebSocket and reloads
9
+ * when the developer fixes the error / adds the missing route
10
+ *
11
+ * Dev-only: this module is only imported by dev-mode code paths. It is
12
+ * never included in production builds.
13
+ *
14
+ * Design doc: 21-dev-server.md §"Error Overlay", §"Dev 404 Page"
15
+ */
16
+
17
+ import { escapeHtml } from '../server/utils/escape-html.js';
18
+
19
+ // ─── HMR Options ────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * HMR connection options for the dev page auto-reload WebSocket.
23
+ * Derived from Vite's resolved server config so the page can connect
24
+ * to the correct HMR endpoint (TIM-789).
25
+ */
26
+ export interface DevErrorHmrOptions {
27
+ protocol?: string;
28
+ host?: string;
29
+ port?: number;
30
+ path?: string;
31
+ token?: string;
32
+ }
33
+
34
+ /**
35
+ * Extract HMR connection options from a Vite resolved config.
36
+ * Used by the dev server to pass HMR config to the dev page generators
37
+ * so the auto-reload WebSocket connects to the correct endpoint.
38
+ */
39
+ export function extractHmrOptions(config: {
40
+ server?: { hmr?: { protocol?: string; host?: string; port?: number; path?: string } | boolean };
41
+ webSocketToken?: string;
42
+ }): DevErrorHmrOptions | undefined {
43
+ const hmr = config.server?.hmr;
44
+ if (hmr === false) return undefined;
45
+ const hmrOpts = typeof hmr === 'object' ? hmr : {};
46
+ const opts: DevErrorHmrOptions = {
47
+ protocol: hmrOpts.protocol,
48
+ host: hmrOpts.host,
49
+ port: hmrOpts.port,
50
+ path: hmrOpts.path,
51
+ token: config.webSocketToken,
52
+ };
53
+ // If no HMR fields were explicitly configured, return undefined so the
54
+ // reload script derives everything from the browser's location, which
55
+ // correctly includes the current port (TIM-805).
56
+ if (!opts.protocol && !opts.host && !opts.port && !opts.path && !opts.token) {
57
+ return undefined;
58
+ }
59
+ return opts;
60
+ }
61
+
62
+ // ─── Auto-Reload Script ─────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Build the inline auto-reload script for dev pages.
66
+ *
67
+ * The page is static HTML, not a Vite-managed module, so the normal HMR
68
+ * update flow can't reach it. Instead we connect to Vite's HMR WebSocket
69
+ * directly and reload on any update event.
70
+ *
71
+ * URL construction mirrors Vite's own client.mjs: explicitly configured
72
+ * values win, and the browser's location fills any gaps at runtime. This
73
+ * matters because Vite 8 requires a `?token=` on browser WS upgrades in
74
+ * every config — a config with only a token still needs the host and port
75
+ * from `location` (TIM-1067).
76
+ */
77
+ export function buildReloadScript(hmrOptions?: DevErrorHmrOptions): string {
78
+ // `<` is escaped as a unicode escape so config text can never close the
79
+ // <script> block (same defense as escJsonForScript in error-page.ts, TIM-788).
80
+ const opts = JSON.stringify(hmrOptions ?? {}).replace(/</g, '\\u003c');
81
+ return ` (function() {
82
+ try {
83
+ var o = ${opts};
84
+ var protocol = o.protocol || (location.protocol === 'https:' ? 'wss' : 'ws');
85
+ var host = o.host || location.hostname;
86
+ var port = o.port || location.port;
87
+ var url = protocol + '://' + host + (port ? ':' + port : '') + (o.path || '')
88
+ + (o.token ? '?token=' + encodeURIComponent(o.token) : '');
89
+ var ws = new WebSocket(url, 'vite-hmr');
90
+ ws.addEventListener('message', function(e) {
91
+ try {
92
+ var data = JSON.parse(e.data);
93
+ if (data.type === 'full-reload' || data.type === 'update') {
94
+ location.reload();
95
+ }
96
+ } catch (ex) {}
97
+ });
98
+ } catch (ex) {}
99
+ })();`;
100
+ }
101
+
102
+ // ─── Page Shell ─────────────────────────────────────────────────────────────
103
+
104
+ export interface DevPageShellOptions {
105
+ /** Page title — escaped by the shell. */
106
+ title: string;
107
+ /** Page-specific CSS appended after the shared base (badge colors, etc.). */
108
+ extraCss?: string;
109
+ /** Inner HTML of the container — must already be escaped by the caller. */
110
+ bodyHtml: string;
111
+ /** Hint text shown next to the timber logo in the footer. */
112
+ footerHint: string;
113
+ /** Additional inline script placed before the auto-reload script. */
114
+ extraScript?: string;
115
+ /** HMR connection options for the auto-reload WebSocket. */
116
+ hmrOptions?: DevErrorHmrOptions;
117
+ }
118
+
119
+ /**
120
+ * Render a self-contained dev page: scaffold + shared CSS + footer +
121
+ * HMR auto-reload script.
122
+ */
123
+ export function renderDevPageShell(opts: DevPageShellOptions): string {
124
+ return `<!DOCTYPE html>
125
+ <html lang="en">
126
+ <head>
127
+ <meta charset="utf-8">
128
+ <meta name="viewport" content="width=device-width, initial-scale=1">
129
+ <title>${escapeHtml(opts.title)}</title>
130
+ <style>${SHARED_CSS}${opts.extraCss ?? ''}</style>
131
+ </head>
132
+ <body>
133
+ <div class="container">
134
+ ${opts.bodyHtml}
135
+
136
+ <footer class="footer">
137
+ <span class="timber-logo">🪵 timber.js</span>
138
+ <span class="footer-hint">${opts.footerHint}</span>
139
+ </footer>
140
+ </div>
141
+
142
+ <script>
143
+ ${opts.extraScript ?? ''}
144
+ // Auto-reload when Vite HMR signals an update (file fixed, route added).
145
+ ${buildReloadScript(opts.hmrOptions)}
146
+ </script>
147
+ </body>
148
+ </html>`;
149
+ }
150
+
151
+ // ─── Shared CSS ─────────────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Base CSS shared by all dev pages. Pages override the --badge-* variables
155
+ * (and add their own component styles) via `extraCss`.
156
+ */
157
+ const SHARED_CSS = `
158
+ :root {
159
+ --bg: #fff;
160
+ --fg: #1a1a1a;
161
+ --fg-dim: #6b7280;
162
+ --border: #e5e7eb;
163
+ --source-bg: #fafafa;
164
+ --link: #2563eb;
165
+ --code-bg: #f3f4f6;
166
+ --btn-bg: #f3f4f6;
167
+ --btn-fg: #374151;
168
+ --btn-border: #d1d5db;
169
+ --code-font: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
170
+ }
171
+
172
+ @media (prefers-color-scheme: dark) {
173
+ :root {
174
+ --bg: #0a0a0a;
175
+ --fg: #f5f5f5;
176
+ --fg-dim: #9ca3af;
177
+ --border: #27272a;
178
+ --source-bg: #18181b;
179
+ --link: #60a5fa;
180
+ --code-bg: #27272a;
181
+ --btn-bg: #27272a;
182
+ --btn-fg: #e5e7eb;
183
+ --btn-border: #3f3f46;
184
+ }
185
+ }
186
+
187
+ * { margin: 0; padding: 0; box-sizing: border-box; }
188
+
189
+ body {
190
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
191
+ background: var(--bg);
192
+ color: var(--fg);
193
+ line-height: 1.6;
194
+ padding: 2rem;
195
+ }
196
+
197
+ .container { max-width: 56rem; margin: 0 auto; }
198
+ .header { margin-bottom: 1.5rem; }
199
+
200
+ .badge {
201
+ display: inline-block;
202
+ font-size: 0.75rem;
203
+ font-weight: 600;
204
+ text-transform: uppercase;
205
+ letter-spacing: 0.05em;
206
+ padding: 0.25rem 0.625rem;
207
+ border-radius: 0.375rem;
208
+ background: var(--badge-bg);
209
+ color: var(--badge-fg);
210
+ border: 1px solid var(--badge-border);
211
+ margin-bottom: 0.75rem;
212
+ }
213
+
214
+ .message {
215
+ font-size: 1.375rem;
216
+ font-weight: 600;
217
+ line-height: 1.3;
218
+ word-break: break-word;
219
+ }
220
+
221
+ .hint {
222
+ margin-top: 0.5rem;
223
+ color: var(--fg-dim);
224
+ font-size: 0.875rem;
225
+ }
226
+
227
+ .section {
228
+ margin-bottom: 1rem;
229
+ border: 1px solid var(--border);
230
+ border-radius: 0.5rem;
231
+ overflow: hidden;
232
+ }
233
+
234
+ .section-header {
235
+ font-size: 0.75rem;
236
+ font-weight: 600;
237
+ text-transform: uppercase;
238
+ letter-spacing: 0.05em;
239
+ padding: 0.5rem 0.75rem;
240
+ background: var(--source-bg);
241
+ border-bottom: 1px solid var(--border);
242
+ color: var(--fg-dim);
243
+ }
244
+
245
+ .footer {
246
+ display: flex;
247
+ align-items: center;
248
+ gap: 0.75rem;
249
+ padding-top: 1rem;
250
+ border-top: 1px solid var(--border);
251
+ font-size: 0.75rem;
252
+ color: var(--fg-dim);
253
+ }
254
+
255
+ .timber-logo { font-weight: 600; white-space: nowrap; }
256
+ `;
@@ -8,15 +8,18 @@
8
8
  * This is NOT a replacement for Vite's error overlay — it's the fallback
9
9
  * for when the overlay's transport (WebSocket) isn't available yet.
10
10
  *
11
- * Dev-only: this module is only imported by dev-server.ts (apply: 'serve').
12
- * It is never included in production builds.
11
+ * Dev-only: this module is only imported by dev-server.ts (apply: 'serve')
12
+ * and via dynamic import from fallback-error.ts in dev mode. It is never
13
+ * included in production builds.
13
14
  *
14
15
  * Design doc: 21-dev-server.md §"Error Overlay"
15
16
  */
16
17
 
18
+ import { readFileSync } from 'node:fs';
17
19
  import { extractComponentStack, parseFirstAppFrame, type ErrorPhase } from './overlay.js';
18
20
  import { classifyStack } from './stack-classifier.js';
19
21
  import { escapeHtml } from '../server/utils/escape-html.js';
22
+ import { renderDevPageShell, type DevErrorHmrOptions } from './dev-page-shell.js';
20
23
 
21
24
  // ─── Phase Labels ───────────────────────────────────────────────────────────
22
25
 
@@ -46,6 +49,10 @@ const PHASE_HINTS: Record<ErrorPhase, string> = {
46
49
  /**
47
50
  * Try to read a few lines of source code around the error location.
48
51
  * Returns null if the file can't be read (e.g., virtual modules).
52
+ *
53
+ * Uses a top-level `node:fs` import — this module is dev-server-only and
54
+ * Node-only. A bare `require()` here would throw in the ESM build and the
55
+ * code frame would silently never render (TIM-1067, B48).
49
56
  */
50
57
  function readSourceContext(
51
58
  filePath: string,
@@ -53,8 +60,6 @@ function readSourceContext(
53
60
  contextLines = 3
54
61
  ): { lines: Array<{ num: number; text: string; highlight: boolean }>; startLine: number } | null {
55
62
  try {
56
- // eslint-disable-next-line no-var-requires -- sync require needed at render time
57
- const { readFileSync } = require('node:fs');
58
63
  const content = readFileSync(filePath, 'utf-8');
59
64
  const allLines = content.split('\n');
60
65
 
@@ -83,8 +88,9 @@ const esc = escapeHtml;
83
88
  *
84
89
  * `JSON.stringify` does not escape `</script>`, so error text containing
85
90
  * that sequence breaks out of the script block — an XSS vector.
86
- * We replace all `<` with `\u003c` which is valid in JSON strings and
87
- * prevents the HTML parser from seeing a closing `</script>` tag.
91
+ * We replace all `<` with the equivalent unicode escape, which is valid in
92
+ * JSON strings and prevents the HTML parser from seeing a closing
93
+ * `</script>` tag.
88
94
  *
89
95
  * Security: TIM-788
90
96
  */
@@ -92,62 +98,6 @@ function escJsonForScript(json: string): string {
92
98
  return json.replace(/</g, '\\u003c');
93
99
  }
94
100
 
95
- // ─── HMR Options ────────────────────────────────────────────────────────────
96
-
97
- /**
98
- * HMR connection options for the dev error page auto-reload WebSocket.
99
- * Derived from Vite's resolved server config so the error page can
100
- * connect to the correct HMR endpoint (TIM-789).
101
- */
102
- export interface DevErrorHmrOptions {
103
- protocol?: string;
104
- host?: string;
105
- port?: number;
106
- path?: string;
107
- token?: string;
108
- }
109
-
110
- /**
111
- * Build a WebSocket URL from Vite HMR options.
112
- * Mirrors the logic in Vite's client.mjs for constructing the WS connection URL.
113
- */
114
- function buildWsUrl(opts: DevErrorHmrOptions): string {
115
- const protocol = opts.protocol ?? 'ws';
116
- const host = opts.host ?? 'localhost';
117
- const port = opts.port ? `:${opts.port}` : '';
118
- const path = opts.path ?? '';
119
- const token = opts.token ? `?token=${opts.token}` : '';
120
- return `${protocol}://${host}${port}${path}${token}`;
121
- }
122
-
123
- /**
124
- * Extract HMR connection options from a Vite resolved config.
125
- * Used by the dev server to pass HMR config to the error page generator
126
- * so the auto-reload WebSocket connects to the correct endpoint.
127
- */
128
- export function extractHmrOptions(config: {
129
- server?: { hmr?: { protocol?: string; host?: string; port?: number; path?: string } | boolean };
130
- webSocketToken?: string;
131
- }): DevErrorHmrOptions | undefined {
132
- const hmr = config.server?.hmr;
133
- if (hmr === false) return undefined;
134
- const hmrOpts = typeof hmr === 'object' ? hmr : {};
135
- const opts: DevErrorHmrOptions = {
136
- protocol: hmrOpts.protocol,
137
- host: hmrOpts.host,
138
- port: hmrOpts.port,
139
- path: hmrOpts.path,
140
- token: config.webSocketToken,
141
- };
142
- // If no HMR fields were explicitly configured, return undefined so the
143
- // error page falls back to `'ws://' + location.host` which correctly
144
- // includes the browser's current port (TIM-805).
145
- if (!opts.protocol && !opts.host && !opts.port && !opts.path && !opts.token) {
146
- return undefined;
147
- }
148
- return opts;
149
- }
150
-
151
101
  // ─── HTML Generation ────────────────────────────────────────────────────────
152
102
 
153
103
  /**
@@ -188,17 +138,7 @@ export function generateDevErrorPage(
188
138
  ? loc.file.slice(projectRoot.length + 1)
189
139
  : loc?.file;
190
140
 
191
- return `<!DOCTYPE html>
192
- <html lang="en">
193
- <head>
194
- <meta charset="utf-8">
195
- <meta name="viewport" content="width=device-width, initial-scale=1">
196
- <title>Error — ${esc(phaseLabel)} | timber.js</title>
197
- <style>${CSS}</style>
198
- </head>
199
- <body>
200
- <div class="container">
201
- <header class="header">
141
+ const bodyHtml = `<header class="header">
202
142
  <div class="badge">${esc(phaseLabel)} Error</div>
203
143
  <h1 class="message">${esc(message)}</h1>
204
144
  ${phaseHint ? `<p class="hint">${esc(phaseHint)}</p>` : ''}
@@ -209,7 +149,7 @@ export function generateDevErrorPage(
209
149
  ${
210
150
  sourceContext
211
151
  ? `<div class="source-context">
212
- <div class="source-header">Source</div>
152
+ <div class="section-header">Source</div>
213
153
  <pre class="source-code"><code>${sourceContext.lines
214
154
  .map(
215
155
  (l) =>
@@ -253,16 +193,9 @@ export function generateDevErrorPage(
253
193
 
254
194
  <div class="actions">
255
195
  <button class="btn" onclick="copyError()">Copy Error</button>
256
- </div>
196
+ </div>`;
257
197
 
258
- <footer class="footer">
259
- <span class="timber-logo">🪵 timber.js</span>
260
- <span class="footer-hint">Fix the error and save — the page will reload automatically via HMR.</span>
261
- </footer>
262
- </div>
263
-
264
- <script>
265
- function copyError() {
198
+ const copyScript = ` function copyError() {
266
199
  var text = ${escJsonForScript(
267
200
  JSON.stringify(
268
201
  `${phaseLabel} Error: ${message}\n\n` +
@@ -276,117 +209,41 @@ export function generateDevErrorPage(
276
209
  if (btn) { btn.textContent = 'Copied!'; setTimeout(function() { btn.textContent = 'Copy Error'; }, 1500); }
277
210
  });
278
211
  }
212
+ `;
279
213
 
280
- // Auto-reload when Vite HMR reconnects.
281
- // When the developer fixes the error and saves, Vite's module graph
282
- // invalidates. We can't rely on the normal HMR update flow (the page
283
- // is a static error page, not a Vite-managed module), so we connect
284
- // to Vite's WebSocket and reload on any update event.
285
- (function() {
286
- try {
287
- var wsUrl = ${hmrOptions ? escJsonForScript(JSON.stringify(buildWsUrl(hmrOptions))) : "'ws://' + location.host"};
288
- var ws = new WebSocket(wsUrl, 'vite-hmr');
289
- ws.addEventListener('message', function(e) {
290
- try {
291
- var data = JSON.parse(e.data);
292
- if (data.type === 'full-reload' || data.type === 'update' || data.type === 'connected') {
293
- if (data.type !== 'connected') location.reload();
294
- }
295
- } catch(ex) {}
296
- });
297
- } catch(ex) {}
298
- })();
299
- </script>
300
- </body>
301
- </html>`;
214
+ return renderDevPageShell({
215
+ title: `Error — ${phaseLabel} | timber.js`,
216
+ extraCss: ERROR_CSS,
217
+ bodyHtml,
218
+ footerHint: 'Fix the error and save — the page will reload automatically via HMR.',
219
+ extraScript: copyScript,
220
+ hmrOptions,
221
+ });
302
222
  }
303
223
 
304
- // ─── CSS ────────────────────────────────────────────────────────────────────
224
+ // ─── Page-Specific CSS ──────────────────────────────────────────────────────
305
225
 
306
- const CSS = `
226
+ const ERROR_CSS = `
307
227
  :root {
308
- --bg: #fff;
309
- --fg: #1a1a1a;
310
- --fg-dim: #6b7280;
311
- --border: #e5e7eb;
312
228
  --badge-bg: #fef2f2;
313
229
  --badge-fg: #991b1b;
314
230
  --badge-border: #fecaca;
315
- --source-bg: #fafafa;
316
231
  --highlight-bg: #fef2f2;
317
232
  --highlight-border: #ef4444;
318
233
  --line-num: #9ca3af;
319
- --btn-bg: #f3f4f6;
320
- --btn-fg: #374151;
321
- --btn-border: #d1d5db;
322
- --link: #2563eb;
323
- --code-font: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
324
234
  }
325
235
 
326
236
  @media (prefers-color-scheme: dark) {
327
237
  :root {
328
- --bg: #0a0a0a;
329
- --fg: #f5f5f5;
330
- --fg-dim: #9ca3af;
331
- --border: #27272a;
332
238
  --badge-bg: #450a0a;
333
239
  --badge-fg: #fca5a5;
334
240
  --badge-border: #7f1d1d;
335
- --source-bg: #18181b;
336
241
  --highlight-bg: #450a0a;
337
242
  --highlight-border: #dc2626;
338
243
  --line-num: #6b7280;
339
- --btn-bg: #27272a;
340
- --btn-fg: #e5e7eb;
341
- --btn-border: #3f3f46;
342
- --link: #60a5fa;
343
244
  }
344
245
  }
345
246
 
346
- * { margin: 0; padding: 0; box-sizing: border-box; }
347
-
348
- body {
349
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
350
- background: var(--bg);
351
- color: var(--fg);
352
- line-height: 1.6;
353
- padding: 2rem;
354
- }
355
-
356
- .container {
357
- max-width: 56rem;
358
- margin: 0 auto;
359
- }
360
-
361
- .header { margin-bottom: 1.5rem; }
362
-
363
- .badge {
364
- display: inline-block;
365
- font-size: 0.75rem;
366
- font-weight: 600;
367
- text-transform: uppercase;
368
- letter-spacing: 0.05em;
369
- padding: 0.25rem 0.625rem;
370
- border-radius: 0.375rem;
371
- background: var(--badge-bg);
372
- color: var(--badge-fg);
373
- border: 1px solid var(--badge-border);
374
- margin-bottom: 0.75rem;
375
- }
376
-
377
- .message {
378
- font-size: 1.375rem;
379
- font-weight: 600;
380
- line-height: 1.3;
381
- word-break: break-word;
382
- }
383
-
384
- .hint {
385
- margin-top: 0.5rem;
386
- color: var(--fg-dim);
387
- font-size: 0.875rem;
388
- }
389
-
390
247
  .location {
391
248
  font-family: var(--code-font);
392
249
  font-size: 0.8125rem;
@@ -405,17 +262,6 @@ const CSS = `
405
262
  overflow: hidden;
406
263
  }
407
264
 
408
- .source-header, .section-header {
409
- font-size: 0.75rem;
410
- font-weight: 600;
411
- text-transform: uppercase;
412
- letter-spacing: 0.05em;
413
- padding: 0.5rem 0.75rem;
414
- background: var(--source-bg);
415
- border-bottom: 1px solid var(--border);
416
- color: var(--fg-dim);
417
- }
418
-
419
265
  .source-code {
420
266
  overflow-x: auto;
421
267
  font-family: var(--code-font);
@@ -449,13 +295,6 @@ const CSS = `
449
295
  user-select: none;
450
296
  }
451
297
 
452
- .section {
453
- margin-bottom: 1rem;
454
- border: 1px solid var(--border);
455
- border-radius: 0.5rem;
456
- overflow: hidden;
457
- }
458
-
459
298
  .clickable { cursor: pointer; }
460
299
  .clickable:hover { background: var(--btn-bg); }
461
300
 
@@ -489,19 +328,4 @@ const CSS = `
489
328
  }
490
329
 
491
330
  .btn:hover { opacity: 0.85; }
492
-
493
- .footer {
494
- display: flex;
495
- align-items: center;
496
- gap: 0.75rem;
497
- padding-top: 1rem;
498
- border-top: 1px solid var(--border);
499
- font-size: 0.75rem;
500
- color: var(--fg-dim);
501
- }
502
-
503
- .timber-logo {
504
- font-weight: 600;
505
- white-space: nowrap;
506
- }
507
331
  `;
@@ -42,7 +42,13 @@ export {
42
42
  } from './overlay.js';
43
43
 
44
44
  // HTML error pages
45
- export { generateDevErrorPage, extractHmrOptions, type DevErrorHmrOptions } from './error-page.js';
45
+ export { generateDevErrorPage } from './error-page.js';
46
+ export {
47
+ extractHmrOptions,
48
+ renderDevPageShell,
49
+ buildReloadScript,
50
+ type DevErrorHmrOptions,
51
+ } from './dev-page-shell.js';
46
52
  export { generateDev404Page, collectRoutes, findSimilarRoutes } from './404-page.js';
47
53
 
48
54
  // Terminal error formatting
@@ -28,13 +28,29 @@ import {
28
28
 
29
29
  export type DevFetchCleanup = () => void;
30
30
 
31
+ /**
32
+ * Well-known key holding the active fetch patch's cleanup function.
33
+ *
34
+ * The RSC entry module re-evaluates on every HMR invalidation and calls
35
+ * instrumentDevFetch() again. Without this guard each call would wrap the
36
+ * previous wrapper — N nested wrappers emitting N duplicate timber.fetch
37
+ * spans per request. Symbol.for() survives module re-evaluation (same
38
+ * pattern as __timber_dev_browser_log_script). See TIM-1067, B49.
39
+ */
40
+ const FETCH_PATCH_KEY = Symbol.for('timber.dev.fetch-instrumentation-cleanup');
41
+
31
42
  /**
32
43
  * Patch globalThis.fetch to wrap every call in an OTEL span.
33
44
  *
34
45
  * Returns a cleanup function that restores the original fetch.
35
- * Only call this in dev mode.
46
+ * Idempotent: a repeated call restores the previous patch before
47
+ * re-patching, so wrappers never stack. Only call this in dev mode.
36
48
  */
37
49
  export function instrumentDevFetch(): DevFetchCleanup {
50
+ const globals = globalThis as Record<symbol, unknown>;
51
+ const priorCleanup = globals[FETCH_PATCH_KEY] as DevFetchCleanup | undefined;
52
+ priorCleanup?.();
53
+
38
54
  const originalFetch = globalThis.fetch;
39
55
  const tracer = api.trace.getTracer('timber.js');
40
56
 
@@ -78,9 +94,14 @@ export function instrumentDevFetch(): DevFetchCleanup {
78
94
  );
79
95
  };
80
96
 
81
- return () => {
97
+ const cleanup: DevFetchCleanup = () => {
82
98
  globalThis.fetch = originalFetch;
99
+ if (globals[FETCH_PATCH_KEY] === cleanup) {
100
+ delete globals[FETCH_PATCH_KEY];
101
+ }
83
102
  };
103
+ globals[FETCH_PATCH_KEY] = cleanup;
104
+ return cleanup;
84
105
  }
85
106
 
86
107
  /**
@@ -122,6 +143,16 @@ function extractFetchInfo(
122
143
  * systems that can drift.
123
144
  */
124
145
  export class DevSpanProcessor implements SpanProcessor {
146
+ /**
147
+ * Max traces retained while waiting for their root span. Traces are only
148
+ * deleted when an `http.server.request` root ends — orphan fetches (no
149
+ * active request) and spans ending after their root would otherwise
150
+ * accumulate for the dev-server lifetime. Beyond the cap, the
151
+ * oldest-inserted trace is evicted (Map preserves insertion order).
152
+ * See TIM-1067, B49.
153
+ */
154
+ private static readonly MAX_PENDING_TRACES = 512;
155
+
125
156
  private spansByTrace = new Map<string, ReadableSpan[]>();
126
157
  private mode: DevLogMode;
127
158
  private config: DevLoggerConfig;
@@ -140,6 +171,12 @@ export class DevSpanProcessor implements SpanProcessor {
140
171
 
141
172
  let spans = this.spansByTrace.get(traceId);
142
173
  if (!spans) {
174
+ if (this.spansByTrace.size >= DevSpanProcessor.MAX_PENDING_TRACES) {
175
+ const oldest = this.spansByTrace.keys().next().value;
176
+ if (oldest !== undefined) {
177
+ this.spansByTrace.delete(oldest);
178
+ }
179
+ }
143
180
  spans = [];
144
181
  this.spansByTrace.set(traceId, spans);
145
182
  }
package/src/index.ts CHANGED
@@ -385,6 +385,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
385
385
  '@timber-js/app/cache',
386
386
  '@timber-js/app/search-params',
387
387
  '@timber-js/app/segment-params',
388
+ '@timber-js/app/params',
388
389
  '@timber-js/app/codec',
389
390
  ];
390
391