@timber-js/app 0.2.0-alpha.37 → 0.2.0-alpha.38
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/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +27 -4
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.d.ts +5 -2
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +32 -8
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/singleflight.d.ts +18 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +53 -4
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-error-overlay.d.ts +26 -1
- package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts +2 -2
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +27 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +13 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/render-timeout.d.ts +51 -0
- package/dist/server/render-timeout.d.ts.map +1 -0
- package/dist/server/rsc-entry/helpers.d.ts +46 -3
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +6 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +3 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +2 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/nitro.ts +27 -4
- package/src/cache/index.ts +5 -2
- package/src/cache/singleflight.ts +54 -4
- package/src/cache/timber-cache.ts +17 -16
- package/src/index.ts +12 -0
- package/src/plugins/dev-error-overlay.ts +70 -1
- package/src/plugins/dev-server.ts +38 -4
- package/src/plugins/entries.ts +1 -0
- package/src/server/html-injectors.ts +32 -7
- package/src/server/index.ts +4 -0
- package/src/server/node-stream-transforms.ts +49 -13
- package/src/server/render-timeout.ts +108 -0
- package/src/server/rsc-entry/helpers.ts +122 -3
- package/src/server/rsc-entry/index.ts +34 -4
- package/src/server/rsc-entry/rsc-payload.ts +11 -3
- package/src/server/rsc-entry/rsc-stream.ts +24 -3
- package/src/server/ssr-entry.ts +9 -2
- package/src/server/ssr-render.ts +94 -13
package/src/server/ssr-entry.ts
CHANGED
|
@@ -243,12 +243,15 @@ export async function handleSsr(
|
|
|
243
243
|
PassThrough,
|
|
244
244
|
} = _nodeStreamImports;
|
|
245
245
|
|
|
246
|
+
const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
|
|
247
|
+
|
|
246
248
|
let nodeHtmlStream: import('node:stream').Readable;
|
|
247
249
|
try {
|
|
248
250
|
nodeHtmlStream = await renderSsrNodeStream(wrappedElement, {
|
|
249
251
|
bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
|
|
250
252
|
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
251
253
|
signal: navContext.signal,
|
|
254
|
+
renderTimeoutMs,
|
|
252
255
|
});
|
|
253
256
|
} catch (renderError) {
|
|
254
257
|
console.error(
|
|
@@ -266,7 +269,9 @@ export async function handleSsr(
|
|
|
266
269
|
// element is <html>, so no framework-level doctype prepend needed.
|
|
267
270
|
const errorHandler = createNodeErrorHandler(navContext.signal);
|
|
268
271
|
const headInjector = createNodeHeadInjector(navContext.headHtml);
|
|
269
|
-
const flightInjector = createNodeFlightInjector(navContext.rscStream
|
|
272
|
+
const flightInjector = createNodeFlightInjector(navContext.rscStream, {
|
|
273
|
+
renderTimeoutMs,
|
|
274
|
+
});
|
|
270
275
|
|
|
271
276
|
const output = new PassThrough();
|
|
272
277
|
pipeline(nodeHtmlStream, errorHandler, headInjector, flightInjector, output).catch(() => {
|
|
@@ -289,12 +294,14 @@ export async function handleSsr(
|
|
|
289
294
|
}
|
|
290
295
|
|
|
291
296
|
// Web Streams path (CF Workers / fallback)
|
|
297
|
+
const renderTimeoutMs = _runtimeConfig.renderTimeoutMs ?? undefined;
|
|
292
298
|
let htmlStream: ReadableStream<Uint8Array>;
|
|
293
299
|
try {
|
|
294
300
|
htmlStream = await renderSsrStream(wrappedElement, {
|
|
295
301
|
bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
|
|
296
302
|
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
297
303
|
signal: navContext.signal,
|
|
304
|
+
renderTimeoutMs,
|
|
298
305
|
});
|
|
299
306
|
} catch (renderError) {
|
|
300
307
|
console.error(
|
|
@@ -312,7 +319,7 @@ export async function handleSsr(
|
|
|
312
319
|
// Inject metadata into <head>, then interleave RSC payload chunks
|
|
313
320
|
// into the body as they arrive from the tee'd RSC stream.
|
|
314
321
|
let outputStream = injectHead(htmlStream, navContext.headHtml);
|
|
315
|
-
outputStream = injectRscPayload(outputStream, navContext.rscStream);
|
|
322
|
+
outputStream = injectRscPayload(outputStream, navContext.rscStream, renderTimeoutMs);
|
|
316
323
|
const _pipelineEnd = performance.now();
|
|
317
324
|
|
|
318
325
|
navContext._ssrTimings = {
|
package/src/server/ssr-render.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { ReactNode } from 'react';
|
|
|
24
24
|
import { renderToReadableStream } from 'react-dom/server';
|
|
25
25
|
|
|
26
26
|
import { formatSsrError } from './error-formatter.js';
|
|
27
|
+
import { createRenderTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Inline script that injects <meta name="robots" content="noindex"> into <head>.
|
|
@@ -105,7 +106,12 @@ try {
|
|
|
105
106
|
*/
|
|
106
107
|
export async function renderSsrStream(
|
|
107
108
|
element: ReactNode,
|
|
108
|
-
options?: {
|
|
109
|
+
options?: {
|
|
110
|
+
bootstrapScriptContent?: string;
|
|
111
|
+
deferSuspenseFor?: number;
|
|
112
|
+
signal?: AbortSignal;
|
|
113
|
+
renderTimeoutMs?: number;
|
|
114
|
+
}
|
|
109
115
|
): Promise<ReadableStream<Uint8Array>> {
|
|
110
116
|
return renderViaReadableStream(element, options);
|
|
111
117
|
}
|
|
@@ -130,17 +136,25 @@ export const useNodeStreams = _useNodeStreams;
|
|
|
130
136
|
*/
|
|
131
137
|
export async function renderSsrNodeStream(
|
|
132
138
|
element: ReactNode,
|
|
133
|
-
options?: {
|
|
139
|
+
options?: {
|
|
140
|
+
bootstrapScriptContent?: string;
|
|
141
|
+
deferSuspenseFor?: number;
|
|
142
|
+
signal?: AbortSignal;
|
|
143
|
+
renderTimeoutMs?: number;
|
|
144
|
+
}
|
|
134
145
|
): Promise<import('node:stream').Readable> {
|
|
135
146
|
const signal = options?.signal;
|
|
136
147
|
const deferMs = options?.deferSuspenseFor;
|
|
148
|
+
const timeoutMs = options?.renderTimeoutMs;
|
|
137
149
|
|
|
138
150
|
return new Promise<import('node:stream').Readable>((resolve, reject) => {
|
|
139
151
|
const passthrough = new _PassThrough!();
|
|
140
152
|
|
|
141
153
|
let allReadyResolve: (() => void) | null = null;
|
|
142
|
-
|
|
143
|
-
|
|
154
|
+
let allReadyReject: ((reason?: unknown) => void) | null = null;
|
|
155
|
+
const allReady = new Promise<void>((resolve, reject) => {
|
|
156
|
+
allReadyResolve = resolve;
|
|
157
|
+
allReadyReject = reject;
|
|
144
158
|
});
|
|
145
159
|
allReady.catch(() => {});
|
|
146
160
|
|
|
@@ -164,6 +178,10 @@ export async function renderSsrNodeStream(
|
|
|
164
178
|
},
|
|
165
179
|
|
|
166
180
|
onShellError(error: unknown) {
|
|
181
|
+
// Reject allReady so the render timeout is cancelled.
|
|
182
|
+
// Without this, a pre-shell failure leaves the timer
|
|
183
|
+
// running for the full timeout window.
|
|
184
|
+
allReadyReject?.(error);
|
|
167
185
|
reject(error);
|
|
168
186
|
},
|
|
169
187
|
|
|
@@ -173,6 +191,9 @@ export async function renderSsrNodeStream(
|
|
|
173
191
|
},
|
|
174
192
|
});
|
|
175
193
|
|
|
194
|
+
// Wire abort to both request signal AND render timeout.
|
|
195
|
+
// If the client stays connected but a downstream fetch hangs,
|
|
196
|
+
// the timeout ensures abort() is eventually called.
|
|
176
197
|
if (signal) {
|
|
177
198
|
if (signal.aborted) {
|
|
178
199
|
abort();
|
|
@@ -180,6 +201,28 @@ export async function renderSsrNodeStream(
|
|
|
180
201
|
signal.addEventListener('abort', () => abort(), { once: true });
|
|
181
202
|
}
|
|
182
203
|
}
|
|
204
|
+
|
|
205
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
206
|
+
const renderTimeout = createRenderTimeout(timeoutMs, signal);
|
|
207
|
+
renderTimeout.signal.addEventListener(
|
|
208
|
+
'abort',
|
|
209
|
+
() => {
|
|
210
|
+
console.error(
|
|
211
|
+
`[timber] SSR render timed out after ${timeoutMs}ms — aborting. ` +
|
|
212
|
+
'A Suspense component or downstream fetch may be hanging.'
|
|
213
|
+
);
|
|
214
|
+
abort(renderTimeout.signal.reason);
|
|
215
|
+
},
|
|
216
|
+
{ once: true }
|
|
217
|
+
);
|
|
218
|
+
// Cancel the timeout when the render completes OR on pre-shell
|
|
219
|
+
// failure. Without the catch branch, onShellError → reject()
|
|
220
|
+
// would leave the timer running for the full timeout window.
|
|
221
|
+
allReady.then(
|
|
222
|
+
() => renderTimeout.cancel(),
|
|
223
|
+
() => renderTimeout.cancel()
|
|
224
|
+
);
|
|
225
|
+
}
|
|
183
226
|
});
|
|
184
227
|
}
|
|
185
228
|
|
|
@@ -199,21 +242,59 @@ export function nodeReadableToWeb(
|
|
|
199
242
|
|
|
200
243
|
async function renderViaReadableStream(
|
|
201
244
|
element: ReactNode,
|
|
202
|
-
options?: {
|
|
245
|
+
options?: {
|
|
246
|
+
bootstrapScriptContent?: string;
|
|
247
|
+
deferSuspenseFor?: number;
|
|
248
|
+
signal?: AbortSignal;
|
|
249
|
+
renderTimeoutMs?: number;
|
|
250
|
+
}
|
|
203
251
|
): Promise<ReadableStream<Uint8Array>> {
|
|
204
252
|
const signal = options?.signal;
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
253
|
+
const timeoutMs = options?.renderTimeoutMs;
|
|
254
|
+
|
|
255
|
+
// If a render timeout is configured, create a combined signal that
|
|
256
|
+
// fires on either request abort OR timeout — whichever comes first.
|
|
257
|
+
let renderTimeout: import('./render-timeout.js').RenderTimeout | null = null;
|
|
258
|
+
let effectiveSignal = signal;
|
|
259
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
260
|
+
renderTimeout = createRenderTimeout(timeoutMs, signal);
|
|
261
|
+
effectiveSignal = renderTimeout.signal;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let stream: Awaited<ReturnType<typeof renderToReadableStream>>;
|
|
265
|
+
try {
|
|
266
|
+
stream = await renderToReadableStream(element, {
|
|
267
|
+
bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
|
|
268
|
+
signal: effectiveSignal,
|
|
269
|
+
onError(error: unknown) {
|
|
270
|
+
if (isAbortError(error) || effectiveSignal?.aborted) return;
|
|
271
|
+
if (error instanceof RenderTimeoutError) {
|
|
272
|
+
console.error(
|
|
273
|
+
`[timber] SSR render timed out after ${timeoutMs}ms — aborting. ` +
|
|
274
|
+
'A Suspense component or downstream fetch may be hanging.'
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
console.error('[timber] SSR render error:', formatSsrError(error));
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// Pre-shell failure (e.g. RSC stream error). Cancel the render
|
|
283
|
+
// timeout so it doesn't leak a timer + abort callback for the
|
|
284
|
+
// full timeout window. Under repeated pre-shell failures this
|
|
285
|
+
// would accumulate unnecessary timers.
|
|
286
|
+
renderTimeout?.cancel();
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
213
289
|
|
|
214
290
|
// Prevent unhandled promise rejection from streaming-phase errors.
|
|
215
291
|
stream.allReady.catch(() => {});
|
|
216
292
|
|
|
293
|
+
// Cancel the render timeout once allReady resolves (render completed).
|
|
294
|
+
if (renderTimeout) {
|
|
295
|
+
stream.allReady.then(() => renderTimeout!.cancel()).catch(() => renderTimeout!.cancel());
|
|
296
|
+
}
|
|
297
|
+
|
|
217
298
|
// deferSuspenseFor hold: delay the first read so React can resolve
|
|
218
299
|
// fast-completing Suspense boundaries before we read the shell HTML.
|
|
219
300
|
// See design/05-streaming.md §"deferSuspenseFor"
|