@timber-js/app 0.1.1 → 0.1.3
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/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +420 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +391 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +214 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-dev-logs — Pipes server console output to the browser console in dev.
|
|
3
|
+
*
|
|
4
|
+
* Patches `console.log/warn/error/debug/info` on the server side and
|
|
5
|
+
* forwards messages to connected browsers via Vite's HMR WebSocket.
|
|
6
|
+
* The browser entry replays them in the browser console with the
|
|
7
|
+
* correct log level and a "[server]" prefix.
|
|
8
|
+
*
|
|
9
|
+
* Dev-only: this plugin only runs during `vite dev` (apply: 'serve').
|
|
10
|
+
* No runtime overhead in production.
|
|
11
|
+
*
|
|
12
|
+
* Design docs: 18-build-system.md §"Dev Server", 02-rendering-pipeline.md
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Plugin, ViteDevServer } from 'vite';
|
|
16
|
+
import type { PluginContext } from '#/index.js';
|
|
17
|
+
|
|
18
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Log levels that are patched and forwarded. */
|
|
21
|
+
export type ServerLogLevel = 'log' | 'warn' | 'error' | 'debug' | 'info';
|
|
22
|
+
|
|
23
|
+
const LOG_LEVELS: ServerLogLevel[] = ['log', 'warn', 'error', 'debug', 'info'];
|
|
24
|
+
|
|
25
|
+
/** Payload sent over Vite's HMR WebSocket. */
|
|
26
|
+
export interface ServerLogPayload {
|
|
27
|
+
level: ServerLogLevel;
|
|
28
|
+
args: unknown[];
|
|
29
|
+
/** Server-side source location (file:line:col) if available. */
|
|
30
|
+
location: string | null;
|
|
31
|
+
/** Timestamp in ms (Date.now()) */
|
|
32
|
+
timestamp: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Serialization ───────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Patterns that look like env vars or secrets — these are redacted. */
|
|
38
|
+
const SENSITIVE_PATTERNS =
|
|
39
|
+
/(?:^|[^a-z])(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|AUTH|CREDENTIAL|SESSION_SECRET)(?:[^a-z]|$)/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Serialize a console argument for transmission over WebSocket.
|
|
43
|
+
*
|
|
44
|
+
* Handles: strings, numbers, booleans, null, undefined, arrays, plain
|
|
45
|
+
* objects, Errors, Dates, RegExps, and falls back to String() for others.
|
|
46
|
+
*
|
|
47
|
+
* Redacts values that match sensitive patterns to avoid leaking secrets.
|
|
48
|
+
*/
|
|
49
|
+
function serializeArg(arg: unknown, depth = 0): unknown {
|
|
50
|
+
if (depth > 5) return '[...]';
|
|
51
|
+
|
|
52
|
+
if (arg === null) return null;
|
|
53
|
+
if (arg === undefined) return '[undefined]';
|
|
54
|
+
|
|
55
|
+
switch (typeof arg) {
|
|
56
|
+
case 'string':
|
|
57
|
+
if (SENSITIVE_PATTERNS.test(arg)) return '[REDACTED]';
|
|
58
|
+
return arg;
|
|
59
|
+
case 'number':
|
|
60
|
+
case 'boolean':
|
|
61
|
+
return arg;
|
|
62
|
+
case 'bigint':
|
|
63
|
+
return `${arg}n`;
|
|
64
|
+
case 'symbol':
|
|
65
|
+
return arg.toString();
|
|
66
|
+
case 'function':
|
|
67
|
+
return `[Function: ${arg.name || 'anonymous'}]`;
|
|
68
|
+
case 'object':
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
return String(arg);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Error
|
|
75
|
+
if (arg instanceof Error) {
|
|
76
|
+
return {
|
|
77
|
+
__type: 'Error',
|
|
78
|
+
name: arg.name,
|
|
79
|
+
message: arg.message,
|
|
80
|
+
stack: arg.stack ?? null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Date
|
|
85
|
+
if (arg instanceof Date) {
|
|
86
|
+
return arg.toISOString();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// RegExp
|
|
90
|
+
if (arg instanceof RegExp) {
|
|
91
|
+
return arg.toString();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Array
|
|
95
|
+
if (Array.isArray(arg)) {
|
|
96
|
+
return arg.map((item) => serializeArg(item, depth + 1));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Map
|
|
100
|
+
if (arg instanceof Map) {
|
|
101
|
+
const entries: Record<string, unknown> = {};
|
|
102
|
+
for (const [key, value] of arg) {
|
|
103
|
+
entries[String(key)] = serializeArg(value, depth + 1);
|
|
104
|
+
}
|
|
105
|
+
return { __type: 'Map', entries };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Set
|
|
109
|
+
if (arg instanceof Set) {
|
|
110
|
+
return { __type: 'Set', values: [...arg].map((v) => serializeArg(v, depth + 1)) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Plain object
|
|
114
|
+
if (Object.getPrototypeOf(arg) === Object.prototype || Object.getPrototypeOf(arg) === null) {
|
|
115
|
+
const result: Record<string, unknown> = {};
|
|
116
|
+
for (const [key, value] of Object.entries(arg as Record<string, unknown>)) {
|
|
117
|
+
if (SENSITIVE_PATTERNS.test(key)) {
|
|
118
|
+
result[key] = '[REDACTED]';
|
|
119
|
+
} else {
|
|
120
|
+
result[key] = serializeArg(value, depth + 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fallback — use toString or constructor name
|
|
127
|
+
try {
|
|
128
|
+
return `[${(arg as object).constructor?.name ?? 'Object'}]`;
|
|
129
|
+
} catch {
|
|
130
|
+
return '[Object]';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Source Location ─────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract the caller's source location from an Error stack trace.
|
|
138
|
+
*
|
|
139
|
+
* Walks the stack to find the first frame that isn't inside this file
|
|
140
|
+
* or Node internals. Returns "file:line:col" or null.
|
|
141
|
+
*/
|
|
142
|
+
function extractCallerLocation(projectRoot: string): string | null {
|
|
143
|
+
const err = new Error();
|
|
144
|
+
const stack = err.stack;
|
|
145
|
+
if (!stack) return null;
|
|
146
|
+
|
|
147
|
+
const lines = stack.split('\n');
|
|
148
|
+
// Skip first line ("Error") and frames inside this module
|
|
149
|
+
for (let i = 1; i < lines.length; i++) {
|
|
150
|
+
const line = lines[i].trim();
|
|
151
|
+
// Skip our own patching frames
|
|
152
|
+
if (line.includes('dev-logs')) continue;
|
|
153
|
+
// Skip node internals
|
|
154
|
+
if (line.includes('node:') || line.includes('node_modules')) continue;
|
|
155
|
+
|
|
156
|
+
// Extract file path from "at ... (file:line:col)" or "at file:line:col"
|
|
157
|
+
const match = line.match(/\((.+?):(\d+):(\d+)\)/) ?? line.match(/at (.+?):(\d+):(\d+)/);
|
|
158
|
+
if (match) {
|
|
159
|
+
let filePath = match[1];
|
|
160
|
+
// Make path relative to project root for readability
|
|
161
|
+
if (filePath.startsWith(projectRoot)) {
|
|
162
|
+
filePath = filePath.slice(projectRoot.length + 1);
|
|
163
|
+
}
|
|
164
|
+
return `${filePath}:${match[2]}:${match[3]}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Framework-Internal Detection ────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if the calling code is from timber's internal plugin/adapter plumbing.
|
|
174
|
+
*
|
|
175
|
+
* Only filters logs from `plugins/` and `adapters/` directories — these are
|
|
176
|
+
* framework operational noise (request summaries, codegen warnings, adapter
|
|
177
|
+
* setup). Logs from `server/` are preserved because they surface user errors
|
|
178
|
+
* (render errors, action errors, route handler errors, etc.).
|
|
179
|
+
*
|
|
180
|
+
* Handles both monorepo paths (timber-app/src/plugins/) and installed
|
|
181
|
+
* package paths (@timber/app/dist/plugins/).
|
|
182
|
+
*/
|
|
183
|
+
export function isFrameworkInternalCaller(): boolean {
|
|
184
|
+
const err = new Error();
|
|
185
|
+
const stack = err.stack;
|
|
186
|
+
if (!stack) return false;
|
|
187
|
+
|
|
188
|
+
const lines = stack.split('\n');
|
|
189
|
+
for (let i = 1; i < lines.length; i++) {
|
|
190
|
+
const line = lines[i].trim();
|
|
191
|
+
// Skip our own patching frames
|
|
192
|
+
if (line.includes('dev-logs')) continue;
|
|
193
|
+
// Skip node internals (but NOT node_modules — we need to inspect those)
|
|
194
|
+
if (line.includes('node:')) continue;
|
|
195
|
+
|
|
196
|
+
// Check if this first real frame is inside timber's own source
|
|
197
|
+
const isTimberPath = line.includes('timber-app/') || line.includes('@timber/app/');
|
|
198
|
+
if (!isTimberPath) return false;
|
|
199
|
+
|
|
200
|
+
// Only filter plugin and adapter internals, not server/ runtime code
|
|
201
|
+
// which surfaces user errors (render errors, action errors, etc.)
|
|
202
|
+
return line.includes('/plugins/') || line.includes('/adapters/');
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Console Patching ────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Patch console methods to forward logs to the browser via HMR WebSocket.
|
|
211
|
+
*
|
|
212
|
+
* Each patched method:
|
|
213
|
+
* 1. Calls the original console method (server terminal still works)
|
|
214
|
+
* 2. Serializes arguments for JSON transport
|
|
215
|
+
* 3. Sends via server.hot.send() — dropped if no clients connected
|
|
216
|
+
*/
|
|
217
|
+
function patchConsole(server: ViteDevServer, projectRoot: string): () => void {
|
|
218
|
+
const originals = new Map<ServerLogLevel, (...args: unknown[]) => void>();
|
|
219
|
+
|
|
220
|
+
for (const level of LOG_LEVELS) {
|
|
221
|
+
originals.set(level, console[level].bind(console));
|
|
222
|
+
|
|
223
|
+
console[level] = (...args: unknown[]) => {
|
|
224
|
+
// Always call the original — server terminal output is preserved
|
|
225
|
+
originals.get(level)!(...args);
|
|
226
|
+
|
|
227
|
+
// Skip framework-internal logs (plugins/, adapters/) from browser forwarding.
|
|
228
|
+
// Server runtime logs (render errors, action errors, etc.) are preserved.
|
|
229
|
+
if (isFrameworkInternalCaller()) return;
|
|
230
|
+
|
|
231
|
+
// Serialize and forward to browser
|
|
232
|
+
try {
|
|
233
|
+
const payload: ServerLogPayload = {
|
|
234
|
+
level,
|
|
235
|
+
args: args.map((arg) => serializeArg(arg)),
|
|
236
|
+
location: extractCallerLocation(projectRoot),
|
|
237
|
+
timestamp: Date.now(),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
server.hot.send('timber:server-log', payload);
|
|
241
|
+
} catch {
|
|
242
|
+
// Never let log forwarding break the server
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Return a cleanup function to restore originals
|
|
248
|
+
return () => {
|
|
249
|
+
for (const [level, original] of originals) {
|
|
250
|
+
console[level] = original;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── Plugin ──────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Create the timber-dev-logs Vite plugin.
|
|
259
|
+
*
|
|
260
|
+
* Patches console methods when the dev server starts and restores them
|
|
261
|
+
* when the server closes. Only active during `vite dev`.
|
|
262
|
+
*/
|
|
263
|
+
export function timberDevLogs(_ctx: PluginContext): Plugin {
|
|
264
|
+
let cleanup: (() => void) | null = null;
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
name: 'timber-dev-logs',
|
|
268
|
+
apply: 'serve',
|
|
269
|
+
|
|
270
|
+
configureServer(server: ViteDevServer) {
|
|
271
|
+
cleanup = patchConsole(server, _ctx.root);
|
|
272
|
+
|
|
273
|
+
// Restore console on server close
|
|
274
|
+
server.httpServer?.on('close', () => {
|
|
275
|
+
cleanup?.();
|
|
276
|
+
cleanup = null;
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-dev-server — Vite sub-plugin for dev server request handling.
|
|
3
|
+
*
|
|
4
|
+
* Registers a configureServer middleware that intercepts requests and
|
|
5
|
+
* routes them through the timber pipeline:
|
|
6
|
+
* proxy.ts → canonicalize → route match → middleware → access → render → flush
|
|
7
|
+
*
|
|
8
|
+
* The RSC entry module is loaded via Vite's ssrLoadModule, which uses
|
|
9
|
+
* Vite's dev module graph instead of built bundles. The full pipeline
|
|
10
|
+
* (including proxy.ts) runs on every request.
|
|
11
|
+
*
|
|
12
|
+
* Design docs: 18-build-system.md §"Dev Server", 02-rendering-pipeline.md
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Plugin, ViteDevServer, DevEnvironment } from 'vite';
|
|
16
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import type { PluginContext } from '#/index.js';
|
|
19
|
+
import { setViteServer } from '#/server/dev-warnings.js';
|
|
20
|
+
import { sendErrorToOverlay, classifyErrorPhase, parseFirstAppFrame } from './dev-error-overlay.js';
|
|
21
|
+
|
|
22
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const RSC_ENTRY_ID = 'virtual:timber-rsc-entry';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Config file names that trigger a full dev server restart when changed.
|
|
28
|
+
* See 21-dev-server.md §HMR Wiring — config is loaded once at startup.
|
|
29
|
+
*/
|
|
30
|
+
const CONFIG_FILE_NAMES = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* URL prefixes that are Vite-internal and should never be intercepted.
|
|
34
|
+
* These are passed through to Vite's built-in middleware.
|
|
35
|
+
*/
|
|
36
|
+
const VITE_INTERNAL_PREFIXES = [
|
|
37
|
+
'/@', // /@vite/client, /@fs/, /@id/
|
|
38
|
+
'/__vite', // /__vite_hmr, /__vite_ping
|
|
39
|
+
'/node_modules/',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* File extensions that indicate static asset requests.
|
|
44
|
+
* These are passed through to Vite's static file serving.
|
|
45
|
+
*/
|
|
46
|
+
const ASSET_EXTENSIONS =
|
|
47
|
+
/\.(?:js|ts|tsx|jsx|css|map|json|svg|png|jpg|jpeg|gif|webp|avif|ico|woff|woff2|ttf|eot|mp4|webm|ogg|mp3|wav)(?:\?.*)?$/;
|
|
48
|
+
|
|
49
|
+
// ─── Plugin ───────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create the timber-dev-server Vite plugin.
|
|
53
|
+
*
|
|
54
|
+
* Hook: configureServer (returns post-hook to register after Vite's middleware)
|
|
55
|
+
*/
|
|
56
|
+
export function timberDevServer(ctx: PluginContext): Plugin {
|
|
57
|
+
return {
|
|
58
|
+
name: 'timber-dev-server',
|
|
59
|
+
|
|
60
|
+
// Only active in dev mode (command === 'serve'), not during build.
|
|
61
|
+
// See 21-dev-server.md §Plugin Registration.
|
|
62
|
+
apply: 'serve',
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Register the dev server middleware and config file watcher.
|
|
66
|
+
*
|
|
67
|
+
* Registers as a pre-hook (no return value) so our middleware runs
|
|
68
|
+
* before Vite's built-in SPA fallback / historyApiFallback. This
|
|
69
|
+
* ensures we see the original URL (e.g. /blog) rather than a
|
|
70
|
+
* rewritten /index.html. Vite-internal and asset requests are
|
|
71
|
+
* filtered out explicitly and passed through to Vite.
|
|
72
|
+
*/
|
|
73
|
+
configureServer(server: ViteDevServer) {
|
|
74
|
+
// Watch config files for full restart.
|
|
75
|
+
// timber.config.ts is loaded once at startup — any change requires
|
|
76
|
+
// a full dev server restart. See 21-dev-server.md §HMR Wiring.
|
|
77
|
+
const configPaths = CONFIG_FILE_NAMES.map((name) => join(ctx.root, name));
|
|
78
|
+
|
|
79
|
+
server.watcher.on('change', (filePath: string) => {
|
|
80
|
+
if (configPaths.includes(filePath)) {
|
|
81
|
+
server.restart();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Register Vite server for browser console warning forwarding.
|
|
86
|
+
// See 21-dev-server.md §Dev-Mode Warnings.
|
|
87
|
+
setViteServer(server);
|
|
88
|
+
|
|
89
|
+
// Listen for client-side errors forwarded from the browser.
|
|
90
|
+
// The browser entry sends 'timber:client-error' events via HMR
|
|
91
|
+
// for uncaught errors and unhandled rejections. We echo them back
|
|
92
|
+
// as Vite's '{ type: "error" }' payload to trigger the overlay.
|
|
93
|
+
listenForClientErrors(server, ctx.root);
|
|
94
|
+
|
|
95
|
+
// Pre-hook — registers middleware before Vite's internals
|
|
96
|
+
server.middlewares.use(createTimberMiddleware(server, ctx.root));
|
|
97
|
+
|
|
98
|
+
// Log startup timing summary. configureServer runs on all plugins
|
|
99
|
+
// before the server listens, so this captures the full cold start.
|
|
100
|
+
ctx.timer.end('dev-server-setup');
|
|
101
|
+
const summary = ctx.timer.formatSummary();
|
|
102
|
+
if (summary) {
|
|
103
|
+
console.log(summary);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Middleware ────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create the Connect middleware that routes requests through the timber pipeline.
|
|
113
|
+
*
|
|
114
|
+
* For route requests (HTML pages, API endpoints), the middleware:
|
|
115
|
+
* 1. Loads the RSC entry via ssrLoadModule
|
|
116
|
+
* 2. Converts the Node request to a Web Request
|
|
117
|
+
* 3. Passes it through the RSC handler (which runs the full pipeline)
|
|
118
|
+
* 4. Converts the Web Response back to a Node response
|
|
119
|
+
*
|
|
120
|
+
* For non-route requests (assets, Vite internals, HMR), the middleware
|
|
121
|
+
* calls next() to let Vite handle them.
|
|
122
|
+
*/
|
|
123
|
+
function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
|
|
124
|
+
return async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void> => {
|
|
125
|
+
const url = req.url;
|
|
126
|
+
if (!url) {
|
|
127
|
+
next();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Pass through Vite-internal requests
|
|
132
|
+
if (isViteInternal(url)) {
|
|
133
|
+
next();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Pass through static asset requests
|
|
138
|
+
if (isAssetRequest(url)) {
|
|
139
|
+
next();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Step 1: Load the RSC entry module from the RSC environment.
|
|
144
|
+
// The RSC entry runs in the 'rsc' Vite environment (separate module
|
|
145
|
+
// graph with react-server conditions). In dev mode, this uses the
|
|
146
|
+
// environment's module runner for HMR-aware loading.
|
|
147
|
+
let handler: (req: Request) => Promise<Response>;
|
|
148
|
+
try {
|
|
149
|
+
const rscEnv = server.environments.rsc as DevEnvironment & { runner?: { import: (id: string) => Promise<any> } };
|
|
150
|
+
// Duck-type check instead of isRunnableDevEnvironment() — the vite
|
|
151
|
+
// import-based check fails across pnpm link boundaries where the
|
|
152
|
+
// linked package resolves a different vite module instance.
|
|
153
|
+
if (!rscEnv?.runner?.import) {
|
|
154
|
+
throw new Error('[timber] RSC environment is not runnable');
|
|
155
|
+
}
|
|
156
|
+
const rscModule = await rscEnv.runner.import(RSC_ENTRY_ID);
|
|
157
|
+
handler = rscModule.default as (req: Request) => Promise<Response>;
|
|
158
|
+
|
|
159
|
+
// Wire pipeline errors into the browser error overlay.
|
|
160
|
+
// setDevPipelineErrorHandler is only defined in dev (rsc-entry.ts exports it).
|
|
161
|
+
const setHandler = rscModule.setDevPipelineErrorHandler as
|
|
162
|
+
| ((fn: (error: Error, phase: string) => void) => void)
|
|
163
|
+
| undefined;
|
|
164
|
+
if (typeof setHandler === 'function') {
|
|
165
|
+
setHandler((error) => {
|
|
166
|
+
sendErrorToOverlay(server, error, classifyErrorPhase(error, projectRoot), projectRoot);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Module transform error — syntax error, missing import, etc.
|
|
171
|
+
// Vite may already show its own overlay for these, but we still
|
|
172
|
+
// log to stderr with frame dimming for the terminal.
|
|
173
|
+
if (error instanceof Error) {
|
|
174
|
+
sendErrorToOverlay(server, error, 'module-transform', projectRoot);
|
|
175
|
+
}
|
|
176
|
+
respond500(res, error);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (typeof handler !== 'function') {
|
|
181
|
+
console.error('[timber] RSC entry module does not export a default function');
|
|
182
|
+
next();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Step 2: Run the pipeline.
|
|
187
|
+
try {
|
|
188
|
+
// Convert Node IncomingMessage → Web Request
|
|
189
|
+
const webRequest = toWebRequest(req);
|
|
190
|
+
|
|
191
|
+
// Run the full pipeline
|
|
192
|
+
const webResponse = await handler(webRequest);
|
|
193
|
+
|
|
194
|
+
// Convert Web Response → Node ServerResponse
|
|
195
|
+
await sendWebResponse(res, webResponse);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// Pipeline error — classify the phase, send to overlay, respond 500.
|
|
198
|
+
// The dev server remains running for recovery on file fix + HMR.
|
|
199
|
+
if (error instanceof Error) {
|
|
200
|
+
const phase = classifyErrorPhase(error, projectRoot);
|
|
201
|
+
sendErrorToOverlay(server, error, phase, projectRoot);
|
|
202
|
+
} else {
|
|
203
|
+
process.stderr.write(`\x1b[31m[timber] Dev server error:\x1b[0m ${String(error)}\n`);
|
|
204
|
+
}
|
|
205
|
+
respond500(res, error);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Send a 500 response without crashing the dev server.
|
|
212
|
+
*/
|
|
213
|
+
function respond500(res: ServerResponse, error: unknown): void {
|
|
214
|
+
if (!res.headersSent) {
|
|
215
|
+
res.statusCode = 500;
|
|
216
|
+
res.setHeader('content-type', 'text/plain');
|
|
217
|
+
res.end(
|
|
218
|
+
`[timber] Internal server error\n\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Request/Response Conversion ──────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Convert a Node IncomingMessage to a Web Request.
|
|
227
|
+
*
|
|
228
|
+
* Constructs the full URL from the Host header and request URL,
|
|
229
|
+
* and forwards the method, headers, and body.
|
|
230
|
+
*/
|
|
231
|
+
function toWebRequest(nodeReq: IncomingMessage): Request {
|
|
232
|
+
const protocol = 'http';
|
|
233
|
+
const host = nodeReq.headers.host ?? 'localhost';
|
|
234
|
+
const url = `${protocol}://${host}${nodeReq.url}`;
|
|
235
|
+
|
|
236
|
+
const headers = new Headers();
|
|
237
|
+
for (const [key, value] of Object.entries(nodeReq.headers)) {
|
|
238
|
+
if (value === undefined) continue;
|
|
239
|
+
if (Array.isArray(value)) {
|
|
240
|
+
for (const v of value) {
|
|
241
|
+
headers.append(key, v);
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
headers.set(key, value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const method = nodeReq.method ?? 'GET';
|
|
249
|
+
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
250
|
+
|
|
251
|
+
return new Request(url, {
|
|
252
|
+
method,
|
|
253
|
+
headers,
|
|
254
|
+
body: hasBody ? nodeReadableToWebStream(nodeReq) : undefined,
|
|
255
|
+
// @ts-expect-error — duplex is required for streaming request bodies
|
|
256
|
+
duplex: hasBody ? 'half' : undefined,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Convert a Node Readable stream to a Web ReadableStream.
|
|
262
|
+
*/
|
|
263
|
+
function nodeReadableToWebStream(nodeStream: IncomingMessage): ReadableStream<Uint8Array> {
|
|
264
|
+
return new ReadableStream({
|
|
265
|
+
start(controller) {
|
|
266
|
+
nodeStream.on('data', (chunk: Buffer) => {
|
|
267
|
+
controller.enqueue(new Uint8Array(chunk));
|
|
268
|
+
});
|
|
269
|
+
nodeStream.on('end', () => {
|
|
270
|
+
controller.close();
|
|
271
|
+
});
|
|
272
|
+
nodeStream.on('error', (err) => {
|
|
273
|
+
controller.error(err);
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Write a Web Response to a Node ServerResponse.
|
|
281
|
+
*
|
|
282
|
+
* Copies status code, headers, and streams the body.
|
|
283
|
+
*/
|
|
284
|
+
async function sendWebResponse(nodeRes: ServerResponse, webResponse: Response): Promise<void> {
|
|
285
|
+
nodeRes.statusCode = webResponse.status;
|
|
286
|
+
|
|
287
|
+
// Copy headers. Set-Cookie needs special handling: Headers.forEach()
|
|
288
|
+
// joins multiple Set-Cookie values with ", " into one entry, but each
|
|
289
|
+
// cookie must be its own header per RFC 6265 §4.1. Use getSetCookie()
|
|
290
|
+
// to preserve individual Set-Cookie headers.
|
|
291
|
+
webResponse.headers.forEach((value, key) => {
|
|
292
|
+
if (key.toLowerCase() !== 'set-cookie') {
|
|
293
|
+
nodeRes.setHeader(key, value);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
const setCookies = webResponse.headers.getSetCookie();
|
|
297
|
+
if (setCookies.length > 0) {
|
|
298
|
+
nodeRes.setHeader('Set-Cookie', setCookies);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Stream the body
|
|
302
|
+
if (!webResponse.body) {
|
|
303
|
+
nodeRes.end();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Flush headers immediately so the client can start processing
|
|
308
|
+
// the response (critical for SSE and other streaming responses).
|
|
309
|
+
nodeRes.flushHeaders();
|
|
310
|
+
|
|
311
|
+
const reader = webResponse.body.getReader();
|
|
312
|
+
try {
|
|
313
|
+
while (true) {
|
|
314
|
+
const { done, value } = await reader.read();
|
|
315
|
+
if (done) break;
|
|
316
|
+
// write() returns false when the kernel buffer is full, but we
|
|
317
|
+
// don't need back-pressure here — just keep pushing chunks.
|
|
318
|
+
nodeRes.write(value);
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
reader.releaseLock();
|
|
322
|
+
nodeRes.end();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── URL Classification ──────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Check if a URL is a Vite-internal request that should be passed through.
|
|
330
|
+
*/
|
|
331
|
+
function isViteInternal(url: string): boolean {
|
|
332
|
+
return VITE_INTERNAL_PREFIXES.some((prefix) => url.startsWith(prefix));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Check if a URL looks like a static asset request.
|
|
337
|
+
*/
|
|
338
|
+
function isAssetRequest(url: string): boolean {
|
|
339
|
+
return ASSET_EXTENSIONS.test(url);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Client Error Listener ─────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
interface ClientErrorPayload {
|
|
345
|
+
message: string;
|
|
346
|
+
stack: string;
|
|
347
|
+
componentStack: string | null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Listen for client-side errors forwarded from the browser via HMR.
|
|
352
|
+
*
|
|
353
|
+
* The browser entry catches uncaught errors and unhandled rejections,
|
|
354
|
+
* then sends them as 'timber:client-error' custom events. We parse
|
|
355
|
+
* the first app frame for the overlay's loc field and forward the
|
|
356
|
+
* error to Vite's overlay protocol.
|
|
357
|
+
*/
|
|
358
|
+
function listenForClientErrors(server: ViteDevServer, projectRoot: string): void {
|
|
359
|
+
server.hot.on('timber:client-error', (data: ClientErrorPayload) => {
|
|
360
|
+
const loc = parseFirstAppFrame(data.stack, projectRoot);
|
|
361
|
+
|
|
362
|
+
let message = data.message;
|
|
363
|
+
if (data.componentStack) {
|
|
364
|
+
message = `${data.message}\n\nComponent Stack:\n${data.componentStack.trim()}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Log to stderr
|
|
368
|
+
const RED = '\x1b[31m';
|
|
369
|
+
const BOLD = '\x1b[1m';
|
|
370
|
+
const RESET = '\x1b[0m';
|
|
371
|
+
process.stderr.write(
|
|
372
|
+
`${RED}${BOLD}[timber] Client Error${RESET}\n${RED}${data.message}${RESET}\n\n`
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Forward to Vite's overlay
|
|
376
|
+
try {
|
|
377
|
+
server.hot.send({
|
|
378
|
+
type: 'error',
|
|
379
|
+
err: {
|
|
380
|
+
message,
|
|
381
|
+
stack: data.stack,
|
|
382
|
+
id: loc?.file,
|
|
383
|
+
plugin: 'timber (Client)',
|
|
384
|
+
loc: loc ? { file: loc.file, line: loc.line, column: loc.column } : undefined,
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
} catch {
|
|
388
|
+
// Overlay send must never crash the dev server
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|