@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.
- package/dist/_chunks/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
- package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
- package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
- package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
- package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
- package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +1 -15
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +10 -28
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +18 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +39 -12
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/redis-handler.d.ts +72 -8
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/stable-stringify.d.ts +9 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +3 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/browser-entry/scroll.d.ts +9 -0
- package/dist/client/browser-entry/scroll.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +11 -6
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +99 -24
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +13 -0
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +7 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/dev-tools/404-page.d.ts +6 -2
- package/dist/dev-tools/404-page.d.ts.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +77 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
- package/dist/dev-tools/error-page.d.ts +4 -30
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/dev-tools/index.d.ts +2 -1
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts +11 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +430 -253
- package/dist/index.js.map +1 -1
- package/dist/plugins/cache.d.ts +43 -9
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +4 -1
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts +16 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts +23 -0
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +3 -2
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/cookie-parsing.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +2 -2
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +1 -1
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +24 -6
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +11 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +1 -11
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +6 -4
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +2 -1
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/shared/head-header.d.ts +54 -0
- package/dist/shared/head-header.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts +11 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +17 -0
- package/dist/shared/redirect-type.d.ts.map +1 -0
- package/dist/shims/headers.d.ts +52 -1
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/navigation-client.d.ts +33 -11
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/adapters/cloudflare.ts +13 -47
- package/src/adapters/compress-module.ts +9 -3
- package/src/adapters/nitro.ts +9 -2
- package/src/cache/redis-handler.ts +77 -13
- package/src/cache/stable-stringify.ts +65 -2
- package/src/cache/timber-cache.ts +25 -13
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +7 -2
- package/src/client/browser-entry/router-init.ts +2 -1
- package/src/client/browser-entry/scroll.ts +23 -0
- package/src/client/error-boundary.tsx +19 -5
- package/src/client/link.tsx +4 -1
- package/src/client/navigation-api.ts +5 -1
- package/src/client/navigation-root.tsx +16 -0
- package/src/client/router.ts +107 -29
- package/src/client/rsc-fetch.ts +6 -6
- package/src/dev-tools/404-page.ts +22 -123
- package/src/dev-tools/dev-page-shell.ts +256 -0
- package/src/dev-tools/error-page.ts +27 -203
- package/src/dev-tools/index.ts +7 -1
- package/src/dev-tools/instrumentation.ts +39 -2
- package/src/index.ts +1 -0
- package/src/plugins/cache.ts +79 -21
- package/src/plugins/cloudflare-wasm.ts +48 -10
- package/src/plugins/dev-server.ts +32 -8
- package/src/plugins/server-bundle.ts +25 -9
- package/src/plugins/shims.ts +94 -52
- package/src/server/action-handler.ts +10 -2
- package/src/server/actions.ts +3 -2
- package/src/server/compress.ts +11 -2
- package/src/server/cookie-context.ts +11 -0
- package/src/server/cookie-parsing.ts +16 -4
- package/src/server/fallback-error.ts +9 -4
- package/src/server/node-stream-transforms.ts +61 -24
- package/src/server/pipeline.ts +12 -0
- package/src/server/primitives.ts +10 -31
- package/src/server/rsc-entry/index.ts +7 -2
- package/src/server/rsc-entry/rsc-payload.ts +4 -3
- package/src/server/slot-resolver.ts +27 -14
- package/src/server/ssr-entry.ts +17 -28
- package/src/server/tracing.ts +17 -1
- package/src/shared/head-header.ts +69 -0
- package/src/shared/href-validation.ts +24 -0
- package/src/shared/redirect-type.ts +17 -0
- package/src/shims/headers.ts +119 -2
- package/src/shims/navigation-client.ts +48 -27
- package/LICENSE +0 -8
- package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
- package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
- 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
|
-
*
|
|
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
|
|
87
|
-
* prevents the HTML parser from seeing a closing
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
`;
|
package/src/dev-tools/index.ts
CHANGED
|
@@ -42,7 +42,13 @@ export {
|
|
|
42
42
|
} from './overlay.js';
|
|
43
43
|
|
|
44
44
|
// HTML error pages
|
|
45
|
-
export { generateDevErrorPage
|
|
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
|
-
*
|
|
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
|
-
|
|
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