@timber-js/app 0.2.0-alpha.97 → 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 (102) 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-BbqMCVXa.js → walkers-VOXgavMF.js} +61 -85
  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/scanner.d.ts +1 -10
  22. package/dist/routing/scanner.d.ts.map +1 -1
  23. package/dist/routing/segment-classify.d.ts +37 -8
  24. package/dist/routing/segment-classify.d.ts.map +1 -1
  25. package/dist/routing/types.d.ts +63 -23
  26. package/dist/routing/types.d.ts.map +1 -1
  27. package/dist/routing/walkers.d.ts +51 -0
  28. package/dist/routing/walkers.d.ts.map +1 -0
  29. package/dist/server/action-handler.d.ts.map +1 -1
  30. package/dist/server/dev-holding-server.d.ts +4 -2
  31. package/dist/server/dev-holding-server.d.ts.map +1 -1
  32. package/dist/server/html-injector-core.d.ts +212 -0
  33. package/dist/server/html-injector-core.d.ts.map +1 -0
  34. package/dist/server/html-injectors.d.ts +59 -59
  35. package/dist/server/html-injectors.d.ts.map +1 -1
  36. package/dist/server/internal.js +710 -563
  37. package/dist/server/internal.js.map +1 -1
  38. package/dist/server/node-stream-transforms.d.ts +46 -49
  39. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  40. package/dist/server/pipeline-helpers.d.ts +88 -0
  41. package/dist/server/pipeline-helpers.d.ts.map +1 -0
  42. package/dist/server/pipeline-phases.d.ts +97 -0
  43. package/dist/server/pipeline-phases.d.ts.map +1 -0
  44. package/dist/server/pipeline.d.ts +53 -32
  45. package/dist/server/pipeline.d.ts.map +1 -1
  46. package/dist/server/port-resolution.d.ts +117 -0
  47. package/dist/server/port-resolution.d.ts.map +1 -0
  48. package/dist/server/route-matcher.d.ts +20 -47
  49. package/dist/server/route-matcher.d.ts.map +1 -1
  50. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
  52. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
  53. package/dist/server/status-code-resolver.d.ts +16 -11
  54. package/dist/server/status-code-resolver.d.ts.map +1 -1
  55. package/dist/server/tree-builder.d.ts.map +1 -1
  56. package/dist/utils/directive-parser.d.ts +0 -45
  57. package/dist/utils/directive-parser.d.ts.map +1 -1
  58. package/package.json +7 -6
  59. package/src/adapters/nitro.ts +55 -5
  60. package/src/cli.ts +0 -0
  61. package/src/index.ts +84 -31
  62. package/src/plugins/build-report.ts +13 -22
  63. package/src/plugins/dev-404-page.ts +15 -41
  64. package/src/plugins/routing.ts +14 -12
  65. package/src/routing/codegen.ts +1 -1
  66. package/src/routing/convention-lint.ts +4 -4
  67. package/src/routing/index.ts +5 -3
  68. package/src/routing/interception.ts +1 -1
  69. package/src/routing/scanner.ts +17 -93
  70. package/src/routing/segment-classify.ts +107 -8
  71. package/src/routing/status-file-lint.ts +3 -3
  72. package/src/routing/types.ts +63 -23
  73. package/src/routing/walkers.ts +90 -0
  74. package/src/server/action-handler.ts +6 -0
  75. package/src/server/deny-renderer.ts +5 -5
  76. package/src/server/dev-holding-server.ts +4 -2
  77. package/src/server/fallback-error.ts +1 -1
  78. package/src/server/html-injector-core.ts +403 -0
  79. package/src/server/html-injectors.ts +158 -297
  80. package/src/server/node-stream-transforms.ts +108 -248
  81. package/src/server/pipeline-helpers.ts +180 -0
  82. package/src/server/pipeline-phases.ts +591 -0
  83. package/src/server/pipeline.ts +76 -539
  84. package/src/server/port-resolution.ts +215 -0
  85. package/src/server/route-element-builder.ts +1 -1
  86. package/src/server/route-matcher.ts +28 -60
  87. package/src/server/rsc-entry/api-handler.ts +2 -2
  88. package/src/server/rsc-entry/error-renderer.ts +1 -1
  89. package/src/server/rsc-entry/index.ts +52 -98
  90. package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
  91. package/src/server/sitemap-generator.ts +1 -1
  92. package/src/server/slot-resolver.ts +1 -1
  93. package/src/server/status-code-resolver.ts +112 -128
  94. package/src/server/tree-builder.ts +6 -4
  95. package/src/utils/directive-parser.ts +0 -392
  96. package/LICENSE +0 -8
  97. package/dist/_chunks/interception-BbqMCVXa.js.map +0 -1
  98. package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
  99. package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
  100. package/dist/server/manifest-status-resolver.d.ts +0 -58
  101. package/dist/server/manifest-status-resolver.d.ts.map +0 -1
  102. package/src/server/manifest-status-resolver.ts +0 -215
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Node.js native stream transforms for SSR HTML post-processing.
3
3
  *
4
- * These are Node.js Transform stream equivalents of the Web Stream
5
- * transforms in html-injectors.ts. Used on Node.js/Bun where native
6
- * streams (C++ backed) are faster than Web Streams (JS reimplementation).
7
- *
8
- * The transforms are pure string operations on HTML chunks the same
9
- * logic as the Web Stream versions, just wrapped in Node.js Transform
10
- * instead of Web TransformStream.
4
+ * Node.js `Transform` wrappers around the pure helpers in
5
+ * `html-injector-core.ts`. Used on Node.js / Bun where C++-backed
6
+ * native streams are significantly faster than the JS-implemented
7
+ * Web Streams. The Web `TransformStream` equivalents live in
8
+ * `html-injectors.ts` and wrap the same core keep the two files
9
+ * byte-for-byte equivalent in behavior. If you fix a streaming bug
10
+ * here, it almost certainly belongs in the core (`html-injector-core.ts`),
11
+ * not in this wrapper.
11
12
  *
12
13
  * Architecture:
13
14
  * renderToPipeableStream → pipe(errorHandler) → pipe(headInjector)
@@ -20,11 +21,21 @@
20
21
  import { Transform } from 'node:stream';
21
22
  import { createGzip, constants } from 'node:zlib';
22
23
 
24
+ import {
25
+ BufferAggregator,
26
+ FlightInjectorCore,
27
+ SUFFIX_BYTES,
28
+ createInjectorState,
29
+ createSuffixState,
30
+ injectorFlush,
31
+ processInjectorChunk,
32
+ processSuffixChunk,
33
+ suffixFlush,
34
+ } from './html-injector-core.js';
35
+ import { logStreamingError } from './logger.js';
36
+
23
37
  // ─── Buffered Transform ──────────────────────────────────────────────────────
24
38
 
25
- /**
26
- * Options for the Node.js buffered transform.
27
- */
28
39
  export interface NodeBufferedTransformOptions {
29
40
  /**
30
41
  * Flush synchronously once the buffer reaches this many bytes.
@@ -35,51 +46,44 @@ export interface NodeBufferedTransformOptions {
35
46
  }
36
47
 
37
48
  /**
38
- * Node.js Transform that buffers incoming chunks and coalesces them
49
+ * Node.js `Transform` that buffers incoming chunks and coalesces them
39
50
  * within a single event loop tick.
40
51
  *
41
- * Equivalent to createBufferedTransformStream() in html-injectors.ts.
42
- * React Fizz may emit multiple micro-chunks within a single flush.
43
- * Without buffering, downstream transforms (especially flight injection)
44
- * could see chunk boundaries in the middle of HTML tags or attributes.
45
- *
46
- * This transform collects all chunks that arrive in the same tick and
47
- * emits them as a single concatenated Buffer on the next `setImmediate`.
52
+ * Equivalent to `createBufferedTransformStream` in `html-injectors.ts` —
53
+ * the actual buffer math lives in `BufferAggregator`. This wrapper only
54
+ * owns the `setImmediate` scheduling and the push to the Node `Transform`.
48
55
  *
49
- * **Not a polling loop.** Uses a single-shot `setImmediate` per flush
50
- * cycle — no recursive scheduling, no busy-wait. See design/02 §"No Polling".
51
- *
52
- * Inspired by Next.js `createBufferedTransformStream`.
56
+ * **Not a polling loop.** Single-shot `setImmediate` per flush cycle —
57
+ * no recursive scheduling. See design/02 §"No Polling".
53
58
  */
54
59
  export function createNodeBufferedTransform(options: NodeBufferedTransformOptions = {}): Transform {
55
- const { maxBufferByteLength = Infinity } = options;
56
-
57
- let bufferedChunks: Buffer[] = [];
58
- let bufferByteLength = 0;
60
+ const buffer = new BufferAggregator(options.maxBufferByteLength ?? Infinity);
59
61
  let pendingImmediate: ReturnType<typeof setImmediate> | null = null;
60
62
 
63
+ const flushBuffer = () => {
64
+ const merged = buffer.drain();
65
+ if (merged) transform.push(merged);
66
+ };
67
+
61
68
  const transform = new Transform({
62
69
  transform(chunk: Buffer, _encoding, callback) {
63
- bufferedChunks.push(chunk);
64
- bufferByteLength += chunk.byteLength;
65
-
66
- if (bufferByteLength >= maxBufferByteLength) {
67
- // Synchronous flush — buffer is too large to hold
70
+ const overCap = buffer.append(chunk);
71
+ if (overCap) {
72
+ // Buffer too large to hold — flush synchronously now.
68
73
  flushBuffer();
69
74
  } else if (!pendingImmediate) {
70
75
  // Schedule a deferred flush for end of this tick.
71
- // Single-shot setImmediate — NOT a recursive loop.
72
- // See design/02 §"No Polling".
76
+ // Single-shot setImmediate — NOT a recursive loop. See design/02
77
+ // §"No Polling".
73
78
  pendingImmediate = setImmediate(() => {
74
79
  pendingImmediate = null;
75
80
  flushBuffer();
76
81
  });
77
82
  }
78
-
79
83
  callback();
80
84
  },
81
85
  flush(callback) {
82
- // Cancel any pending deferred flush and flush synchronously
86
+ // Cancel any pending deferred flush and flush synchronously.
83
87
  if (pendingImmediate) {
84
88
  clearImmediate(pendingImmediate);
85
89
  pendingImmediate = null;
@@ -89,94 +93,52 @@ export function createNodeBufferedTransform(options: NodeBufferedTransformOption
89
93
  },
90
94
  });
91
95
 
92
- function flushBuffer() {
93
- if (bufferedChunks.length === 0) return;
94
-
95
- const merged = Buffer.concat(bufferedChunks, bufferByteLength);
96
- bufferedChunks = [];
97
- bufferByteLength = 0;
98
- transform.push(merged);
99
- }
100
-
101
96
  return transform;
102
97
  }
103
98
 
104
- // ─── Injection Transforms ────────────────────────────────────────────────────
105
-
106
- import { createMachine } from '../utils/state-machine.js';
107
- import { flightChunkScript } from './flight-scripts.js';
108
- import {
109
- flightInjectionTransitions,
110
- isHtmlDone,
111
- isPullDone,
112
- type FlightInjectionState,
113
- type FlightInjectionEvent,
114
- } from './flight-injection-state.js';
115
- import { withTimeout, RenderTimeoutError } from './render-timeout.js';
116
- import { logStreamingError } from './logger.js';
117
-
118
99
  // ─── Move Suffix Transform ───────────────────────────────────────────────────
119
100
 
120
- const SUFFIX = '</body></html>';
121
- const SUFFIX_BUF = Buffer.from(SUFFIX, 'utf-8');
122
-
123
101
  /**
124
- * Node.js Transform that moves `</body></html>` to the end of the stream.
102
+ * Node.js `Transform` that moves `</body></html>` to the end of the stream.
125
103
  *
126
- * Equivalent to createMoveSuffixStream() in html-injectors.ts.
127
- * Strips the suffix when first encountered and re-emits it in flush().
128
- * If no suffix is found, it's appended anyway for well-formed HTML.
104
+ * Equivalent to `createMoveSuffixStream` in `html-injectors.ts`. Strips
105
+ * the suffix on first sight and re-emits it from `flush()`. If the
106
+ * suffix never appeared in the input, it is appended anyway so output
107
+ * is well-formed.
129
108
  */
130
109
  export function createNodeMoveSuffixTransform(): Transform {
131
- let foundSuffix = false;
110
+ const state = createSuffixState();
132
111
 
133
112
  return new Transform({
134
113
  transform(chunk: Buffer, _encoding, callback) {
135
- if (foundSuffix) {
114
+ const result = processSuffixChunk(state, chunk);
115
+ if (result.kind === 'passthrough' || result.kind === 'noSuffix') {
136
116
  this.push(chunk);
137
117
  callback();
138
118
  return;
139
119
  }
140
-
141
- const text = chunk.toString('utf-8');
142
- const idx = text.indexOf(SUFFIX);
143
- if (idx === -1) {
144
- this.push(chunk);
145
- callback();
146
- return;
147
- }
148
-
149
- foundSuffix = true;
150
-
151
- // If the entire chunk is exactly the suffix, skip it
152
- if (chunk.byteLength === SUFFIX_BUF.byteLength) {
153
- callback();
154
- return;
155
- }
156
-
157
- // Emit content before the suffix
158
- const before = text.slice(0, idx);
159
- const after = text.slice(idx + SUFFIX.length);
160
- if (before) this.push(Buffer.from(before, 'utf-8'));
161
- if (after) this.push(Buffer.from(after, 'utf-8'));
120
+ if (result.before) this.push(Buffer.from(result.before));
121
+ if (result.after) this.push(Buffer.from(result.after));
162
122
  callback();
163
123
  },
164
124
  flush(callback) {
165
- // Always emit the suffix at the very end
166
- this.push(SUFFIX_BUF);
125
+ this.push(Buffer.from(suffixFlush(state)));
167
126
  callback();
168
127
  },
169
128
  });
170
129
  }
171
130
 
131
+ // Re-export for tests / consumers that need the suffix bytes constant.
132
+ export { SUFFIX_BYTES };
133
+
172
134
  // ─── Head Injection ──────────────────────────────────────────────────────────
173
135
 
174
136
  /**
175
- * Node.js Transform that injects HTML content before </head>.
137
+ * Node.js `Transform` that injects HTML content before `</head>`.
176
138
  *
177
- * Equivalent to injectHead() in html-injectors.ts. Streams chunks
139
+ * Equivalent to `injectHead` in `html-injectors.ts`. Streams chunks
178
140
  * through immediately, keeping only a small trailing buffer to handle
179
- * </head> split across chunk boundaries.
141
+ * `</head>` split across chunk boundaries.
180
142
  */
181
143
  export function createNodeHeadInjector(headHtml: string): Transform {
182
144
  if (!headHtml) {
@@ -187,41 +149,21 @@ export function createNodeHeadInjector(headHtml: string): Transform {
187
149
  });
188
150
  }
189
151
 
190
- const target = '</head>';
191
- const tailLen = target.length - 1;
192
- let injected = false;
193
- let tail = '';
152
+ const state = createInjectorState({ content: headHtml, targetTag: '</head>' });
194
153
 
195
154
  return new Transform({
196
155
  transform(chunk: Buffer, _encoding, callback) {
197
- if (injected) {
156
+ const result = processInjectorChunk(state, chunk);
157
+ if (result.kind === 'passthrough') {
198
158
  callback(null, chunk);
199
159
  return;
200
160
  }
201
-
202
- const text = tail + chunk.toString('utf-8');
203
- const tagIndex = text.indexOf(target);
204
-
205
- if (tagIndex !== -1) {
206
- const before = text.slice(0, tagIndex);
207
- const after = text.slice(tagIndex);
208
- this.push(Buffer.from(before + headHtml + after, 'utf-8'));
209
- injected = true;
210
- tail = '';
211
- callback();
212
- } else {
213
- const safeEnd = Math.max(0, text.length - tailLen);
214
- if (safeEnd > 0) {
215
- this.push(Buffer.from(text.slice(0, safeEnd), 'utf-8'));
216
- }
217
- tail = text.slice(safeEnd);
218
- callback();
219
- }
161
+ if (result.bytes) this.push(Buffer.from(result.bytes));
162
+ callback();
220
163
  },
221
164
  flush(callback) {
222
- if (!injected && tail) {
223
- this.push(Buffer.from(tail, 'utf-8'));
224
- }
165
+ const leftover = injectorFlush(state);
166
+ if (leftover) this.push(Buffer.from(leftover));
225
167
  callback();
226
168
  },
227
169
  });
@@ -229,35 +171,37 @@ export function createNodeHeadInjector(headHtml: string): Transform {
229
171
 
230
172
  // ─── RSC Flight Injection ────────────────────────────────────────────────────
231
173
 
232
- /**
233
- * Node.js Transform that merges RSC script tags into the HTML stream.
234
- *
235
- * Reads RSC chunks from the provided ReadableStream and injects them
236
- * as `<script>` tags between HTML chunks. Scripts are buffered in
237
- * pending[] and only drained from transform() (after a complete HTML
238
- * chunk) or flush() — never pushed directly from the pull loop.
239
- *
240
- * Suffix stripping (</body></html>) is handled upstream by
241
- * createNodeMoveSuffixTransform. This transform only interleaves
242
- * RSC scripts at safe chunk boundaries.
243
- *
244
- * The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
245
- * stream). We read from it using the Web API — this is the one bridge
246
- * point between Web Streams and Node.js streams in the pipeline.
247
- */
248
174
  /**
249
175
  * Options for the Node.js flight injector.
250
176
  */
251
177
  export interface NodeFlightInjectorOptions {
252
178
  /**
253
- * Timeout in milliseconds for individual RSC stream reads.
254
- * If a single `rscReader.read()` call does not resolve within
255
- * this duration, the read is aborted and the stream errors with
256
- * a RenderTimeoutError. Default: 30000 (30s).
179
+ * Timeout in milliseconds for individual RSC stream reads. If a single
180
+ * `rscReader.read()` call does not resolve within this duration, the
181
+ * read is aborted and the stream errors with a `RenderTimeoutError`.
182
+ * Default: 30000 (30s).
257
183
  */
258
184
  renderTimeoutMs?: number;
259
185
  }
260
186
 
187
+ /**
188
+ * Node.js `Transform` that merges RSC script tags into the HTML stream.
189
+ *
190
+ * Equivalent to `createFlightInjectionTransform` in `html-injectors.ts`.
191
+ * The state machine, pending queue, and pull loop all live in
192
+ * `FlightInjectorCore` — this wrapper only shuffles bytes between the
193
+ * core and the Node `Transform.push()` API.
194
+ *
195
+ * Suffix stripping (`</body></html>`) is handled upstream by
196
+ * `createNodeMoveSuffixTransform`. RSC scripts are buffered in
197
+ * `core.pending[]` and only drained from `transform()` (after a complete
198
+ * HTML chunk) or `flush()` — never mid-tag. See design/02
199
+ * §"Scripts at Chunk Boundaries Only".
200
+ *
201
+ * The RSC stream is a Web `ReadableStream` (from the tee'd RSC Flight
202
+ * stream). The core reads from it using the Web API — this is the one
203
+ * bridge point between Web Streams and Node.js streams in the pipeline.
204
+ */
261
205
  export function createNodeFlightInjector(
262
206
  rscStream: ReadableStream<Uint8Array> | undefined,
263
207
  options?: NodeFlightInjectorOptions
@@ -270,136 +214,53 @@ export function createNodeFlightInjector(
270
214
  });
271
215
  }
272
216
 
273
- const timeoutMs = options?.renderTimeoutMs ?? 30_000;
274
- const rscReader = rscStream.getReader();
275
- const decoder = new TextDecoder('utf-8', { fatal: true });
217
+ const core = new FlightInjectorCore(rscStream, options?.renderTimeoutMs);
276
218
 
277
- const machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
278
- initial: { phase: 'init' },
279
- transitions: flightInjectionTransitions,
280
- });
281
-
282
- // Stored promise from pullLoop — awaited in flush() via .then()
283
- // instead of polling. See design/02 §"No Polling".
284
- let pullPromise: Promise<void> | null = null;
285
-
286
- // RSC script chunks waiting to be drained at a safe boundary.
287
- // pullLoop buffers here; transform() and flush() drain.
288
- // RSC script chunks waiting to be drained at a safe boundary.
289
- // pullLoop buffers here; transform(), flush(), and pullLoop's
290
- // post-yield drain all consume from this buffer.
291
- const pending: Buffer[] = [];
292
- async function pullLoop(): Promise<void> {
293
- // Yield once so the first transform() call can process the shell
294
- // HTML chunk before we start reading RSC data.
295
- await new Promise<void>((r) => setImmediate(r));
296
- try {
297
- for (;;) {
298
- // Guard each RSC read with a timeout so a permanently hung
299
- // RSC stream eventually aborts instead of blocking forever.
300
- // See design/02-rendering-pipeline.md §"Streaming Constraints".
301
- const readPromise = rscReader.read();
302
- const { done, value } =
303
- timeoutMs > 0
304
- ? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
305
- : await readPromise;
306
- if (done) {
307
- machine.send({ type: 'PULL_DONE' });
308
- return;
309
- }
310
- const decoded = decoder.decode(value, { stream: true });
311
- const scriptBuf = Buffer.from(flightChunkScript(decoded), 'utf-8');
312
- // Buffer the script — drained by the next transform() call,
313
- // flush(), or by the scheduled drain below.
314
- pending.push(scriptBuf);
315
- // Yield between reads so HTML chunks get priority in the event
316
- // loop — but only while HTML is still streaming. Once flush()
317
- // fires, read without yielding to drain remaining RSC data.
318
- if (!isHtmlDone(machine.state)) {
319
- await new Promise<void>((r) => setImmediate(r));
320
- // After yielding, if no transform() call drained the buffer
321
- // (i.e., no new HTML chunk arrived), drain now. This ensures
322
- // RSC flight data reaches the client at shell-flush time —
323
- // without this, hydration blocks on createFromReadableStream
324
- // waiting for data that's stuck in pending[].
325
- // This is safe: we're between event loop ticks, so no
326
- // transform() call is mid-execution (no mid-tag risk).
327
- if (pending.length > 0) {
328
- drainPending();
329
- }
330
- }
331
- }
332
- } catch (err) {
333
- // On timeout, cancel the RSC reader to release resources.
334
- if (err instanceof RenderTimeoutError) {
335
- rscReader.cancel(err).catch(() => {});
336
- }
337
- machine.send({ type: 'PULL_ERROR', error: err });
338
- }
339
- }
340
-
341
- /** Drain all buffered RSC script chunks to the transform output. */
342
- function drainPending(): void {
343
- while (pending.length > 0) {
344
- transform.push(pending.shift()!);
345
- }
346
- }
347
-
348
- // No bootstrap script here — the init script is in <head> via
349
- // flightInitScript() (see flight-scripts.ts). This ensures __timber_f
350
- // exists before any chunk scripts execute.
219
+ const drain = (): void => {
220
+ while (core.pending.length > 0) transform.push(Buffer.from(core.pending.shift()!));
221
+ };
351
222
 
352
223
  const transform = new Transform({
353
224
  transform(chunk: Buffer, _encoding, callback) {
354
- const isFirst = machine.state.phase === 'init';
355
- if (isFirst) {
356
- machine.send({ type: 'FIRST_CHUNK' });
357
- }
225
+ const wasInit = core.isInit;
226
+ if (wasInit) core.notifyFirstChunk();
358
227
 
359
228
  // Emit the HTML chunk, then drain any buffered RSC scripts.
360
229
  // Scripts always come AFTER a complete HTML chunk — never mid-tag.
361
- // The buffered transform upstream (TIM-528) ensures each chunk is
362
- // a coherent HTML fragment. Suffix stripping is handled upstream
363
- // by createNodeMoveSuffixTransform (TIM-530).
230
+ // The buffered transform upstream (TIM-528) ensures coherent chunks.
231
+ // Suffix stripping is upstream via createNodeMoveSuffixTransform (TIM-530).
364
232
  transform.push(chunk);
365
- drainPending();
233
+ drain();
366
234
 
367
235
  // Start the pull loop on the first HTML chunk.
368
- if (isFirst) {
369
- pullPromise = pullLoop();
370
- }
236
+ if (wasInit) core.ensurePullLoop(drain);
371
237
  callback();
372
238
  },
373
239
  flush(callback) {
374
- // All HTML chunks have been emitted. Transition to flushing
375
- // the pull loop will stop yielding between RSC reads since
376
- // isHtmlDone() now returns true.
377
- machine.send({ type: 'HTML_DONE' });
240
+ // All HTML chunks have been emitted. Pull loop stops yielding.
241
+ core.notifyHtmlDone();
378
242
 
379
243
  const finish = () => {
380
- // Drain any remaining buffered RSC scripts
381
- drainPending();
382
- if (machine.state.phase === 'error') {
383
- const err = machine.state.error;
244
+ drain();
245
+ const err = core.terminalError;
246
+ if (err !== null) {
384
247
  transform.destroy(err instanceof Error ? err : new Error(String(err)));
385
248
  return;
386
249
  }
387
250
  callback();
388
251
  };
389
252
 
390
- if (isPullDone(machine.state)) {
253
+ if (core.isPullDone) {
391
254
  finish();
392
255
  return;
393
256
  }
394
- // Wait for the RSC pull loop promise to resolve instead of
395
- // polling with setImmediate. No CPU spin, no busy-poll —
396
- // just a Promise chain. See design/02 §"No Polling".
397
- if (!pullPromise) {
398
- pullPromise = pullLoop();
399
- }
400
- pullPromise.then(finish, (err) => {
401
- machine.send({ type: 'PULL_ERROR', error: err });
402
- finish();
257
+ // Wait on the pull-loop promise instead of polling. Zero CPU cost
258
+ // while waiting. See design/02 §"No Polling".
259
+ core.ensurePullLoop(drain).then(finish, (err) => {
260
+ // ensurePullLoop's underlying loop catches its own errors and
261
+ // sends PULL_ERROR — this catch is defence-in-depth for any
262
+ // synchronous throws inside the .then() chain itself.
263
+ transform.destroy(err instanceof Error ? err : new Error(String(err)));
403
264
  });
404
265
  },
405
266
  });
@@ -459,7 +320,6 @@ const COMPRESSIBLE_TYPES = new Set([
459
320
  'application/xml',
460
321
  'application/xhtml+xml',
461
322
  'application/rss+xml',
462
- 'application/atom+xml',
463
323
  'image/svg+xml',
464
324
  ]);
465
325
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Pipeline helpers — small utility functions used by `pipeline.ts` and
3
+ * `pipeline-phases.ts`. Lifted out of `pipeline.ts` to keep that file
4
+ * focused on the request handler entry point.
5
+ *
6
+ * Each helper is intentionally a free function with no closure capture, so
7
+ * it can be unit-tested in isolation.
8
+ *
9
+ * See design/07-routing.md §"Request Lifecycle".
10
+ */
11
+
12
+ import type { ProxyExport } from './proxy.js';
13
+ import { getSetCookieHeaders } from './request-context.js';
14
+ import { callOnRequestError } from './instrumentation.js';
15
+ import { getTraceId } from './tracing.js';
16
+ import { RedirectSignal } from './primitives.js';
17
+ import type { ProxyConfig } from './pipeline.js';
18
+
19
+ // ─── Prototype-Pollution-Safe Merge ────────────────────────────────────────
20
+
21
+ /** Keys that must never be merged via Object.assign — they pollute Object.prototype. */
22
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
23
+
24
+ /**
25
+ * Shallow merge that skips top-level prototype-polluting keys.
26
+ *
27
+ * This is intentionally NOT a deep sanitizer. It only blocks shallow
28
+ * pollution via top-level `__proto__` / `constructor` / `prototype`
29
+ * keys. The deeper guarantee for segment params comes from merging
30
+ * codec output into a null-prototype target inside coerceSegmentParams().
31
+ *
32
+ * See TIM-655, TIM-855, design/13-security.md
33
+ */
34
+ export function safeMerge(target: Record<string, unknown>, source: Record<string, unknown>): void {
35
+ for (const key of Object.keys(source)) {
36
+ if (!DANGEROUS_KEYS.has(key)) {
37
+ target[key] = source[key];
38
+ }
39
+ }
40
+ }
41
+
42
+ // ─── Proxy Resolver ────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Resolver closure produced once at pipeline construction. The lazy variant
46
+ * still calls `loader()` per-request (HMR relies on re-importing), but the
47
+ * choice of which branch to take is made once, not on every request.
48
+ */
49
+ export type ProxyResolver = () => ProxyExport | Promise<ProxyExport>;
50
+
51
+ /**
52
+ * Build a proxy resolver closure from the declared source. Called exactly
53
+ * once at `createPipeline` setup time, so the hot path sees only the branch
54
+ * that corresponds to this pipeline's configured variant.
55
+ *
56
+ * Returns `null` when the app has no proxy.ts — the hot path short-circuits
57
+ * around `runProxyPhase` entirely in that case.
58
+ *
59
+ * Accepts the sugar form (a bare `ProxyExport` — function or function array)
60
+ * and normalises it to the static variant. Functions and arrays are
61
+ * structurally distinct from the tagged `{ kind: 'lazy', loader }` object,
62
+ * so discrimination is unambiguous.
63
+ */
64
+ export function makeProxyResolver(
65
+ proxy: ProxyConfig | ProxyExport | undefined
66
+ ): ProxyResolver | null {
67
+ if (proxy === undefined) return null;
68
+ // Sugar: a bare ProxyExport (function or function array) — treat as static.
69
+ if (typeof proxy === 'function' || Array.isArray(proxy)) {
70
+ const exp = proxy;
71
+ return () => exp;
72
+ }
73
+ if (proxy.kind === 'static') {
74
+ const exp = proxy.export;
75
+ return () => exp;
76
+ }
77
+ const loader = proxy.loader;
78
+ return async () => (await loader()).default;
79
+ }
80
+
81
+ // ─── Cookie / Header Helpers ───────────────────────────────────────────────
82
+
83
+ /**
84
+ * Apply all Set-Cookie headers from the cookie jar to a Headers object.
85
+ * Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
86
+ */
87
+ export function applyCookieJar(headers: Headers): void {
88
+ for (const value of getSetCookieHeaders()) {
89
+ headers.append('Set-Cookie', value);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Merge framework-managed response headers onto a terminal response without
95
+ * overwriting headers the terminal response already set itself.
96
+ */
97
+ export function mergeMissingHeaders(target: Headers, source: Headers): void {
98
+ const existingKeys = new Set([...target.keys()].map((key) => key.toLowerCase()));
99
+ for (const [key, value] of source.entries()) {
100
+ if (!existingKeys.has(key.toLowerCase())) {
101
+ target.append(key, value);
102
+ }
103
+ }
104
+ }
105
+
106
+ // ─── Mutable Response Cloning ──────────────────────────────────────────────
107
+
108
+ /**
109
+ * Clone a Response into a fresh one whose header bag is guaranteed mutable.
110
+ *
111
+ * `Response.redirect()` and some platform-level passthrough responses (notably
112
+ * on Cloudflare Workers) return objects with frozen header bags. Calling
113
+ * `.set()` or `.append()` on them throws `TypeError: immutable`, which the
114
+ * pipeline can hit when it appends Set-Cookie or Server-Timing entries.
115
+ *
116
+ * The pipeline calls this at the producer sites where user-controlled
117
+ * responses enter the framework — `outcomeToResponse` for all phase outcomes,
118
+ * and `handleRequest` for metadata-route and auto-sitemap user handlers — so
119
+ * downstream code can write headers without runtime feature-detection.
120
+ *
121
+ * The clone is unconditional. This is a deliberate trade: we avoid a
122
+ * try/catch + thrown `TypeError` on every request (the previous probe-based
123
+ * approach paid that cost on the hot path) and accept one cheap Response
124
+ * rewrap at the framework boundary instead.
125
+ */
126
+ export function cloneWithMutableHeaders(response: Response): Response {
127
+ return new Response(response.body, {
128
+ status: response.status,
129
+ statusText: response.statusText,
130
+ headers: new Headers(response.headers),
131
+ });
132
+ }
133
+
134
+ // ─── Redirect Builder ──────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Build a redirect Response from a RedirectSignal.
138
+ *
139
+ * For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
140
+ * so the client router can perform a soft SPA redirect. A raw 302 would be
141
+ * turned into an opaque redirect by fetch({redirect:'manual'}), crashing
142
+ * createFromFetch. See design/19-client-navigation.md.
143
+ */
144
+ export function buildRedirectResponse(
145
+ signal: RedirectSignal,
146
+ req: Request,
147
+ headers: Headers
148
+ ): Response {
149
+ const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
150
+ if (isRsc) {
151
+ headers.set('X-Timber-Redirect', signal.location);
152
+ return new Response(null, { status: 204, headers });
153
+ }
154
+ headers.set('Location', signal.location);
155
+ return new Response(null, { status: signal.status, headers });
156
+ }
157
+
158
+ // ─── Instrumentation ───────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Fire the user's onRequestError hook with request context.
162
+ * Extracts request info from the Request object and calls the instrumentation hook.
163
+ */
164
+ export async function fireOnRequestError(
165
+ error: unknown,
166
+ req: Request,
167
+ phase: 'proxy' | 'handler' | 'render' | 'action' | 'route'
168
+ ): Promise<void> {
169
+ const url = new URL(req.url);
170
+ const headersObj: Record<string, string> = {};
171
+ req.headers.forEach((v, k) => {
172
+ headersObj[k] = v;
173
+ });
174
+
175
+ await callOnRequestError(
176
+ error,
177
+ { method: req.method, path: url.pathname, headers: headersObj },
178
+ { phase, routePath: url.pathname, routeType: 'page', traceId: getTraceId() }
179
+ );
180
+ }