@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.
Files changed (56) hide show
  1. package/dist/adapters/nitro.d.ts.map +1 -1
  2. package/dist/adapters/nitro.js +27 -4
  3. package/dist/adapters/nitro.js.map +1 -1
  4. package/dist/cache/index.d.ts +5 -2
  5. package/dist/cache/index.d.ts.map +1 -1
  6. package/dist/cache/index.js +32 -8
  7. package/dist/cache/index.js.map +1 -1
  8. package/dist/cache/singleflight.d.ts +18 -1
  9. package/dist/cache/singleflight.d.ts.map +1 -1
  10. package/dist/cache/timber-cache.d.ts.map +1 -1
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +53 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/plugins/dev-error-overlay.d.ts +26 -1
  16. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  17. package/dist/plugins/entries.d.ts.map +1 -1
  18. package/dist/server/html-injectors.d.ts +2 -2
  19. package/dist/server/html-injectors.d.ts.map +1 -1
  20. package/dist/server/index.d.ts +2 -0
  21. package/dist/server/index.d.ts.map +1 -1
  22. package/dist/server/index.js +27 -1
  23. package/dist/server/index.js.map +1 -1
  24. package/dist/server/node-stream-transforms.d.ts +13 -1
  25. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  26. package/dist/server/render-timeout.d.ts +51 -0
  27. package/dist/server/render-timeout.d.ts.map +1 -0
  28. package/dist/server/rsc-entry/helpers.d.ts +46 -3
  29. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  30. package/dist/server/rsc-entry/index.d.ts +6 -1
  31. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  32. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  33. package/dist/server/rsc-entry/rsc-stream.d.ts +3 -0
  34. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  35. package/dist/server/ssr-entry.d.ts.map +1 -1
  36. package/dist/server/ssr-render.d.ts +2 -0
  37. package/dist/server/ssr-render.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/adapters/nitro.ts +27 -4
  40. package/src/cache/index.ts +5 -2
  41. package/src/cache/singleflight.ts +54 -4
  42. package/src/cache/timber-cache.ts +17 -16
  43. package/src/index.ts +12 -0
  44. package/src/plugins/dev-error-overlay.ts +70 -1
  45. package/src/plugins/dev-server.ts +38 -4
  46. package/src/plugins/entries.ts +1 -0
  47. package/src/server/html-injectors.ts +32 -7
  48. package/src/server/index.ts +4 -0
  49. package/src/server/node-stream-transforms.ts +49 -13
  50. package/src/server/render-timeout.ts +108 -0
  51. package/src/server/rsc-entry/helpers.ts +122 -3
  52. package/src/server/rsc-entry/index.ts +34 -4
  53. package/src/server/rsc-entry/rsc-payload.ts +11 -3
  54. package/src/server/rsc-entry/rsc-stream.ts +24 -3
  55. package/src/server/ssr-entry.ts +9 -2
  56. package/src/server/ssr-render.ts +94 -13
@@ -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 = {
@@ -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?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
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?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
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
- const allReady = new Promise<void>((r) => {
143
- allReadyResolve = r;
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?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
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 stream = await renderToReadableStream(element, {
206
- bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
207
- signal,
208
- onError(error: unknown) {
209
- if (isAbortError(error) || signal?.aborted) return;
210
- console.error('[timber] SSR render error:', formatSsrError(error));
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"