@timber-js/app 0.2.0-alpha.200 → 0.2.0-alpha.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/timber.mjs +15 -1
- package/dist/_chunks/{actions-d1hCqnU3.js → actions-HUdJADAD.js} +3 -3
- package/dist/_chunks/{actions-d1hCqnU3.js.map → actions-HUdJADAD.js.map} +1 -1
- package/dist/_chunks/{build-manifest-DTmSGLRz.js → build-manifest-DWppEdLB.js} +2 -51
- package/dist/_chunks/build-manifest-DWppEdLB.js.map +1 -0
- package/dist/_chunks/{cache-api-ByagcC-J.js → cache-api-CAPbZTga.js} +2 -2
- package/dist/_chunks/{cache-api-ByagcC-J.js.map → cache-api-CAPbZTga.js.map} +1 -1
- package/dist/_chunks/{chains-Bpb0W4ax.js → chains-CBNA0Ozj.js} +3 -3
- package/dist/_chunks/{chains-Bpb0W4ax.js.map → chains-CBNA0Ozj.js.map} +1 -1
- package/dist/_chunks/{cli-check-D6VolrDV.js → cli-check-BzMGuIH6.js} +3 -3
- package/dist/_chunks/{cli-check-D6VolrDV.js.map → cli-check-BzMGuIH6.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js → cli-schema-sync-DnXqcIIj.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js.map → cli-schema-sync-DnXqcIIj.js.map} +1 -1
- package/dist/_chunks/{cloudflare-BFb__LYG.js → cloudflare-DxX1SU0g.js} +3 -3
- package/dist/_chunks/{cloudflare-BFb__LYG.js.map → cloudflare-DxX1SU0g.js.map} +1 -1
- package/dist/_chunks/{convention-lint-fRkwVwEH.js → convention-lint-DHOFvX5s.js} +5 -95
- package/dist/_chunks/convention-lint-DHOFvX5s.js.map +1 -0
- package/dist/_chunks/csp-nonce-hOGniaG4.js +227 -0
- package/dist/_chunks/csp-nonce-hOGniaG4.js.map +1 -0
- package/dist/_chunks/dev-server-DioP7tkQ.js +2288 -0
- package/dist/_chunks/dev-server-DioP7tkQ.js.map +1 -0
- package/dist/_chunks/{error-boundary-BfPHZjm0.js → error-boundary-BQKxl6EX.js} +11 -17
- package/dist/_chunks/{error-boundary-BfPHZjm0.js.map → error-boundary-BQKxl6EX.js.map} +1 -1
- package/dist/_chunks/{graph-cache-CP4GEmf9.js → graph-cache-Cv3njEH8.js} +2 -2
- package/dist/_chunks/{graph-cache-CP4GEmf9.js.map → graph-cache-Cv3njEH8.js.map} +1 -1
- package/dist/_chunks/{live-graph-D_2D32Ad.js → live-graph-VjHFF5EV.js} +3 -3
- package/dist/_chunks/{live-graph-D_2D32Ad.js.map → live-graph-VjHFF5EV.js.map} +1 -1
- package/dist/_chunks/{logger-uLBuGKDI.js → logger-DqJ2VoAY.js} +450 -458
- package/dist/_chunks/logger-DqJ2VoAY.js.map +1 -0
- package/dist/_chunks/{segment-keys-lqtdookO.js → metadata-routes-DSDjM_hJ.js} +2 -61
- package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -0
- package/dist/_chunks/{poison-scan-Bm9Yyqk9.js → poison-scan-lEbz4pQE.js} +2 -2
- package/dist/_chunks/{poison-scan-Bm9Yyqk9.js.map → poison-scan-lEbz4pQE.js.map} +1 -1
- package/dist/_chunks/{scanner-AiazgH_f.js → scanner-B_tnqFcF.js} +37 -137
- package/dist/_chunks/scanner-B_tnqFcF.js.map +1 -0
- package/dist/_chunks/segment-keys-D5hu1hz4.js +62 -0
- package/dist/_chunks/segment-keys-D5hu1hz4.js.map +1 -0
- package/dist/_chunks/tree-match-CdbvYTBz.js +122 -0
- package/dist/_chunks/tree-match-CdbvYTBz.js.map +1 -0
- package/dist/_chunks/{walkers-B6XUtmqK.js → walkers-Cm3PC5JT.js} +2 -2
- package/dist/_chunks/{walkers-B6XUtmqK.js.map → walkers-Cm3PC5JT.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +3 -3
- package/dist/cache/index.js +1 -1
- package/dist/cli.d.ts +7 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -7
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/action-queue.d.ts +20 -1
- package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
- package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts +3 -15
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/error-reconstituter.d.ts +4 -4
- package/dist/client/error-reconstituter.d.ts.map +1 -1
- package/dist/client/internal.js +1 -1
- package/dist/dev-tools/debug-channel.d.ts +55 -0
- package/dist/dev-tools/debug-channel.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -2119
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts +7 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/routing/scanner.d.ts +4 -4
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/server/access-gate.d.ts +2 -2
- package/dist/server/deny-boundary.d.ts +3 -5
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +0 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +5 -12
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +20 -195
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline.d.ts +8 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +15 -13
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts +3 -7
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +6 -27
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts +1 -0
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +4 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +1 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/docs/api/30-api-server.mdx +5 -5
- package/docs/api/34-api-config.mdx +2 -2
- package/docs/api/36-cli.mdx +1 -1
- package/docs/learn/12-error-handling.mdx +7 -8
- package/package.json +1 -2
- package/src/cli.ts +62 -8
- package/src/client/browser-entry/action-dispatch.ts +12 -0
- package/src/client/browser-entry/action-queue.ts +70 -8
- package/src/client/browser-entry/rsc-stream.ts +78 -22
- package/src/client/error-boundary.tsx +13 -39
- package/src/client/error-reconstituter.tsx +5 -5
- package/src/dev-tools/debug-channel.ts +151 -0
- package/src/index.ts +8 -12
- package/src/plugins/dev-server.ts +58 -7
- package/src/plugins/mdx.ts +2 -1
- package/src/routing/convention-lint.ts +4 -111
- package/src/routing/scanner.ts +33 -22
- package/src/server/access-gate.tsx +3 -3
- package/src/server/deny-boundary.ts +5 -23
- package/src/server/deny-renderer.ts +4 -7
- package/src/server/error-boundary-wrapper.ts +11 -35
- package/src/server/pipeline.ts +9 -0
- package/src/server/primitives.ts +20 -26
- package/src/server/rsc-entry/error-renderer.ts +15 -43
- package/src/server/rsc-entry/helpers.ts +10 -67
- package/src/server/rsc-entry/index.ts +1 -0
- package/src/server/rsc-entry/render-route.ts +12 -1
- package/src/server/rsc-entry/rsc-stream.ts +27 -24
- package/src/server/rsc-entry/ssr-renderer.ts +7 -1
- package/dist/_chunks/build-manifest-DTmSGLRz.js.map +0 -1
- package/dist/_chunks/convention-lint-fRkwVwEH.js.map +0 -1
- package/dist/_chunks/logger-uLBuGKDI.js.map +0 -1
- package/dist/_chunks/scanner-AiazgH_f.js.map +0 -1
- package/dist/_chunks/segment-keys-lqtdookO.js.map +0 -1
- package/dist/server/utils/mdx-file.d.ts +0 -17
- package/dist/server/utils/mdx-file.d.ts.map +0 -1
- package/src/server/utils/mdx-file.ts +0 -22
|
@@ -95,6 +95,8 @@ export function createRscPayloadStream(): RscStreamResult | null {
|
|
|
95
95
|
},
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
const debugChannel = createHmrDebugChannel();
|
|
99
|
+
|
|
98
100
|
// Close the stream when the document finishes loading.
|
|
99
101
|
// DOMContentLoaded fires after the HTML parser has processed all
|
|
100
102
|
// inline scripts (including streamed Suspense replacements and
|
|
@@ -109,27 +111,6 @@ export function createRscPayloadStream(): RscStreamResult | null {
|
|
|
109
111
|
function onDOMContentLoaded(): void {
|
|
110
112
|
if (isPageUnloading()) return;
|
|
111
113
|
|
|
112
|
-
// In dev mode, do NOT close the stream. React's RSC renderer
|
|
113
|
-
// includes debug owner/stack references ($1, $14, etc.) in the
|
|
114
|
-
// Flight payload that point to rows delivered through the debug
|
|
115
|
-
// channel, not the main Flight stream. The browser Flight client
|
|
116
|
-
// tracks these as pending chunks. Closing the stream with
|
|
117
|
-
// unresolved chunks triggers reportGlobalError("Connection closed")
|
|
118
|
-
// which kills the entire React tree.
|
|
119
|
-
//
|
|
120
|
-
// Leaving the stream open is harmless: React has already received
|
|
121
|
-
// all data rows and can hydrate fully. The pending debug chunks
|
|
122
|
-
// just remain unresolved (they're only used for React DevTools
|
|
123
|
-
// component stacks, not rendering).
|
|
124
|
-
//
|
|
125
|
-
// In production, debug rows are not emitted, so closing is safe.
|
|
126
|
-
if (process.env.NODE_ENV === 'development') {
|
|
127
|
-
// Mark as flushed so no more data is buffered, but don't close.
|
|
128
|
-
streamFlushed = true;
|
|
129
|
-
dataBuffer = undefined;
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
114
|
if (streamWriter && !streamFlushed) {
|
|
134
115
|
streamWriter.close();
|
|
135
116
|
streamFlushed = true;
|
|
@@ -160,7 +141,82 @@ export function createRscPayloadStream(): RscStreamResult | null {
|
|
|
160
141
|
queueMicrotask(onDOMContentLoaded);
|
|
161
142
|
}
|
|
162
143
|
|
|
163
|
-
const {
|
|
144
|
+
const options = debugChannel ? { debugChannel: { readable: debugChannel } } : {};
|
|
145
|
+
|
|
146
|
+
const { tree, params } = splitPayloadRoot(createFromReadableStream(rscPayload, options));
|
|
164
147
|
// The stream is a thenable, so the split is asynchronous on both halves.
|
|
165
148
|
return { element: tree, params: params as Promise<PublishedParams> };
|
|
166
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create a ReadableStream that receives RSC debug channel data via HMR.
|
|
153
|
+
*
|
|
154
|
+
* The server sends debug data (owner stacks, component info) over the
|
|
155
|
+
* Vite HMR WebSocket as 'timber:debug-data' events, tagged with a
|
|
156
|
+
* request ID embedded in the HTML as `self.__timber_debug_id`.
|
|
157
|
+
*
|
|
158
|
+
* Returns null in production or when no debug ID is present.
|
|
159
|
+
*/
|
|
160
|
+
function createHmrDebugChannel(): ReadableStream<Uint8Array> | null {
|
|
161
|
+
if (process.env.NODE_ENV === 'production') return null;
|
|
162
|
+
|
|
163
|
+
const debugId = (self as unknown as Record<string, string | undefined>).__timber_debug_id;
|
|
164
|
+
if (!debugId) return null;
|
|
165
|
+
|
|
166
|
+
const hot = (
|
|
167
|
+
import.meta as unknown as {
|
|
168
|
+
hot?: {
|
|
169
|
+
on(event: string, cb: (...args: unknown[]) => void): void;
|
|
170
|
+
send(event: string, data: unknown): void;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
).hot;
|
|
174
|
+
if (!hot) return null;
|
|
175
|
+
|
|
176
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
177
|
+
const pending: Uint8Array[] = [];
|
|
178
|
+
let done = false;
|
|
179
|
+
|
|
180
|
+
hot.on('timber:debug-data', (data: unknown) => {
|
|
181
|
+
const msg = data as { id: string; chunk?: string; done?: boolean };
|
|
182
|
+
if (msg.id !== debugId) return;
|
|
183
|
+
|
|
184
|
+
if (msg.done) {
|
|
185
|
+
done = true;
|
|
186
|
+
try {
|
|
187
|
+
controller?.close();
|
|
188
|
+
} catch {
|
|
189
|
+
/* already closed */
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (msg.chunk && !done) {
|
|
195
|
+
const bytes = Uint8Array.from(atob(msg.chunk), (c) => c.charCodeAt(0));
|
|
196
|
+
if (controller) {
|
|
197
|
+
try {
|
|
198
|
+
controller.enqueue(bytes);
|
|
199
|
+
} catch {
|
|
200
|
+
/* stream closed */
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
pending.push(bytes);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Subscribe to trigger replay of buffered debug chunks. The server
|
|
209
|
+
// buffers chunks during RSC render (before the browser connects) and
|
|
210
|
+
// replays them when this subscribe arrives. Vite's client.send()
|
|
211
|
+
// awaits the WebSocket connect promise, so this is safe at init time.
|
|
212
|
+
hot.send('timber:debug-subscribe', { id: debugId });
|
|
213
|
+
|
|
214
|
+
return new ReadableStream<Uint8Array>({
|
|
215
|
+
start(ctrl) {
|
|
216
|
+
controller = ctrl;
|
|
217
|
+
for (const chunk of pending) ctrl.enqueue(chunk);
|
|
218
|
+
pending.length = 0;
|
|
219
|
+
if (done) ctrl.close();
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Catches errors thrown by children and renders a fallback component
|
|
7
7
|
* with the appropriate props based on error type:
|
|
8
|
-
* - DenySignal (4xx) → { status,
|
|
8
|
+
* - DenySignal (4xx) → { status, data }
|
|
9
9
|
* - RenderError (5xx) → { error, digest, reset }
|
|
10
10
|
* - Unhandled error → { error, digest: null, reset }
|
|
11
11
|
*
|
|
@@ -82,15 +82,6 @@ type ParsedDigest = DenyDigest | RenderErrorDigest | RedirectDigest;
|
|
|
82
82
|
export interface TimberErrorBoundaryProps {
|
|
83
83
|
/** The component to render when an error is caught. */
|
|
84
84
|
fallbackComponent?: (...args: unknown[]) => ReactNode;
|
|
85
|
-
/**
|
|
86
|
-
* Pre-rendered fallback element. Used for MDX status files which are server
|
|
87
|
-
* components and cannot be passed as function props across the RSC→client
|
|
88
|
-
* boundary. When set, rendered directly instead of calling fallbackComponent.
|
|
89
|
-
*
|
|
90
|
-
* See design/10-error-handling.md §"Status-Code File Variants" — MDX status
|
|
91
|
-
* files are server components by default (zero client JS).
|
|
92
|
-
*/
|
|
93
|
-
fallbackElement?: ReactNode;
|
|
94
85
|
/**
|
|
95
86
|
* Status code filter. If set, only catches errors matching this status.
|
|
96
87
|
* 400 = any 4xx, 500 = any 5xx, specific number = exact match.
|
|
@@ -345,16 +336,10 @@ export class TimberErrorBoundary extends Component<
|
|
|
345
336
|
}
|
|
346
337
|
|
|
347
338
|
// Report DenySignal handling so the pipeline skips the redundant
|
|
348
|
-
// renderDenyPage() re-render
|
|
349
|
-
// handle the deny in-tree:
|
|
350
|
-
//
|
|
351
|
-
// ✅ TSX boundaries (fallbackComponent) — can receive runtime props
|
|
352
|
-
// (status, dangerouslyPassData) dynamically in render()
|
|
353
|
-
// ❌ MDX boundaries (fallbackElement) — pre-rendered at tree-build time,
|
|
354
|
-
// cannot receive runtime deny props. Must fall through to re-render.
|
|
339
|
+
// renderDenyPage() re-render.
|
|
355
340
|
//
|
|
356
|
-
// For
|
|
357
|
-
//
|
|
341
|
+
// For segment boundaries: also set the HTTP status code from the
|
|
342
|
+
// DenySignal so the Response has the correct 4xx status. This runs
|
|
358
343
|
// synchronously during Fizz rendering, BEFORE onShellReady.
|
|
359
344
|
//
|
|
360
345
|
// For slot boundaries: set _denyHandledByBoundary but do NOT change
|
|
@@ -362,15 +347,11 @@ export class TimberErrorBoundary extends Component<
|
|
|
362
347
|
//
|
|
363
348
|
// See TIM-664, LOCAL-298.
|
|
364
349
|
if (parsed?.type === 'deny') {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
ssrData._navContext._denyHandledByBoundary = true;
|
|
371
|
-
if (!this.props.isSlotBoundary) {
|
|
372
|
-
ssrData._navContext.statusCode = parsed.status;
|
|
373
|
-
}
|
|
350
|
+
const ssrData = getSsrData();
|
|
351
|
+
if (ssrData?._navContext) {
|
|
352
|
+
ssrData._navContext._denyHandledByBoundary = true;
|
|
353
|
+
if (!this.props.isSlotBoundary) {
|
|
354
|
+
ssrData._navContext.statusCode = parsed.status;
|
|
374
355
|
}
|
|
375
356
|
}
|
|
376
357
|
}
|
|
@@ -381,23 +362,16 @@ export class TimberErrorBoundary extends Component<
|
|
|
381
362
|
/**
|
|
382
363
|
* The status page this boundary shows for a row it claimed. Its shape is
|
|
383
364
|
* the file's documented contract: TSX status files get `{ status,
|
|
384
|
-
*
|
|
365
|
+
* data }`, `error.tsx` gets `{ error, digest, reset }` (plus
|
|
385
366
|
* the deny extras for dual-shape implementations — without the error prop,
|
|
386
|
-
* an error.tsx reading error.message crashes on deny fall-through, TIM-1081)
|
|
387
|
-
* and an MDX status file is the element pre-rendered for it at tree-build
|
|
388
|
-
* time, since a server component cannot cross the RSC→client boundary as a
|
|
389
|
-
* function prop.
|
|
367
|
+
* an error.tsx reading error.message crashes on deny fall-through, TIM-1081).
|
|
390
368
|
*/
|
|
391
369
|
private renderFallback(parsed: ParsedDigest | null, error: Error): ReactNode {
|
|
392
|
-
if (this.props.fallbackElement != null) {
|
|
393
|
-
return this.props.fallbackElement;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
370
|
if (parsed?.type === 'deny') {
|
|
397
371
|
if (this.props.status != null) {
|
|
398
372
|
return createElement(this.props.fallbackComponent as never, {
|
|
399
373
|
status: parsed.status,
|
|
400
|
-
|
|
374
|
+
data: parsed.data,
|
|
401
375
|
});
|
|
402
376
|
}
|
|
403
377
|
return createElement(this.props.fallbackComponent as never, {
|
|
@@ -405,7 +379,7 @@ export class TimberErrorBoundary extends Component<
|
|
|
405
379
|
digest: null,
|
|
406
380
|
reset: this.reset,
|
|
407
381
|
status: parsed.status,
|
|
408
|
-
|
|
382
|
+
data: parsed.data,
|
|
409
383
|
});
|
|
410
384
|
}
|
|
411
385
|
|
|
@@ -33,7 +33,7 @@ export interface SerializableError {
|
|
|
33
33
|
* - digest: plain JSON or null
|
|
34
34
|
* - reset: undefined (only meaningful on client after boundary catch)
|
|
35
35
|
* - component: client module reference (RSC Flight serializes as opaque ref)
|
|
36
|
-
* - status /
|
|
36
|
+
* - status / data: set only when error.tsx serves a deny()
|
|
37
37
|
* as the last entry in the 4xx fallback chain — forwarded so dual-shape
|
|
38
38
|
* error.tsx implementations can branch on the deny status (TIM-1081)
|
|
39
39
|
*/
|
|
@@ -46,10 +46,10 @@ interface ErrorReconstituterProps {
|
|
|
46
46
|
digest: { code: string; data: unknown } | null;
|
|
47
47
|
reset: (() => void) | undefined;
|
|
48
48
|
status?: number;
|
|
49
|
-
|
|
49
|
+
data?: unknown;
|
|
50
50
|
}>;
|
|
51
51
|
status?: number;
|
|
52
|
-
|
|
52
|
+
data?: unknown;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
@@ -62,7 +62,7 @@ export function ErrorReconstituter({
|
|
|
62
62
|
reset,
|
|
63
63
|
component,
|
|
64
64
|
status,
|
|
65
|
-
|
|
65
|
+
data,
|
|
66
66
|
}: ErrorReconstituterProps): ReactNode {
|
|
67
67
|
// Reconstitute a real Error so instanceof checks work in user code
|
|
68
68
|
const error = Object.assign(new Error(serialized.message), {
|
|
@@ -74,6 +74,6 @@ export function ErrorReconstituter({
|
|
|
74
74
|
error,
|
|
75
75
|
digest,
|
|
76
76
|
reset,
|
|
77
|
-
...(status != null ? { status,
|
|
77
|
+
...(status != null ? { status, data } : {}),
|
|
78
78
|
});
|
|
79
79
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-mode RSC debug channel transport — pipes Flight debug data to
|
|
3
|
+
* the browser via Vite's HMR WebSocket.
|
|
4
|
+
*
|
|
5
|
+
* React Flight writes debug rows (component info, owner stacks, $E
|
|
6
|
+
* source entries) to a debug channel writable. This module reads
|
|
7
|
+
* from the paired readable and buffers chunks until the browser
|
|
8
|
+
* subscribes via 'timber:debug-subscribe'. Chunks are then replayed
|
|
9
|
+
* and live-forwarded to that specific client (never broadcast).
|
|
10
|
+
*
|
|
11
|
+
* This matches the approach used by Next.js and Waku — debug data
|
|
12
|
+
* travels out-of-band via WebSocket, not inline in the HTML response.
|
|
13
|
+
*
|
|
14
|
+
* Design ref: 13-security.md §7, TIM-1507
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { parseDebugRows, type DebugComponentEntry } from '../server/rsc-entry/helpers.ts';
|
|
19
|
+
|
|
20
|
+
export const DEBUG_CHANNEL_EVENT = 'timber:debug-data';
|
|
21
|
+
export const DEBUG_SUBSCRIBE_EVENT = 'timber:debug-subscribe';
|
|
22
|
+
|
|
23
|
+
export interface DebugChannelMessage {
|
|
24
|
+
id: string;
|
|
25
|
+
chunk?: string;
|
|
26
|
+
done?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Per-client send function (Vite's HotChannelClient shape). */
|
|
30
|
+
export type ClientSendFn = (event: string, payload: unknown) => void;
|
|
31
|
+
|
|
32
|
+
/** Stored session for a single render's debug channel. */
|
|
33
|
+
interface DebugSession {
|
|
34
|
+
chunks: string[];
|
|
35
|
+
done: boolean;
|
|
36
|
+
client?: ClientSendFn;
|
|
37
|
+
timer: ReturnType<typeof setTimeout>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SESSION_TTL_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Registry of active debug channel sessions. The dev-server plugin
|
|
44
|
+
* creates one instance and wires it into the HMR handlers.
|
|
45
|
+
*/
|
|
46
|
+
export class DebugChannelRegistry {
|
|
47
|
+
private sessions = new Map<string, DebugSession>();
|
|
48
|
+
|
|
49
|
+
/** Called by pipeToTransport when a chunk arrives from the RSC render. */
|
|
50
|
+
push(id: string, chunk: string): void {
|
|
51
|
+
const session = this.sessions.get(id);
|
|
52
|
+
if (!session) return;
|
|
53
|
+
session.chunks.push(chunk);
|
|
54
|
+
if (session.client) {
|
|
55
|
+
session.client(DEBUG_CHANNEL_EVENT, { id, chunk } satisfies DebugChannelMessage);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Called by pipeToTransport when the debug channel stream ends. */
|
|
60
|
+
finish(id: string): void {
|
|
61
|
+
const session = this.sessions.get(id);
|
|
62
|
+
if (!session) return;
|
|
63
|
+
session.done = true;
|
|
64
|
+
if (session.client) {
|
|
65
|
+
session.client(DEBUG_CHANNEL_EVENT, { id, done: true } satisfies DebugChannelMessage);
|
|
66
|
+
this.evict(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Called by the HMR subscribe handler when the browser requests its debug data. */
|
|
71
|
+
subscribe(id: string, clientSend: ClientSendFn): void {
|
|
72
|
+
const session = this.sessions.get(id);
|
|
73
|
+
if (!session) {
|
|
74
|
+
// Unknown ID (stale page, expired session) — close the browser readable.
|
|
75
|
+
clientSend(DEBUG_CHANNEL_EVENT, { id, done: true } satisfies DebugChannelMessage);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
session.client = clientSend;
|
|
79
|
+
// Replay buffered chunks.
|
|
80
|
+
for (const chunk of session.chunks) {
|
|
81
|
+
clientSend(DEBUG_CHANNEL_EVENT, { id, chunk } satisfies DebugChannelMessage);
|
|
82
|
+
}
|
|
83
|
+
if (session.done) {
|
|
84
|
+
clientSend(DEBUG_CHANNEL_EVENT, { id, done: true } satisfies DebugChannelMessage);
|
|
85
|
+
this.evict(id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Parse buffered debug rows into component entries for the error overlay. */
|
|
90
|
+
getComponents(id: string): DebugComponentEntry[] {
|
|
91
|
+
const session = this.sessions.get(id);
|
|
92
|
+
if (!session) return [];
|
|
93
|
+
const text = session.chunks.map((b64) => Buffer.from(b64, 'base64').toString('utf-8')).join('');
|
|
94
|
+
return parseDebugRows(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Create a new session for a render request. Returns the requestId. */
|
|
98
|
+
create(): string {
|
|
99
|
+
const id = randomUUID();
|
|
100
|
+
const timer = setTimeout(() => this.evict(id), SESSION_TTL_MS);
|
|
101
|
+
if (typeof timer === 'object' && 'unref' in timer) timer.unref();
|
|
102
|
+
this.sessions.set(id, { chunks: [], done: false, timer });
|
|
103
|
+
return id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private evict(id: string): void {
|
|
107
|
+
const session = this.sessions.get(id);
|
|
108
|
+
if (session) {
|
|
109
|
+
// Notify a subscribed client so its debug readable closes and
|
|
110
|
+
// Flight's streamDoneCount can reach 2.
|
|
111
|
+
if (session.client && !session.done) {
|
|
112
|
+
session.client(DEBUG_CHANNEL_EVENT, { id, done: true } satisfies DebugChannelMessage);
|
|
113
|
+
}
|
|
114
|
+
clearTimeout(session.timer);
|
|
115
|
+
this.sessions.delete(id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a debug channel for a single render request.
|
|
122
|
+
*
|
|
123
|
+
* Returns `{ writable }` for Flight's renderToReadableStream, plus a
|
|
124
|
+
* `requestId` to embed in the HTML so the browser can correlate.
|
|
125
|
+
*/
|
|
126
|
+
export function createDevDebugChannel(registry: DebugChannelRegistry): {
|
|
127
|
+
writable: WritableStream;
|
|
128
|
+
requestId: string;
|
|
129
|
+
getComponents: () => DebugComponentEntry[];
|
|
130
|
+
} {
|
|
131
|
+
const requestId = registry.create();
|
|
132
|
+
// Plain WritableStream sink — React's Flight server `pipeTo`s into
|
|
133
|
+
// this. No TransformStream, no reader.read(), no cancel race.
|
|
134
|
+
// Matches Next.js's createWebDebugChannel pattern.
|
|
135
|
+
const writable = new WritableStream<Uint8Array>({
|
|
136
|
+
write(chunk) {
|
|
137
|
+
registry.push(requestId, Buffer.from(chunk).toString('base64'));
|
|
138
|
+
},
|
|
139
|
+
close() {
|
|
140
|
+
registry.finish(requestId);
|
|
141
|
+
},
|
|
142
|
+
abort() {
|
|
143
|
+
registry.finish(requestId);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
writable,
|
|
148
|
+
requestId,
|
|
149
|
+
getComponents: () => registry.getComponents(requestId),
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -490,18 +490,14 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
490
490
|
// payloads) reads the same value. See TIM-1261.
|
|
491
491
|
ctx.base = resolved.base;
|
|
492
492
|
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
const configNames = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
|
|
502
|
-
for (const name of configNames) {
|
|
503
|
-
resolved.configFileDependencies.push(resolve(resolved.root, name));
|
|
504
|
-
}
|
|
493
|
+
// timber.config.* files are NOT registered as configFileDependencies.
|
|
494
|
+
// Vite's in-process restart cannot bust Node's require cache for ESM
|
|
495
|
+
// modules loaded via require(), so the restarted server would serve
|
|
496
|
+
// stale config. Instead, timber-dev-server watches these files and
|
|
497
|
+
// calls process.exit(RESTART_EXIT_CODE) so the CLI supervisor
|
|
498
|
+
// respawns a fresh process. See TIM-1444.
|
|
499
|
+
// The watcher.add() call in dev-server.ts ensures the files are
|
|
500
|
+
// watched even if created after server start.
|
|
505
501
|
|
|
506
502
|
// Sync buildDir from Vite's resolved build.outDir. Vite keeps
|
|
507
503
|
// outDir as-is (may be relative), so resolve against root.
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { generateDevErrorPage } from '../dev-tools/error-page.ts';
|
|
30
30
|
import { extractHmrOptions, type DevErrorHmrOptions } from '../dev-tools/dev-page-shell.ts';
|
|
31
31
|
import { addVirtualModuleContext } from '../config-validation.ts';
|
|
32
|
+
import { DebugChannelRegistry, DEBUG_SUBSCRIBE_EVENT } from '../dev-tools/debug-channel.ts';
|
|
32
33
|
import { registerDevDiscovery } from './dev-discovery.ts';
|
|
33
34
|
import {
|
|
34
35
|
createGraphEndpointMiddleware,
|
|
@@ -46,6 +47,14 @@ import type { RouteTree } from '../routing/types.ts';
|
|
|
46
47
|
|
|
47
48
|
const RSC_ENTRY_ID = 'virtual:timber-rsc-entry';
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Exit code used to signal the CLI supervisor that the dev server needs
|
|
52
|
+
* a full process restart (e.g. timber.config.ts changed). The supervisor
|
|
53
|
+
* in cli.ts respawns the worker on this code. 75 = EX_TEMPFAIL (sysexits).
|
|
54
|
+
* See TIM-1444.
|
|
55
|
+
*/
|
|
56
|
+
export const RESTART_EXIT_CODE = 75;
|
|
57
|
+
|
|
49
58
|
/**
|
|
50
59
|
* Config file names that trigger a full dev server restart when changed.
|
|
51
60
|
* See 21-dev-server.md §HMR Wiring — config is loaded once at startup.
|
|
@@ -120,15 +129,32 @@ export function timberDevServer(ctx: PluginContext): Plugin {
|
|
|
120
129
|
* filtered out explicitly and passed through to Vite.
|
|
121
130
|
*/
|
|
122
131
|
async configureServer(server: ViteDevServer) {
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// See 21-dev-server.md §HMR Wiring.
|
|
132
|
+
// Watch config files for changes. When running under the CLI
|
|
133
|
+
// supervisor (__TIMBER_DEV_WORKER), exit the process so the
|
|
134
|
+
// supervisor respawns with a fresh require cache. When running
|
|
135
|
+
// via `vite dev` directly (no supervisor), fall back to Vite's
|
|
136
|
+
// in-process server.restart() — it serves stale config, but at
|
|
137
|
+
// least the server stays alive. See TIM-1444.
|
|
130
138
|
const configPaths = CONFIG_FILE_NAMES.map((name) => join(ctx.root, name));
|
|
131
139
|
server.watcher.add(configPaths);
|
|
140
|
+
const configPathSet = new Set(configPaths);
|
|
141
|
+
const hasSupervisor = Boolean(process.env.__TIMBER_DEV_WORKER);
|
|
142
|
+
const onConfigChange = (filePath: string) => {
|
|
143
|
+
if (!configPathSet.has(filePath)) return;
|
|
144
|
+
if (hasSupervisor) {
|
|
145
|
+
console.log(`\n[timber] ${basename(filePath)} changed — restarting dev server...\n`);
|
|
146
|
+
process.exit(RESTART_EXIT_CODE);
|
|
147
|
+
} else {
|
|
148
|
+
console.log(
|
|
149
|
+
`\n[timber] ${basename(filePath)} changed — restarting dev server...` +
|
|
150
|
+
`\n[timber] Run \`timber dev\` instead of \`vite dev\` for guaranteed fresh config on restart.\n`
|
|
151
|
+
);
|
|
152
|
+
server.restart();
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
server.watcher.on('change', onConfigChange);
|
|
156
|
+
server.watcher.on('add', onConfigChange);
|
|
157
|
+
server.watcher.on('unlink', onConfigChange);
|
|
132
158
|
|
|
133
159
|
// Listen for client-side errors forwarded from the browser.
|
|
134
160
|
// The browser entry sends 'timber:client-error' events via HMR
|
|
@@ -235,6 +261,27 @@ function createTimberMiddleware(server: ViteDevServer, ctx: PluginContext) {
|
|
|
235
261
|
// a correct WebSocket URL (TIM-789).
|
|
236
262
|
const hmrOptions = extractHmrOptions(server.config);
|
|
237
263
|
|
|
264
|
+
// Debug channel registry: buffers RSC debug rows per request and replays
|
|
265
|
+
// them when the browser subscribes via HMR. Created ONCE per server, not
|
|
266
|
+
// per request — the middleware below re-imports the RSC entry on every
|
|
267
|
+
// request, and a per-request registry + listener would leak a stale
|
|
268
|
+
// listener per request. Each stale listener answers a subscribe with
|
|
269
|
+
// `done: true` (unknown ID), closing the browser's debug readable before
|
|
270
|
+
// the real replay arrives, which blocks Flight's root on unresolved owner
|
|
271
|
+
// refs and prevents hydration entirely. See TIM-1507.
|
|
272
|
+
const debugRegistry = new DebugChannelRegistry();
|
|
273
|
+
server.hot.on(
|
|
274
|
+
DEBUG_SUBSCRIBE_EVENT,
|
|
275
|
+
(data: unknown, client: { send: (event: string, payload: unknown) => void }) => {
|
|
276
|
+
const msg = data as { id?: string };
|
|
277
|
+
if (msg?.id) {
|
|
278
|
+
debugRegistry.subscribe(msg.id, (event, payload) => {
|
|
279
|
+
client.send(event, payload);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
);
|
|
284
|
+
|
|
238
285
|
return async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void> => {
|
|
239
286
|
const url = req.url;
|
|
240
287
|
if (!url) {
|
|
@@ -308,6 +355,7 @@ function createTimberMiddleware(server: ViteDevServer, ctx: PluginContext) {
|
|
|
308
355
|
}>
|
|
309
356
|
) => void;
|
|
310
357
|
devHmrOptions?: DevErrorHmrOptions;
|
|
358
|
+
devDebugRegistry?: DebugChannelRegistry;
|
|
311
359
|
}
|
|
312
360
|
| undefined;
|
|
313
361
|
if (config) {
|
|
@@ -325,6 +373,9 @@ function createTimberMiddleware(server: ViteDevServer, ctx: PluginContext) {
|
|
|
325
373
|
// but its dev 404 page and fallback error page need the HMR endpoint
|
|
326
374
|
// (including Vite 8's required WS token) for auto-reload (TIM-1067).
|
|
327
375
|
config.devHmrOptions = hmrOptions;
|
|
376
|
+
// Server-lifetime debug channel registry (created above). Re-assigned
|
|
377
|
+
// per request because the RSC module may be re-evaluated on HMR.
|
|
378
|
+
config.devDebugRegistry = debugRegistry;
|
|
328
379
|
}
|
|
329
380
|
|
|
330
381
|
// Wire source-map handler so error pages show original positions.
|
package/src/plugins/mdx.ts
CHANGED
|
@@ -26,7 +26,8 @@ import { join } from 'node:path';
|
|
|
26
26
|
import { createRequire } from 'node:module';
|
|
27
27
|
import { pathToFileURL } from 'node:url';
|
|
28
28
|
import type { PluginContext } from '../plugin-context.ts';
|
|
29
|
-
|
|
29
|
+
/** MDX/markdown extensions (dotted) — the canonical list for the compiler plugin. */
|
|
30
|
+
const MDX_EXTENSIONS = new Set(['.mdx', '.md']);
|
|
30
31
|
|
|
31
32
|
const MDX_EXTENSIONS_UNDOTTED = [...MDX_EXTENSIONS].map((ext) => ext.slice(1));
|
|
32
33
|
|