@timber-js/app 0.2.0-alpha.96 → 0.2.0-alpha.98

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 (104) hide show
  1. package/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
  2. package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  3. package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
  4. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
  5. package/dist/_chunks/{interception-BsLCA9gk.js → walkers-VOXgavMF.js} +66 -92
  6. package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
  7. package/dist/adapters/nitro.d.ts.map +1 -1
  8. package/dist/adapters/nitro.js +55 -5
  9. package/dist/adapters/nitro.js.map +1 -1
  10. package/dist/client/index.js +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +189 -62
  13. package/dist/index.js.map +1 -1
  14. package/dist/plugins/build-report.d.ts +6 -4
  15. package/dist/plugins/build-report.d.ts.map +1 -1
  16. package/dist/plugins/dev-404-page.d.ts +8 -18
  17. package/dist/plugins/dev-404-page.d.ts.map +1 -1
  18. package/dist/routing/index.d.ts +5 -3
  19. package/dist/routing/index.d.ts.map +1 -1
  20. package/dist/routing/index.js +3 -3
  21. package/dist/routing/link-codegen.d.ts.map +1 -1
  22. package/dist/routing/scanner.d.ts +1 -10
  23. package/dist/routing/scanner.d.ts.map +1 -1
  24. package/dist/routing/segment-classify.d.ts +37 -8
  25. package/dist/routing/segment-classify.d.ts.map +1 -1
  26. package/dist/routing/types.d.ts +63 -23
  27. package/dist/routing/types.d.ts.map +1 -1
  28. package/dist/routing/walkers.d.ts +51 -0
  29. package/dist/routing/walkers.d.ts.map +1 -0
  30. package/dist/server/action-handler.d.ts.map +1 -1
  31. package/dist/server/dev-holding-server.d.ts +4 -2
  32. package/dist/server/dev-holding-server.d.ts.map +1 -1
  33. package/dist/server/html-injector-core.d.ts +212 -0
  34. package/dist/server/html-injector-core.d.ts.map +1 -0
  35. package/dist/server/html-injectors.d.ts +59 -59
  36. package/dist/server/html-injectors.d.ts.map +1 -1
  37. package/dist/server/internal.js +710 -563
  38. package/dist/server/internal.js.map +1 -1
  39. package/dist/server/node-stream-transforms.d.ts +46 -49
  40. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  41. package/dist/server/pipeline-helpers.d.ts +88 -0
  42. package/dist/server/pipeline-helpers.d.ts.map +1 -0
  43. package/dist/server/pipeline-phases.d.ts +97 -0
  44. package/dist/server/pipeline-phases.d.ts.map +1 -0
  45. package/dist/server/pipeline.d.ts +53 -32
  46. package/dist/server/pipeline.d.ts.map +1 -1
  47. package/dist/server/port-resolution.d.ts +117 -0
  48. package/dist/server/port-resolution.d.ts.map +1 -0
  49. package/dist/server/route-matcher.d.ts +20 -47
  50. package/dist/server/route-matcher.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
  53. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
  54. package/dist/server/status-code-resolver.d.ts +16 -11
  55. package/dist/server/status-code-resolver.d.ts.map +1 -1
  56. package/dist/server/tree-builder.d.ts.map +1 -1
  57. package/dist/utils/directive-parser.d.ts +0 -45
  58. package/dist/utils/directive-parser.d.ts.map +1 -1
  59. package/package.json +7 -6
  60. package/src/adapters/nitro.ts +55 -5
  61. package/src/cli.ts +0 -0
  62. package/src/index.ts +84 -31
  63. package/src/plugins/build-report.ts +13 -22
  64. package/src/plugins/dev-404-page.ts +15 -41
  65. package/src/plugins/routing.ts +14 -12
  66. package/src/routing/codegen.ts +1 -1
  67. package/src/routing/convention-lint.ts +4 -4
  68. package/src/routing/index.ts +5 -3
  69. package/src/routing/interception.ts +1 -1
  70. package/src/routing/link-codegen.ts +25 -13
  71. package/src/routing/scanner.ts +17 -93
  72. package/src/routing/segment-classify.ts +107 -8
  73. package/src/routing/status-file-lint.ts +3 -3
  74. package/src/routing/types.ts +63 -23
  75. package/src/routing/walkers.ts +90 -0
  76. package/src/server/action-handler.ts +6 -0
  77. package/src/server/deny-renderer.ts +5 -5
  78. package/src/server/dev-holding-server.ts +4 -2
  79. package/src/server/fallback-error.ts +1 -1
  80. package/src/server/html-injector-core.ts +403 -0
  81. package/src/server/html-injectors.ts +158 -297
  82. package/src/server/node-stream-transforms.ts +108 -248
  83. package/src/server/pipeline-helpers.ts +180 -0
  84. package/src/server/pipeline-phases.ts +591 -0
  85. package/src/server/pipeline.ts +76 -539
  86. package/src/server/port-resolution.ts +215 -0
  87. package/src/server/route-element-builder.ts +1 -1
  88. package/src/server/route-matcher.ts +28 -60
  89. package/src/server/rsc-entry/api-handler.ts +2 -2
  90. package/src/server/rsc-entry/error-renderer.ts +1 -1
  91. package/src/server/rsc-entry/index.ts +52 -98
  92. package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
  93. package/src/server/sitemap-generator.ts +1 -1
  94. package/src/server/slot-resolver.ts +1 -1
  95. package/src/server/status-code-resolver.ts +112 -128
  96. package/src/server/tree-builder.ts +6 -4
  97. package/src/utils/directive-parser.ts +0 -392
  98. package/LICENSE +0 -8
  99. package/dist/_chunks/interception-BsLCA9gk.js.map +0 -1
  100. package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
  101. package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
  102. package/dist/server/manifest-status-resolver.d.ts +0 -58
  103. package/dist/server/manifest-status-resolver.d.ts.map +0 -1
  104. package/src/server/manifest-status-resolver.ts +0 -215
@@ -1,17 +1,39 @@
1
1
  /**
2
- * HTML stream injectors — TransformStreams that modify streamed HTML.
2
+ * HTML stream injectors — Web `TransformStream` wrappers around the
3
+ * pure helpers in `html-injector-core.ts`.
3
4
  *
4
- * These are extracted into a separate module so they can be tested
5
- * independently of rsc-entry.ts (which imports virtual modules).
5
+ * Used on edge runtimes (Cloudflare Workers, Deno, browser SSR) where
6
+ * Web Streams are the native shape. The Node.js / Bun adapters live in
7
+ * `node-stream-transforms.ts` and wrap the same core in Node `Transform`s.
8
+ * Both wrappers must stay byte-for-byte equivalent — the core enforces
9
+ * that. If you fix a streaming bug here, you almost certainly need to
10
+ * fix it (or its cause) in the core, not in the Node wrapper.
11
+ *
12
+ * Also exports the `buildClientScripts` helper used by RSC entry to
13
+ * resolve bootstrap script URLs from the build manifest. That helper
14
+ * is unrelated to streaming but lives here for historical reasons (it
15
+ * shares no code with the core and was previously co-located with the
16
+ * Web stream transforms).
6
17
  *
7
18
  * Design docs: 02-rendering-pipeline.md, 18-build-system.md §"Entry Files"
8
19
  */
9
20
 
21
+ import {
22
+ BufferAggregator,
23
+ FlightInjectorCore,
24
+ createInjectorState,
25
+ createSuffixState,
26
+ encodeUtf8,
27
+ injectorFlush,
28
+ processInjectorChunk,
29
+ processSuffixChunk,
30
+ suffixFlush,
31
+ } from './html-injector-core.js';
32
+ import { flightChunkScript } from './flight-scripts.js';
33
+ import { withTimeout, RenderTimeoutError } from './render-timeout.js';
34
+
10
35
  // ─── Buffered Transform ──────────────────────────────────────────────────────
11
36
 
12
- /**
13
- * Options for the buffered transform stream.
14
- */
15
37
  export interface BufferedTransformOptions {
16
38
  /**
17
39
  * Flush synchronously once the buffer reaches this many bytes.
@@ -24,85 +46,55 @@ export interface BufferedTransformOptions {
24
46
  /**
25
47
  * Buffer incoming chunks and coalesce them within a single event loop tick.
26
48
  *
27
- * React Fizz may emit multiple micro-chunks within a single flush (e.g.,
28
- * opening tags, attributes, closing tags as separate writes). Without
29
- * buffering, downstream transforms (especially flight injection) could
30
- * see chunk boundaries in the middle of HTML tags or attribute values.
31
- *
32
- * This transform collects all chunks that arrive in the same tick and
33
- * emits them as a single concatenated chunk on the next `setImmediate`.
34
- * This ensures each output chunk represents a coherent HTML fragment
35
- * from a single Fizz flush — safe for downstream script injection at
36
- * chunk boundaries.
49
+ * React Fizz may emit multiple micro-chunks within a single flush. Without
50
+ * coalescing, downstream transforms (especially flight injection) could
51
+ * see chunk boundaries in the middle of HTML tags. This transform collects
52
+ * everything that arrives in the same tick and emits it as one concatenated
53
+ * chunk on the next `setImmediate` — a single-shot schedule, not a poll.
37
54
  *
38
- * **Not a polling loop.** Uses a single-shot `setImmediate` per flush
39
- * cycle — no recursive scheduling, no busy-wait. See design/02 §"No Polling".
40
- *
41
- * Inspired by Next.js `createBufferedTransformStream`.
55
+ * See design/02 §"No Polling" and §"Scripts at Chunk Boundaries Only".
42
56
  */
43
57
  export function createBufferedTransformStream(
44
58
  options: BufferedTransformOptions = {}
45
59
  ): TransformStream<Uint8Array, Uint8Array> {
46
- const { maxBufferByteLength = Infinity } = options;
47
-
48
- let bufferedChunks: Uint8Array[] = [];
49
- let bufferByteLength = 0;
60
+ const buffer = new BufferAggregator(options.maxBufferByteLength ?? Infinity);
50
61
  let pendingFlush: Promise<void> | undefined;
51
62
 
52
- const flush = (controller: TransformStreamDefaultController<Uint8Array>) => {
53
- if (bufferedChunks.length === 0) return;
54
-
55
- // Concatenate all buffered chunks into a single output chunk
56
- const merged = new Uint8Array(bufferByteLength);
57
- let offset = 0;
58
- for (const chunk of bufferedChunks) {
59
- merged.set(chunk, offset);
60
- offset += chunk.byteLength;
61
- }
62
-
63
- bufferedChunks = [];
64
- bufferByteLength = 0;
65
-
63
+ const drainTo = (controller: TransformStreamDefaultController<Uint8Array>) => {
64
+ const merged = buffer.drain();
65
+ if (!merged) return;
66
66
  try {
67
67
  controller.enqueue(merged);
68
68
  } catch {
69
- // Controller may be errored (e.g., stream cancelled) — ignore
69
+ // Controller may be errored (e.g. cancelled) — ignore.
70
70
  }
71
71
  };
72
72
 
73
- const scheduleFlush = (controller: TransformStreamDefaultController<Uint8Array>) => {
74
- if (pendingFlush) return;
75
-
76
- // Single-shot setImmediate — fires once at the end of the current
77
- // event loop iteration (check phase), then the promise resolves.
78
- // NOT a recursive loop — no CPU spin risk.
79
- pendingFlush = new Promise<void>((resolve) => {
80
- setImmediate(() => {
81
- try {
82
- flush(controller);
83
- } finally {
84
- pendingFlush = undefined;
85
- resolve();
86
- }
87
- });
88
- });
89
- };
90
-
91
73
  return new TransformStream<Uint8Array, Uint8Array>({
92
74
  transform(chunk, controller) {
93
- bufferedChunks.push(chunk);
94
- bufferByteLength += chunk.byteLength;
95
-
96
- if (bufferByteLength >= maxBufferByteLength) {
97
- // Synchronous flush — buffer is too large to hold
98
- flush(controller);
99
- } else {
100
- // Schedule a deferred flush for end of this tick
101
- scheduleFlush(controller);
75
+ const overCap = buffer.append(chunk);
76
+ if (overCap) {
77
+ // Buffer too large to hold — flush synchronously now.
78
+ drainTo(controller);
79
+ return;
102
80
  }
81
+ if (pendingFlush) return;
82
+ // Single-shot setImmediate — fires once at the end of this tick,
83
+ // then the promise resolves. NOT a recursive loop. See design/02
84
+ // §"No Polling".
85
+ pendingFlush = new Promise<void>((resolve) => {
86
+ setImmediate(() => {
87
+ try {
88
+ drainTo(controller);
89
+ } finally {
90
+ pendingFlush = undefined;
91
+ resolve();
92
+ }
93
+ });
94
+ });
103
95
  },
104
96
  flush() {
105
- // Wait for any pending scheduled flush to complete
97
+ // Wait for any pending scheduled flush to complete.
106
98
  return pendingFlush;
107
99
  },
108
100
  });
@@ -110,84 +102,47 @@ export function createBufferedTransformStream(
110
102
 
111
103
  // ─── Move Suffix Transform ───────────────────────────────────────────────────
112
104
 
113
- const SUFFIX = '</body></html>';
114
-
115
105
  /**
116
106
  * Move `</body></html>` to the end of the stream.
117
107
  *
118
- * React's renderToReadableStream emits `</body></html>` as part of the
119
- * shell chunk. Content that arrives after the shell (Suspense resolutions,
120
- * RSC script tags) would appear after the closing tags, producing invalid
121
- * HTML like `</body></html><script>...</script>`.
122
- *
123
- * This transform strips the suffix when first encountered and re-emits
124
- * it in `flush()` — after all content has passed through. If no suffix
125
- * is found, it's appended anyway to ensure well-formed HTML.
108
+ * React's renderToReadableStream emits the closing tags as part of the
109
+ * shell chunk. Content arriving after the shell (Suspense resolutions,
110
+ * RSC scripts) would otherwise appear after `</html>` and produce invalid
111
+ * HTML. This transform strips the suffix on first sight and re-emits it
112
+ * from `flush()` — after every other byte has passed through. If the
113
+ * suffix never appeared in the input, it is appended anyway so output
114
+ * is well-formed.
126
115
  *
127
116
  * Equivalent to Next.js's `createMoveSuffixStream`.
128
117
  */
129
118
  export function createMoveSuffixStream(): TransformStream<Uint8Array, Uint8Array> {
130
- const encoder = new TextEncoder();
131
- const suffixBytes = encoder.encode(SUFFIX);
132
- let foundSuffix = false;
119
+ const state = createSuffixState();
133
120
 
134
121
  return new TransformStream<Uint8Array, Uint8Array>({
135
122
  transform(chunk, controller) {
136
- if (foundSuffix) {
137
- controller.enqueue(chunk);
138
- return;
139
- }
140
-
141
- // Search for the suffix in this chunk
142
- const text = new TextDecoder().decode(chunk, { stream: true });
143
- const idx = text.indexOf(SUFFIX);
144
- if (idx === -1) {
123
+ const result = processSuffixChunk(state, chunk);
124
+ if (result.kind === 'passthrough' || result.kind === 'noSuffix') {
145
125
  controller.enqueue(chunk);
146
126
  return;
147
127
  }
148
-
149
- foundSuffix = true;
150
-
151
- // If the entire chunk is exactly the suffix, skip it
152
- if (chunk.byteLength === suffixBytes.byteLength) return;
153
-
154
- // Emit content before the suffix
155
- const before = text.slice(0, idx);
156
- const after = text.slice(idx + SUFFIX.length);
157
- if (before) controller.enqueue(encoder.encode(before));
158
- // Emit content after the suffix (shouldn't normally exist,
159
- // but handle it for robustness)
160
- if (after) controller.enqueue(encoder.encode(after));
128
+ if (result.before) controller.enqueue(result.before);
129
+ if (result.after) controller.enqueue(result.after);
161
130
  },
162
131
  flush(controller) {
163
- // Always emit the suffix at the very end — even if we didn't
164
- // find it in the input (ensures well-formed HTML).
165
- controller.enqueue(suffixBytes);
132
+ controller.enqueue(suffixFlush(state));
166
133
  },
167
134
  });
168
135
  }
169
136
 
170
- // ─── Injection Transforms ────────────────────────────────────────────────────
171
-
172
- import { createMachine } from '../utils/state-machine.js';
173
- import { flightChunkScript } from './flight-scripts.js';
174
- import {
175
- flightInjectionTransitions,
176
- isHtmlDone,
177
- isPullDone,
178
- type FlightInjectionState,
179
- type FlightInjectionEvent,
180
- } from './flight-injection-state.js';
181
- import { withTimeout, RenderTimeoutError } from './render-timeout.js';
137
+ // ─── Tag Injection ───────────────────────────────────────────────────────────
182
138
 
183
139
  /**
184
- * Inject HTML content before a closing tag in the stream.
140
+ * Inject `content` immediately before (or after) `targetTag` in the stream.
185
141
  *
186
- * Streams chunks through immediately, keeping only a small trailing
187
- * buffer (the length of the target tag minus one) to handle the case
188
- * where the target tag spans two chunks. This preserves React's
189
- * streaming behavior for Suspense boundaries — chunks are not held
190
- * back waiting for the closing tag.
142
+ * Streams chunks through immediately, keeping only a small trailing buffer
143
+ * (length of target tag minus one) so a tag split across chunks is still
144
+ * detected. Suspense streaming chunks are never held back waiting for the
145
+ * closing tag.
191
146
  */
192
147
  function createInjector(
193
148
  stream: ReadableStream<Uint8Array>,
@@ -196,57 +151,29 @@ function createInjector(
196
151
  position: 'before' | 'after' = 'before'
197
152
  ): ReadableStream<Uint8Array> {
198
153
  if (!content) return stream;
199
-
200
- const decoder = new TextDecoder();
201
- const encoder = new TextEncoder();
202
- let injected = false;
203
- // Keep a trailing buffer just large enough that the target tag
204
- // can't be split across the boundary without us seeing it.
205
- let tail = '';
206
- const tailLen = targetTag.length - 1;
154
+ const state = createInjectorState({ content, targetTag, position });
207
155
 
208
156
  return stream.pipeThrough(
209
157
  new TransformStream<Uint8Array, Uint8Array>({
210
158
  transform(chunk, controller) {
211
- if (injected) {
159
+ const result = processInjectorChunk(state, chunk);
160
+ if (result.kind === 'passthrough') {
212
161
  controller.enqueue(chunk);
213
162
  return;
214
163
  }
215
-
216
- // Combine the trailing buffer with the new chunk
217
- const text = tail + decoder.decode(chunk, { stream: true });
218
- const tagIndex = text.indexOf(targetTag);
219
-
220
- if (tagIndex !== -1) {
221
- const splitPoint = position === 'before' ? tagIndex : tagIndex + targetTag.length;
222
- const before = text.slice(0, splitPoint);
223
- const after = text.slice(splitPoint);
224
- controller.enqueue(encoder.encode(before + content + after));
225
- injected = true;
226
- tail = '';
227
- } else {
228
- // Flush everything except the last tailLen chars (which might
229
- // be the start of the target tag split across chunks).
230
- const safeEnd = Math.max(0, text.length - tailLen);
231
- if (safeEnd > 0) {
232
- controller.enqueue(encoder.encode(text.slice(0, safeEnd)));
233
- }
234
- tail = text.slice(safeEnd);
235
- }
164
+ if (result.bytes) controller.enqueue(result.bytes);
236
165
  },
237
166
  flush(controller) {
238
- if (!injected && tail) {
239
- controller.enqueue(encoder.encode(tail));
240
- }
167
+ const leftover = injectorFlush(state);
168
+ if (leftover) controller.enqueue(leftover);
241
169
  },
242
170
  })
243
171
  );
244
172
  }
245
173
 
246
174
  /**
247
- * Inject metadata elements before </head> in the HTML stream.
248
- *
249
- * If no </head> is found, the buffer is emitted as-is.
175
+ * Inject metadata elements before `</head>` in the HTML stream.
176
+ * If no `</head>` is found, the buffer is emitted as-is.
250
177
  */
251
178
  export function injectHead(
252
179
  stream: ReadableStream<Uint8Array>,
@@ -256,10 +183,9 @@ export function injectHead(
256
183
  }
257
184
 
258
185
  /**
259
- * Inject client bootstrap scripts before </body> in the HTML stream.
260
- *
261
- * Returns the stream unchanged if scriptsHtml is empty (client JS disabled mode).
262
- * If no </body> is found, the buffer is emitted as-is.
186
+ * Inject client bootstrap scripts before `</body>` in the HTML stream.
187
+ * Returns the stream unchanged if `scriptsHtml` is empty (client JS
188
+ * disabled mode). If no `</body>` is found, the buffer is emitted as-is.
263
189
  */
264
190
  export function injectScripts(
265
191
  stream: ReadableStream<Uint8Array>,
@@ -268,30 +194,27 @@ export function injectScripts(
268
194
  return createInjector(stream, scriptsHtml, '</body>');
269
195
  }
270
196
 
197
+ // ─── RSC Flight Injection ────────────────────────────────────────────────────
198
+
271
199
  /**
272
200
  * Transform an RSC Flight stream into a stream of inline `<script>` tags.
273
201
  *
274
202
  * Uses a **pull-based** ReadableStream — the consumer (the injection
275
203
  * transform) drives reads from the RSC stream on demand. No background
276
- * reader, no shared mutable arrays, no race conditions.
277
- *
278
- * Each RSC chunk becomes a `<script>self.__timber_f.push([1,"data"])</script>`.
279
- * The init script (which creates __timber_f) is in `<head>` via
280
- * flightInitScript() — see flight-scripts.ts.
204
+ * reader, no shared mutable arrays, no race conditions. Each RSC chunk
205
+ * becomes `<script>self.__timber_f.push([1,"data"])</script>`. The
206
+ * init script (which creates `__timber_f`) is in `<head>` via
207
+ * `flightInitScript()` see flight-scripts.ts.
281
208
  */
282
209
  export function createInlinedRscStream(
283
210
  rscStream: ReadableStream<Uint8Array>,
284
211
  renderTimeoutMs?: number
285
212
  ): ReadableStream<Uint8Array> {
286
- const encoder = new TextEncoder();
287
213
  const rscReader = rscStream.getReader();
288
214
  const decoder = new TextDecoder('utf-8', { fatal: true });
289
215
  const timeoutMs = renderTimeoutMs ?? 30_000;
290
216
 
291
217
  return new ReadableStream<Uint8Array>({
292
- // No bootstrap signal here — the init script is in <head> via
293
- // flightInitScript() (see flight-scripts.ts). This ensures the
294
- // __timber_f array exists before any chunk scripts execute.
295
218
  async pull(controller) {
296
219
  try {
297
220
  const readPromise = rscReader.read();
@@ -305,7 +228,7 @@ export function createInlinedRscStream(
305
228
  }
306
229
  if (value) {
307
230
  const decoded = decoder.decode(value, { stream: true });
308
- controller.enqueue(encoder.encode(flightChunkScript(decoded)));
231
+ controller.enqueue(encodeUtf8(flightChunkScript(decoded)));
309
232
  }
310
233
  } catch (error) {
311
234
  if (error instanceof RenderTimeoutError) {
@@ -318,105 +241,50 @@ export function createInlinedRscStream(
318
241
  }
319
242
 
320
243
  /**
321
- * Merge RSC script stream into the HTML stream, injecting scripts
322
- * between HTML chunks at safe boundaries.
244
+ * Merge a raw RSC byte stream into the HTML stream, wrapping each RSC
245
+ * chunk in a `<script>` and injecting it between HTML chunks at safe
246
+ * boundaries. Suffix stripping is handled upstream by
247
+ * `createMoveSuffixStream` — this transform only interleaves.
323
248
  *
324
- * Suffix stripping (</body></html>) is handled upstream by
325
- * createMoveSuffixStream. This transform only interleaves RSC
326
- * scripts no suffix tracking needed.
249
+ * The state machine, pending queue, script wrapping (`flightChunkScript`),
250
+ * and pull loop all live in `FlightInjectorCore`. This wrapper only
251
+ * shuffles bytes between the core and the Web `TransformStream` controller.
327
252
  *
328
- * RSC scripts are buffered in pending[] by pullLoop and only
329
- * drained from transform() (after a complete HTML chunk) or
330
- * flush(). Never pushed directly from pullLoop to avoid mid-tag
331
- * injection (TIM-527).
253
+ * **Input must be the raw RSC stream, not the pre-scripted output of
254
+ * `createInlinedRscStream`.** The core wraps each chunk itself; passing
255
+ * an already-wrapped stream would double-wrap and break hydration.
332
256
  *
333
- * State machine phases:
334
- * init → streaming → flushing → done
335
- * (any) → error
257
+ * State machine phases: init → streaming → flushing → done; (any) → error.
336
258
  *
337
- * Inspired by Next.js createFlightDataInjectionTransformStream.
259
+ * Inspired by Next.js `createFlightDataInjectionTransformStream`. See
260
+ * design/02 §"Scripts at Chunk Boundaries Only".
338
261
  */
339
262
  function createFlightInjectionTransform(
340
- rscScriptStream: ReadableStream<Uint8Array>,
263
+ rscStream: ReadableStream<Uint8Array>,
341
264
  renderTimeoutMs?: number
342
265
  ): TransformStream<Uint8Array, Uint8Array> {
343
- const rscReader = rscScriptStream.getReader();
344
- const timeoutMs = renderTimeoutMs ?? 30_000;
345
-
346
- const machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
347
- initial: { phase: 'init' },
348
- transitions: flightInjectionTransitions,
349
- });
350
-
351
- let pullPromise: Promise<void> | null = null;
352
-
353
- // RSC script chunks waiting to be drained at a safe boundary.
354
- // pullLoop buffers here; transform(), flush(), and pullLoop's
355
- // post-yield drain all consume from this buffer.
356
- const pending: Uint8Array[] = [];
357
-
358
- // Controller reference — set on first transform() call so pullLoop
359
- // can drain pending scripts between HTML chunks (after yielding).
266
+ const core = new FlightInjectorCore(rscStream, renderTimeoutMs);
267
+ // Controller reference set on first transform() call so the pull
268
+ // loop can drain pending scripts between HTML chunks (after yielding).
360
269
  let _controller: TransformStreamDefaultController<Uint8Array> | null = null;
361
270
 
362
- async function pullLoop(): Promise<void> {
363
- // Yield once so the first HTML shell chunk flows through
364
- // transform() before we start reading RSC data.
365
- await new Promise<void>((r) => setImmediate(r));
366
-
367
- try {
368
- for (;;) {
369
- // Guard each RSC read with a timeout.
370
- // See design/02 §"Streaming Constraints".
371
- const readPromise = rscReader.read();
372
- const { done, value } =
373
- timeoutMs > 0
374
- ? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
375
- : await readPromise;
376
- if (done) {
377
- machine.send({ type: 'PULL_DONE' });
378
- return;
379
- }
380
- pending.push(value);
381
- // Yield between reads so HTML chunks get priority.
382
- // Once flush() fires, drain without yielding.
383
- if (!isHtmlDone(machine.state)) {
384
- await new Promise<void>((r) => setImmediate(r));
385
- // After yielding, drain pending scripts. This ensures RSC
386
- // flight data reaches the client at shell-flush time — without
387
- // this, hydration blocks waiting for data stuck in pending[].
388
- // Safe because the upstream buffered transform (TIM-528)
389
- // guarantees chunks end at tag boundaries.
390
- if (pending.length > 0 && _controller) {
391
- drainPending(_controller);
392
- }
393
- }
394
- }
395
- } catch (err) {
396
- if (err instanceof RenderTimeoutError) {
397
- rscReader.cancel(err).catch(() => {});
398
- }
399
- machine.send({ type: 'PULL_ERROR', error: err });
400
- }
401
- }
271
+ const drain = (controller: TransformStreamDefaultController<Uint8Array>): void => {
272
+ while (core.pending.length > 0) controller.enqueue(core.pending.shift()!);
273
+ const err = core.terminalError;
274
+ if (err !== null) controller.error(err);
275
+ };
402
276
 
403
- /** Drain all buffered RSC script chunks to the output. */
404
- function drainPending(controller: TransformStreamDefaultController<Uint8Array>): void {
405
- while (pending.length > 0) {
406
- controller.enqueue(pending.shift()!);
407
- }
408
- if (machine.state.phase === 'error') {
409
- controller.error(machine.state.error);
410
- }
411
- }
277
+ const onYieldDrain = (): void => {
278
+ if (_controller) drain(_controller);
279
+ };
412
280
 
413
281
  return new TransformStream<Uint8Array, Uint8Array>({
414
282
  transform(chunk, controller) {
415
283
  _controller = controller;
416
284
 
417
- if (machine.state.phase === 'init') {
418
- machine.send({ type: 'FIRST_CHUNK' });
419
- pullPromise = pullLoop();
285
+ if (core.isInit) {
286
+ core.notifyFirstChunk();
287
+ core.ensurePullLoop(onYieldDrain);
420
288
  }
421
289
 
422
290
  // Emit the HTML chunk, then drain any buffered RSC scripts.
@@ -424,24 +292,19 @@ function createFlightInjectionTransform(
424
292
  // The buffered transform upstream (TIM-528) ensures coherent chunks.
425
293
  // Suffix stripping is upstream via createMoveSuffixStream (TIM-530).
426
294
  controller.enqueue(chunk);
427
- if (pending.length > 0) drainPending(controller);
295
+ if (core.pending.length > 0) drain(controller);
428
296
  },
429
297
  flush(controller) {
430
298
  // All HTML chunks emitted. Pull loop stops yielding.
431
- machine.send({ type: 'HTML_DONE' });
299
+ core.notifyHtmlDone();
432
300
 
433
- const finish = () => {
434
- drainPending(controller);
435
- };
301
+ const finish = () => drain(controller);
436
302
 
437
- if (isPullDone(machine.state)) {
303
+ if (core.isPullDone) {
438
304
  finish();
439
305
  return;
440
306
  }
441
- if (!pullPromise) {
442
- pullPromise = pullLoop();
443
- }
444
- return pullPromise.then(finish);
307
+ return core.ensurePullLoop(onYieldDrain).then(finish);
445
308
  },
446
309
  });
447
310
  }
@@ -449,19 +312,23 @@ function createFlightInjectionTransform(
449
312
  /**
450
313
  * Progressively inline RSC Flight payload chunks into the HTML stream.
451
314
  *
452
- * Architecture (3 TransformStream pipeline):
453
- * 1. HTML stream → moveSuffix (captures </body></html>, re-emits at end)
454
- * 2. → flightInjection (merges RSC <script> tags between HTML chunks)
455
- * 3. output (well-formed HTML with interleaved RSC data)
315
+ * Architecture (2 TransformStream pipeline):
316
+ * HTML stream → flightInjection moveSuffix output
317
+ *
318
+ * 1. flightInjection wraps each RSC chunk in a `<script>` and interleaves
319
+ * them between HTML chunks.
320
+ * 2. moveSuffix strips `</body></html>` and re-emits at end so injected
321
+ * scripts appear before the closing tags.
456
322
  *
457
- * The RSC stream is transformed into <script> tags via createInlinedRscStream
458
- * (pull-based, no shared mutable state) and merged into the HTML pipeline
459
- * via createFlightInjectionTransform.
323
+ * Wrapping happens inside `FlightInjectorCore` (via `flightChunkScript`).
324
+ * The raw RSC stream is passed straight to `createFlightInjectionTransform`
325
+ * do not pre-wrap with `createInlinedRscStream` (that helper still
326
+ * exists for callers that want a standalone scripted stream).
460
327
  *
461
328
  * The client reads these script tags via `self.__timber_f` and feeds
462
- * them to `createFromReadableStream` for progressive hydration.
463
- * Stream completion is signaled by the DOMContentLoaded event on the
464
- * client side — no custom done flag needed.
329
+ * them to `createFromReadableStream` for progressive hydration. Stream
330
+ * completion is signaled by the `DOMContentLoaded` event on the client
331
+ * side — no custom done flag needed.
465
332
  */
466
333
  export function injectRscPayload(
467
334
  htmlStream: ReadableStream<Uint8Array>,
@@ -469,23 +336,17 @@ export function injectRscPayload(
469
336
  renderTimeoutMs?: number
470
337
  ): ReadableStream<Uint8Array> {
471
338
  if (!rscStream) return htmlStream;
472
-
473
- // Transform RSC binary stream stream of <script> tags
474
- const rscScriptStream = createInlinedRscStream(rscStream, renderTimeoutMs);
475
-
476
- // Pipeline: flightInjection → moveSuffix
477
- //
478
- // 1. flightInjection interleaves RSC scripts between HTML chunks
479
- // 2. moveSuffix strips </body></html> and re-emits at end
480
- //
481
- // The flight injector is upstream and interleaves scripts between
482
- // HTML chunks. The moveSuffix transform then ensures </body></html>
483
- // appears after all injected scripts, producing well-formed HTML.
339
+ // Pass the raw RSC stream directly to the flight injection transform.
340
+ // The transform's core (FlightInjectorCore) wraps each chunk in a
341
+ // <script> via flightChunkScript itself — do NOT pre-wrap with
342
+ // createInlinedRscStream here, that would double-wrap.
484
343
  return htmlStream
485
- .pipeThrough(createFlightInjectionTransform(rscScriptStream, renderTimeoutMs))
344
+ .pipeThrough(createFlightInjectionTransform(rscStream, renderTimeoutMs))
486
345
  .pipeThrough(createMoveSuffixStream());
487
346
  }
488
347
 
348
+ // ─── Client Bootstrap Script Resolution ──────────────────────────────────────
349
+
489
350
  /**
490
351
  * Client bootstrap configuration returned by buildClientScripts.
491
352
  *
@@ -526,19 +387,19 @@ function findManifestEntryArray(
526
387
  /**
527
388
  * Build client bootstrap configuration based on runtime config.
528
389
  *
529
- * Returns empty strings when client JavaScript is disabled,
530
- * which produces zero-JS output. When `enableHMRInDev` is true and
531
- * running in dev mode, injects only the Vite HMR client (no app
532
- * bootstrap) so hot reloading works during development.
390
+ * Returns empty strings when client JavaScript is disabled, which produces
391
+ * zero-JS output. When `enableHMRInDev` is true and running in dev mode,
392
+ * injects only the Vite HMR client (no app bootstrap) so hot reloading
393
+ * works during development.
533
394
  *
534
395
  * In production, uses hashed chunk URLs from the build manifest.
535
396
  *
536
397
  * The bootstrap uses dynamic `import()` inside a regular (non-module)
537
398
  * inline script so it executes immediately during HTML parsing. This
538
- * is critical for streaming: `<script type="module">` is deferred
539
- * until the document finishes parsing, which blocks hydration behind
540
- * Suspense boundaries. Dynamic `import()` starts module loading and
541
- * execution as soon as the shell HTML is parsed.
399
+ * is critical for streaming: `<script type="module">` is deferred until
400
+ * the document finishes parsing, which blocks hydration behind Suspense
401
+ * boundaries. Dynamic `import()` starts module loading and execution as
402
+ * soon as the shell HTML is parsed.
542
403
  */
543
404
  export function buildClientScripts(runtimeConfig: {
544
405
  output: string;