@timber-js/app 0.2.0-alpha.22 → 0.2.0-alpha.23
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/ssr-render.d.ts +18 -21
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/server/ssr-render.ts +149 -58
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
* independently of the Vite RSC plugin runtime (which provides
|
|
6
6
|
* createFromReadableStream for decoding RSC streams).
|
|
7
7
|
*
|
|
8
|
+
* Uses a platform-adaptive rendering strategy:
|
|
9
|
+
* - **Node.js / Bun**: `renderToPipeableStream` — React pipes HTML chunks
|
|
10
|
+
* through Node.js native streams (C++ implementation). Each chunk flows
|
|
11
|
+
* through libuv buffers with zero Promise overhead.
|
|
12
|
+
* - **Cloudflare Workers / Edge**: `renderToReadableStream` — React outputs
|
|
13
|
+
* to Web Streams which are V8-native C++ built-ins on these platforms.
|
|
14
|
+
*
|
|
15
|
+
* The detection is automatic at runtime. Both paths produce a Web
|
|
16
|
+
* `ReadableStream<Uint8Array>` so downstream transforms (injectHead,
|
|
17
|
+
* injectRscPayload, compression) work identically regardless of platform.
|
|
18
|
+
*
|
|
8
19
|
* Design docs: 02-rendering-pipeline.md §"Single-Pass Rendering",
|
|
9
20
|
* 18-build-system.md §"Entry Files"
|
|
10
21
|
*/
|
|
@@ -12,24 +23,15 @@ import type { ReactNode } from 'react';
|
|
|
12
23
|
/**
|
|
13
24
|
* Render a React element tree to a ReadableStream of HTML.
|
|
14
25
|
*
|
|
15
|
-
*
|
|
26
|
+
* Automatically selects the optimal rendering path for the platform:
|
|
27
|
+
* - Node.js/Bun: `renderToPipeableStream` → Node.js native streams → `Readable.toWeb()`
|
|
28
|
+
* - CF Workers/Edge: `renderToReadableStream` → native Web Streams
|
|
29
|
+
*
|
|
16
30
|
* The returned stream begins yielding after onShellReady — everything
|
|
17
31
|
* outside <Suspense> boundaries is in the shell.
|
|
18
32
|
*
|
|
19
|
-
* With progressive streaming, the RSC stream is piped directly to SSR
|
|
20
|
-
* without buffering. If deny() was called outside a Suspense boundary,
|
|
21
|
-
* the RSC stream encodes an error in the shell — renderToReadableStream
|
|
22
|
-
* rejects, and the RSC entry catches this to render a deny page with
|
|
23
|
-
* the correct HTTP status code. If deny() was inside Suspense, the shell
|
|
24
|
-
* succeeds (200 committed) and the error streams as an error boundary.
|
|
25
|
-
*
|
|
26
33
|
* @param element - The React element tree decoded from the RSC stream
|
|
27
34
|
* @param options - Optional configuration
|
|
28
|
-
* @param options.bootstrapScriptContent - Inline JS injected by React as a
|
|
29
|
-
* non-deferred `<script>` in the shell HTML. Executes immediately during
|
|
30
|
-
* parsing — even while Suspense boundaries are still streaming. Used to
|
|
31
|
-
* kick off module loading via dynamic `import()` so hydration can start
|
|
32
|
-
* before the HTML stream closes.
|
|
33
35
|
* @returns A ReadableStream of HTML bytes with hydration markers
|
|
34
36
|
*/
|
|
35
37
|
export declare function renderSsrStream(element: ReactNode, options?: {
|
|
@@ -42,9 +44,9 @@ export declare function renderSsrStream(element: ReactNode, options?: {
|
|
|
42
44
|
*
|
|
43
45
|
* During progressive RSC→SSR streaming, errors in Suspense boundaries
|
|
44
46
|
* (e.g. deny() inside Suspense, throws in async components) cause
|
|
45
|
-
* React DOM's
|
|
46
|
-
*
|
|
47
|
-
*
|
|
47
|
+
* React DOM's stream to error after the shell has been flushed. Without
|
|
48
|
+
* this wrapper, the stream error becomes an unhandled promise rejection
|
|
49
|
+
* that crashes the process.
|
|
48
50
|
*
|
|
49
51
|
* The wrapper catches streaming-phase errors, logs them, and closes
|
|
50
52
|
* the output stream cleanly. The shell (headers, status code, content
|
|
@@ -57,11 +59,6 @@ export declare function wrapStreamWithErrorHandling(stream: ReadableStream<Uint8
|
|
|
57
59
|
* status code and headers from the navigation context.
|
|
58
60
|
*
|
|
59
61
|
* Sets content-type to text/html if not already set by middleware.
|
|
60
|
-
*
|
|
61
|
-
* @param htmlStream - The HTML stream from renderSsrStream
|
|
62
|
-
* @param statusCode - The committed HTTP status code from RSC
|
|
63
|
-
* @param responseHeaders - Response headers from middleware/proxy
|
|
64
|
-
* @returns A Response ready to send to the client
|
|
65
62
|
*/
|
|
66
63
|
export declare function buildSsrResponse(htmlStream: ReadableStream<Uint8Array>, statusCode: number, responseHeaders: Headers): Response;
|
|
67
64
|
//# sourceMappingURL=ssr-render.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssr-render.d.ts","sourceRoot":"","sources":["../../src/server/ssr-render.ts"],"names":[],"mappings":"AAAA
|
|
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;AA0DvC;;;;;;;;;;;;;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,CAKrC;AAkHD;;;;;;;;;;;;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.23",
|
|
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",
|
package/src/server/ssr-render.ts
CHANGED
|
@@ -5,12 +5,23 @@
|
|
|
5
5
|
* independently of the Vite RSC plugin runtime (which provides
|
|
6
6
|
* createFromReadableStream for decoding RSC streams).
|
|
7
7
|
*
|
|
8
|
+
* Uses a platform-adaptive rendering strategy:
|
|
9
|
+
* - **Node.js / Bun**: `renderToPipeableStream` — React pipes HTML chunks
|
|
10
|
+
* through Node.js native streams (C++ implementation). Each chunk flows
|
|
11
|
+
* through libuv buffers with zero Promise overhead.
|
|
12
|
+
* - **Cloudflare Workers / Edge**: `renderToReadableStream` — React outputs
|
|
13
|
+
* to Web Streams which are V8-native C++ built-ins on these platforms.
|
|
14
|
+
*
|
|
15
|
+
* The detection is automatic at runtime. Both paths produce a Web
|
|
16
|
+
* `ReadableStream<Uint8Array>` so downstream transforms (injectHead,
|
|
17
|
+
* injectRscPayload, compression) work identically regardless of platform.
|
|
18
|
+
*
|
|
8
19
|
* Design docs: 02-rendering-pipeline.md §"Single-Pass Rendering",
|
|
9
20
|
* 18-build-system.md §"Entry Files"
|
|
10
21
|
*/
|
|
11
22
|
|
|
12
23
|
import type { ReactNode } from 'react';
|
|
13
|
-
import { renderToReadableStream } from 'react-dom/server';
|
|
24
|
+
import { renderToReadableStream, renderToPipeableStream } from 'react-dom/server';
|
|
14
25
|
|
|
15
26
|
import { formatSsrError } from './error-formatter.js';
|
|
16
27
|
|
|
@@ -28,60 +39,167 @@ import { formatSsrError } from './error-formatter.js';
|
|
|
28
39
|
const NOINDEX_SCRIPT =
|
|
29
40
|
'<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
|
|
30
41
|
|
|
42
|
+
// ─── Platform Detection ──────────────────────────────────────────────────────
|
|
43
|
+
//
|
|
44
|
+
// Detect whether we're running on a platform with native Node.js streams.
|
|
45
|
+
// On Node.js/Bun, `node:stream` is backed by C++ (libuv). On Cloudflare
|
|
46
|
+
// Workers, `node:stream` via nodejs_compat is a JS polyfill — Web Streams
|
|
47
|
+
// are the faster path there (V8-native C++ built-ins).
|
|
48
|
+
//
|
|
49
|
+
// We detect once at module load to avoid per-request overhead.
|
|
50
|
+
// The check: process.versions.node exists AND we can import node:stream.
|
|
51
|
+
// Cloudflare Workers with nodejs_compat may polyfill process.versions but
|
|
52
|
+
// the streams won't be native. The Readable.toWeb check confirms native support.
|
|
53
|
+
|
|
54
|
+
let _useNodeStreams = false;
|
|
55
|
+
let _PassThrough: typeof import('node:stream').PassThrough | null = null;
|
|
56
|
+
let _ReadableToWeb: ((readable: import('node:stream').Readable) => ReadableStream) | null = null;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Dynamic import to avoid bundling node:stream for CF Workers builds.
|
|
60
|
+
// On Node.js/Bun this resolves to native C++ streams.
|
|
61
|
+
// On CF Workers this either fails or returns a JS polyfill.
|
|
62
|
+
const nodeStream = await import('node:stream');
|
|
63
|
+
if (
|
|
64
|
+
typeof nodeStream.PassThrough === 'function' &&
|
|
65
|
+
typeof nodeStream.Readable.toWeb === 'function' &&
|
|
66
|
+
// Real Node.js — not a polyfill. Polyfills typically don't set
|
|
67
|
+
// process.release.name to 'node'.
|
|
68
|
+
typeof process !== 'undefined' &&
|
|
69
|
+
process.release?.name === 'node'
|
|
70
|
+
) {
|
|
71
|
+
_useNodeStreams = true;
|
|
72
|
+
_PassThrough = nodeStream.PassThrough;
|
|
73
|
+
_ReadableToWeb = nodeStream.Readable.toWeb as (
|
|
74
|
+
readable: import('node:stream').Readable
|
|
75
|
+
) => ReadableStream;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// node:stream not available — use Web Streams path
|
|
79
|
+
}
|
|
80
|
+
|
|
31
81
|
/**
|
|
32
82
|
* Render a React element tree to a ReadableStream of HTML.
|
|
33
83
|
*
|
|
34
|
-
*
|
|
84
|
+
* Automatically selects the optimal rendering path for the platform:
|
|
85
|
+
* - Node.js/Bun: `renderToPipeableStream` → Node.js native streams → `Readable.toWeb()`
|
|
86
|
+
* - CF Workers/Edge: `renderToReadableStream` → native Web Streams
|
|
87
|
+
*
|
|
35
88
|
* The returned stream begins yielding after onShellReady — everything
|
|
36
89
|
* outside <Suspense> boundaries is in the shell.
|
|
37
90
|
*
|
|
38
|
-
* With progressive streaming, the RSC stream is piped directly to SSR
|
|
39
|
-
* without buffering. If deny() was called outside a Suspense boundary,
|
|
40
|
-
* the RSC stream encodes an error in the shell — renderToReadableStream
|
|
41
|
-
* rejects, and the RSC entry catches this to render a deny page with
|
|
42
|
-
* the correct HTTP status code. If deny() was inside Suspense, the shell
|
|
43
|
-
* succeeds (200 committed) and the error streams as an error boundary.
|
|
44
|
-
*
|
|
45
91
|
* @param element - The React element tree decoded from the RSC stream
|
|
46
92
|
* @param options - Optional configuration
|
|
47
|
-
* @param options.bootstrapScriptContent - Inline JS injected by React as a
|
|
48
|
-
* non-deferred `<script>` in the shell HTML. Executes immediately during
|
|
49
|
-
* parsing — even while Suspense boundaries are still streaming. Used to
|
|
50
|
-
* kick off module loading via dynamic `import()` so hydration can start
|
|
51
|
-
* before the HTML stream closes.
|
|
52
93
|
* @returns A ReadableStream of HTML bytes with hydration markers
|
|
53
94
|
*/
|
|
54
95
|
export async function renderSsrStream(
|
|
55
96
|
element: ReactNode,
|
|
56
97
|
options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
|
|
98
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
99
|
+
if (_useNodeStreams) {
|
|
100
|
+
return renderViaPipeableStream(element, options);
|
|
101
|
+
}
|
|
102
|
+
return renderViaReadableStream(element, options);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Node.js Path: renderToPipeableStream ────────────────────────────────────
|
|
106
|
+
//
|
|
107
|
+
// Uses React's Node.js-native API. HTML chunks flow through C++ stream
|
|
108
|
+
// buffers with zero Promise allocations per chunk. The PassThrough stream
|
|
109
|
+
// is converted to a Web ReadableStream via Readable.toWeb() (zero-copy
|
|
110
|
+
// bridge available in Node.js 17+) for compatibility with downstream
|
|
111
|
+
// Web Stream transforms (injectHead, injectRscPayload).
|
|
112
|
+
|
|
113
|
+
async function renderViaPipeableStream(
|
|
114
|
+
element: ReactNode,
|
|
115
|
+
options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
|
|
116
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
117
|
+
const signal = options?.signal;
|
|
118
|
+
const deferMs = options?.deferSuspenseFor;
|
|
119
|
+
|
|
120
|
+
return new Promise<ReadableStream<Uint8Array>>((resolve, reject) => {
|
|
121
|
+
const passthrough = new _PassThrough!();
|
|
122
|
+
|
|
123
|
+
let allReadyResolve: (() => void) | null = null;
|
|
124
|
+
const allReady = new Promise<void>((r) => {
|
|
125
|
+
allReadyResolve = r;
|
|
126
|
+
});
|
|
127
|
+
// Suppress unhandled rejection if nobody awaits allReady
|
|
128
|
+
allReady.catch(() => {});
|
|
129
|
+
|
|
130
|
+
const { pipe, abort } = renderToPipeableStream(element, {
|
|
131
|
+
bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
|
|
132
|
+
|
|
133
|
+
onShellReady() {
|
|
134
|
+
// deferSuspenseFor: delay piping so React can resolve fast-completing
|
|
135
|
+
// Suspense boundaries before we read the shell. When we delay, React
|
|
136
|
+
// inlines resolved content instead of serializing fallbacks.
|
|
137
|
+
// See design/05-streaming.md §"deferSuspenseFor"
|
|
138
|
+
if (deferMs && deferMs > 0) {
|
|
139
|
+
Promise.race([allReady, new Promise<void>((r) => setTimeout(r, deferMs))]).then(() => {
|
|
140
|
+
pipe(passthrough);
|
|
141
|
+
const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
|
|
142
|
+
resolve(wrapStreamWithErrorHandling(webStream, signal));
|
|
143
|
+
});
|
|
144
|
+
} else {
|
|
145
|
+
pipe(passthrough);
|
|
146
|
+
const webStream = _ReadableToWeb!(passthrough) as ReadableStream<Uint8Array>;
|
|
147
|
+
resolve(wrapStreamWithErrorHandling(webStream, signal));
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
onAllReady() {
|
|
152
|
+
allReadyResolve?.();
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
onShellError(error: unknown) {
|
|
156
|
+
reject(error);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
onError(error: unknown) {
|
|
160
|
+
// Suppress connection abort logging — not an application error.
|
|
161
|
+
if (isAbortError(error) || signal?.aborted) return;
|
|
162
|
+
console.error('[timber] SSR render error:', formatSsrError(error));
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Wire up abort signal — cancel React rendering if the client disconnects.
|
|
167
|
+
if (signal) {
|
|
168
|
+
if (signal.aborted) {
|
|
169
|
+
abort();
|
|
170
|
+
} else {
|
|
171
|
+
signal.addEventListener('abort', () => abort(), { once: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Web Streams Path: renderToReadableStream ────────────────────────────────
|
|
178
|
+
//
|
|
179
|
+
// Uses React's Web Streams API. On Cloudflare Workers, ReadableStream is a
|
|
180
|
+
// V8-native C++ built-in, making this the fastest path for that platform.
|
|
181
|
+
// On Node.js, Web Streams are a JS reimplementation — slower, but this path
|
|
182
|
+
// is only used as a fallback when Node.js native streams aren't available.
|
|
183
|
+
|
|
184
|
+
async function renderViaReadableStream(
|
|
185
|
+
element: ReactNode,
|
|
186
|
+
options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
|
|
57
187
|
): Promise<ReadableStream<Uint8Array>> {
|
|
58
188
|
const signal = options?.signal;
|
|
59
189
|
const stream = await renderToReadableStream(element, {
|
|
60
190
|
bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
|
|
61
191
|
signal,
|
|
62
192
|
onError(error: unknown) {
|
|
63
|
-
// Suppress logging for connection aborts — the user refreshed or
|
|
64
|
-
// navigated away, not an application error.
|
|
65
193
|
if (isAbortError(error) || signal?.aborted) return;
|
|
66
194
|
console.error('[timber] SSR render error:', formatSsrError(error));
|
|
67
195
|
},
|
|
68
196
|
});
|
|
69
197
|
|
|
70
198
|
// Prevent unhandled promise rejection from streaming-phase errors.
|
|
71
|
-
// React DOM Server exposes `allReady` — a promise that resolves when
|
|
72
|
-
// ALL content (including Suspense boundaries) has been rendered. If a
|
|
73
|
-
// streaming-phase error occurs (e.g. React boundary flush failure),
|
|
74
|
-
// `allReady` rejects independently of the stream. Without this catch,
|
|
75
|
-
// the rejection becomes an unhandled promise rejection that crashes
|
|
76
|
-
// the Node.js process.
|
|
77
199
|
stream.allReady.catch(() => {});
|
|
78
200
|
|
|
79
201
|
// deferSuspenseFor hold: delay the first read so React can resolve
|
|
80
202
|
// fast-completing Suspense boundaries before we read the shell HTML.
|
|
81
|
-
// renderToReadableStream generates HTML lazily on pull — if we wait
|
|
82
|
-
// before reading, React resolves pending boundaries and inlines their
|
|
83
|
-
// content instead of serializing fallbacks. Race allReady against
|
|
84
|
-
// deferSuspenseFor so we don't wait longer than necessary.
|
|
85
203
|
// See design/05-streaming.md §"deferSuspenseFor"
|
|
86
204
|
const deferMs = options?.deferSuspenseFor;
|
|
87
205
|
if (deferMs && deferMs > 0) {
|
|
@@ -91,30 +209,19 @@ export async function renderSsrStream(
|
|
|
91
209
|
]);
|
|
92
210
|
}
|
|
93
211
|
|
|
94
|
-
// renderToReadableStream resolves after onShellReady by default.
|
|
95
|
-
// The stream is ready to read — the shell (everything outside
|
|
96
|
-
// Suspense boundaries) is available. Suspense content streams
|
|
97
|
-
// into the open connection as it resolves.
|
|
98
|
-
//
|
|
99
|
-
// Wrap the stream in an error-resilient transform. With progressive
|
|
100
|
-
// streaming, errors inside Suspense boundaries (e.g. deny() or throws
|
|
101
|
-
// in async components) cause React's stream to error during the flush
|
|
102
|
-
// phase. The onError callback logs the error, but the stream error
|
|
103
|
-
// would become an unhandled promise rejection and crash the process.
|
|
104
|
-
// The transform catches these post-shell streaming errors and closes
|
|
105
|
-
// the stream cleanly — the shell (with correct status code) has
|
|
106
|
-
// already been sent.
|
|
107
212
|
return wrapStreamWithErrorHandling(stream, signal);
|
|
108
213
|
}
|
|
109
214
|
|
|
215
|
+
// ─── Shared Utilities ────────────────────────────────────────────────────────
|
|
216
|
+
|
|
110
217
|
/**
|
|
111
218
|
* Wrap an HTML stream with error handling for the streaming phase.
|
|
112
219
|
*
|
|
113
220
|
* During progressive RSC→SSR streaming, errors in Suspense boundaries
|
|
114
221
|
* (e.g. deny() inside Suspense, throws in async components) cause
|
|
115
|
-
* React DOM's
|
|
116
|
-
*
|
|
117
|
-
*
|
|
222
|
+
* React DOM's stream to error after the shell has been flushed. Without
|
|
223
|
+
* this wrapper, the stream error becomes an unhandled promise rejection
|
|
224
|
+
* that crashes the process.
|
|
118
225
|
*
|
|
119
226
|
* The wrapper catches streaming-phase errors, logs them, and closes
|
|
120
227
|
* the output stream cleanly. The shell (headers, status code, content
|
|
@@ -138,17 +245,10 @@ export function wrapStreamWithErrorHandling(
|
|
|
138
245
|
}
|
|
139
246
|
controller.enqueue(value);
|
|
140
247
|
} catch (error) {
|
|
141
|
-
// Connection abort (user refreshed or navigated away) — close
|
|
142
|
-
// silently without logging. This is not an application error.
|
|
143
248
|
if (isAbortError(error) || signal?.aborted) {
|
|
144
249
|
controller.close();
|
|
145
250
|
return;
|
|
146
251
|
}
|
|
147
|
-
// Streaming-phase error (e.g. React boundary flush failure,
|
|
148
|
-
// deny() or throw inside Suspense after flush).
|
|
149
|
-
// The shell has already been sent with status 200. Inject a
|
|
150
|
-
// noindex meta tag so search engines don't index this error page,
|
|
151
|
-
// then close cleanly. See design/05-streaming.md.
|
|
152
252
|
console.error('[timber] SSR streaming error (post-shell):', formatSsrError(error));
|
|
153
253
|
controller.enqueue(encoder.encode(NOINDEX_SCRIPT));
|
|
154
254
|
controller.close();
|
|
@@ -162,10 +262,6 @@ export function wrapStreamWithErrorHandling(
|
|
|
162
262
|
|
|
163
263
|
/**
|
|
164
264
|
* Check if an error is an abort error (connection closed by client).
|
|
165
|
-
*
|
|
166
|
-
* When the browser aborts a request (page refresh, navigation away),
|
|
167
|
-
* the AbortSignal fires and React/streams throw an AbortError. This
|
|
168
|
-
* is not an application error — suppress it from error boundaries and logs.
|
|
169
265
|
*/
|
|
170
266
|
function isAbortError(error: unknown): boolean {
|
|
171
267
|
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
|
@@ -178,11 +274,6 @@ function isAbortError(error: unknown): boolean {
|
|
|
178
274
|
* status code and headers from the navigation context.
|
|
179
275
|
*
|
|
180
276
|
* Sets content-type to text/html if not already set by middleware.
|
|
181
|
-
*
|
|
182
|
-
* @param htmlStream - The HTML stream from renderSsrStream
|
|
183
|
-
* @param statusCode - The committed HTTP status code from RSC
|
|
184
|
-
* @param responseHeaders - Response headers from middleware/proxy
|
|
185
|
-
* @returns A Response ready to send to the client
|
|
186
277
|
*/
|
|
187
278
|
export function buildSsrResponse(
|
|
188
279
|
htmlStream: ReadableStream<Uint8Array>,
|