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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/bin/timber.mjs +15 -1
  2. package/dist/_chunks/{actions-d1hCqnU3.js → actions-HUdJADAD.js} +3 -3
  3. package/dist/_chunks/{actions-d1hCqnU3.js.map → actions-HUdJADAD.js.map} +1 -1
  4. package/dist/_chunks/{build-manifest-DTmSGLRz.js → build-manifest-DWppEdLB.js} +2 -51
  5. package/dist/_chunks/build-manifest-DWppEdLB.js.map +1 -0
  6. package/dist/_chunks/{cache-api-ByagcC-J.js → cache-api-CAPbZTga.js} +2 -2
  7. package/dist/_chunks/{cache-api-ByagcC-J.js.map → cache-api-CAPbZTga.js.map} +1 -1
  8. package/dist/_chunks/{chains-Bpb0W4ax.js → chains-CBNA0Ozj.js} +3 -3
  9. package/dist/_chunks/{chains-Bpb0W4ax.js.map → chains-CBNA0Ozj.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-D6VolrDV.js → cli-check-BzMGuIH6.js} +3 -3
  11. package/dist/_chunks/{cli-check-D6VolrDV.js.map → cli-check-BzMGuIH6.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js → cli-schema-sync-DnXqcIIj.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-D6rO-VcS.js.map → cli-schema-sync-DnXqcIIj.js.map} +1 -1
  14. package/dist/_chunks/{cloudflare-BFb__LYG.js → cloudflare-DxX1SU0g.js} +3 -3
  15. package/dist/_chunks/{cloudflare-BFb__LYG.js.map → cloudflare-DxX1SU0g.js.map} +1 -1
  16. package/dist/_chunks/{convention-lint-fRkwVwEH.js → convention-lint-DHOFvX5s.js} +5 -95
  17. package/dist/_chunks/convention-lint-DHOFvX5s.js.map +1 -0
  18. package/dist/_chunks/csp-nonce-hOGniaG4.js +227 -0
  19. package/dist/_chunks/csp-nonce-hOGniaG4.js.map +1 -0
  20. package/dist/_chunks/dev-server-DioP7tkQ.js +2288 -0
  21. package/dist/_chunks/dev-server-DioP7tkQ.js.map +1 -0
  22. package/dist/_chunks/{error-boundary-BfPHZjm0.js → error-boundary-BQKxl6EX.js} +11 -17
  23. package/dist/_chunks/{error-boundary-BfPHZjm0.js.map → error-boundary-BQKxl6EX.js.map} +1 -1
  24. package/dist/_chunks/{graph-cache-CP4GEmf9.js → graph-cache-Cv3njEH8.js} +2 -2
  25. package/dist/_chunks/{graph-cache-CP4GEmf9.js.map → graph-cache-Cv3njEH8.js.map} +1 -1
  26. package/dist/_chunks/{live-graph-D_2D32Ad.js → live-graph-VjHFF5EV.js} +3 -3
  27. package/dist/_chunks/{live-graph-D_2D32Ad.js.map → live-graph-VjHFF5EV.js.map} +1 -1
  28. package/dist/_chunks/{logger-uLBuGKDI.js → logger-DqJ2VoAY.js} +450 -458
  29. package/dist/_chunks/logger-DqJ2VoAY.js.map +1 -0
  30. package/dist/_chunks/{segment-keys-lqtdookO.js → metadata-routes-DSDjM_hJ.js} +2 -61
  31. package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -0
  32. package/dist/_chunks/{poison-scan-Bm9Yyqk9.js → poison-scan-lEbz4pQE.js} +2 -2
  33. package/dist/_chunks/{poison-scan-Bm9Yyqk9.js.map → poison-scan-lEbz4pQE.js.map} +1 -1
  34. package/dist/_chunks/{scanner-AiazgH_f.js → scanner-B_tnqFcF.js} +37 -137
  35. package/dist/_chunks/scanner-B_tnqFcF.js.map +1 -0
  36. package/dist/_chunks/segment-keys-D5hu1hz4.js +62 -0
  37. package/dist/_chunks/segment-keys-D5hu1hz4.js.map +1 -0
  38. package/dist/_chunks/tree-match-CdbvYTBz.js +122 -0
  39. package/dist/_chunks/tree-match-CdbvYTBz.js.map +1 -0
  40. package/dist/_chunks/{walkers-B6XUtmqK.js → walkers-Cm3PC5JT.js} +2 -2
  41. package/dist/_chunks/{walkers-B6XUtmqK.js.map → walkers-Cm3PC5JT.js.map} +1 -1
  42. package/dist/adapters/cloudflare-dev.js +1 -1
  43. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  44. package/dist/adapters/cloudflare.js +1 -1
  45. package/dist/analyze/crawl-entry.js +2 -2
  46. package/dist/analyze/graph-command.js +3 -3
  47. package/dist/cache/index.js +1 -1
  48. package/dist/cli.d.ts +7 -2
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +44 -7
  51. package/dist/cli.js.map +1 -1
  52. package/dist/client/browser-dev.d.ts +5 -1
  53. package/dist/client/browser-dev.d.ts.map +1 -1
  54. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  55. package/dist/client/browser-entry/action-queue.d.ts +20 -1
  56. package/dist/client/browser-entry/action-queue.d.ts.map +1 -1
  57. package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -1
  58. package/dist/client/error-boundary.d.ts +3 -15
  59. package/dist/client/error-boundary.d.ts.map +1 -1
  60. package/dist/client/error-boundary.js +1 -1
  61. package/dist/client/error-reconstituter.d.ts +4 -4
  62. package/dist/client/error-reconstituter.d.ts.map +1 -1
  63. package/dist/client/internal.js +1 -1
  64. package/dist/dev-tools/debug-channel.d.ts +55 -0
  65. package/dist/dev-tools/debug-channel.d.ts.map +1 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +302 -2245
  68. package/dist/index.js.map +1 -1
  69. package/dist/plugins/dev-server.d.ts +7 -0
  70. package/dist/plugins/dev-server.d.ts.map +1 -1
  71. package/dist/plugins/mdx.d.ts.map +1 -1
  72. package/dist/routing/convention-lint.d.ts.map +1 -1
  73. package/dist/routing/index.js +2 -2
  74. package/dist/routing/scanner.d.ts +4 -4
  75. package/dist/routing/scanner.d.ts.map +1 -1
  76. package/dist/server/access-gate.d.ts +2 -2
  77. package/dist/server/deny-boundary.d.ts +3 -5
  78. package/dist/server/deny-boundary.d.ts.map +1 -1
  79. package/dist/server/deny-renderer.d.ts +0 -1
  80. package/dist/server/deny-renderer.d.ts.map +1 -1
  81. package/dist/server/error-boundary-wrapper.d.ts +5 -12
  82. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  83. package/dist/server/index.js +2 -2
  84. package/dist/server/internal.js +20 -195
  85. package/dist/server/internal.js.map +1 -1
  86. package/dist/server/pipeline.d.ts +8 -0
  87. package/dist/server/pipeline.d.ts.map +1 -1
  88. package/dist/server/primitives.d.ts +15 -13
  89. package/dist/server/primitives.d.ts.map +1 -1
  90. package/dist/server/rsc-entry/error-renderer.d.ts +3 -7
  91. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  92. package/dist/server/rsc-entry/helpers.d.ts +6 -27
  93. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  94. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  95. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  96. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  97. package/dist/server/rsc-entry/rsc-stream.d.ts +4 -1
  98. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  99. package/dist/server/rsc-entry/ssr-renderer.d.ts +1 -0
  100. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  101. package/dist/server/slot-resolver.d.ts.map +1 -1
  102. package/dist/server/slot-subtree-contain.d.ts +1 -1
  103. package/dist/server/slot-subtree-contain.d.ts.map +1 -1
  104. package/docs/api/30-api-server.mdx +5 -5
  105. package/docs/api/34-api-config.mdx +2 -2
  106. package/docs/api/36-cli.mdx +1 -1
  107. package/docs/learn/12-error-handling.mdx +7 -8
  108. package/package.json +2 -3
  109. package/src/cli.ts +62 -8
  110. package/src/client/browser-dev.ts +35 -3
  111. package/src/client/browser-entry/action-dispatch.ts +12 -0
  112. package/src/client/browser-entry/action-queue.ts +70 -8
  113. package/src/client/browser-entry/rsc-stream.ts +78 -22
  114. package/src/client/error-boundary.tsx +13 -39
  115. package/src/client/error-reconstituter.tsx +5 -5
  116. package/src/dev-tools/debug-channel.ts +151 -0
  117. package/src/index.ts +8 -12
  118. package/src/plugins/dev-server.ts +58 -7
  119. package/src/plugins/mdx.ts +2 -1
  120. package/src/routing/convention-lint.ts +4 -111
  121. package/src/routing/scanner.ts +33 -22
  122. package/src/server/access-gate.tsx +3 -3
  123. package/src/server/deny-boundary.ts +5 -23
  124. package/src/server/deny-renderer.ts +4 -7
  125. package/src/server/error-boundary-wrapper.ts +11 -35
  126. package/src/server/pipeline.ts +9 -0
  127. package/src/server/primitives.ts +20 -26
  128. package/src/server/rsc-entry/error-renderer.ts +15 -43
  129. package/src/server/rsc-entry/helpers.ts +10 -67
  130. package/src/server/rsc-entry/index.ts +1 -0
  131. package/src/server/rsc-entry/render-route.ts +12 -1
  132. package/src/server/rsc-entry/rsc-stream.ts +27 -24
  133. package/src/server/rsc-entry/ssr-renderer.ts +7 -1
  134. package/src/server/slot-resolver.ts +29 -7
  135. package/src/server/slot-subtree-contain.ts +13 -4
  136. package/dist/_chunks/build-manifest-DTmSGLRz.js.map +0 -1
  137. package/dist/_chunks/convention-lint-fRkwVwEH.js.map +0 -1
  138. package/dist/_chunks/logger-uLBuGKDI.js.map +0 -1
  139. package/dist/_chunks/scanner-AiazgH_f.js.map +0 -1
  140. package/dist/_chunks/segment-keys-lqtdookO.js.map +0 -1
  141. package/dist/server/utils/mdx-file.d.ts +0 -17
  142. package/dist/server/utils/mdx-file.d.ts.map +0 -1
  143. package/src/server/utils/mdx-file.ts +0 -22
@@ -13,10 +13,54 @@
13
13
 
14
14
  export interface ActionQueueDeps {
15
15
  refreshWhenIdle: () => void;
16
+ /**
17
+ * Called once when an action's holds exceed `holdTimeoutMs`. The chain
18
+ * releases regardless; this is the recovery hook. It must not wait on
19
+ * the same thing the hold is stuck on — in dispatch the hold IS the
20
+ * router's own refresh/commit, so a deferred `refreshWhenIdle` would
21
+ * park forever. Dispatch forces a fresh `router.refresh()` instead,
22
+ * which supersedes the stuck owner (TIM-1485).
23
+ */
24
+ recoverStalledHold: () => void;
25
+ /**
26
+ * Maximum time (ms) the queue stays blocked on held promises after an
27
+ * action's result has been returned. A hung hold (e.g. a refresh commit
28
+ * awaiting a stalled flight stream) would otherwise brick the chain for
29
+ * the life of the page. `0` disables the guard. Default: 30s.
30
+ */
31
+ holdTimeoutMs?: number;
16
32
  }
17
33
 
34
+ const DEFAULT_HOLD_TIMEOUT_MS = 30_000;
35
+
18
36
  const noop = (): void => {};
19
37
 
38
+ /**
39
+ * Resolve when every hold settles, or when `timeoutMs` elapses — whichever
40
+ * comes first. Never rejects. `onTimeout` runs only if the timer wins.
41
+ * Same race-against-timer shape as `cache/singleflight.ts` (TIM-518),
42
+ * minus the AbortSignal: holds are fire-and-forget side effects with no
43
+ * cancellation contract.
44
+ */
45
+ function awaitHolds(
46
+ holds: Promise<unknown>[],
47
+ timeoutMs: number,
48
+ onTimeout: () => void
49
+ ): Promise<void> {
50
+ const all = Promise.all(holds).then(noop);
51
+ if (holds.length === 0 || timeoutMs <= 0) return all;
52
+ return new Promise<void>((resolve) => {
53
+ const timer = setTimeout(() => {
54
+ onTimeout();
55
+ resolve();
56
+ }, timeoutMs);
57
+ void all.then(() => {
58
+ clearTimeout(timer);
59
+ resolve();
60
+ });
61
+ });
62
+ }
63
+
20
64
  /**
21
65
  * Passed into `work` so it can return a result to React immediately
22
66
  * while keeping the queue blocked on a side effect (e.g. a refresh
@@ -41,7 +85,10 @@ export interface ActionQueue {
41
85
  run: <T>(work: (q: ActionQueueRun) => Promise<T>) => Promise<T>;
42
86
  /**
43
87
  * Mark that a discarded action would have revalidated. When the queue
44
- * drains, one refresh fires via `deps.refreshWhenIdle`.
88
+ * drains, one refresh fires via `deps.refreshWhenIdle`. If the queue is
89
+ * already idle — a hold settling after its timeout released the chain —
90
+ * the refresh fires immediately rather than waiting for a future
91
+ * action's drain (TIM-1485).
45
92
  */
46
93
  markNeedsRefresh: () => void;
47
94
  }
@@ -49,6 +96,8 @@ export interface ActionQueue {
49
96
  export function createActionQueue(deps: ActionQueueDeps): ActionQueue {
50
97
  let tail: Promise<void> = Promise.resolve();
51
98
  let needsRefresh = false;
99
+ let idle = true;
100
+ const holdTimeoutMs = deps.holdTimeoutMs ?? DEFAULT_HOLD_TIMEOUT_MS;
52
101
 
53
102
  function drain(): void {
54
103
  if (!needsRefresh) return;
@@ -56,6 +105,16 @@ export function createActionQueue(deps: ActionQueueDeps): ActionQueue {
56
105
  deps.refreshWhenIdle();
57
106
  }
58
107
 
108
+ // The caller's promise is untouched: React already has the action
109
+ // result. Recovery is delegated — the queue cannot know what the hold
110
+ // is stuck on, and a deferred drain refresh would wait on exactly that.
111
+ function onHoldTimeout(): void {
112
+ if (process.env.NODE_ENV === 'development') {
113
+ console.warn(`[timber] action queue: hold exceeded ${holdTimeoutMs}ms — releasing chain`);
114
+ }
115
+ deps.recoverStalledHold();
116
+ }
117
+
59
118
  function run<T>(work: (q: ActionQueueRun) => Promise<T>): Promise<T> {
60
119
  const holds: Promise<unknown>[] = [];
61
120
  const hold = (p: Promise<unknown>): void => {
@@ -63,27 +122,30 @@ export function createActionQueue(deps: ActionQueueDeps): ActionQueue {
63
122
  };
64
123
  // `p` is the caller-visible promise — it resolves/rejects with work's
65
124
  // result (React sees this). `settled` is the chain link: work settled
66
- // AND every held promise settled. It never rejects, so the chain never
67
- // breaks and the next action always runs.
125
+ // AND every held promise settled (or the hold timeout fired). It never
126
+ // rejects, so the chain never breaks and the next action always runs.
68
127
  const p = tail.then(
69
128
  () => work({ hold }),
70
129
  () => work({ hold })
71
130
  );
72
- const settled = p
73
- .then(noop, noop)
74
- .then(() => Promise.all(holds))
75
- .then(noop);
131
+ const settled = p.then(noop, noop).then(() => awaitHolds(holds, holdTimeoutMs, onHoldTimeout));
76
132
  tail = settled;
133
+ idle = false;
77
134
  // Drain when the queue empties: tail identity means nobody enqueued
78
135
  // behind this action between its start and its settlement.
79
136
  void settled.then(() => {
80
- if (tail === settled) drain();
137
+ if (tail !== settled) return;
138
+ idle = true;
139
+ drain();
81
140
  });
82
141
  return p;
83
142
  }
84
143
 
85
144
  function markNeedsRefresh(): void {
86
145
  needsRefresh = true;
146
+ // Nothing is queued to drain this — act now rather than strand the
147
+ // flag until an unrelated future action fires it.
148
+ if (idle) drain();
87
149
  }
88
150
 
89
151
  return { run, markNeedsRefresh };
@@ -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 { tree, params } = splitPayloadRoot(createFromReadableStream(rscPayload));
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, dangerouslyPassData }
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 — but ONLY when this boundary can fully
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 qualifying segment boundaries: also set the HTTP status code from
357
- // the DenySignal so the Response has the correct 4xx status. This runs
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 canHandleInTree = this.props.fallbackElement == null;
366
-
367
- if (canHandleInTree || this.props.isSlotBoundary) {
368
- const ssrData = getSsrData();
369
- if (ssrData?._navContext) {
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
- * dangerouslyPassData }`, `error.tsx` gets `{ error, digest, reset }` (plus
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
- dangerouslyPassData: parsed.data,
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
- dangerouslyPassData: parsed.data,
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 / dangerouslyPassData: set only when error.tsx serves a deny()
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
- dangerouslyPassData?: unknown;
49
+ data?: unknown;
50
50
  }>;
51
51
  status?: number;
52
- dangerouslyPassData?: unknown;
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
- dangerouslyPassData,
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, dangerouslyPassData } : {}),
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
- // Register all timber config file candidates as Vite config file
494
- // dependencies so Vite's built-in restart-on-change handles them
495
- // (including atomic writes that emit unlink+add instead of change).
496
- // All candidates are registered — not just the one that exists — so
497
- // that creating a config file after server start also triggers a
498
- // restart. The watcher.add() call in dev-server.ts ensures all
499
- // candidates are in the watch set.
500
- // See design/21-dev-server.md §HMR Wiring.
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
- // Ensure config files are in the watch set. Vite's watcher covers
124
- // the project root, but `server.watcher.add()` is a no-op for files
125
- // already watched, and ensures newly-created config files (e.g. from
126
- // create-timber-app) are picked up. The actual restart logic is
127
- // handled by Vite's built-in configFileDependencies check — see the
128
- // configResolved hook in index.ts where we register the config path.
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.