@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
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type FlightInjectionState,
|
|
31
31
|
type FlightInjectionEvent,
|
|
32
32
|
} from './flight-injection-state.js';
|
|
33
|
+
import { withTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
33
34
|
|
|
34
35
|
// ─── Head Injection ──────────────────────────────────────────────────────────
|
|
35
36
|
|
|
@@ -108,8 +109,22 @@ export function createNodeHeadInjector(headHtml: string): Transform {
|
|
|
108
109
|
* stream). We read from it using the Web API — this is the one bridge
|
|
109
110
|
* point between Web Streams and Node.js streams in the pipeline.
|
|
110
111
|
*/
|
|
112
|
+
/**
|
|
113
|
+
* Options for the Node.js flight injector.
|
|
114
|
+
*/
|
|
115
|
+
export interface NodeFlightInjectorOptions {
|
|
116
|
+
/**
|
|
117
|
+
* Timeout in milliseconds for individual RSC stream reads.
|
|
118
|
+
* If a single `rscReader.read()` call does not resolve within
|
|
119
|
+
* this duration, the read is aborted and the stream errors with
|
|
120
|
+
* a RenderTimeoutError. Default: 30000 (30s).
|
|
121
|
+
*/
|
|
122
|
+
renderTimeoutMs?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
export function createNodeFlightInjector(
|
|
112
|
-
rscStream: ReadableStream<Uint8Array> | undefined
|
|
126
|
+
rscStream: ReadableStream<Uint8Array> | undefined,
|
|
127
|
+
options?: NodeFlightInjectorOptions
|
|
113
128
|
): Transform {
|
|
114
129
|
if (!rscStream) {
|
|
115
130
|
return new Transform({
|
|
@@ -119,6 +134,7 @@ export function createNodeFlightInjector(
|
|
|
119
134
|
});
|
|
120
135
|
}
|
|
121
136
|
|
|
137
|
+
const timeoutMs = options?.renderTimeoutMs ?? 30_000;
|
|
122
138
|
const suffix = '</body></html>';
|
|
123
139
|
const suffixBuf = Buffer.from(suffix, 'utf-8');
|
|
124
140
|
const rscReader = rscStream.getReader();
|
|
@@ -129,6 +145,11 @@ export function createNodeFlightInjector(
|
|
|
129
145
|
transitions: flightInjectionTransitions,
|
|
130
146
|
});
|
|
131
147
|
|
|
148
|
+
// Stored promise from pullLoop — awaited in flush() via .then()
|
|
149
|
+
// instead of polling. Matches the Web Streams pattern in
|
|
150
|
+
// html-injectors.ts (pullPromise.then(finish)).
|
|
151
|
+
let pullPromise: Promise<void> | null = null;
|
|
152
|
+
|
|
132
153
|
// pullLoop reads RSC chunks and pushes them directly to the transform
|
|
133
154
|
// output as <script> tags. This ensures RSC data is delivered to the
|
|
134
155
|
// browser as soon as it's available — not deferred until the next HTML
|
|
@@ -141,7 +162,16 @@ export function createNodeFlightInjector(
|
|
|
141
162
|
await new Promise<void>((r) => setImmediate(r));
|
|
142
163
|
try {
|
|
143
164
|
for (;;) {
|
|
144
|
-
|
|
165
|
+
// Guard each RSC read with a timeout so a permanently hung
|
|
166
|
+
// RSC stream (e.g. a Suspense component with a fetch that
|
|
167
|
+
// never resolves) eventually aborts instead of blocking
|
|
168
|
+
// forever. When timeoutMs <= 0, the guard is disabled.
|
|
169
|
+
// See design/02-rendering-pipeline.md §"Streaming Constraints".
|
|
170
|
+
const readPromise = rscReader.read();
|
|
171
|
+
const { done, value } =
|
|
172
|
+
timeoutMs > 0
|
|
173
|
+
? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
|
|
174
|
+
: await readPromise;
|
|
145
175
|
if (done) {
|
|
146
176
|
machine.send({ type: 'PULL_DONE' });
|
|
147
177
|
return;
|
|
@@ -160,6 +190,10 @@ export function createNodeFlightInjector(
|
|
|
160
190
|
}
|
|
161
191
|
}
|
|
162
192
|
} catch (err) {
|
|
193
|
+
// On timeout, cancel the RSC reader to release resources.
|
|
194
|
+
if (err instanceof RenderTimeoutError) {
|
|
195
|
+
rscReader.cancel(err).catch(() => {});
|
|
196
|
+
}
|
|
163
197
|
machine.send({ type: 'PULL_ERROR', error: err });
|
|
164
198
|
}
|
|
165
199
|
}
|
|
@@ -196,8 +230,9 @@ export function createNodeFlightInjector(
|
|
|
196
230
|
// Start the pull loop on the first HTML chunk to stream RSC
|
|
197
231
|
// data chunks alongside the HTML. The __timber_f init script is
|
|
198
232
|
// already in <head> (via flightInitScript), so no bootstrap needed.
|
|
233
|
+
// Store the promise so flush() can await it instead of polling.
|
|
199
234
|
if (isFirst) {
|
|
200
|
-
pullLoop(transform);
|
|
235
|
+
pullPromise = pullLoop(transform);
|
|
201
236
|
}
|
|
202
237
|
callback();
|
|
203
238
|
},
|
|
@@ -226,16 +261,17 @@ export function createNodeFlightInjector(
|
|
|
226
261
|
finish();
|
|
227
262
|
return;
|
|
228
263
|
}
|
|
229
|
-
// Wait for the RSC
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
264
|
+
// Wait for the RSC pull loop promise to resolve instead of
|
|
265
|
+
// polling with setImmediate. This matches the Web Streams
|
|
266
|
+
// pattern in html-injectors.ts: `pullPromise.then(finish)`.
|
|
267
|
+
// No CPU spin, no busy-poll — just a Promise chain.
|
|
268
|
+
if (!pullPromise) {
|
|
269
|
+
pullPromise = pullLoop(transform);
|
|
270
|
+
}
|
|
271
|
+
pullPromise.then(finish, (err) => {
|
|
272
|
+
machine.send({ type: 'PULL_ERROR', error: err });
|
|
273
|
+
finish();
|
|
274
|
+
});
|
|
239
275
|
},
|
|
240
276
|
});
|
|
241
277
|
|
package/src/server/pipeline.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
setMutableCookieContext,
|
|
22
22
|
getSetCookieHeaders,
|
|
23
23
|
markResponseFlushed,
|
|
24
|
+
setSegmentParams,
|
|
24
25
|
} from './request-context.js';
|
|
25
26
|
import {
|
|
26
27
|
generateTraceId,
|
|
@@ -484,6 +485,11 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
484
485
|
throw error;
|
|
485
486
|
}
|
|
486
487
|
|
|
488
|
+
// Store coerced segment params in ALS so components can access them
|
|
489
|
+
// via rawSegmentParams() instead of receiving them as a prop.
|
|
490
|
+
// See design/07-routing.md §"params.ts — Convention File for Typed Params"
|
|
491
|
+
setSegmentParams(match.params);
|
|
492
|
+
|
|
487
493
|
// Stage 3: Leaf middleware.ts (only the leaf route's middleware runs)
|
|
488
494
|
if (match.middleware) {
|
|
489
495
|
const ctx: MiddlewareContext = {
|
package/src/server/primitives.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import type { JsonSerializable } from './types.js';
|
|
7
7
|
import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
|
|
8
8
|
import { isDebug } from './debug.js';
|
|
9
|
+
import { getRequestSearchString } from './request-context.js';
|
|
10
|
+
import { mergePreservedSearchParams } from '#/shared/merge-search-params.js';
|
|
9
11
|
|
|
10
12
|
// ─── Dev-mode validation ────────────────────────────────────────────────────
|
|
11
13
|
|
|
@@ -209,14 +211,46 @@ export class RedirectSignal extends Error {
|
|
|
209
211
|
/** Pattern matching absolute URLs: http(s):// or protocol-relative // */
|
|
210
212
|
const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
211
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Options for redirect() — alternative to passing a bare status code.
|
|
216
|
+
*/
|
|
217
|
+
export interface RedirectOptions {
|
|
218
|
+
/** HTTP redirect status code (3xx). Defaults to 302. */
|
|
219
|
+
status?: number;
|
|
220
|
+
/**
|
|
221
|
+
* Preserve search params from the current request URL on the redirect target.
|
|
222
|
+
*
|
|
223
|
+
* - `true` — preserve ALL current search params (target params take precedence)
|
|
224
|
+
* - `string[]` — preserve only the named params (e.g. `['private', 'token']`)
|
|
225
|
+
*
|
|
226
|
+
* Target path's own query params always take precedence over preserved ones.
|
|
227
|
+
*/
|
|
228
|
+
preserveSearchParams?: true | string[];
|
|
229
|
+
}
|
|
230
|
+
|
|
212
231
|
/**
|
|
213
232
|
* Redirect to a relative path. Rejects absolute and protocol-relative URLs.
|
|
214
233
|
* Use `redirectExternal()` for external redirects with an allow-list.
|
|
215
234
|
*
|
|
216
235
|
* @param path - Relative path (e.g. '/login', 'settings', '/login?returnTo=/dash')
|
|
217
|
-
* @param
|
|
236
|
+
* @param statusOrOptions - HTTP status code (3xx, default 302) or options object.
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* // Simple redirect
|
|
240
|
+
* redirect('/login');
|
|
241
|
+
*
|
|
242
|
+
* // With status code
|
|
243
|
+
* redirect('/login', 301);
|
|
244
|
+
*
|
|
245
|
+
* // With preserved search params
|
|
246
|
+
* redirect(`/docs/${version}/${slug}`, { preserveSearchParams: ['foo'] });
|
|
218
247
|
*/
|
|
219
|
-
export function redirect(path: string,
|
|
248
|
+
export function redirect(path: string, statusOrOptions?: number | RedirectOptions): never {
|
|
249
|
+
const status =
|
|
250
|
+
typeof statusOrOptions === 'number' ? statusOrOptions : (statusOrOptions?.status ?? 302);
|
|
251
|
+
const preserveSearchParams =
|
|
252
|
+
typeof statusOrOptions === 'object' ? statusOrOptions.preserveSearchParams : undefined;
|
|
253
|
+
|
|
220
254
|
if (status < 300 || status > 399) {
|
|
221
255
|
throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
|
|
222
256
|
}
|
|
@@ -226,7 +260,14 @@ export function redirect(path: string, status: number = 302): never {
|
|
|
226
260
|
'Use redirectExternal(url, allowList) for external redirects.'
|
|
227
261
|
);
|
|
228
262
|
}
|
|
229
|
-
|
|
263
|
+
|
|
264
|
+
let resolvedPath = path;
|
|
265
|
+
if (preserveSearchParams) {
|
|
266
|
+
const currentSearch = getRequestSearchString();
|
|
267
|
+
resolvedPath = mergePreservedSearchParams(path, currentSearch, preserveSearchParams);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
throw new RedirectSignal(resolvedPath, status);
|
|
230
271
|
}
|
|
231
272
|
|
|
232
273
|
/**
|
|
@@ -236,9 +277,10 @@ export function redirect(path: string, status: number = 302): never {
|
|
|
236
277
|
* will replay POST requests to the new location. This matches Next.js behavior.
|
|
237
278
|
*
|
|
238
279
|
* @param path - Relative path (e.g. '/new-page', '/dashboard')
|
|
280
|
+
* @param options - Optional redirect options (e.g. preserveSearchParams).
|
|
239
281
|
*/
|
|
240
|
-
export function permanentRedirect(path: string): never {
|
|
241
|
-
redirect(path, 308);
|
|
282
|
+
export function permanentRedirect(path: string, options?: Omit<RedirectOptions, 'status'>): never {
|
|
283
|
+
redirect(path, { status: 308, ...options });
|
|
242
284
|
}
|
|
243
285
|
|
|
244
286
|
/**
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render timeout utilities for SSR streaming pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Provides a RenderTimeoutError class and a helper to create
|
|
5
|
+
* timeout-guarded AbortSignals. Used to defend against hung RSC
|
|
6
|
+
* streams and infinite SSR renders.
|
|
7
|
+
*
|
|
8
|
+
* Design doc: 02-rendering-pipeline.md §"Streaming Constraints"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Error thrown when an SSR render or RSC stream read exceeds the
|
|
13
|
+
* configured timeout. Callers can check `instanceof RenderTimeoutError`
|
|
14
|
+
* to distinguish timeout from other errors and return a 504 or close
|
|
15
|
+
* the connection cleanly.
|
|
16
|
+
*/
|
|
17
|
+
export class RenderTimeoutError extends Error {
|
|
18
|
+
readonly timeoutMs: number;
|
|
19
|
+
|
|
20
|
+
constructor(timeoutMs: number, context?: string) {
|
|
21
|
+
const message = context
|
|
22
|
+
? `Render timeout after ${timeoutMs}ms: ${context}`
|
|
23
|
+
: `Render timeout after ${timeoutMs}ms`;
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'RenderTimeoutError';
|
|
26
|
+
this.timeoutMs = timeoutMs;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Result of createRenderTimeout — an AbortSignal that fires after
|
|
32
|
+
* the given duration, plus a cancel function to clear the timer
|
|
33
|
+
* when the render completes normally.
|
|
34
|
+
*/
|
|
35
|
+
export interface RenderTimeout {
|
|
36
|
+
/** AbortSignal that aborts after timeoutMs. */
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
/** Cancel the timeout timer. Call this when the render completes. */
|
|
39
|
+
cancel: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a render timeout that aborts after the given duration.
|
|
44
|
+
*
|
|
45
|
+
* Returns an AbortSignal and a cancel function. The signal fires
|
|
46
|
+
* with a RenderTimeoutError as the abort reason after `timeoutMs`.
|
|
47
|
+
* Call `cancel()` when the render completes to prevent the timeout
|
|
48
|
+
* from firing.
|
|
49
|
+
*
|
|
50
|
+
* If an existing `parentSignal` is provided, the returned signal
|
|
51
|
+
* aborts when either the parent signal or the timeout fires —
|
|
52
|
+
* whichever comes first.
|
|
53
|
+
*/
|
|
54
|
+
export function createRenderTimeout(timeoutMs: number, parentSignal?: AbortSignal): RenderTimeout {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const reason = new RenderTimeoutError(timeoutMs, 'RSC stream read timed out');
|
|
57
|
+
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
controller.abort(reason);
|
|
60
|
+
}, timeoutMs);
|
|
61
|
+
|
|
62
|
+
// If there's a parent signal (e.g. request abort), chain it
|
|
63
|
+
if (parentSignal) {
|
|
64
|
+
if (parentSignal.aborted) {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
controller.abort(parentSignal.reason);
|
|
67
|
+
} else {
|
|
68
|
+
parentSignal.addEventListener(
|
|
69
|
+
'abort',
|
|
70
|
+
() => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
controller.abort(parentSignal.reason);
|
|
73
|
+
},
|
|
74
|
+
{ once: true }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
cancel: () => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Race a promise against a timeout. Rejects with RenderTimeoutError
|
|
89
|
+
* if the promise does not resolve within `timeoutMs`.
|
|
90
|
+
*
|
|
91
|
+
* Used to guard individual `rscReader.read()` calls inside pullLoop.
|
|
92
|
+
*/
|
|
93
|
+
export function withTimeout<T>(
|
|
94
|
+
promise: Promise<T>,
|
|
95
|
+
timeoutMs: number,
|
|
96
|
+
context?: string
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
99
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
100
|
+
timer = setTimeout(() => {
|
|
101
|
+
reject(new RenderTimeoutError(timeoutMs, context));
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
106
|
+
clearTimeout(timer!);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -178,6 +178,72 @@ export function rawSearchParams(): Promise<URLSearchParams> {
|
|
|
178
178
|
return store.searchParamsPromise;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Returns a Promise resolving to the current request's coerced segment params.
|
|
183
|
+
*
|
|
184
|
+
* Segment params are set by the pipeline after route matching and param
|
|
185
|
+
* coercion (via params.ts codecs). When no params.ts exists, values are
|
|
186
|
+
* raw strings. When codecs are defined, values are already coerced
|
|
187
|
+
* (e.g., `id` is a `number` if `defineSegmentParams({ id: z.coerce.number() })`).
|
|
188
|
+
*
|
|
189
|
+
* This is the primary way page and layout components access route params:
|
|
190
|
+
*
|
|
191
|
+
* ```ts
|
|
192
|
+
* import { rawSegmentParams } from '@timber-js/app/server'
|
|
193
|
+
*
|
|
194
|
+
* export default async function Page() {
|
|
195
|
+
* const { slug } = await rawSegmentParams()
|
|
196
|
+
* // ...
|
|
197
|
+
* }
|
|
198
|
+
* ```
|
|
199
|
+
*
|
|
200
|
+
* Throws if called outside a request context.
|
|
201
|
+
*/
|
|
202
|
+
export function rawSegmentParams(): Promise<Record<string, string | string[]>> {
|
|
203
|
+
const store = requestContextAls.getStore();
|
|
204
|
+
if (!store) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
'[timber] rawSegmentParams() called outside of a request context. ' +
|
|
207
|
+
'It can only be used in middleware, access checks, server components, and server actions.'
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (!store.segmentParamsPromise) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
'[timber] rawSegmentParams() called before route matching completed. ' +
|
|
213
|
+
'Segment params are not available until after the route is matched.'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return store.segmentParamsPromise;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Set the segment params promise on the current request context.
|
|
221
|
+
* Called by the pipeline after route matching and param coercion.
|
|
222
|
+
*
|
|
223
|
+
* @internal — framework use only
|
|
224
|
+
*/
|
|
225
|
+
export function setSegmentParams(params: Record<string, string | string[]>): void {
|
|
226
|
+
const store = requestContextAls.getStore();
|
|
227
|
+
if (!store) {
|
|
228
|
+
throw new Error('[timber] setSegmentParams() called outside of a request context.');
|
|
229
|
+
}
|
|
230
|
+
store.segmentParamsPromise = Promise.resolve(params);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns the raw search string from the current request URL (e.g. "?foo=bar").
|
|
235
|
+
* Synchronous — safe for use in `redirect()` which throws synchronously.
|
|
236
|
+
*
|
|
237
|
+
* Returns empty string if called outside a request context (non-throwing for
|
|
238
|
+
* use in redirect's optional preserveSearchParams path).
|
|
239
|
+
*
|
|
240
|
+
* @internal — used by redirect() for preserveSearchParams support.
|
|
241
|
+
*/
|
|
242
|
+
export function getRequestSearchString(): string {
|
|
243
|
+
const store = requestContextAls.getStore();
|
|
244
|
+
return store?.searchString ?? '';
|
|
245
|
+
}
|
|
246
|
+
|
|
181
247
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
182
248
|
|
|
183
249
|
/**
|
|
@@ -253,11 +319,13 @@ export interface RequestCookies {
|
|
|
253
319
|
*/
|
|
254
320
|
export function runWithRequestContext<T>(req: Request, fn: () => T): T {
|
|
255
321
|
const originalCopy = new Headers(req.headers);
|
|
322
|
+
const parsedUrl = new URL(req.url);
|
|
256
323
|
const store: RequestContextStore = {
|
|
257
324
|
headers: freezeHeaders(req.headers),
|
|
258
325
|
originalHeaders: originalCopy,
|
|
259
326
|
cookieHeader: req.headers.get('cookie') ?? '',
|
|
260
|
-
searchParamsPromise: Promise.resolve(
|
|
327
|
+
searchParamsPromise: Promise.resolve(parsedUrl.searchParams),
|
|
328
|
+
searchString: parsedUrl.search,
|
|
261
329
|
cookieJar: new Map(),
|
|
262
330
|
flushed: false,
|
|
263
331
|
mutableContext: false,
|
|
@@ -110,7 +110,7 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
|
|
|
110
110
|
` // Before\n` +
|
|
111
111
|
` export async function generateMetadata({ params }) { ... }\n\n` +
|
|
112
112
|
` // After\n` +
|
|
113
|
-
` export async function metadata(
|
|
113
|
+
` export async function metadata() { ... }`
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
@@ -119,19 +119,21 @@ function rejectLegacyGenerateMetadata(mod: Record<string, unknown>, filePath: st
|
|
|
119
119
|
* Extract and resolve metadata from a module (layout or page).
|
|
120
120
|
* Handles both static metadata objects and async metadata functions.
|
|
121
121
|
* Returns the resolved Metadata, or null if none exported.
|
|
122
|
+
*
|
|
123
|
+
* Metadata functions no longer receive { params } — they access params
|
|
124
|
+
* via rawSegmentParams() from ALS, same as page/layout components.
|
|
122
125
|
*/
|
|
123
126
|
async function extractMetadata(
|
|
124
127
|
mod: Record<string, unknown>,
|
|
125
|
-
segment: ManifestSegmentNode
|
|
126
|
-
paramsPromise: Promise<Record<string, string | string[]>>
|
|
128
|
+
segment: ManifestSegmentNode
|
|
127
129
|
): Promise<Metadata | null> {
|
|
128
130
|
if (typeof mod.metadata === 'function') {
|
|
129
|
-
type MetadataFn = (
|
|
131
|
+
type MetadataFn = () => Promise<Metadata>;
|
|
130
132
|
return (
|
|
131
133
|
(await withSpan(
|
|
132
134
|
'timber.metadata',
|
|
133
135
|
{ 'timber.segment': segment.segmentName ?? segment.urlPath },
|
|
134
|
-
() => (mod.metadata as MetadataFn)(
|
|
136
|
+
() => (mod.metadata as MetadataFn)()
|
|
135
137
|
)) ?? null
|
|
136
138
|
);
|
|
137
139
|
}
|
|
@@ -172,9 +174,6 @@ export async function buildRouteElement(
|
|
|
172
174
|
): Promise<RouteElementResult> {
|
|
173
175
|
const segments = match.segments as unknown as ManifestSegmentNode[];
|
|
174
176
|
|
|
175
|
-
// Params are passed as a Promise to match Next.js 15+ convention.
|
|
176
|
-
const paramsPromise = Promise.resolve(match.params);
|
|
177
|
-
|
|
178
177
|
// Load all modules along the segment chain
|
|
179
178
|
const metadataEntries: Array<{ metadata: Metadata; isPage: boolean }> = [];
|
|
180
179
|
const layoutComponents: LayoutComponentEntry[] = [];
|
|
@@ -199,7 +198,7 @@ export async function buildRouteElement(
|
|
|
199
198
|
// middleware and rendering. See coerceSegmentParams() in pipeline.ts.
|
|
200
199
|
|
|
201
200
|
rejectLegacyGenerateMetadata(mod, segment.layout.filePath ?? segment.urlPath);
|
|
202
|
-
const layoutMetadata = await extractMetadata(mod, segment
|
|
201
|
+
const layoutMetadata = await extractMetadata(mod, segment);
|
|
203
202
|
if (layoutMetadata) {
|
|
204
203
|
metadataEntries.push({ metadata: layoutMetadata, isPage: false });
|
|
205
204
|
}
|
|
@@ -217,7 +216,7 @@ export async function buildRouteElement(
|
|
|
217
216
|
PageComponent = mod.default as (...args: unknown[]) => unknown;
|
|
218
217
|
}
|
|
219
218
|
rejectLegacyGenerateMetadata(mod, segment.page.filePath ?? segment.urlPath);
|
|
220
|
-
const pageMetadata = await extractMetadata(mod, segment
|
|
219
|
+
const pageMetadata = await extractMetadata(mod, segment);
|
|
221
220
|
if (pageMetadata) {
|
|
222
221
|
metadataEntries.push({ metadata: pageMetadata, isPage: true });
|
|
223
222
|
}
|
|
@@ -317,9 +316,7 @@ export async function buildRouteElement(
|
|
|
317
316
|
);
|
|
318
317
|
};
|
|
319
318
|
|
|
320
|
-
let element = h(TracedPage, {
|
|
321
|
-
params: paramsPromise,
|
|
322
|
-
});
|
|
319
|
+
let element = h(TracedPage, {});
|
|
323
320
|
|
|
324
321
|
// Build a lookup of layout components by segment for O(1) access.
|
|
325
322
|
const layoutBySegment = new Map(
|
|
@@ -399,7 +396,6 @@ export async function buildRouteElement(
|
|
|
399
396
|
if (accessFn) {
|
|
400
397
|
element = h(AccessGate, {
|
|
401
398
|
accessFn,
|
|
402
|
-
params: match.params,
|
|
403
399
|
segmentName: segment.segmentName,
|
|
404
400
|
verdict: accessVerdicts.get(i),
|
|
405
401
|
children: element,
|
|
@@ -416,7 +412,6 @@ export async function buildRouteElement(
|
|
|
416
412
|
slotProps[slotName] = await resolveSlotElement(
|
|
417
413
|
slotNode as ManifestSegmentNode,
|
|
418
414
|
match,
|
|
419
|
-
paramsPromise,
|
|
420
415
|
h,
|
|
421
416
|
interception
|
|
422
417
|
);
|
|
@@ -447,7 +442,6 @@ export async function buildRouteElement(
|
|
|
447
442
|
parallelRouteKeys,
|
|
448
443
|
children: h(TracedLayout, {
|
|
449
444
|
...slotProps,
|
|
450
|
-
params: paramsPromise,
|
|
451
445
|
children: element,
|
|
452
446
|
}),
|
|
453
447
|
});
|
|
@@ -20,9 +20,6 @@ export const RSC_CONTENT_TYPE = 'text/x-component';
|
|
|
20
20
|
* stream that we drain and discard.
|
|
21
21
|
*
|
|
22
22
|
* See design/13-security.md §"Server component source leak"
|
|
23
|
-
*
|
|
24
|
-
* TODO: In the future, expose this debug data to the browser in dev mode
|
|
25
|
-
* for inline error overlays (e.g. component stack traces).
|
|
26
23
|
*/
|
|
27
24
|
export function createDebugChannelSink(): { readable: ReadableStream; writable: WritableStream } {
|
|
28
25
|
const sink = new TransformStream();
|
|
@@ -34,6 +31,128 @@ export function createDebugChannelSink(): { readable: ReadableStream; writable:
|
|
|
34
31
|
};
|
|
35
32
|
}
|
|
36
33
|
|
|
34
|
+
// ─── Debug Channel Collector (dev mode only) ────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parsed component debug info extracted from the Flight debug channel.
|
|
38
|
+
*
|
|
39
|
+
* Contains only component names, environment labels, and stack frames —
|
|
40
|
+
* never source code or props. See design/13-security.md §"Server source
|
|
41
|
+
* never reaches the client".
|
|
42
|
+
*/
|
|
43
|
+
export interface DebugComponentEntry {
|
|
44
|
+
name: string;
|
|
45
|
+
env: string | null;
|
|
46
|
+
key: string | null;
|
|
47
|
+
stack: unknown[] | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A debug channel that collects Flight debug rows instead of discarding them.
|
|
52
|
+
*
|
|
53
|
+
* Used in dev mode to capture server component tree information for the
|
|
54
|
+
* Vite error overlay. The collector provides the same `{ readable, writable }`
|
|
55
|
+
* shape as the discard sink, plus methods to retrieve collected data.
|
|
56
|
+
*
|
|
57
|
+
* Security: only component names, environments, and stack frames are
|
|
58
|
+
* extracted — props and source code are stripped. In production builds,
|
|
59
|
+
* use `createDebugChannelSink()` instead (this function is never called).
|
|
60
|
+
*/
|
|
61
|
+
export interface DebugChannelCollector {
|
|
62
|
+
readable: ReadableStream;
|
|
63
|
+
writable: WritableStream;
|
|
64
|
+
/** Get the raw collected text from the debug channel. */
|
|
65
|
+
getCollectedText(): string;
|
|
66
|
+
/** Get parsed component entries (names, stacks — no props or source). */
|
|
67
|
+
getComponents(): DebugComponentEntry[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createDebugChannelCollector(): DebugChannelCollector {
|
|
71
|
+
const chunks: string[] = [];
|
|
72
|
+
const decoder = new TextDecoder();
|
|
73
|
+
|
|
74
|
+
const sink = new TransformStream();
|
|
75
|
+
|
|
76
|
+
// Collect chunks from the readable side instead of discarding them.
|
|
77
|
+
sink.readable
|
|
78
|
+
.pipeTo(
|
|
79
|
+
new WritableStream({
|
|
80
|
+
write(chunk: Uint8Array) {
|
|
81
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
|
82
|
+
},
|
|
83
|
+
close() {
|
|
84
|
+
// Flush any remaining bytes in the decoder
|
|
85
|
+
const remaining = decoder.decode();
|
|
86
|
+
if (remaining) chunks.push(remaining);
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
.catch(() => {
|
|
91
|
+
// Stream abort — request cancelled. Not an error.
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
readable: new ReadableStream(), // no commands to send to Flight
|
|
96
|
+
writable: sink.writable,
|
|
97
|
+
getCollectedText() {
|
|
98
|
+
return chunks.join('');
|
|
99
|
+
},
|
|
100
|
+
getComponents() {
|
|
101
|
+
return parseDebugRows(chunks.join(''));
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse React Flight debug rows into component entries.
|
|
108
|
+
*
|
|
109
|
+
* The Flight debug channel writes rows in `hexId:json\n` format. Each row
|
|
110
|
+
* with a JSON object containing a `name` field is a component debug info
|
|
111
|
+
* entry. Rows without `name` (timing rows, reference rows like `D"$id"`)
|
|
112
|
+
* are skipped.
|
|
113
|
+
*
|
|
114
|
+
* Security: `props` are explicitly stripped from parsed entries — they may
|
|
115
|
+
* contain rendered output or user data. Only `name`, `env`, `key`, and
|
|
116
|
+
* `stack` are retained.
|
|
117
|
+
*/
|
|
118
|
+
export function parseDebugRows(text: string): DebugComponentEntry[] {
|
|
119
|
+
if (!text) return [];
|
|
120
|
+
|
|
121
|
+
const entries: DebugComponentEntry[] = [];
|
|
122
|
+
const lines = text.split('\n');
|
|
123
|
+
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (!line) continue;
|
|
126
|
+
|
|
127
|
+
// Flight row format: hexId:payload
|
|
128
|
+
const colonIdx = line.indexOf(':');
|
|
129
|
+
if (colonIdx === -1) continue;
|
|
130
|
+
|
|
131
|
+
const payload = line.slice(colonIdx + 1);
|
|
132
|
+
// Skip non-JSON payloads (e.g., D"$a" reference rows)
|
|
133
|
+
if (!payload.startsWith('{')) continue;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(payload);
|
|
137
|
+
if (typeof parsed !== 'object' || parsed === null) continue;
|
|
138
|
+
if (typeof parsed.name !== 'string') continue;
|
|
139
|
+
|
|
140
|
+
// Strip props — may contain source-derived data or user data.
|
|
141
|
+
// Only retain: name, env, key, stack.
|
|
142
|
+
entries.push({
|
|
143
|
+
name: parsed.name,
|
|
144
|
+
env: parsed.env ?? null,
|
|
145
|
+
key: parsed.key ?? null,
|
|
146
|
+
stack: Array.isArray(parsed.stack) ? parsed.stack : null,
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
// Malformed JSON — skip this row
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return entries;
|
|
154
|
+
}
|
|
155
|
+
|
|
37
156
|
/**
|
|
38
157
|
* Build segment metadata for the X-Timber-Segments response header.
|
|
39
158
|
* Describes the rendered segment chain with async status, enabling
|