@timber-js/app 0.2.0-alpha.36 → 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/LICENSE +8 -0
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -1
- package/dist/_chunks/{define-cookie-w5GWm_bL.js → define-cookie-BmKbSyp0.js} +4 -4
- package/dist/_chunks/{define-cookie-w5GWm_bL.js.map → define-cookie-BmKbSyp0.js.map} +1 -1
- package/dist/_chunks/{error-boundary-TYEQJZ1-.js → error-boundary-BAN3751q.js} +1 -1
- package/dist/_chunks/{error-boundary-TYEQJZ1-.js.map → error-boundary-BAN3751q.js.map} +1 -1
- package/dist/_chunks/{request-context-CZz_T0Bc.js → request-context-BxYIJM24.js} +59 -4
- package/dist/_chunks/request-context-BxYIJM24.js.map +1 -0
- package/dist/_chunks/segment-context-C6byCyZU.js +69 -0
- package/dist/_chunks/segment-context-C6byCyZU.js.map +1 -0
- package/dist/_chunks/{tracing-BPyIzIdu.js → tracing-CuXiCP5p.js} +1 -1
- package/dist/_chunks/{tracing-BPyIzIdu.js.map → tracing-CuXiCP5p.js.map} +1 -1
- package/dist/_chunks/{wrappers-C1SN725w.js → wrappers-C6J0nNji.js} +2 -2
- package/dist/_chunks/{wrappers-C1SN725w.js.map → wrappers-C6J0nNji.js.map} +1 -1
- 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 +33 -9
- 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/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +25 -8
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +15 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/cookies/index.js +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/params/index.js +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/search-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +14 -0
- package/dist/server/als-registry.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 +4 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +68 -26
- 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/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +30 -3
- package/dist/server/primitives.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/request-context.d.ts +39 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- 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/slot-resolver.d.ts +1 -1
- package/dist/server/slot-resolver.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/dist/server/tree-builder.d.ts +7 -4
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/shared/merge-search-params.d.ts +22 -0
- package/dist/shared/merge-search-params.d.ts.map +1 -0
- package/package.json +6 -7
- 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/cli.ts +0 -0
- package/src/client/index.ts +1 -0
- package/src/client/link.tsx +57 -3
- 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/access-gate.tsx +6 -5
- package/src/server/als-registry.ts +14 -0
- package/src/server/html-injectors.ts +32 -7
- package/src/server/index.ts +7 -0
- package/src/server/node-stream-transforms.ts +49 -13
- package/src/server/pipeline.ts +6 -0
- package/src/server/primitives.ts +47 -5
- package/src/server/render-timeout.ts +108 -0
- package/src/server/request-context.ts +69 -1
- package/src/server/route-element-builder.ts +10 -16
- 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/slot-resolver.ts +10 -19
- package/src/server/ssr-entry.ts +9 -2
- package/src/server/ssr-render.ts +94 -13
- package/src/server/tree-builder.ts +13 -15
- package/src/shared/merge-search-params.ts +48 -0
- package/dist/_chunks/request-context-CZz_T0Bc.js.map +0 -1
- package/dist/_chunks/segment-context-Dpq2XOKg.js +0 -34
- package/dist/_chunks/segment-context-Dpq2XOKg.js.map +0 -1
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
createDebugChannelSink,
|
|
66
66
|
escapeHtml,
|
|
67
67
|
isRscPayloadRequest,
|
|
68
|
+
type DebugComponentEntry,
|
|
68
69
|
} from './helpers.js';
|
|
69
70
|
import { parseClientStateTree } from '#/server/state-tree-diff.js';
|
|
70
71
|
import { buildRscPayloadResponse } from './rsc-payload.js';
|
|
@@ -91,18 +92,35 @@ function resolveServerTimingMode(
|
|
|
91
92
|
|
|
92
93
|
// Dev-only pipeline error handler, set by the dev server after import.
|
|
93
94
|
// In production this is always undefined — no overhead.
|
|
94
|
-
|
|
95
|
+
// The third argument provides RSC debug component data (from the Flight
|
|
96
|
+
// debug channel) when available — used by the error overlay to show the
|
|
97
|
+
// server component tree context for render errors.
|
|
98
|
+
let _devPipelineErrorHandler:
|
|
99
|
+
| ((error: Error, phase: string, debugComponents?: DebugComponentEntry[]) => void)
|
|
100
|
+
| undefined;
|
|
95
101
|
|
|
96
102
|
/**
|
|
97
103
|
* Set the dev pipeline error handler.
|
|
98
104
|
*
|
|
99
105
|
* Called by the dev server after importing this module to wire pipeline
|
|
100
106
|
* errors into the Vite browser error overlay. No-op in production.
|
|
107
|
+
*
|
|
108
|
+
* The handler receives an optional third argument with RSC debug component
|
|
109
|
+
* info — component names, environments, and stack frames from the Flight
|
|
110
|
+
* debug channel. This is only populated for render-phase errors.
|
|
101
111
|
*/
|
|
102
|
-
export function setDevPipelineErrorHandler(
|
|
112
|
+
export function setDevPipelineErrorHandler(
|
|
113
|
+
handler: (error: Error, phase: string, debugComponents?: DebugComponentEntry[]) => void
|
|
114
|
+
): void {
|
|
103
115
|
_devPipelineErrorHandler = handler;
|
|
104
116
|
}
|
|
105
117
|
|
|
118
|
+
// Dev-only: holds a getter for the current request's RSC debug components.
|
|
119
|
+
// Updated on each renderRscStream call so the onPipelineError callback can
|
|
120
|
+
// include component tree context for render-phase errors. This is request-
|
|
121
|
+
// scoped by convention — each renderRoute call sets it before returning.
|
|
122
|
+
let _lastDebugComponentsGetter: (() => DebugComponentEntry[]) | undefined;
|
|
123
|
+
|
|
106
124
|
/**
|
|
107
125
|
* Create the RSC request handler from the route manifest.
|
|
108
126
|
*
|
|
@@ -231,7 +249,15 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
231
249
|
serverTiming: resolveServerTimingMode(runtimeConfig, isDev),
|
|
232
250
|
onPipelineError: isDev
|
|
233
251
|
? (error: Error, phase: string) => {
|
|
234
|
-
if (_devPipelineErrorHandler)
|
|
252
|
+
if (_devPipelineErrorHandler) {
|
|
253
|
+
// For render-phase errors, include RSC debug component data
|
|
254
|
+
// from the Flight debug channel (if available from the current request).
|
|
255
|
+
const debugComponents =
|
|
256
|
+
phase === 'render' && _lastDebugComponentsGetter
|
|
257
|
+
? _lastDebugComponentsGetter()
|
|
258
|
+
: undefined;
|
|
259
|
+
_devPipelineErrorHandler(error, phase, debugComponents);
|
|
260
|
+
}
|
|
235
261
|
}
|
|
236
262
|
: undefined,
|
|
237
263
|
renderFallbackError: (error, req, responseHeaders) =>
|
|
@@ -441,7 +467,11 @@ async function renderRoute(
|
|
|
441
467
|
|
|
442
468
|
// Render to RSC Flight stream with signal tracking.
|
|
443
469
|
const _rscStart = performance.now();
|
|
444
|
-
const { rscStream, signals } = renderRscStream(element, _req);
|
|
470
|
+
const { rscStream, signals, getDebugComponents } = renderRscStream(element, _req);
|
|
471
|
+
|
|
472
|
+
// Store the debug components getter so onPipelineError can include
|
|
473
|
+
// component tree context for render-phase errors (dev mode only).
|
|
474
|
+
_lastDebugComponentsGetter = getDebugComponents;
|
|
445
475
|
recordTiming({
|
|
446
476
|
name: 'rsc-init',
|
|
447
477
|
dur: Math.round(performance.now() - _rscStart),
|
|
@@ -103,9 +103,17 @@ export async function buildRscPayloadResponse(
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
// Extract the first chunk from the race result.
|
|
106
|
-
// If the signal won the race
|
|
107
|
-
//
|
|
108
|
-
|
|
106
|
+
// If the signal won the race but neither redirect nor deny was detected
|
|
107
|
+
// (edge case), cancel the reader immediately rather than issuing a bare
|
|
108
|
+
// read() that could hang forever if the RSC stream has stalled.
|
|
109
|
+
// See TIM-519.
|
|
110
|
+
let firstRead: ReadableStreamReadResult<Uint8Array>;
|
|
111
|
+
if (first.type === 'data') {
|
|
112
|
+
firstRead = first.chunk;
|
|
113
|
+
} else {
|
|
114
|
+
await reader.cancel();
|
|
115
|
+
firstRead = { done: true, value: undefined };
|
|
116
|
+
}
|
|
109
117
|
|
|
110
118
|
// Reconstruct the stream: prepend the buffered first chunk,
|
|
111
119
|
// then continue piping from the original reader.
|
|
@@ -16,8 +16,14 @@ import { logRenderError } from '#/server/logger.js';
|
|
|
16
16
|
import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js';
|
|
17
17
|
import { checkAndWarnRscPropError } from '#/server/rsc-prop-warnings.js';
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
createDebugChannelSink,
|
|
21
|
+
createDebugChannelCollector,
|
|
22
|
+
isAbortError,
|
|
23
|
+
type DebugComponentEntry,
|
|
24
|
+
} from './helpers.js';
|
|
20
25
|
import { isDebug } from '#/server/debug.js';
|
|
26
|
+
import { isDevMode } from '#/server/debug.js';
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
29
|
* Mutable signal state captured during RSC rendering.
|
|
@@ -40,6 +46,8 @@ export interface RenderSignals {
|
|
|
40
46
|
export interface RscStreamResult {
|
|
41
47
|
rscStream: ReadableStream<Uint8Array> | undefined;
|
|
42
48
|
signals: RenderSignals;
|
|
49
|
+
/** Dev-only: server component debug info from the Flight debug channel. */
|
|
50
|
+
getDebugComponents?: () => DebugComponentEntry[];
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
/**
|
|
@@ -62,6 +70,10 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
62
70
|
|
|
63
71
|
let rscStream: ReadableStream<Uint8Array> | undefined;
|
|
64
72
|
|
|
73
|
+
// In dev mode, collect debug channel data for the error overlay.
|
|
74
|
+
// In production, use the discard sink (no overhead).
|
|
75
|
+
const debugChannel = isDevMode() ? createDebugChannelCollector() : createDebugChannelSink();
|
|
76
|
+
|
|
65
77
|
try {
|
|
66
78
|
rscStream = renderToReadableStream(
|
|
67
79
|
element,
|
|
@@ -132,7 +144,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
132
144
|
}
|
|
133
145
|
logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
|
|
134
146
|
},
|
|
135
|
-
debugChannel
|
|
147
|
+
debugChannel,
|
|
136
148
|
},
|
|
137
149
|
{
|
|
138
150
|
onClientReference(info: { id: string; name: string; deps: unknown }) {
|
|
@@ -160,5 +172,14 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
|
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
174
|
|
|
163
|
-
return {
|
|
175
|
+
return {
|
|
176
|
+
rscStream,
|
|
177
|
+
signals,
|
|
178
|
+
// Expose the debug channel collector's getComponents in dev mode.
|
|
179
|
+
// The caller can retrieve component tree info when handling errors.
|
|
180
|
+
getDebugComponents:
|
|
181
|
+
'getComponents' in debugChannel
|
|
182
|
+
? (debugChannel as { getComponents: () => DebugComponentEntry[] }).getComponents
|
|
183
|
+
: undefined,
|
|
184
|
+
};
|
|
164
185
|
}
|
|
@@ -45,13 +45,12 @@ async function loadComponent(loader: {
|
|
|
45
45
|
*/
|
|
46
46
|
async function renderDefaultFallback(
|
|
47
47
|
slotNode: ManifestSegmentNode,
|
|
48
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
49
48
|
h: CreateElementFn
|
|
50
49
|
): Promise<React.ReactElement | null> {
|
|
51
50
|
if (!slotNode.default) return null;
|
|
52
51
|
const DefaultComp = await loadComponent(slotNode.default);
|
|
53
52
|
if (!DefaultComp) return null;
|
|
54
|
-
return h(DefaultComp, {
|
|
53
|
+
return h(DefaultComp, {});
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
// ─── Segment Tree Matching ──────────────────────────────────────────────────
|
|
@@ -153,7 +152,6 @@ function walkSegmentTree(
|
|
|
153
152
|
export async function resolveSlotElement(
|
|
154
153
|
slotNode: ManifestSegmentNode,
|
|
155
154
|
match: RouteMatch,
|
|
156
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
157
155
|
h: CreateElementFn,
|
|
158
156
|
interception?: InterceptionContext
|
|
159
157
|
): Promise<React.ReactElement | null> {
|
|
@@ -174,7 +172,7 @@ export async function resolveSlotElement(
|
|
|
174
172
|
// degrade to default.tsx or null — not crash the page. This matches
|
|
175
173
|
// Next.js behavior. See design/02-rendering-pipeline.md
|
|
176
174
|
// §"Slot Access Failure = Graceful Degradation"
|
|
177
|
-
const denyFallback = await renderDefaultFallback(slotNode,
|
|
175
|
+
const denyFallback = await renderDefaultFallback(slotNode, h);
|
|
178
176
|
|
|
179
177
|
// Wrap the slot page to catch DenySignal (from notFound() or deny())
|
|
180
178
|
// at the component level. This prevents the signal from reaching the
|
|
@@ -192,23 +190,21 @@ export async function resolveSlotElement(
|
|
|
192
190
|
}
|
|
193
191
|
};
|
|
194
192
|
|
|
195
|
-
let element: React.ReactElement = h(SafeSlotPage, {
|
|
196
|
-
params: paramsPromise,
|
|
197
|
-
});
|
|
193
|
+
let element: React.ReactElement = h(SafeSlotPage, {});
|
|
198
194
|
|
|
199
195
|
// Wrap with error boundaries and layouts from intermediate slot segments
|
|
200
196
|
// (everything between slot root and leaf). Process innermost-first, same
|
|
201
197
|
// order as route-element-builder.ts handles main segments. The slot root
|
|
202
198
|
// (index 0) is handled separately after the access gate below.
|
|
203
|
-
element = await wrapWithIntermediateSegments(slotMatch.chain, element,
|
|
199
|
+
element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
|
|
204
200
|
|
|
205
201
|
// Wrap in SlotAccessGate if slot root has access.ts.
|
|
206
202
|
// On denial: denied.tsx → default.tsx → null (graceful degradation).
|
|
207
203
|
// See design/04-authorization.md §"Slot-Level Auth".
|
|
208
|
-
element = await wrapWithAccessGate(slotNode, element,
|
|
204
|
+
element = await wrapWithAccessGate(slotNode, element, h);
|
|
209
205
|
|
|
210
206
|
// Wrap with slot root's layout (outermost, outside access gate)
|
|
211
|
-
element = await wrapWithLayout(slotNode, element,
|
|
207
|
+
element = await wrapWithLayout(slotNode, element, h);
|
|
212
208
|
|
|
213
209
|
// Wrap with slot root's error boundaries (outermost)
|
|
214
210
|
element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
|
|
@@ -231,7 +227,7 @@ export async function resolveSlotElement(
|
|
|
231
227
|
}
|
|
232
228
|
|
|
233
229
|
// No matching page — render default.tsx fallback
|
|
234
|
-
return renderDefaultFallback(slotNode,
|
|
230
|
+
return renderDefaultFallback(slotNode, h);
|
|
235
231
|
}
|
|
236
232
|
|
|
237
233
|
// ─── Element Wrapping Helpers ───────────────────────────────────────────────
|
|
@@ -244,13 +240,12 @@ export async function resolveSlotElement(
|
|
|
244
240
|
async function wrapWithIntermediateSegments(
|
|
245
241
|
chain: ManifestSegmentNode[],
|
|
246
242
|
element: React.ReactElement,
|
|
247
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
248
243
|
h: CreateElementFn
|
|
249
244
|
): Promise<React.ReactElement> {
|
|
250
245
|
for (let i = chain.length - 1; i > 0; i--) {
|
|
251
246
|
const seg = chain[i];
|
|
252
247
|
element = await wrapSegmentWithErrorBoundaries(seg, element, h);
|
|
253
|
-
element = await wrapWithLayout(seg, element,
|
|
248
|
+
element = await wrapWithLayout(seg, element, h);
|
|
254
249
|
}
|
|
255
250
|
return element;
|
|
256
251
|
}
|
|
@@ -261,13 +256,12 @@ async function wrapWithIntermediateSegments(
|
|
|
261
256
|
async function wrapWithLayout(
|
|
262
257
|
node: ManifestSegmentNode,
|
|
263
258
|
element: React.ReactElement,
|
|
264
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
265
259
|
h: CreateElementFn
|
|
266
260
|
): Promise<React.ReactElement> {
|
|
267
261
|
if (!node.layout) return element;
|
|
268
262
|
const Layout = await loadComponent(node.layout);
|
|
269
263
|
if (!Layout) return element;
|
|
270
|
-
return h(Layout, {
|
|
264
|
+
return h(Layout, { children: element });
|
|
271
265
|
}
|
|
272
266
|
|
|
273
267
|
/**
|
|
@@ -277,7 +271,6 @@ async function wrapWithLayout(
|
|
|
277
271
|
async function wrapWithAccessGate(
|
|
278
272
|
slotNode: ManifestSegmentNode,
|
|
279
273
|
element: React.ReactElement,
|
|
280
|
-
paramsPromise: Promise<Record<string, string | string[]>>,
|
|
281
274
|
h: CreateElementFn
|
|
282
275
|
): Promise<React.ReactElement> {
|
|
283
276
|
if (!slotNode.access) return element;
|
|
@@ -295,12 +288,10 @@ async function wrapWithAccessGate(
|
|
|
295
288
|
// Extract slot name from the directory name (strip @ prefix)
|
|
296
289
|
const slotName = slotNode.segmentName?.replace(/^@/, '') ?? '';
|
|
297
290
|
|
|
298
|
-
const defaultFallback = await renderDefaultFallback(slotNode,
|
|
299
|
-
const params = await paramsPromise;
|
|
291
|
+
const defaultFallback = await renderDefaultFallback(slotNode, h);
|
|
300
292
|
|
|
301
293
|
return h(SlotAccessGate, {
|
|
302
294
|
accessFn,
|
|
303
|
-
params,
|
|
304
295
|
DeniedComponent,
|
|
305
296
|
slotName,
|
|
306
297
|
createElement: h,
|
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"
|
|
@@ -46,8 +46,13 @@ export type SlotElements = Map<string, ReactElement>;
|
|
|
46
46
|
export interface TreeBuilderConfig {
|
|
47
47
|
/** The matched segment chain from root to leaf. */
|
|
48
48
|
segments: SegmentNode[];
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Route params extracted by the matcher (catch-all segments produce string[]).
|
|
51
|
+
* @deprecated Params are now accessed via rawSegmentParams() from ALS.
|
|
52
|
+
* This field is kept for backward compatibility but is no longer used
|
|
53
|
+
* by the tree builder itself.
|
|
54
|
+
*/
|
|
55
|
+
params?: Record<string, string | string[]>;
|
|
51
56
|
/** Loads a route file's module. */
|
|
52
57
|
loadModule: ModuleLoader;
|
|
53
58
|
/** React.createElement or equivalent. */
|
|
@@ -76,7 +81,6 @@ export interface TreeBuilderConfig {
|
|
|
76
81
|
*/
|
|
77
82
|
export interface AccessGateProps {
|
|
78
83
|
accessFn: (ctx: { params: Record<string, string | string[]> }) => unknown;
|
|
79
|
-
params: Record<string, string | string[]>;
|
|
80
84
|
/** Segment name for dev logging (e.g. "authenticated", "dashboard"). */
|
|
81
85
|
segmentName?: string;
|
|
82
86
|
/**
|
|
@@ -102,7 +106,6 @@ export interface AccessGateProps {
|
|
|
102
106
|
*/
|
|
103
107
|
export interface SlotAccessGateProps {
|
|
104
108
|
accessFn: (ctx: { params: Record<string, string | string[]> }) => unknown;
|
|
105
|
-
params: Record<string, string | string[]>;
|
|
106
109
|
/** The denied.tsx component (not a pre-built element). null if no denied.tsx exists. */
|
|
107
110
|
DeniedComponent: ((...args: unknown[]) => unknown) | null;
|
|
108
111
|
/** Slot directory name without @ prefix (e.g. "admin", "sidebar"). */
|
|
@@ -149,7 +152,7 @@ export interface TreeBuildResult {
|
|
|
149
152
|
* Parallel slots are resolved at each layout level and composed as named props.
|
|
150
153
|
*/
|
|
151
154
|
export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeBuildResult> {
|
|
152
|
-
const { segments,
|
|
155
|
+
const { segments, loadModule, createElement, errorBoundaryComponent } = config;
|
|
153
156
|
|
|
154
157
|
if (segments.length === 0) {
|
|
155
158
|
throw new Error('[timber] buildElementTree: empty segment chain');
|
|
@@ -173,8 +176,8 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
173
176
|
);
|
|
174
177
|
}
|
|
175
178
|
|
|
176
|
-
// Build the page element
|
|
177
|
-
let element: ReactElement = createElement(PageComponent, {
|
|
179
|
+
// Build the page element — params are accessed via rawSegmentParams() from ALS
|
|
180
|
+
let element: ReactElement = createElement(PageComponent, {});
|
|
178
181
|
|
|
179
182
|
// Build tree bottom-up: wrap page, then walk segments from leaf to root
|
|
180
183
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
@@ -195,7 +198,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
195
198
|
const accessFn = accessModule.default as AccessGateProps['accessFn'];
|
|
196
199
|
element = createElement('timber:access-gate', {
|
|
197
200
|
accessFn,
|
|
198
|
-
params,
|
|
199
201
|
segmentName: segment.segmentName,
|
|
200
202
|
children: element,
|
|
201
203
|
} satisfies AccessGateProps);
|
|
@@ -215,7 +217,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
215
217
|
for (const [slotName, slotNode] of segment.slots) {
|
|
216
218
|
slotProps[slotName] = await buildSlotElement(
|
|
217
219
|
slotNode,
|
|
218
|
-
params,
|
|
219
220
|
loadModule,
|
|
220
221
|
createElement,
|
|
221
222
|
errorBoundaryComponent
|
|
@@ -225,7 +226,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
225
226
|
|
|
226
227
|
element = createElement(LayoutComponent, {
|
|
227
228
|
...slotProps,
|
|
228
|
-
params,
|
|
229
229
|
children: element,
|
|
230
230
|
});
|
|
231
231
|
}
|
|
@@ -245,7 +245,6 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
245
245
|
*/
|
|
246
246
|
async function buildSlotElement(
|
|
247
247
|
slotNode: SegmentNode,
|
|
248
|
-
params: Record<string, string | string[]>,
|
|
249
248
|
loadModule: ModuleLoader,
|
|
250
249
|
createElement: CreateElement,
|
|
251
250
|
errorBoundaryComponent: unknown
|
|
@@ -262,10 +261,10 @@ async function buildSlotElement(
|
|
|
262
261
|
|
|
263
262
|
// If no page, render default.tsx or null
|
|
264
263
|
if (!PageComponent) {
|
|
265
|
-
return DefaultComponent ? createElement(DefaultComponent, {
|
|
264
|
+
return DefaultComponent ? createElement(DefaultComponent, {}) : null;
|
|
266
265
|
}
|
|
267
266
|
|
|
268
|
-
let element: ReactElement = createElement(PageComponent, {
|
|
267
|
+
let element: ReactElement = createElement(PageComponent, {});
|
|
269
268
|
|
|
270
269
|
// Wrap in error boundaries
|
|
271
270
|
element = await wrapWithErrorBoundaries(
|
|
@@ -287,11 +286,10 @@ async function buildSlotElement(
|
|
|
287
286
|
const DeniedComponent =
|
|
288
287
|
(deniedModule?.default as ((...args: unknown[]) => ReactElement) | undefined) ?? null;
|
|
289
288
|
|
|
290
|
-
const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {
|
|
289
|
+
const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {}) : null;
|
|
291
290
|
|
|
292
291
|
element = createElement('timber:slot-access-gate', {
|
|
293
292
|
accessFn,
|
|
294
|
-
params,
|
|
295
293
|
DeniedComponent,
|
|
296
294
|
slotName: slotNode.segmentName.replace(/^@/, ''),
|
|
297
295
|
createElement,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility for merging preserved search params into a target URL.
|
|
3
|
+
*
|
|
4
|
+
* Used by both <Link> (client) and redirect() (server). Extracted to a shared
|
|
5
|
+
* module to avoid importing client code ('use client') from server modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Merge preserved search params from the current URL into a target href.
|
|
10
|
+
*
|
|
11
|
+
* When `preserve` is `true`, all current search params are merged.
|
|
12
|
+
* When `preserve` is a `string[]`, only the named params are merged.
|
|
13
|
+
*
|
|
14
|
+
* The target href's own search params take precedence — preserved params
|
|
15
|
+
* are only added if the target doesn't already define them.
|
|
16
|
+
*
|
|
17
|
+
* @param targetHref - The resolved target href (may already contain query string)
|
|
18
|
+
* @param currentSearch - The current URL's search string (e.g. "?private=access&page=2")
|
|
19
|
+
* @param preserve - `true` to preserve all, or `string[]` to preserve specific params
|
|
20
|
+
* @returns The target href with preserved search params merged in
|
|
21
|
+
*/
|
|
22
|
+
export function mergePreservedSearchParams(
|
|
23
|
+
targetHref: string,
|
|
24
|
+
currentSearch: string,
|
|
25
|
+
preserve: true | string[]
|
|
26
|
+
): string {
|
|
27
|
+
const currentParams = new URLSearchParams(currentSearch);
|
|
28
|
+
if (currentParams.size === 0) return targetHref;
|
|
29
|
+
|
|
30
|
+
// Split target into path and existing query
|
|
31
|
+
const qIdx = targetHref.indexOf('?');
|
|
32
|
+
const targetPath = qIdx >= 0 ? targetHref.slice(0, qIdx) : targetHref;
|
|
33
|
+
const targetParams = new URLSearchParams(qIdx >= 0 ? targetHref.slice(qIdx + 1) : '');
|
|
34
|
+
|
|
35
|
+
// Collect params to preserve (that aren't already in the target)
|
|
36
|
+
const merged = new URLSearchParams(targetParams);
|
|
37
|
+
for (const [key, value] of currentParams) {
|
|
38
|
+
// Only preserve if: (a) not already in target, and (b) included in preserve list
|
|
39
|
+
if (!targetParams.has(key)) {
|
|
40
|
+
if (preserve === true || preserve.includes(key)) {
|
|
41
|
+
merged.append(key, value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const qs = merged.toString();
|
|
47
|
+
return qs ? `${targetPath}?${qs}` : targetPath;
|
|
48
|
+
}
|