@timber-js/app 0.2.0-alpha.47 → 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.
- package/dist/server/flight-injection-state.d.ts +9 -21
- package/dist/server/flight-injection-state.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts +46 -0
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +44 -8
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +22 -0
- package/src/server/flight-injection-state.ts +14 -53
- package/src/server/html-injectors.ts +195 -88
- package/src/server/node-stream-transforms.ts +185 -62
- package/src/server/ssr-entry.ts +35 -6
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* states and the transition map once.
|
|
8
8
|
*
|
|
9
9
|
* Valid state flow:
|
|
10
|
-
* init → streaming →
|
|
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
|
|
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 |
|
|
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 |
|
|
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
|
|
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;
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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;
|
|
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;
|
|
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.
|
|
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",
|
|
@@ -310,6 +310,28 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
310
310
|
// Leaving the stream open is harmless: the page is being torn down.
|
|
311
311
|
function onDOMContentLoaded(): void {
|
|
312
312
|
if (isPageUnloading()) return;
|
|
313
|
+
|
|
314
|
+
// In dev mode, do NOT close the stream. React's RSC renderer
|
|
315
|
+
// includes debug owner/stack references ($1, $14, etc.) in the
|
|
316
|
+
// Flight payload that point to rows delivered through the debug
|
|
317
|
+
// channel, not the main Flight stream. The browser Flight client
|
|
318
|
+
// tracks these as pending chunks. Closing the stream with
|
|
319
|
+
// unresolved chunks triggers reportGlobalError("Connection closed")
|
|
320
|
+
// which kills the entire React tree.
|
|
321
|
+
//
|
|
322
|
+
// Leaving the stream open is harmless: React has already received
|
|
323
|
+
// all data rows and can hydrate fully. The pending debug chunks
|
|
324
|
+
// just remain unresolved (they're only used for React DevTools
|
|
325
|
+
// component stacks, not rendering).
|
|
326
|
+
//
|
|
327
|
+
// In production, debug rows are not emitted, so closing is safe.
|
|
328
|
+
if (process.env.NODE_ENV === 'development') {
|
|
329
|
+
// Mark as flushed so no more data is buffered, but don't close.
|
|
330
|
+
streamFlushed = true;
|
|
331
|
+
dataBuffer = undefined;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
313
335
|
if (streamWriter && !streamFlushed) {
|
|
314
336
|
streamWriter.close();
|
|
315
337
|
streamFlushed = true;
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* states and the transition map once.
|
|
8
8
|
*
|
|
9
9
|
* Valid state flow:
|
|
10
|
-
* init → streaming →
|
|
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
|
|
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
|
-
|
|
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'
|
|
90
|
+
HTML_DONE: (): FlushingState => ({ phase: 'flushing' }),
|
|
109
91
|
},
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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 →
|
|
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
|
|
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.
|
|
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
|
|
221
|
-
//
|
|
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
|
|
235
|
-
//
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
//
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
267
|
-
//
|
|
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(
|
|
393
|
+
pullPromise = pullLoop();
|
|
271
394
|
}
|
|
272
395
|
pullPromise.then(finish, (err) => {
|
|
273
396
|
machine.send({ type: 'PULL_ERROR', error: err });
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -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,
|
|
@@ -212,7 +216,7 @@ export async function handleSsr(
|
|
|
212
216
|
// (eliminates Promise-per-chunk overhead from Web Streams reader)
|
|
213
217
|
// On Workers: createFromReadableStream (Web Streams are V8-native C++ there)
|
|
214
218
|
let element: React.ReactNode;
|
|
215
|
-
|
|
219
|
+
if (hasNodeStreamDecode && _nodeStreamImports) {
|
|
216
220
|
const nodeRscStream = _nodeStreamImports.ReadableFromWeb(
|
|
217
221
|
rscStream as import('stream/web').ReadableStream
|
|
218
222
|
);
|
|
@@ -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(
|
|
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
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
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
|
|