@timber-js/app 0.2.0-alpha.25 → 0.2.0-alpha.27
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/node-stream-transforms.d.ts +54 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -0
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +16 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/server/node-stream-transforms.ts +255 -0
- package/src/server/ssr-entry.ts +64 -6
- package/src/server/ssr-render.ts +31 -27
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js native stream transforms for SSR HTML post-processing.
|
|
3
|
+
*
|
|
4
|
+
* These are Node.js Transform stream equivalents of the Web Stream
|
|
5
|
+
* transforms in html-injectors.ts. Used on Node.js/Bun where native
|
|
6
|
+
* streams (C++ backed) are faster than Web Streams (JS reimplementation).
|
|
7
|
+
*
|
|
8
|
+
* The transforms are pure string operations on HTML chunks — the same
|
|
9
|
+
* logic as the Web Stream versions, just wrapped in Node.js Transform
|
|
10
|
+
* instead of Web TransformStream.
|
|
11
|
+
*
|
|
12
|
+
* Architecture:
|
|
13
|
+
* renderToPipeableStream → pipe(errorHandler) → pipe(headInjector)
|
|
14
|
+
* → pipe(flightInjector) → Readable.toWeb() → Response
|
|
15
|
+
*
|
|
16
|
+
* All chunks stay in C++ Node.js stream buffers until the final
|
|
17
|
+
* Readable.toWeb() conversion for the Response body.
|
|
18
|
+
*/
|
|
19
|
+
import { Transform } from 'node:stream';
|
|
20
|
+
/**
|
|
21
|
+
* Node.js Transform that injects HTML content before </head>.
|
|
22
|
+
*
|
|
23
|
+
* Equivalent to injectHead() in html-injectors.ts. Streams chunks
|
|
24
|
+
* through immediately, keeping only a small trailing buffer to handle
|
|
25
|
+
* </head> split across chunk boundaries.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createNodeHeadInjector(headHtml: string): Transform;
|
|
28
|
+
/**
|
|
29
|
+
* Node.js Transform that merges RSC script tags into the HTML stream.
|
|
30
|
+
*
|
|
31
|
+
* Equivalent to injectRscPayload() in html-injectors.ts. Combines
|
|
32
|
+
* createInlinedRscStream + createFlightInjectionTransform into a single
|
|
33
|
+
* Node.js Transform.
|
|
34
|
+
*
|
|
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.
|
|
40
|
+
*
|
|
41
|
+
* The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
|
|
42
|
+
* stream). We read from it using the Web API — this is the one bridge
|
|
43
|
+
* point between Web Streams and Node.js streams in the pipeline.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createNodeFlightInjector(rscStream: ReadableStream<Uint8Array> | undefined): Transform;
|
|
46
|
+
/**
|
|
47
|
+
* Node.js Transform that catches post-shell streaming errors.
|
|
48
|
+
*
|
|
49
|
+
* Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts.
|
|
50
|
+
* Catches errors from React's streaming phase (deny/throw inside Suspense
|
|
51
|
+
* after the shell has flushed) and closes the stream cleanly.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createNodeErrorHandler(signal?: AbortSignal): Transform;
|
|
54
|
+
//# sourceMappingURL=node-stream-transforms.d.ts.map
|
|
@@ -0,0 +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;AAIxC;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CA+ClE;AAeD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,SAAS,GAChD,SAAS,CAyGX;AAOD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAwBtE"}
|
|
@@ -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;AAmCH;;;;;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;CAClC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,QAAQ,CAAC,CAgInB;AAED,eAAe,SAAS,CAAC"}
|
|
@@ -39,6 +39,22 @@ export declare function renderSsrStream(element: ReactNode, options?: {
|
|
|
39
39
|
deferSuspenseFor?: number;
|
|
40
40
|
signal?: AbortSignal;
|
|
41
41
|
}): Promise<ReadableStream<Uint8Array>>;
|
|
42
|
+
/** Whether the current platform uses native Node.js streams for SSR. */
|
|
43
|
+
export declare const useNodeStreams: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Render via renderToPipeableStream, returning a Node.js Readable.
|
|
46
|
+
*
|
|
47
|
+
* The entire HTML rendering + post-processing pipeline stays in native
|
|
48
|
+
* Node.js streams (C++ backed). Only converted to Web ReadableStream
|
|
49
|
+
* at the very end for the Response body.
|
|
50
|
+
*/
|
|
51
|
+
export declare function renderSsrNodeStream(element: ReactNode, options?: {
|
|
52
|
+
bootstrapScriptContent?: string;
|
|
53
|
+
deferSuspenseFor?: number;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}): Promise<import('node:stream').Readable>;
|
|
56
|
+
/** Convert a Node.js Readable to a Web ReadableStream (zero-copy bridge). */
|
|
57
|
+
export declare function nodeReadableToWeb(readable: import('node:stream').Readable): ReadableStream<Uint8Array>;
|
|
42
58
|
/**
|
|
43
59
|
* Wrap an HTML stream with error handling for the streaming phase.
|
|
44
60
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAqEvC;;;;;;;;;;;;;GAaG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAErC;AAED,wEAAwE;AACxE,eAAO,MAAM,cAAc,SAAkB,CAAC;AAU9C;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,SAAS,EAClB,OAAO,CAAC,EAAE;IAAE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC7F,OAAO,CAAC,OAAO,aAAa,EAAE,QAAQ,CAAC,CAsDzC;AAED,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,OAAO,aAAa,EAAE,QAAQ,GACvC,cAAc,CAAC,UAAU,CAAC,CAE5B;AA0CD;;;;;;;;;;;;GAYG;AACH,2CAA2C;AAC3C,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,MAAM,CAAC,EAAE,WAAW,GACnB,cAAc,CAAC,UAAU,CAAC,CA2B5B;AAWD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,EACtC,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,OAAO,GACvB,QAAQ,CASV"}
|
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.27",
|
|
4
4
|
"description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js native stream transforms for SSR HTML post-processing.
|
|
3
|
+
*
|
|
4
|
+
* These are Node.js Transform stream equivalents of the Web Stream
|
|
5
|
+
* transforms in html-injectors.ts. Used on Node.js/Bun where native
|
|
6
|
+
* streams (C++ backed) are faster than Web Streams (JS reimplementation).
|
|
7
|
+
*
|
|
8
|
+
* The transforms are pure string operations on HTML chunks — the same
|
|
9
|
+
* logic as the Web Stream versions, just wrapped in Node.js Transform
|
|
10
|
+
* instead of Web TransformStream.
|
|
11
|
+
*
|
|
12
|
+
* Architecture:
|
|
13
|
+
* renderToPipeableStream → pipe(errorHandler) → pipe(headInjector)
|
|
14
|
+
* → pipe(flightInjector) → Readable.toWeb() → Response
|
|
15
|
+
*
|
|
16
|
+
* All chunks stay in C++ Node.js stream buffers until the final
|
|
17
|
+
* Readable.toWeb() conversion for the Response body.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Transform } from 'node:stream';
|
|
21
|
+
|
|
22
|
+
// ─── Head Injection ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Node.js Transform that injects HTML content before </head>.
|
|
26
|
+
*
|
|
27
|
+
* Equivalent to injectHead() in html-injectors.ts. Streams chunks
|
|
28
|
+
* through immediately, keeping only a small trailing buffer to handle
|
|
29
|
+
* </head> split across chunk boundaries.
|
|
30
|
+
*/
|
|
31
|
+
export function createNodeHeadInjector(headHtml: string): Transform {
|
|
32
|
+
if (!headHtml) {
|
|
33
|
+
return new Transform({
|
|
34
|
+
transform(chunk, _enc, cb) {
|
|
35
|
+
cb(null, chunk);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const target = '</head>';
|
|
41
|
+
const tailLen = target.length - 1;
|
|
42
|
+
let injected = false;
|
|
43
|
+
let tail = '';
|
|
44
|
+
|
|
45
|
+
return new Transform({
|
|
46
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
47
|
+
if (injected) {
|
|
48
|
+
callback(null, chunk);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const text = tail + chunk.toString('utf-8');
|
|
53
|
+
const tagIndex = text.indexOf(target);
|
|
54
|
+
|
|
55
|
+
if (tagIndex !== -1) {
|
|
56
|
+
const before = text.slice(0, tagIndex);
|
|
57
|
+
const after = text.slice(tagIndex);
|
|
58
|
+
this.push(Buffer.from(before + headHtml + after, 'utf-8'));
|
|
59
|
+
injected = true;
|
|
60
|
+
tail = '';
|
|
61
|
+
callback();
|
|
62
|
+
} else {
|
|
63
|
+
const safeEnd = Math.max(0, text.length - tailLen);
|
|
64
|
+
if (safeEnd > 0) {
|
|
65
|
+
this.push(Buffer.from(text.slice(0, safeEnd), 'utf-8'));
|
|
66
|
+
}
|
|
67
|
+
tail = text.slice(safeEnd);
|
|
68
|
+
callback();
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
flush(callback) {
|
|
72
|
+
if (!injected && tail) {
|
|
73
|
+
this.push(Buffer.from(tail, 'utf-8'));
|
|
74
|
+
}
|
|
75
|
+
callback();
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── RSC Flight Injection ────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Escape a string for safe embedding inside a `<script>` tag within
|
|
84
|
+
* a JSON-encoded value. Same as htmlEscapeJsonString in html-injectors.ts.
|
|
85
|
+
*/
|
|
86
|
+
function htmlEscapeJsonString(str: string): string {
|
|
87
|
+
return str
|
|
88
|
+
.replace(/</g, '\\u003c')
|
|
89
|
+
.replace(/\u2028/g, '\\u2028')
|
|
90
|
+
.replace(/\u2029/g, '\\u2029');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Node.js Transform that merges RSC script tags into the HTML stream.
|
|
95
|
+
*
|
|
96
|
+
* Equivalent to injectRscPayload() in html-injectors.ts. Combines
|
|
97
|
+
* createInlinedRscStream + createFlightInjectionTransform into a single
|
|
98
|
+
* Node.js Transform.
|
|
99
|
+
*
|
|
100
|
+
* 1. Strips `</body></html>` from the shell so all subsequent content
|
|
101
|
+
* is at `<body>` level.
|
|
102
|
+
* 2. Reads RSC chunks from the provided ReadableStream and injects them
|
|
103
|
+
* as `<script>` tags after HTML chunks.
|
|
104
|
+
* 3. Re-emits `</body></html>` at the very end.
|
|
105
|
+
*
|
|
106
|
+
* The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
|
|
107
|
+
* stream). We read from it using the Web API — this is the one bridge
|
|
108
|
+
* point between Web Streams and Node.js streams in the pipeline.
|
|
109
|
+
*/
|
|
110
|
+
export function createNodeFlightInjector(
|
|
111
|
+
rscStream: ReadableStream<Uint8Array> | undefined
|
|
112
|
+
): Transform {
|
|
113
|
+
if (!rscStream) {
|
|
114
|
+
return new Transform({
|
|
115
|
+
transform(chunk, _enc, cb) {
|
|
116
|
+
cb(null, chunk);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const suffix = '</body></html>';
|
|
122
|
+
const suffixBuf = Buffer.from(suffix, 'utf-8');
|
|
123
|
+
const rscReader = rscStream.getReader();
|
|
124
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
125
|
+
|
|
126
|
+
let pullPromise: Promise<void> | null = null;
|
|
127
|
+
let donePulling = false;
|
|
128
|
+
let pullError: unknown = null;
|
|
129
|
+
let foundSuffix = false;
|
|
130
|
+
let htmlStreamFinished = false;
|
|
131
|
+
const pending: Buffer[] = [];
|
|
132
|
+
|
|
133
|
+
// Emit bootstrap signal
|
|
134
|
+
const bootstrap = `<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`;
|
|
135
|
+
pending.push(Buffer.from(bootstrap, 'utf-8'));
|
|
136
|
+
|
|
137
|
+
async function pullLoop(): Promise<void> {
|
|
138
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
139
|
+
try {
|
|
140
|
+
for (;;) {
|
|
141
|
+
const { done, value } = await rscReader.read();
|
|
142
|
+
if (done) {
|
|
143
|
+
donePulling = true;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
147
|
+
const escaped = htmlEscapeJsonString(JSON.stringify([1, decoded]));
|
|
148
|
+
pending.push(Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8'));
|
|
149
|
+
if (!htmlStreamFinished) {
|
|
150
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
pullError = err;
|
|
155
|
+
donePulling = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function drainPending(transform: Transform): void {
|
|
160
|
+
while (pending.length > 0) {
|
|
161
|
+
transform.push(pending.shift()!);
|
|
162
|
+
}
|
|
163
|
+
if (pullError) {
|
|
164
|
+
transform.destroy(pullError instanceof Error ? pullError : new Error(String(pullError)));
|
|
165
|
+
pullError = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return new Transform({
|
|
170
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
171
|
+
if (!pullPromise) {
|
|
172
|
+
pullPromise = pullLoop();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (foundSuffix) {
|
|
176
|
+
this.push(chunk);
|
|
177
|
+
if (pending.length > 0) drainPending(this);
|
|
178
|
+
callback();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const text = chunk.toString('utf-8');
|
|
183
|
+
const idx = text.indexOf(suffix);
|
|
184
|
+
if (idx !== -1) {
|
|
185
|
+
foundSuffix = true;
|
|
186
|
+
const before = text.slice(0, idx);
|
|
187
|
+
const after = text.slice(idx + suffix.length);
|
|
188
|
+
if (before) this.push(Buffer.from(before, 'utf-8'));
|
|
189
|
+
if (pending.length > 0) drainPending(this);
|
|
190
|
+
if (after) this.push(Buffer.from(after, 'utf-8'));
|
|
191
|
+
} else {
|
|
192
|
+
this.push(chunk);
|
|
193
|
+
}
|
|
194
|
+
callback();
|
|
195
|
+
},
|
|
196
|
+
flush(callback) {
|
|
197
|
+
htmlStreamFinished = true;
|
|
198
|
+
|
|
199
|
+
const finish = () => {
|
|
200
|
+
drainPending(this);
|
|
201
|
+
if (foundSuffix) {
|
|
202
|
+
this.push(suffixBuf);
|
|
203
|
+
}
|
|
204
|
+
callback();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (donePulling) {
|
|
208
|
+
finish();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (!pullPromise) {
|
|
212
|
+
pullPromise = pullLoop();
|
|
213
|
+
}
|
|
214
|
+
pullPromise.then(finish);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Error Handling ──────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
const NOINDEX_SCRIPT =
|
|
222
|
+
'<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Node.js Transform that catches post-shell streaming errors.
|
|
226
|
+
*
|
|
227
|
+
* Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts.
|
|
228
|
+
* Catches errors from React's streaming phase (deny/throw inside Suspense
|
|
229
|
+
* after the shell has flushed) and closes the stream cleanly.
|
|
230
|
+
*/
|
|
231
|
+
export function createNodeErrorHandler(signal?: AbortSignal): Transform {
|
|
232
|
+
const transform = new Transform({
|
|
233
|
+
transform(chunk, _encoding, callback) {
|
|
234
|
+
callback(null, chunk);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
transform.on('error', (error) => {
|
|
239
|
+
const isAbort =
|
|
240
|
+
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
241
|
+
(error instanceof Error && error.name === 'AbortError') ||
|
|
242
|
+
signal?.aborted;
|
|
243
|
+
|
|
244
|
+
if (isAbort) {
|
|
245
|
+
transform.end();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.error('[timber] SSR streaming error (post-shell):', error.message || error);
|
|
250
|
+
transform.push(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
|
|
251
|
+
transform.end();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return transform;
|
|
255
|
+
}
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -17,7 +17,13 @@ import config from 'virtual:timber-config';
|
|
|
17
17
|
import { createFromReadableStream } from '#/rsc-runtime/ssr.js';
|
|
18
18
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
renderSsrStream,
|
|
22
|
+
renderSsrNodeStream,
|
|
23
|
+
nodeReadableToWeb,
|
|
24
|
+
useNodeStreams,
|
|
25
|
+
buildSsrResponse,
|
|
26
|
+
} from './ssr-render.js';
|
|
21
27
|
import { formatSsrError } from './error-formatter.js';
|
|
22
28
|
import { SsrStreamError } from './primitives.js';
|
|
23
29
|
import { injectHead, injectRscPayload } from './html-injectors.js';
|
|
@@ -137,7 +143,9 @@ export async function handleSsr(
|
|
|
137
143
|
// createFromReadableStream resolves client component references
|
|
138
144
|
// (from "use client" modules) using the SSR environment's module
|
|
139
145
|
// map, importing the actual components for server-side rendering.
|
|
146
|
+
const _s0 = performance.now();
|
|
140
147
|
const element = createFromReadableStream(rscStream) as React.ReactNode;
|
|
148
|
+
const _s1 = performance.now();
|
|
141
149
|
|
|
142
150
|
// Wrap with a server-safe nuqs adapter so that 'use client' components
|
|
143
151
|
// that call nuqs hooks (useQueryStates, useQueryState) can SSR correctly.
|
|
@@ -145,12 +153,67 @@ export async function handleSsr(
|
|
|
145
153
|
// over after hydration. This provider supplies the request's search params
|
|
146
154
|
// as a static snapshot so nuqs renders the right initial values on the server.
|
|
147
155
|
const wrappedElement = withNuqsSsrAdapter(navContext.searchParams, element);
|
|
156
|
+
const _s2 = performance.now();
|
|
148
157
|
|
|
149
158
|
// Render to HTML stream (waits for onShellReady).
|
|
150
159
|
// Pass bootstrapScriptContent so React injects a non-deferred <script>
|
|
151
160
|
// in the shell HTML. This executes immediately during parsing — even
|
|
152
161
|
// while Suspense boundaries are still streaming — triggering module
|
|
153
162
|
// loading via dynamic import() so hydration can start early.
|
|
163
|
+
//
|
|
164
|
+
// Two paths based on platform:
|
|
165
|
+
// - Node.js: renderToPipeableStream → Node Transform pipeline → Readable.toWeb() → Response
|
|
166
|
+
// Entire pipeline stays in C++ native streams until the Response boundary.
|
|
167
|
+
// - Workers: renderToReadableStream → Web TransformStream pipeline → Response
|
|
168
|
+
// Web Streams are V8-native C++ built-ins on Workers.
|
|
169
|
+
if (useNodeStreams) {
|
|
170
|
+
// Node.js fast path: full pipeline in native streams
|
|
171
|
+
const { createNodeHeadInjector, createNodeFlightInjector, createNodeErrorHandler } =
|
|
172
|
+
await import('./node-stream-transforms.js');
|
|
173
|
+
const { pipeline } = await import('node:stream/promises');
|
|
174
|
+
|
|
175
|
+
const _s3 = performance.now();
|
|
176
|
+
let nodeHtmlStream: import('node:stream').Readable;
|
|
177
|
+
try {
|
|
178
|
+
nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
|
|
179
|
+
bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
|
|
180
|
+
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
181
|
+
signal: navContext.signal,
|
|
182
|
+
});
|
|
183
|
+
} catch (renderError) {
|
|
184
|
+
console.error(
|
|
185
|
+
'[timber] SSR shell failed from RSC stream error:',
|
|
186
|
+
formatSsrError(renderError)
|
|
187
|
+
);
|
|
188
|
+
throw new SsrStreamError(
|
|
189
|
+
'SSR renderToReadableStream failed due to RSC stream error',
|
|
190
|
+
renderError
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build Node.js Transform pipeline: errorHandler → headInjector → flightInjector
|
|
195
|
+
const errorHandler = createNodeErrorHandler(navContext.signal);
|
|
196
|
+
const headInjector = createNodeHeadInjector(navContext.headHtml);
|
|
197
|
+
const flightInjector = createNodeFlightInjector(navContext.rscStream);
|
|
198
|
+
|
|
199
|
+
// Pipe through the chain. pipeline() handles backpressure and error propagation.
|
|
200
|
+
// The last stream in the chain is the output — convert to Web ReadableStream
|
|
201
|
+
// only at the Response boundary.
|
|
202
|
+
const { PassThrough } = await import('node:stream');
|
|
203
|
+
const output = new PassThrough();
|
|
204
|
+
pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
|
|
205
|
+
// Pipeline errors are handled by errorHandler transform
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const _s4 = performance.now();
|
|
209
|
+
// eslint-disable-next-line no-console
|
|
210
|
+
console.log(`[ssr-perf] decode=${(_s1 - _s0).toFixed(1)}ms nuqs=${(_s2 - _s1).toFixed(1)}ms imports=${(_s3 - _s2).toFixed(1)}ms renderToPipeable=${(_s4 - _s3).toFixed(1)}ms pipeline=${(performance.now() - _s4).toFixed(1)}ms total=${(performance.now() - _s0).toFixed(1)}ms`);
|
|
211
|
+
|
|
212
|
+
const webStream = nodeReadableToWeb(output);
|
|
213
|
+
return buildSsrResponse(webStream, navContext.statusCode, navContext.responseHeaders);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Web Streams path (CF Workers / fallback)
|
|
154
217
|
let htmlStream: ReadableStream<Uint8Array>;
|
|
155
218
|
try {
|
|
156
219
|
htmlStream = await renderSsrStream(wrappedElement, {
|
|
@@ -159,11 +222,6 @@ export async function handleSsr(
|
|
|
159
222
|
signal: navContext.signal,
|
|
160
223
|
});
|
|
161
224
|
} catch (renderError) {
|
|
162
|
-
// SSR shell rendering failed — the RSC stream contained an error
|
|
163
|
-
// that wasn't caught by any error boundary in the decoded tree.
|
|
164
|
-
// Wrap in SsrStreamError so the RSC entry can handle it without
|
|
165
|
-
// re-executing server components via renderDenyPage.
|
|
166
|
-
// See LOCAL-293.
|
|
167
225
|
console.error(
|
|
168
226
|
'[timber] SSR shell failed from RSC stream error:',
|
|
169
227
|
formatSsrError(renderError)
|
package/src/server/ssr-render.ts
CHANGED
|
@@ -85,15 +85,10 @@ try {
|
|
|
85
85
|
_renderToPipeableStream = reactDomServer.renderToPipeableStream;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
} catch
|
|
88
|
+
} catch {
|
|
89
89
|
// node:stream or renderToPipeableStream not available — use Web Streams path
|
|
90
|
-
// eslint-disable-next-line no-console
|
|
91
|
-
console.log(`[timber] SSR stream detection: falling back to Web Streams (${e instanceof Error ? e.message : e})`);
|
|
92
90
|
}
|
|
93
91
|
|
|
94
|
-
// eslint-disable-next-line no-console
|
|
95
|
-
console.log(`[timber] SSR render path: ${_useNodeStreams ? 'renderToPipeableStream (Node.js native)' : 'renderToReadableStream (Web Streams)'}`);
|
|
96
|
-
|
|
97
92
|
/**
|
|
98
93
|
* Render a React element tree to a ReadableStream of HTML.
|
|
99
94
|
*
|
|
@@ -112,55 +107,59 @@ export async function renderSsrStream(
|
|
|
112
107
|
element: ReactNode,
|
|
113
108
|
options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
|
|
114
109
|
): Promise<ReadableStream<Uint8Array>> {
|
|
115
|
-
if (_useNodeStreams) {
|
|
116
|
-
return renderViaPipeableStream(element, options);
|
|
117
|
-
}
|
|
118
110
|
return renderViaReadableStream(element, options);
|
|
119
111
|
}
|
|
120
112
|
|
|
113
|
+
/** Whether the current platform uses native Node.js streams for SSR. */
|
|
114
|
+
export const useNodeStreams = _useNodeStreams;
|
|
115
|
+
|
|
121
116
|
// ─── Node.js Path: renderToPipeableStream ────────────────────────────────────
|
|
122
117
|
//
|
|
123
118
|
// Uses React's Node.js-native API. HTML chunks flow through C++ stream
|
|
124
|
-
// buffers with zero Promise allocations per chunk.
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
119
|
+
// buffers with zero Promise allocations per chunk. Returns a Node.js
|
|
120
|
+
// Readable — the caller (ssr-entry.ts) pipes through Node.js Transform
|
|
121
|
+
// streams for injectHead/injectRscPayload before converting to Web
|
|
122
|
+
// ReadableStream at the Response boundary.
|
|
128
123
|
|
|
129
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Render via renderToPipeableStream, returning a Node.js Readable.
|
|
126
|
+
*
|
|
127
|
+
* The entire HTML rendering + post-processing pipeline stays in native
|
|
128
|
+
* Node.js streams (C++ backed). Only converted to Web ReadableStream
|
|
129
|
+
* at the very end for the Response body.
|
|
130
|
+
*/
|
|
131
|
+
export async function renderSsrNodeStream(
|
|
130
132
|
element: ReactNode,
|
|
131
133
|
options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
|
|
132
|
-
): Promise<
|
|
134
|
+
): Promise<import('node:stream').Readable> {
|
|
133
135
|
const signal = options?.signal;
|
|
134
136
|
const deferMs = options?.deferSuspenseFor;
|
|
135
137
|
|
|
136
|
-
return new Promise<
|
|
138
|
+
return new Promise<import('node:stream').Readable>((resolve, reject) => {
|
|
139
|
+
const _startTime = performance.now();
|
|
137
140
|
const passthrough = new _PassThrough!();
|
|
138
141
|
|
|
139
142
|
let allReadyResolve: (() => void) | null = null;
|
|
140
143
|
const allReady = new Promise<void>((r) => {
|
|
141
144
|
allReadyResolve = r;
|
|
142
145
|
});
|
|
143
|
-
// Suppress unhandled rejection if nobody awaits allReady
|
|
144
146
|
allReady.catch(() => {});
|
|
145
147
|
|
|
146
148
|
const { pipe, abort } = _renderToPipeableStream!(element, {
|
|
147
149
|
bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
|
|
148
150
|
|
|
149
151
|
onShellReady() {
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
// See design/05-streaming.md §"deferSuspenseFor"
|
|
152
|
+
const _shellReady = performance.now();
|
|
153
|
+
// eslint-disable-next-line no-console
|
|
154
|
+
console.log(`[ssr-perf] onShellReady in ${(_shellReady - _startTime).toFixed(1)}ms`);
|
|
154
155
|
if (deferMs && deferMs > 0) {
|
|
155
156
|
Promise.race([allReady, new Promise<void>((r) => setTimeout(r, deferMs))]).then(() => {
|
|
156
157
|
pipe(passthrough);
|
|
157
|
-
|
|
158
|
-
resolve(wrapStreamWithErrorHandling(webStream, signal));
|
|
158
|
+
resolve(passthrough);
|
|
159
159
|
});
|
|
160
160
|
} else {
|
|
161
161
|
pipe(passthrough);
|
|
162
|
-
|
|
163
|
-
resolve(wrapStreamWithErrorHandling(webStream, signal));
|
|
162
|
+
resolve(passthrough);
|
|
164
163
|
}
|
|
165
164
|
},
|
|
166
165
|
|
|
@@ -173,13 +172,11 @@ async function renderViaPipeableStream(
|
|
|
173
172
|
},
|
|
174
173
|
|
|
175
174
|
onError(error: unknown) {
|
|
176
|
-
// Suppress connection abort logging — not an application error.
|
|
177
175
|
if (isAbortError(error) || signal?.aborted) return;
|
|
178
176
|
console.error('[timber] SSR render error:', formatSsrError(error));
|
|
179
177
|
},
|
|
180
178
|
});
|
|
181
179
|
|
|
182
|
-
// Wire up abort signal — cancel React rendering if the client disconnects.
|
|
183
180
|
if (signal) {
|
|
184
181
|
if (signal.aborted) {
|
|
185
182
|
abort();
|
|
@@ -190,6 +187,13 @@ async function renderViaPipeableStream(
|
|
|
190
187
|
});
|
|
191
188
|
}
|
|
192
189
|
|
|
190
|
+
/** Convert a Node.js Readable to a Web ReadableStream (zero-copy bridge). */
|
|
191
|
+
export function nodeReadableToWeb(
|
|
192
|
+
readable: import('node:stream').Readable
|
|
193
|
+
): ReadableStream<Uint8Array> {
|
|
194
|
+
return _ReadableToWeb!(readable) as ReadableStream<Uint8Array>;
|
|
195
|
+
}
|
|
196
|
+
|
|
193
197
|
// ─── Web Streams Path: renderToReadableStream ────────────────────────────────
|
|
194
198
|
//
|
|
195
199
|
// Uses React's Web Streams API. On Cloudflare Workers, ReadableStream is a
|