@timber-js/app 0.2.0-alpha.48 → 0.2.0-alpha.49

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.
@@ -7,10 +7,14 @@
7
7
  * states and the transition map once.
8
8
  *
9
9
  * Valid state flow:
10
- * init → streaming → body-level → flushing → done
11
- * └──────────────→ flushing → done
10
+ * init → streaming → flushing → done
12
11
  * (any) → error
13
12
  *
13
+ * Suffix stripping (</body></html>) is handled by a separate
14
+ * createMoveSuffixStream / createNodeMoveSuffixTransform upstream
15
+ * in the pipeline (TIM-530). The flight injector only interleaves
16
+ * RSC scripts between HTML chunks — no suffix tracking needed.
17
+ *
14
18
  * Design doc: 02-rendering-pipeline.md
15
19
  */
16
20
  import type { TransitionMap } from '../utils/state-machine.js';
@@ -18,42 +22,28 @@ import type { TransitionMap } from '../utils/state-machine.js';
18
22
  export interface InitState {
19
23
  phase: 'init';
20
24
  }
21
- /** HTML chunks flowing, pull loop running, suffix not yet stripped. */
25
+ /** HTML chunks flowing, pull loop running. */
22
26
  export interface StreamingState {
23
27
  phase: 'streaming';
24
28
  }
25
- /**
26
- * Suffix (</body></html>) stripped. RSC scripts injected at body level.
27
- * HTML may still be streaming (Suspense chunks after suffix).
28
- */
29
- export interface BodyLevelState {
30
- phase: 'body-level';
31
- }
32
29
  /** HTML stream done (flush fired). Draining remaining RSC chunks. */
33
30
  export interface FlushingState {
34
31
  phase: 'flushing';
35
- /** When true, suffix was found before flushing. */
36
- hadSuffix: boolean;
37
32
  }
38
33
  /** All streams consumed. Terminal state. */
39
34
  export interface DoneState {
40
35
  phase: 'done';
41
- hadSuffix: boolean;
42
36
  }
43
37
  /** Pull loop failed. Terminal state with captured error. */
44
38
  export interface ErrorState {
45
39
  phase: 'error';
46
40
  error: unknown;
47
41
  }
48
- export type FlightInjectionState = InitState | StreamingState | BodyLevelState | FlushingState | DoneState | ErrorState;
42
+ export type FlightInjectionState = InitState | StreamingState | FlushingState | DoneState | ErrorState;
49
43
  /** First HTML chunk arrived — start the pull loop. */
50
44
  export interface FirstChunkEvent {
51
45
  type: 'FIRST_CHUNK';
52
46
  }
53
- /** The </body></html> suffix was found and stripped. */
54
- export interface SuffixFoundEvent {
55
- type: 'SUFFIX_FOUND';
56
- }
57
47
  /** HTML stream finished (flush/end). */
58
48
  export interface HtmlDoneEvent {
59
49
  type: 'HTML_DONE';
@@ -67,10 +57,8 @@ export interface PullErrorEvent {
67
57
  type: 'PULL_ERROR';
68
58
  error: unknown;
69
59
  }
70
- export type FlightInjectionEvent = FirstChunkEvent | SuffixFoundEvent | HtmlDoneEvent | PullDoneEvent | PullErrorEvent;
60
+ export type FlightInjectionEvent = FirstChunkEvent | HtmlDoneEvent | PullDoneEvent | PullErrorEvent;
71
61
  export declare const flightInjectionTransitions: TransitionMap<FlightInjectionState, FlightInjectionEvent>;
72
- /** Whether the machine is in a state where the suffix has been stripped. */
73
- export declare function isSuffixStripped(state: FlightInjectionState): boolean;
74
62
  /** Whether the HTML stream has finished (flush/end fired). */
75
63
  export declare function isHtmlDone(state: FlightInjectionState): boolean;
76
64
  /** Whether the RSC pull loop has completed. */
@@ -1 +1 @@
1
- {"version":3,"file":"flight-injection-state.d.ts","sourceRoot":"","sources":["../../src/server/flight-injection-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAI/D,2DAA2D;AAC3D,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,uEAAuE;AACvE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,WAAW,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,YAAY,CAAC;CACrB;AAED,qEAAqE;AACrE,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,UAAU,CAAC;IAClB,mDAAmD;IACnD,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,4CAA4C;AAC5C,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,4DAA4D;AAC5D,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,MAAM,oBAAoB,GAC5B,SAAS,GACT,cAAc,GACd,cAAc,GACd,aAAa,GACb,SAAS,GACT,UAAU,CAAC;AAIf,sDAAsD;AACtD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,aAAa,CAAC;CACrB;AAED,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,cAAc,CAAC;CACtB;AAED,wCAAwC;AACxC,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,0CAA0C;AAC1C,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,MAAM,oBAAoB,GAC5B,eAAe,GACf,gBAAgB,GAChB,aAAa,GACb,aAAa,GACb,cAAc,CAAC;AAInB,eAAO,MAAM,0BAA0B,EAAE,aAAa,CAAC,oBAAoB,EAAE,oBAAoB,CAwB9F,CAAC;AAIJ,4EAA4E;AAC5E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAUrE;AAED,8DAA8D;AAC9D,wBAAgB,UAAU,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAE/D;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAE/D"}
1
+ {"version":3,"file":"flight-injection-state.d.ts","sourceRoot":"","sources":["../../src/server/flight-injection-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAI/D,2DAA2D;AAC3D,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,8CAA8C;AAC9C,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,WAAW,CAAC;CACpB;AAED,qEAAqE;AACrE,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,4CAA4C;AAC5C,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,4DAA4D;AAC5D,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,MAAM,oBAAoB,GAC5B,SAAS,GACT,cAAc,GACd,aAAa,GACb,SAAS,GACT,UAAU,CAAC;AAIf,sDAAsD;AACtD,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,aAAa,CAAC;CACrB;AAED,wCAAwC;AACxC,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,0CAA0C;AAC1C,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,MAAM,oBAAoB,GAAG,eAAe,GAAG,aAAa,GAAG,aAAa,GAAG,cAAc,CAAC;AAIpG,eAAO,MAAM,0BAA0B,EAAE,aAAa,CAAC,oBAAoB,EAAE,oBAAoB,CAgB9F,CAAC;AAIJ,8DAA8D;AAC9D,wBAAgB,UAAU,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAE/D;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAE/D"}
@@ -6,6 +6,52 @@
6
6
  *
7
7
  * Design docs: 02-rendering-pipeline.md, 18-build-system.md §"Entry Files"
8
8
  */
9
+ /**
10
+ * Options for the buffered transform stream.
11
+ */
12
+ export interface BufferedTransformOptions {
13
+ /**
14
+ * Flush synchronously once the buffer reaches this many bytes.
15
+ * Prevents unbounded memory growth for very large Fizz flushes.
16
+ * Default: Infinity (no byte limit — flush only on tick boundary).
17
+ */
18
+ readonly maxBufferByteLength?: number;
19
+ }
20
+ /**
21
+ * Buffer incoming chunks and coalesce them within a single event loop tick.
22
+ *
23
+ * React Fizz may emit multiple micro-chunks within a single flush (e.g.,
24
+ * opening tags, attributes, closing tags as separate writes). Without
25
+ * buffering, downstream transforms (especially flight injection) could
26
+ * see chunk boundaries in the middle of HTML tags or attribute values.
27
+ *
28
+ * This transform collects all chunks that arrive in the same tick and
29
+ * emits them as a single concatenated chunk on the next `setImmediate`.
30
+ * This ensures each output chunk represents a coherent HTML fragment
31
+ * from a single Fizz flush — safe for downstream script injection at
32
+ * chunk boundaries.
33
+ *
34
+ * **Not a polling loop.** Uses a single-shot `setImmediate` per flush
35
+ * cycle — no recursive scheduling, no busy-wait. See design/02 §"No Polling".
36
+ *
37
+ * Inspired by Next.js `createBufferedTransformStream`.
38
+ */
39
+ export declare function createBufferedTransformStream(options?: BufferedTransformOptions): TransformStream<Uint8Array, Uint8Array>;
40
+ /**
41
+ * Move `</body></html>` to the end of the stream.
42
+ *
43
+ * React's renderToReadableStream emits `</body></html>` as part of the
44
+ * shell chunk. Content that arrives after the shell (Suspense resolutions,
45
+ * RSC script tags) would appear after the closing tags, producing invalid
46
+ * HTML like `</body></html><script>...</script>`.
47
+ *
48
+ * This transform strips the suffix when first encountered and re-emits
49
+ * it in `flush()` — after all content has passed through. If no suffix
50
+ * is found, it's appended anyway to ensure well-formed HTML.
51
+ *
52
+ * Equivalent to Next.js's `createMoveSuffixStream`.
53
+ */
54
+ export declare function createMoveSuffixStream(): TransformStream<Uint8Array, Uint8Array>;
9
55
  /**
10
56
  * Inject metadata elements before </head> in the HTML stream.
11
57
  *
@@ -1 +1 @@
1
- {"version":3,"file":"html-injectors.d.ts","sourceRoot":"","sources":["../../src/server/html-injectors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AA6EH;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,QAAQ,EAAE,MAAM,GACf,cAAc,CAAC,UAAU,CAAC,CAE5B;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,WAAW,EAAE,MAAM,GAClB,cAAc,CAAC,UAAU,CAAC,CAE5B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,eAAe,CAAC,EAAE,MAAM,GACvB,cAAc,CAAC,UAAU,CAAC,CAiC5B;AAiLD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,EACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,EACjD,eAAe,CAAC,EAAE,MAAM,GACvB,cAAc,CAAC,UAAU,CAAC,CAS5B;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,sBAAsB,EAAE,MAAM,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;CACtB;AAqBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE;IAChD,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,OAAO,CAAA;KAAE,CAAC;IACjE,GAAG,EAAE,OAAO,CAAC;IACb,aAAa,CAAC,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC;CAC7D,GAAG,qBAAqB,CA8DxB"}
1
+ {"version":3,"file":"html-injectors.d.ts","sourceRoot":"","sources":["../../src/server/html-injectors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,GAAE,wBAA6B,GACrC,eAAe,CAAC,UAAU,EAAE,UAAU,CAAC,CAgEzC;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,IAAI,eAAe,CAAC,UAAU,EAAE,UAAU,CAAC,CAuChF;AA8ED;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,QAAQ,EAAE,MAAM,GACf,cAAc,CAAC,UAAU,CAAC,CAE5B;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,WAAW,EAAE,MAAM,GAClB,cAAc,CAAC,UAAU,CAAC,CAE5B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,eAAe,CAAC,EAAE,MAAM,GACvB,cAAc,CAAC,UAAU,CAAC,CAiC5B;AAmHD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,EACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,EACjD,eAAe,CAAC,EAAE,MAAM,GACvB,cAAc,CAAC,UAAU,CAAC,CAiB5B;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,sBAAsB,EAAE,MAAM,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;CACtB;AAqBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE;IAChD,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,OAAO,CAAA;KAAE,CAAC;IACjE,GAAG,EAAE,OAAO,CAAC;IACb,aAAa,CAAC,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC;CAC7D,GAAG,qBAAqB,CA8DxB"}
@@ -17,6 +17,43 @@
17
17
  * Readable.toWeb() conversion for the Response body.
18
18
  */
19
19
  import { Transform } from 'node:stream';
20
+ /**
21
+ * Options for the Node.js buffered transform.
22
+ */
23
+ export interface NodeBufferedTransformOptions {
24
+ /**
25
+ * Flush synchronously once the buffer reaches this many bytes.
26
+ * Prevents unbounded memory growth for very large Fizz flushes.
27
+ * Default: Infinity (no byte limit — flush only on tick boundary).
28
+ */
29
+ readonly maxBufferByteLength?: number;
30
+ }
31
+ /**
32
+ * Node.js Transform that buffers incoming chunks and coalesces them
33
+ * within a single event loop tick.
34
+ *
35
+ * Equivalent to createBufferedTransformStream() in html-injectors.ts.
36
+ * React Fizz may emit multiple micro-chunks within a single flush.
37
+ * Without buffering, downstream transforms (especially flight injection)
38
+ * could see chunk boundaries in the middle of HTML tags or attributes.
39
+ *
40
+ * This transform collects all chunks that arrive in the same tick and
41
+ * emits them as a single concatenated Buffer on the next `setImmediate`.
42
+ *
43
+ * **Not a polling loop.** Uses a single-shot `setImmediate` per flush
44
+ * cycle — no recursive scheduling, no busy-wait. See design/02 §"No Polling".
45
+ *
46
+ * Inspired by Next.js `createBufferedTransformStream`.
47
+ */
48
+ export declare function createNodeBufferedTransform(options?: NodeBufferedTransformOptions): Transform;
49
+ /**
50
+ * Node.js Transform that moves `</body></html>` to the end of the stream.
51
+ *
52
+ * Equivalent to createMoveSuffixStream() in html-injectors.ts.
53
+ * Strips the suffix when first encountered and re-emits it in flush().
54
+ * If no suffix is found, it's appended anyway for well-formed HTML.
55
+ */
56
+ export declare function createNodeMoveSuffixTransform(): Transform;
20
57
  /**
21
58
  * Node.js Transform that injects HTML content before </head>.
22
59
  *
@@ -28,15 +65,14 @@ export declare function createNodeHeadInjector(headHtml: string): Transform;
28
65
  /**
29
66
  * Node.js Transform that merges RSC script tags into the HTML stream.
30
67
  *
31
- * Equivalent to injectRscPayload() in html-injectors.ts. Combines
32
- * createInlinedRscStream + createFlightInjectionTransform into a single
33
- * Node.js Transform.
68
+ * Reads RSC chunks from the provided ReadableStream and injects them
69
+ * as `<script>` tags between HTML chunks. Scripts are buffered in
70
+ * pending[] and only drained from transform() (after a complete HTML
71
+ * chunk) or flush() — never pushed directly from the pull loop.
34
72
  *
35
- * 1. Strips `</body></html>` from the shell so all subsequent content
36
- * is at `<body>` level.
37
- * 2. Reads RSC chunks from the provided ReadableStream and injects them
38
- * as `<script>` tags after HTML chunks.
39
- * 3. Re-emits `</body></html>` at the very end.
73
+ * Suffix stripping (</body></html>) is handled upstream by
74
+ * createNodeMoveSuffixTransform. This transform only interleaves
75
+ * RSC scripts at safe chunk boundaries.
40
76
  *
41
77
  * The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
42
78
  * stream). We read from it using the Web API — this is the one bridge
@@ -1 +1 @@
1
- {"version":3,"file":"node-stream-transforms.d.ts","sourceRoot":"","sources":["../../src/server/node-stream-transforms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAkBxC;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CA+ClE;AAID;;;;;;;;;;;;;;;;GAgBG;AACH;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,EACjD,OAAO,CAAC,EAAE,yBAAyB,GAClC,SAAS,CAuJX;AAOD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAwBtE;AAoBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,OAAO,GACvB,SAAS,GAAG,IAAI,CA8BlB"}
1
+ {"version":3,"file":"node-stream-transforms.d.ts","sourceRoot":"","sources":["../../src/server/node-stream-transforms.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAKxC;;GAEG;AACH,MAAM,WAAW,4BAA4B;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,GAAE,4BAAiC,GAAG,SAAS,CAgDjG;AAqBD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,IAAI,SAAS,CAwCzD;AAID;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CA+ClE;AAID;;;;;;;;;;;;;;;GAeG;AACH;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,EACjD,OAAO,CAAC,EAAE,yBAAyB,GAClC,SAAS,CA2IX;AAOD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAwBtE;AAoBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,OAAO,GACvB,SAAS,GAAG,IAAI,CA8BlB"}
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA0EH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE;QACZ,gFAAgF;QAChF,QAAQ,EAAE,MAAM,CAAC;QACjB,uDAAuD;QACvD,OAAO,EAAE,MAAM,CAAC;QAChB,gEAAgE;QAChE,MAAM,EAAE,MAAM,CAAC;QACf,wEAAwE;QACxE,UAAU,EAAE,MAAM,CAAC;QACnB,+CAA+C;QAC/C,OAAO,EAAE,MAAM,CAAC;QAChB,sDAAsD;QACtD,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CA0JnB;AAED,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"ssr-entry.d.ts","sourceRoot":"","sources":["../../src/server/ssr-entry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AA8EH;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,iCAAiC;IACjC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,eAAe,EAAE,OAAO,CAAC;IACzB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAC;IAC/B,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACvC;;;0DAGsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;iFAE6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;4DACwD;IACxD,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAEjC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE;QACZ,gFAAgF;QAChF,QAAQ,EAAE,MAAM,CAAC;QACjB,uDAAuD;QACvD,OAAO,EAAE,MAAM,CAAC;QAChB,gEAAgE;QAChE,MAAM,EAAE,MAAM,CAAC;QACf,wEAAwE;QACxE,UAAU,EAAE,MAAM,CAAC;QACnB,+CAA+C;QAC/C,OAAO,EAAE,MAAM,CAAC;QAChB,sDAAsD;QACtD,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAmLnB;AAED,eAAe,SAAS,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.48",
3
+ "version": "0.2.0-alpha.49",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -7,10 +7,14 @@
7
7
  * states and the transition map once.
8
8
  *
9
9
  * Valid state flow:
10
- * init → streaming → body-level → flushing → done
11
- * └──────────────→ flushing → done
10
+ * init → streaming → flushing → done
12
11
  * (any) → error
13
12
  *
13
+ * Suffix stripping (</body></html>) is handled by a separate
14
+ * createMoveSuffixStream / createNodeMoveSuffixTransform upstream
15
+ * in the pipeline (TIM-530). The flight injector only interleaves
16
+ * RSC scripts between HTML chunks — no suffix tracking needed.
17
+ *
14
18
  * Design doc: 02-rendering-pipeline.md
15
19
  */
16
20
 
@@ -23,30 +27,19 @@ export interface InitState {
23
27
  phase: 'init';
24
28
  }
25
29
 
26
- /** HTML chunks flowing, pull loop running, suffix not yet stripped. */
30
+ /** HTML chunks flowing, pull loop running. */
27
31
  export interface StreamingState {
28
32
  phase: 'streaming';
29
33
  }
30
34
 
31
- /**
32
- * Suffix (</body></html>) stripped. RSC scripts injected at body level.
33
- * HTML may still be streaming (Suspense chunks after suffix).
34
- */
35
- export interface BodyLevelState {
36
- phase: 'body-level';
37
- }
38
-
39
35
  /** HTML stream done (flush fired). Draining remaining RSC chunks. */
40
36
  export interface FlushingState {
41
37
  phase: 'flushing';
42
- /** When true, suffix was found before flushing. */
43
- hadSuffix: boolean;
44
38
  }
45
39
 
46
40
  /** All streams consumed. Terminal state. */
47
41
  export interface DoneState {
48
42
  phase: 'done';
49
- hadSuffix: boolean;
50
43
  }
51
44
 
52
45
  /** Pull loop failed. Terminal state with captured error. */
@@ -58,7 +51,6 @@ export interface ErrorState {
58
51
  export type FlightInjectionState =
59
52
  | InitState
60
53
  | StreamingState
61
- | BodyLevelState
62
54
  | FlushingState
63
55
  | DoneState
64
56
  | ErrorState;
@@ -70,11 +62,6 @@ export interface FirstChunkEvent {
70
62
  type: 'FIRST_CHUNK';
71
63
  }
72
64
 
73
- /** The </body></html> suffix was found and stripped. */
74
- export interface SuffixFoundEvent {
75
- type: 'SUFFIX_FOUND';
76
- }
77
-
78
65
  /** HTML stream finished (flush/end). */
79
66
  export interface HtmlDoneEvent {
80
67
  type: 'HTML_DONE';
@@ -91,56 +78,30 @@ export interface PullErrorEvent {
91
78
  error: unknown;
92
79
  }
93
80
 
94
- export type FlightInjectionEvent =
95
- | FirstChunkEvent
96
- | SuffixFoundEvent
97
- | HtmlDoneEvent
98
- | PullDoneEvent
99
- | PullErrorEvent;
81
+ export type FlightInjectionEvent = FirstChunkEvent | HtmlDoneEvent | PullDoneEvent | PullErrorEvent;
100
82
 
101
83
  // ─── Transitions ─────────────────────────────────────────────────────────────
102
84
 
103
85
  export const flightInjectionTransitions: TransitionMap<FlightInjectionState, FlightInjectionEvent> =
104
86
  {
105
- 'init': {
87
+ init: {
106
88
  FIRST_CHUNK: (): StreamingState => ({ phase: 'streaming' }),
107
89
  // Edge case: HTML stream ends immediately (empty body)
108
- HTML_DONE: (): FlushingState => ({ phase: 'flushing', hadSuffix: false }),
90
+ HTML_DONE: (): FlushingState => ({ phase: 'flushing' }),
109
91
  },
110
- 'streaming': {
111
- SUFFIX_FOUND: (): BodyLevelState => ({ phase: 'body-level' }),
112
- HTML_DONE: (): FlushingState => ({ phase: 'flushing', hadSuffix: false }),
92
+ streaming: {
93
+ HTML_DONE: (): FlushingState => ({ phase: 'flushing' }),
113
94
  PULL_DONE: (): StreamingState => ({ phase: 'streaming' }),
114
95
  PULL_ERROR: (_s, e): ErrorState => ({ phase: 'error', error: e.error }),
115
96
  },
116
- 'body-level': {
117
- HTML_DONE: (): FlushingState => ({ phase: 'flushing', hadSuffix: true }),
118
- PULL_DONE: (): BodyLevelState => ({ phase: 'body-level' }),
119
- PULL_ERROR: (_s, e): ErrorState => ({ phase: 'error', error: e.error }),
120
- },
121
- 'flushing': {
122
- PULL_DONE: (s): DoneState => ({ phase: 'done', hadSuffix: s.hadSuffix }),
97
+ flushing: {
98
+ PULL_DONE: (): DoneState => ({ phase: 'done' }),
123
99
  PULL_ERROR: (_s, e): ErrorState => ({ phase: 'error', error: e.error }),
124
- // Suffix can be found during flushing if flush() processes remaining text
125
- SUFFIX_FOUND: (_s): FlushingState => ({ phase: 'flushing', hadSuffix: true }),
126
100
  },
127
101
  };
128
102
 
129
103
  // ─── Helpers ─────────────────────────────────────────────────────────────────
130
104
 
131
- /** Whether the machine is in a state where the suffix has been stripped. */
132
- export function isSuffixStripped(state: FlightInjectionState): boolean {
133
- switch (state.phase) {
134
- case 'body-level':
135
- return true;
136
- case 'flushing':
137
- case 'done':
138
- return state.hadSuffix;
139
- default:
140
- return false;
141
- }
142
- }
143
-
144
105
  /** Whether the HTML stream has finished (flush/end fired). */
145
106
  export function isHtmlDone(state: FlightInjectionState): boolean {
146
107
  return state.phase === 'flushing' || state.phase === 'done';
@@ -7,11 +7,172 @@
7
7
  * Design docs: 02-rendering-pipeline.md, 18-build-system.md §"Entry Files"
8
8
  */
9
9
 
10
+ // ─── Buffered Transform ──────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Options for the buffered transform stream.
14
+ */
15
+ export interface BufferedTransformOptions {
16
+ /**
17
+ * Flush synchronously once the buffer reaches this many bytes.
18
+ * Prevents unbounded memory growth for very large Fizz flushes.
19
+ * Default: Infinity (no byte limit — flush only on tick boundary).
20
+ */
21
+ readonly maxBufferByteLength?: number;
22
+ }
23
+
24
+ /**
25
+ * Buffer incoming chunks and coalesce them within a single event loop tick.
26
+ *
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.
37
+ *
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`.
42
+ */
43
+ export function createBufferedTransformStream(
44
+ options: BufferedTransformOptions = {}
45
+ ): TransformStream<Uint8Array, Uint8Array> {
46
+ const { maxBufferByteLength = Infinity } = options;
47
+
48
+ let bufferedChunks: Uint8Array[] = [];
49
+ let bufferByteLength = 0;
50
+ let pendingFlush: Promise<void> | undefined;
51
+
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
+
66
+ try {
67
+ controller.enqueue(merged);
68
+ } catch {
69
+ // Controller may be errored (e.g., stream cancelled) — ignore
70
+ }
71
+ };
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
+ return new TransformStream<Uint8Array, Uint8Array>({
92
+ 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);
102
+ }
103
+ },
104
+ flush() {
105
+ // Wait for any pending scheduled flush to complete
106
+ return pendingFlush;
107
+ },
108
+ });
109
+ }
110
+
111
+ // ─── Move Suffix Transform ───────────────────────────────────────────────────
112
+
113
+ const SUFFIX = '</body></html>';
114
+
115
+ /**
116
+ * Move `</body></html>` to the end of the stream.
117
+ *
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.
126
+ *
127
+ * Equivalent to Next.js's `createMoveSuffixStream`.
128
+ */
129
+ export function createMoveSuffixStream(): TransformStream<Uint8Array, Uint8Array> {
130
+ const encoder = new TextEncoder();
131
+ const suffixBytes = encoder.encode(SUFFIX);
132
+ let foundSuffix = false;
133
+
134
+ return new TransformStream<Uint8Array, Uint8Array>({
135
+ 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) {
145
+ controller.enqueue(chunk);
146
+ return;
147
+ }
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));
161
+ },
162
+ 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);
166
+ },
167
+ });
168
+ }
169
+
170
+ // ─── Injection Transforms ────────────────────────────────────────────────────
171
+
10
172
  import { createMachine } from '../utils/state-machine.js';
11
173
  import { flightChunkScript } from './flight-scripts.js';
12
174
  import {
13
175
  flightInjectionTransitions,
14
- isSuffixStripped,
15
176
  isHtmlDone,
16
177
  isPullDone,
17
178
  type FlightInjectionState,
@@ -158,26 +319,19 @@ export function createInlinedRscStream(
158
319
 
159
320
  /**
160
321
  * Merge RSC script stream into the HTML stream, injecting scripts
161
- * only as direct children of `<body>`.
162
- *
163
- * This single transform replaces the previous two-stage pipeline
164
- * (createFlightInjectionTransform + createMoveSuffixStream). It:
322
+ * between HTML chunks at safe boundaries.
165
323
  *
166
- * 1. Strips `</body></html>` from the shell chunk so all subsequent
167
- * content is at the `<body>` level.
168
- * 2. Buffers RSC `<script>` tags and drains them after the suffix
169
- * has been stripped — guaranteeing body-level injection.
170
- * 3. Re-emits `</body></html>` at the very end after all RSC scripts.
324
+ * Suffix stripping (</body></html>) is handled upstream by
325
+ * createMoveSuffixStream. This transform only interleaves RSC
326
+ * scripts no suffix tracking needed.
171
327
  *
172
- * Because the suffix is stripped before any scripts are injected,
173
- * scripts are always direct children of `<body>` regardless of how
174
- * React's renderToReadableStream chunks the HTML. No HTML structure
175
- * scanning or depth tracking needed — the suffix removal is the
176
- * structural guarantee.
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).
177
332
  *
178
333
  * State machine phases:
179
- * init → streaming → body-level → flushing → done
180
- * └──────────────→ flushing → done
334
+ * init → streaming → flushing → done
181
335
  * (any) → error
182
336
  *
183
337
  * Inspired by Next.js createFlightDataInjectionTransformStream.
@@ -186,11 +340,6 @@ function createFlightInjectionTransform(
186
340
  rscScriptStream: ReadableStream<Uint8Array>,
187
341
  renderTimeoutMs?: number
188
342
  ): TransformStream<Uint8Array, Uint8Array> {
189
- const encoder = new TextEncoder();
190
- const decoder = new TextDecoder();
191
- const suffix = '</body></html>';
192
- const suffixBytes = encoder.encode(suffix);
193
-
194
343
  const rscReader = rscScriptStream.getReader();
195
344
  const timeoutMs = renderTimeoutMs ?? 30_000;
196
345
 
@@ -201,26 +350,18 @@ function createFlightInjectionTransform(
201
350
 
202
351
  let pullPromise: Promise<void> | null = null;
203
352
 
204
- // RSC script chunks waiting to be injected at the body level.
353
+ // RSC script chunks waiting to be drained at a safe boundary.
205
354
  const pending: Uint8Array[] = [];
206
355
 
207
356
  async function pullLoop(): Promise<void> {
208
357
  // Yield once so the first HTML shell chunk flows through
209
- // transform() before we start reading RSC data. Uses
210
- // setImmediate (check phase — end of current event loop
211
- // iteration) instead of setTimeout(0) (timer phase — next
212
- // iteration). Under concurrency, setTimeout(0) yields to
213
- // ALL pending timer callbacks from other requests, adding
214
- // 1-4ms per yield. setImmediate fires before timers.
215
- // Available on both Node.js and Cloudflare Workers.
358
+ // transform() before we start reading RSC data.
216
359
  await new Promise<void>((r) => setImmediate(r));
217
360
 
218
361
  try {
219
362
  for (;;) {
220
- // Guard each RSC read with a timeout so a permanently hung
221
- // RSC stream eventually aborts. When timeoutMs <= 0, the
222
- // guard is disabled. See design/02-rendering-pipeline.md
223
- // §"Streaming Constraints".
363
+ // Guard each RSC read with a timeout.
364
+ // See design/02 §"Streaming Constraints".
224
365
  const readPromise = rscReader.read();
225
366
  const { done, value } =
226
367
  timeoutMs > 0
@@ -231,16 +372,13 @@ function createFlightInjectionTransform(
231
372
  return;
232
373
  }
233
374
  pending.push(value);
234
- // Yield between reads so HTML chunks get a chance to flow
235
- // through transform() first but only while HTML is still
236
- // streaming. Once flush() fires (all HTML emitted), drain
237
- // remaining RSC chunks without yielding.
375
+ // Yield between reads so HTML chunks get priority.
376
+ // Once flush() fires, drain without yielding.
238
377
  if (!isHtmlDone(machine.state)) {
239
378
  await new Promise<void>((r) => setImmediate(r));
240
379
  }
241
380
  }
242
381
  } catch (err) {
243
- // On timeout, cancel the RSC reader to release resources.
244
382
  if (err instanceof RenderTimeoutError) {
245
383
  rscReader.cancel(err).catch(() => {});
246
384
  }
@@ -260,63 +398,24 @@ function createFlightInjectionTransform(
260
398
 
261
399
  return new TransformStream<Uint8Array, Uint8Array>({
262
400
  transform(chunk, controller) {
263
- // Pull-based start: don't begin reading RSC until the first
264
- // HTML chunk flows through. This matches Next.js's approach
265
- // and ensures the shell HTML is enqueued before any RSC
266
- // script tags. Without this, the pull loop starts eagerly
267
- // and may read RSC data before the browser has any HTML.
268
401
  if (machine.state.phase === 'init') {
269
402
  machine.send({ type: 'FIRST_CHUNK' });
270
403
  pullPromise = pullLoop();
271
404
  }
272
405
 
273
- if (isSuffixStripped(machine.state)) {
274
- // Post-suffix: everything is body-level (Suspense chunks).
275
- // Emit HTML, then drain any buffered scripts.
276
- controller.enqueue(chunk);
277
- if (pending.length > 0) drainPending(controller);
278
- return;
279
- }
280
-
281
- // Look for </body></html> in the shell chunk.
282
- const text = decoder.decode(chunk, { stream: true });
283
- const idx = text.indexOf(suffix);
284
- if (idx !== -1) {
285
- machine.send({ type: 'SUFFIX_FOUND' });
286
- // Emit everything before the suffix (still inside <body>'s
287
- // child elements — don't inject scripts here).
288
- const before = text.slice(0, idx);
289
- const after = text.slice(idx + suffix.length);
290
- if (before) controller.enqueue(encoder.encode(before));
291
- // Now we're at body level — drain buffered scripts
292
- if (pending.length > 0) drainPending(controller);
293
- // Emit any content after the suffix (shouldn't normally exist)
294
- if (after) controller.enqueue(encoder.encode(after));
295
- } else {
296
- // Pre-suffix: inside nested elements. Pass through, don't
297
- // inject scripts (they'd become children of nested elements).
298
- controller.enqueue(chunk);
299
- }
406
+ // Emit the HTML chunk, then drain any buffered RSC scripts.
407
+ // Scripts always come AFTER a complete HTML chunk — never mid-tag.
408
+ // The buffered transform upstream (TIM-528) ensures coherent chunks.
409
+ // Suffix stripping is upstream via createMoveSuffixStream (TIM-530).
410
+ controller.enqueue(chunk);
411
+ if (pending.length > 0) drainPending(controller);
300
412
  },
301
413
  flush(controller) {
302
- // All HTML chunks have been emitted. Transition to flushing
303
- // the pull loop will stop yielding between RSC reads since
304
- // isHtmlDone() now returns true. This eliminates ~36 macrotask
305
- // yields per request (18 chunks × 2 yields each) that were the
306
- // primary source of SSR overhead vs Next.js.
414
+ // All HTML chunks emitted. Pull loop stops yielding.
307
415
  machine.send({ type: 'HTML_DONE' });
308
416
 
309
- // Drain remaining RSC chunks at body level
310
417
  const finish = () => {
311
418
  drainPending(controller);
312
- // Re-emit the suffix at the very end so HTML is well-formed
313
- if (machine.state.phase === 'done' && machine.state.hadSuffix) {
314
- controller.enqueue(suffixBytes);
315
- } else if (machine.state.phase === 'flushing' && machine.state.hadSuffix) {
316
- // Pull was already done before flush — drainPending didn't
317
- // transition, but we still need the suffix
318
- controller.enqueue(suffixBytes);
319
- }
320
419
  };
321
420
 
322
421
  if (isPullDone(machine.state)) {
@@ -358,9 +457,17 @@ export function injectRscPayload(
358
457
  // Transform RSC binary stream → stream of <script> tags
359
458
  const rscScriptStream = createInlinedRscStream(rscStream, renderTimeoutMs);
360
459
 
361
- // Single transform: strip </body></html>, inject RSC scripts at
362
- // body level, re-emit suffix at the very end.
363
- return htmlStream.pipeThrough(createFlightInjectionTransform(rscScriptStream, renderTimeoutMs));
460
+ // Pipeline: flightInjection moveSuffix
461
+ //
462
+ // 1. flightInjection interleaves RSC scripts between HTML chunks
463
+ // 2. moveSuffix strips </body></html> and re-emits at end
464
+ //
465
+ // The flight injector is upstream and interleaves scripts between
466
+ // HTML chunks. The moveSuffix transform then ensures </body></html>
467
+ // appears after all injected scripts, producing well-formed HTML.
468
+ return htmlStream
469
+ .pipeThrough(createFlightInjectionTransform(rscScriptStream, renderTimeoutMs))
470
+ .pipeThrough(createMoveSuffixStream());
364
471
  }
365
472
 
366
473
  /**
@@ -20,11 +20,93 @@
20
20
  import { Transform } from 'node:stream';
21
21
  import { createGzip, constants } from 'node:zlib';
22
22
 
23
+ // ─── Buffered Transform ──────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Options for the Node.js buffered transform.
27
+ */
28
+ export interface NodeBufferedTransformOptions {
29
+ /**
30
+ * Flush synchronously once the buffer reaches this many bytes.
31
+ * Prevents unbounded memory growth for very large Fizz flushes.
32
+ * Default: Infinity (no byte limit — flush only on tick boundary).
33
+ */
34
+ readonly maxBufferByteLength?: number;
35
+ }
36
+
37
+ /**
38
+ * Node.js Transform that buffers incoming chunks and coalesces them
39
+ * within a single event loop tick.
40
+ *
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`.
48
+ *
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`.
53
+ */
54
+ export function createNodeBufferedTransform(options: NodeBufferedTransformOptions = {}): Transform {
55
+ const { maxBufferByteLength = Infinity } = options;
56
+
57
+ let bufferedChunks: Buffer[] = [];
58
+ let bufferByteLength = 0;
59
+ let pendingImmediate: ReturnType<typeof setImmediate> | null = null;
60
+
61
+ const transform = new Transform({
62
+ 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
68
+ flushBuffer();
69
+ } else if (!pendingImmediate) {
70
+ // Schedule a deferred flush for end of this tick.
71
+ // Single-shot setImmediate — NOT a recursive loop.
72
+ // See design/02 §"No Polling".
73
+ pendingImmediate = setImmediate(() => {
74
+ pendingImmediate = null;
75
+ flushBuffer();
76
+ });
77
+ }
78
+
79
+ callback();
80
+ },
81
+ flush(callback) {
82
+ // Cancel any pending deferred flush and flush synchronously
83
+ if (pendingImmediate) {
84
+ clearImmediate(pendingImmediate);
85
+ pendingImmediate = null;
86
+ }
87
+ flushBuffer();
88
+ callback();
89
+ },
90
+ });
91
+
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
+ return transform;
102
+ }
103
+
104
+ // ─── Injection Transforms ────────────────────────────────────────────────────
105
+
23
106
  import { createMachine } from '../utils/state-machine.js';
24
107
  import { flightChunkScript } from './flight-scripts.js';
25
108
  import {
26
109
  flightInjectionTransitions,
27
- isSuffixStripped,
28
110
  isHtmlDone,
29
111
  isPullDone,
30
112
  type FlightInjectionState,
@@ -33,6 +115,60 @@ import {
33
115
  import { withTimeout, RenderTimeoutError } from './render-timeout.js';
34
116
  import { logStreamingError } from './logger.js';
35
117
 
118
+ // ─── Move Suffix Transform ───────────────────────────────────────────────────
119
+
120
+ const SUFFIX = '</body></html>';
121
+ const SUFFIX_BUF = Buffer.from(SUFFIX, 'utf-8');
122
+
123
+ /**
124
+ * Node.js Transform that moves `</body></html>` to the end of the stream.
125
+ *
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.
129
+ */
130
+ export function createNodeMoveSuffixTransform(): Transform {
131
+ let foundSuffix = false;
132
+
133
+ return new Transform({
134
+ transform(chunk: Buffer, _encoding, callback) {
135
+ if (foundSuffix) {
136
+ this.push(chunk);
137
+ callback();
138
+ return;
139
+ }
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'));
162
+ callback();
163
+ },
164
+ flush(callback) {
165
+ // Always emit the suffix at the very end
166
+ this.push(SUFFIX_BUF);
167
+ callback();
168
+ },
169
+ });
170
+ }
171
+
36
172
  // ─── Head Injection ──────────────────────────────────────────────────────────
37
173
 
38
174
  /**
@@ -96,15 +232,14 @@ export function createNodeHeadInjector(headHtml: string): Transform {
96
232
  /**
97
233
  * Node.js Transform that merges RSC script tags into the HTML stream.
98
234
  *
99
- * Equivalent to injectRscPayload() in html-injectors.ts. Combines
100
- * createInlinedRscStream + createFlightInjectionTransform into a single
101
- * Node.js Transform.
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.
102
239
  *
103
- * 1. Strips `</body></html>` from the shell so all subsequent content
104
- * is at `<body>` level.
105
- * 2. Reads RSC chunks from the provided ReadableStream and injects them
106
- * as `<script>` tags after HTML chunks.
107
- * 3. Re-emits `</body></html>` at the very end.
240
+ * Suffix stripping (</body></html>) is handled upstream by
241
+ * createNodeMoveSuffixTransform. This transform only interleaves
242
+ * RSC scripts at safe chunk boundaries.
108
243
  *
109
244
  * The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
110
245
  * stream). We read from it using the Web API — this is the one bridge
@@ -136,8 +271,6 @@ export function createNodeFlightInjector(
136
271
  }
137
272
 
138
273
  const timeoutMs = options?.renderTimeoutMs ?? 30_000;
139
- const suffix = '</body></html>';
140
- const suffixBuf = Buffer.from(suffix, 'utf-8');
141
274
  const rscReader = rscStream.getReader();
142
275
  const decoder = new TextDecoder('utf-8', { fatal: true });
143
276
 
@@ -147,26 +280,29 @@ export function createNodeFlightInjector(
147
280
  });
148
281
 
149
282
  // Stored promise from pullLoop — awaited in flush() via .then()
150
- // instead of polling. Matches the Web Streams pattern in
151
- // html-injectors.ts (pullPromise.then(finish)).
283
+ // instead of polling. See design/02 §"No Polling".
152
284
  let pullPromise: Promise<void> | null = null;
153
285
 
154
- // pullLoop reads RSC chunks and pushes them directly to the transform
155
- // output as <script> tags. This ensures RSC data is delivered to the
156
- // browser as soon as it's availablenot deferred until the next HTML
157
- // chunk. Critical for streaming: the shell RSC payload must arrive
158
- // with the shell HTML so hydration can start before Suspense resolves.
159
-
160
- async function pullLoop(stream: Transform): Promise<void> {
161
- // Yield once so the first transform() call can emit the bootstrap
162
- // signal before we start pushing data chunks.
286
+ // RSC script chunks waiting to be drained at a safe boundary.
287
+ // pullLoop buffers here; transform() and flush() drain.
288
+ // Scripts are NEVER pushed directly from pullLoop they are only
289
+ // emitted from transform() (after a complete HTML chunk) or flush().
290
+ // This guarantees scripts never land mid-tag. See TIM-527/TIM-529.
291
+ const pending: Buffer[] = [];
292
+
293
+ // pullLoop reads RSC chunks and buffers them as <script> tags in
294
+ // pending[]. It does NOT push directly to the transform output —
295
+ // that would cause scripts to interleave at arbitrary byte
296
+ // boundaries within HTML chunks (TIM-527). Pending scripts are
297
+ // drained only from transform() or flush().
298
+ async function pullLoop(): Promise<void> {
299
+ // Yield once so the first transform() call can process the shell
300
+ // HTML chunk before we start reading RSC data.
163
301
  await new Promise<void>((r) => setImmediate(r));
164
302
  try {
165
303
  for (;;) {
166
304
  // Guard each RSC read with a timeout so a permanently hung
167
- // RSC stream (e.g. a Suspense component with a fetch that
168
- // never resolves) eventually aborts instead of blocking
169
- // forever. When timeoutMs <= 0, the guard is disabled.
305
+ // RSC stream eventually aborts instead of blocking forever.
170
306
  // See design/02-rendering-pipeline.md §"Streaming Constraints".
171
307
  const readPromise = rscReader.read();
172
308
  const { done, value } =
@@ -179,13 +315,11 @@ export function createNodeFlightInjector(
179
315
  }
180
316
  const decoded = decoder.decode(value, { stream: true });
181
317
  const scriptBuf = Buffer.from(flightChunkScript(decoded), 'utf-8');
182
- // Push directly to the transform output don't wait for an
183
- // HTML chunk to trigger drainPending.
184
- stream.push(scriptBuf);
318
+ // Buffer the script drained by the next transform() or flush().
319
+ pending.push(scriptBuf);
185
320
  // Yield between reads so HTML chunks get a chance to flow
186
321
  // through transform() first — but only while HTML is still
187
- // streaming. Once flush() fires (all HTML emitted), drain
188
- // remaining RSC chunks without yielding.
322
+ // streaming. Once flush() fires, drain without yielding.
189
323
  if (!isHtmlDone(machine.state)) {
190
324
  await new Promise<void>((r) => setImmediate(r));
191
325
  }
@@ -199,6 +333,13 @@ export function createNodeFlightInjector(
199
333
  }
200
334
  }
201
335
 
336
+ /** Drain all buffered RSC script chunks to the transform output. */
337
+ function drainPending(): void {
338
+ while (pending.length > 0) {
339
+ transform.push(pending.shift()!);
340
+ }
341
+ }
342
+
202
343
  // No bootstrap script here — the init script is in <head> via
203
344
  // flightInitScript() (see flight-scripts.ts). This ensures __timber_f
204
345
  // exists before any chunk scripts execute.
@@ -210,30 +351,17 @@ export function createNodeFlightInjector(
210
351
  machine.send({ type: 'FIRST_CHUNK' });
211
352
  }
212
353
 
213
- if (isSuffixStripped(machine.state)) {
214
- transform.push(chunk);
215
- callback();
216
- return;
217
- }
354
+ // Emit the HTML chunk, then drain any buffered RSC scripts.
355
+ // Scripts always come AFTER a complete HTML chunk — never mid-tag.
356
+ // The buffered transform upstream (TIM-528) ensures each chunk is
357
+ // a coherent HTML fragment. Suffix stripping is handled upstream
358
+ // by createNodeMoveSuffixTransform (TIM-530).
359
+ transform.push(chunk);
360
+ drainPending();
218
361
 
219
- const text = chunk.toString('utf-8');
220
- const idx = text.indexOf(suffix);
221
- if (idx !== -1) {
222
- machine.send({ type: 'SUFFIX_FOUND' });
223
- const before = text.slice(0, idx);
224
- const after = text.slice(idx + suffix.length);
225
- if (before) transform.push(Buffer.from(before, 'utf-8'));
226
- if (after) transform.push(Buffer.from(after, 'utf-8'));
227
- } else {
228
- transform.push(chunk);
229
- }
230
-
231
- // Start the pull loop on the first HTML chunk to stream RSC
232
- // data chunks alongside the HTML. The __timber_f init script is
233
- // already in <head> (via flightInitScript), so no bootstrap needed.
234
- // Store the promise so flush() can await it instead of polling.
362
+ // Start the pull loop on the first HTML chunk.
235
363
  if (isFirst) {
236
- pullPromise = pullLoop(transform);
364
+ pullPromise = pullLoop();
237
365
  }
238
366
  callback();
239
367
  },
@@ -244,17 +372,13 @@ export function createNodeFlightInjector(
244
372
  machine.send({ type: 'HTML_DONE' });
245
373
 
246
374
  const finish = () => {
375
+ // Drain any remaining buffered RSC scripts
376
+ drainPending();
247
377
  if (machine.state.phase === 'error') {
248
378
  const err = machine.state.error;
249
379
  transform.destroy(err instanceof Error ? err : new Error(String(err)));
250
380
  return;
251
381
  }
252
- const hadSuffix =
253
- (machine.state.phase === 'done' && machine.state.hadSuffix) ||
254
- (machine.state.phase === 'flushing' && machine.state.hadSuffix);
255
- if (hadSuffix) {
256
- transform.push(suffixBuf);
257
- }
258
382
  callback();
259
383
  };
260
384
 
@@ -263,11 +387,10 @@ export function createNodeFlightInjector(
263
387
  return;
264
388
  }
265
389
  // Wait for the RSC pull loop promise to resolve instead of
266
- // polling with setImmediate. This matches the Web Streams
267
- // pattern in html-injectors.ts: `pullPromise.then(finish)`.
268
- // No CPU spin, no busy-poll — just a Promise chain.
390
+ // polling with setImmediate. No CPU spin, no busy-poll —
391
+ // just a Promise chain. See design/02 §"No Polling".
269
392
  if (!pullPromise) {
270
- pullPromise = pullLoop(transform);
393
+ pullPromise = pullLoop();
271
394
  }
272
395
  pullPromise.then(finish, (err) => {
273
396
  machine.send({ type: 'PULL_ERROR', error: err });
@@ -30,7 +30,7 @@ import {
30
30
  } from './ssr-render.js';
31
31
  import { logRenderError } from './logger.js';
32
32
  import { SsrStreamError } from './primitives.js';
33
- import { injectHead, injectRscPayload } from './html-injectors.js';
33
+ import { createBufferedTransformStream, injectHead, injectRscPayload } from './html-injectors.js';
34
34
  import { withNuqsSsrAdapter } from './nuqs-ssr-provider.js';
35
35
  import { withSpan } from './tracing.js';
36
36
  import { setCurrentParams } from '#/client/use-params.js';
@@ -40,7 +40,9 @@ import { registerSsrDataProvider, type SsrData } from '#/client/ssr-data.js';
40
40
  // Dynamic imports of node-stream-transforms and node:stream/promises were
41
41
  // costing 3-17ms per request due to module resolution overhead.
42
42
  let _nodeStreamImports: {
43
+ createNodeBufferedTransform: typeof import('./node-stream-transforms.js').createNodeBufferedTransform;
43
44
  createNodeHeadInjector: typeof import('./node-stream-transforms.js').createNodeHeadInjector;
45
+ createNodeMoveSuffixTransform: typeof import('./node-stream-transforms.js').createNodeMoveSuffixTransform;
44
46
  createNodeFlightInjector: typeof import('./node-stream-transforms.js').createNodeFlightInjector;
45
47
  createNodeErrorHandler: typeof import('./node-stream-transforms.js').createNodeErrorHandler;
46
48
  pipeline: typeof import('node:stream/promises').pipeline;
@@ -58,7 +60,9 @@ if (useNodeStreams) {
58
60
  import('node:stream'),
59
61
  ]);
60
62
  _nodeStreamImports = {
63
+ createNodeBufferedTransform: transforms.createNodeBufferedTransform,
61
64
  createNodeHeadInjector: transforms.createNodeHeadInjector,
65
+ createNodeMoveSuffixTransform: transforms.createNodeMoveSuffixTransform,
62
66
  createNodeFlightInjector: transforms.createNodeFlightInjector,
63
67
  createNodeErrorHandler: transforms.createNodeErrorHandler,
64
68
  pipeline: streamPromises.pipeline,
@@ -236,7 +240,9 @@ export async function handleSsr(
236
240
  if (_nodeStreamImports) {
237
241
  // Node.js fast path: full pipeline in native streams
238
242
  const {
243
+ createNodeBufferedTransform,
239
244
  createNodeHeadInjector,
245
+ createNodeMoveSuffixTransform,
240
246
  createNodeFlightInjector,
241
247
  createNodeErrorHandler,
242
248
  pipeline,
@@ -264,14 +270,32 @@ export async function handleSsr(
264
270
 
265
271
  // React 19.3+ emits <!DOCTYPE html> automatically when the root
266
272
  // element is <html>, so no framework-level doctype prepend needed.
273
+ //
274
+ // Pipeline: buffer → errorHandler → headInjector → flightInjector → moveSuffix → output
275
+ //
276
+ // 1. buffer: coalesces Fizz micro-chunks into coherent fragments (TIM-528)
277
+ // 2. errorHandler: catches post-shell streaming errors
278
+ // 3. headInjector: injects metadata before </head>
279
+ // 4. flightInjector: interleaves RSC scripts between HTML chunks (TIM-529)
280
+ // 5. moveSuffix: strips </body></html>, re-emits at end (TIM-530)
281
+ const bufferedTransform = createNodeBufferedTransform();
267
282
  const errorHandler = createNodeErrorHandler(navContext.signal);
268
283
  const headInjector = createNodeHeadInjector(navContext.headHtml);
269
284
  const flightInjector = createNodeFlightInjector(navContext.rscStream, {
270
285
  renderTimeoutMs,
271
286
  });
287
+ const moveSuffix = createNodeMoveSuffixTransform();
272
288
 
273
289
  const output = new PassThrough();
274
- pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
290
+ pipeline(
291
+ nodeHtmlStream,
292
+ bufferedTransform,
293
+ errorHandler,
294
+ headInjector,
295
+ flightInjector,
296
+ moveSuffix,
297
+ output
298
+ ).catch(() => {
275
299
  // Pipeline errors are handled by errorHandler transform
276
300
  });
277
301
  const _pipelineEnd = performance.now();
@@ -310,9 +334,14 @@ export async function handleSsr(
310
334
 
311
335
  const _renderEnd = performance.now();
312
336
 
313
- // Inject metadata into <head>, then interleave RSC payload chunks
314
- // into the body as they arrive from the tee'd RSC stream.
315
- let outputStream = injectHead(htmlStream, navContext.headHtml);
337
+ // Pipeline: buffer injectHead injectRscPayload
338
+ //
339
+ // The buffered transform coalesces Fizz micro-chunks into coherent
340
+ // HTML fragments so downstream transforms never see chunk boundaries
341
+ // mid-tag. This prevents RSC script injection from breaking HTML
342
+ // structure (TIM-527).
343
+ let outputStream = htmlStream.pipeThrough(createBufferedTransformStream());
344
+ outputStream = injectHead(outputStream, navContext.headHtml);
316
345
  outputStream = injectRscPayload(outputStream, navContext.rscStream, renderTimeoutMs);
317
346
  const _pipelineEnd = performance.now();
318
347