@timber-js/app 0.2.0-alpha.35 → 0.2.0-alpha.37
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/cache/index.js +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/params/index.js +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/deny-renderer.d.ts.map +1 -1
- package/dist/server/flight-scripts.d.ts +39 -0
- package/dist/server/flight-scripts.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts +3 -9
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +42 -26
- package/dist/server/index.js.map +1 -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/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/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.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/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/cli.ts +0 -0
- package/src/client/browser-entry.ts +3 -12
- package/src/client/index.ts +1 -0
- package/src/client/link.tsx +57 -3
- package/src/server/access-gate.tsx +6 -5
- package/src/server/als-registry.ts +14 -0
- package/src/server/deny-renderer.ts +2 -1
- package/src/server/flight-scripts.ts +59 -0
- package/src/server/html-injectors.ts +8 -32
- package/src/server/index.ts +3 -0
- package/src/server/node-stream-transforms.ts +8 -24
- package/src/server/pipeline.ts +6 -0
- package/src/server/primitives.ts +47 -5
- package/src/server/request-context.ts +69 -1
- package/src/server/route-element-builder.ts +10 -16
- package/src/server/rsc-entry/error-renderer.ts +2 -1
- package/src/server/rsc-entry/ssr-renderer.ts +9 -1
- package/src/server/slot-resolver.ts +10 -19
- 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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { createMachine } from '../utils/state-machine.js';
|
|
11
|
+
import { flightChunkScript } from './flight-scripts.js';
|
|
11
12
|
import {
|
|
12
13
|
flightInjectionTransitions,
|
|
13
14
|
isSuffixStripped,
|
|
@@ -105,22 +106,6 @@ export function injectScripts(
|
|
|
105
106
|
return createInjector(stream, scriptsHtml, '</body>');
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
-
* Escape a string for safe embedding inside a `<script>` tag within
|
|
110
|
-
* a JSON-encoded value.
|
|
111
|
-
*
|
|
112
|
-
* Only needs to prevent `</script>` from closing the tag early and
|
|
113
|
-
* handle U+2028/U+2029 (line/paragraph separators valid in JSON but
|
|
114
|
-
* historically problematic in JS). Since we use JSON.stringify for the
|
|
115
|
-
* outer encoding, we only escape `<` and the line separators.
|
|
116
|
-
*/
|
|
117
|
-
function htmlEscapeJsonString(str: string): string {
|
|
118
|
-
return str
|
|
119
|
-
.replace(/</g, '\\u003c')
|
|
120
|
-
.replace(/\u2028/g, '\\u2028')
|
|
121
|
-
.replace(/\u2029/g, '\\u2029');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
109
|
/**
|
|
125
110
|
* Transform an RSC Flight stream into a stream of inline `<script>` tags.
|
|
126
111
|
*
|
|
@@ -128,15 +113,9 @@ function htmlEscapeJsonString(str: string): string {
|
|
|
128
113
|
* transform) drives reads from the RSC stream on demand. No background
|
|
129
114
|
* reader, no shared mutable arrays, no race conditions.
|
|
130
115
|
*
|
|
131
|
-
* Each RSC chunk becomes
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* The first chunk emitted is the bootstrap signal [0] which the client
|
|
135
|
-
* uses to initialize its buffer.
|
|
136
|
-
*
|
|
137
|
-
* Uses JSON-encoded typed tuples matching the pattern from Next.js:
|
|
138
|
-
* [0] — bootstrap signal
|
|
139
|
-
* [1, data] — RSC Flight data chunk (UTF-8 string)
|
|
116
|
+
* Each RSC chunk becomes a `<script>self.__timber_f.push([1,"data"])</script>`.
|
|
117
|
+
* The init script (which creates __timber_f) is in `<head>` via
|
|
118
|
+
* flightInitScript() — see flight-scripts.ts.
|
|
140
119
|
*/
|
|
141
120
|
export function createInlinedRscStream(
|
|
142
121
|
rscStream: ReadableStream<Uint8Array>
|
|
@@ -146,11 +125,9 @@ export function createInlinedRscStream(
|
|
|
146
125
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
147
126
|
|
|
148
127
|
return new ReadableStream<Uint8Array>({
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
controller.enqueue(encoder.encode(bootstrap));
|
|
153
|
-
},
|
|
128
|
+
// No bootstrap signal here — the init script is in <head> via
|
|
129
|
+
// flightInitScript() (see flight-scripts.ts). This ensures the
|
|
130
|
+
// __timber_f array exists before any chunk scripts execute.
|
|
154
131
|
async pull(controller) {
|
|
155
132
|
try {
|
|
156
133
|
const { done, value } = await rscReader.read();
|
|
@@ -160,8 +137,7 @@ export function createInlinedRscStream(
|
|
|
160
137
|
}
|
|
161
138
|
if (value) {
|
|
162
139
|
const decoded = decoder.decode(value, { stream: true });
|
|
163
|
-
|
|
164
|
-
controller.enqueue(encoder.encode(`<script>self.__timber_f.push(${escaped})</script>`));
|
|
140
|
+
controller.enqueue(encoder.encode(flightChunkScript(decoded)));
|
|
165
141
|
}
|
|
166
142
|
} catch (error) {
|
|
167
143
|
controller.error(error);
|
package/src/server/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ export {
|
|
|
13
13
|
headers,
|
|
14
14
|
cookies,
|
|
15
15
|
rawSearchParams,
|
|
16
|
+
rawSegmentParams,
|
|
17
|
+
setSegmentParams,
|
|
16
18
|
runWithRequestContext,
|
|
17
19
|
setMutableCookieContext,
|
|
18
20
|
markResponseFlushed,
|
|
@@ -32,6 +34,7 @@ export {
|
|
|
32
34
|
waitUntil,
|
|
33
35
|
DenySignal,
|
|
34
36
|
RedirectSignal,
|
|
37
|
+
type RedirectOptions,
|
|
35
38
|
} from './primitives';
|
|
36
39
|
export type { RenderErrorDigest, WaitUntilAdapter } from './primitives';
|
|
37
40
|
export type { JsonSerializable } from './types';
|
|
@@ -21,6 +21,7 @@ import { Transform } from 'node:stream';
|
|
|
21
21
|
import { createGzip, constants } from 'node:zlib';
|
|
22
22
|
|
|
23
23
|
import { createMachine } from '../utils/state-machine.js';
|
|
24
|
+
import { flightChunkScript } from './flight-scripts.js';
|
|
24
25
|
import {
|
|
25
26
|
flightInjectionTransitions,
|
|
26
27
|
isSuffixStripped,
|
|
@@ -90,17 +91,6 @@ export function createNodeHeadInjector(headHtml: string): Transform {
|
|
|
90
91
|
|
|
91
92
|
// ─── RSC Flight Injection ────────────────────────────────────────────────────
|
|
92
93
|
|
|
93
|
-
/**
|
|
94
|
-
* Escape a string for safe embedding inside a `<script>` tag within
|
|
95
|
-
* a JSON-encoded value. Same as htmlEscapeJsonString in html-injectors.ts.
|
|
96
|
-
*/
|
|
97
|
-
function htmlEscapeJsonString(str: string): string {
|
|
98
|
-
return str
|
|
99
|
-
.replace(/</g, '\\u003c')
|
|
100
|
-
.replace(/\u2028/g, '\\u2028')
|
|
101
|
-
.replace(/\u2029/g, '\\u2029');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
94
|
/**
|
|
105
95
|
* Node.js Transform that merges RSC script tags into the HTML stream.
|
|
106
96
|
*
|
|
@@ -157,8 +147,7 @@ export function createNodeFlightInjector(
|
|
|
157
147
|
return;
|
|
158
148
|
}
|
|
159
149
|
const decoded = decoder.decode(value, { stream: true });
|
|
160
|
-
const
|
|
161
|
-
const scriptBuf = Buffer.from(`<script>self.__timber_f.push(${escaped})</script>`, 'utf-8');
|
|
150
|
+
const scriptBuf = Buffer.from(flightChunkScript(decoded), 'utf-8');
|
|
162
151
|
// Push directly to the transform output — don't wait for an
|
|
163
152
|
// HTML chunk to trigger drainPending.
|
|
164
153
|
stream.push(scriptBuf);
|
|
@@ -175,13 +164,9 @@ export function createNodeFlightInjector(
|
|
|
175
164
|
}
|
|
176
165
|
}
|
|
177
166
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
const bootstrapBuf = Buffer.from(
|
|
182
|
-
`<script>(self.__timber_f=self.__timber_f||[]).push(${htmlEscapeJsonString(JSON.stringify([0]))})</script>`,
|
|
183
|
-
'utf-8'
|
|
184
|
-
);
|
|
167
|
+
// No bootstrap script here — the init script is in <head> via
|
|
168
|
+
// flightInitScript() (see flight-scripts.ts). This ensures __timber_f
|
|
169
|
+
// exists before any chunk scripts execute.
|
|
185
170
|
|
|
186
171
|
const transform = new Transform({
|
|
187
172
|
transform(chunk: Buffer, _encoding, callback) {
|
|
@@ -208,11 +193,10 @@ export function createNodeFlightInjector(
|
|
|
208
193
|
transform.push(chunk);
|
|
209
194
|
}
|
|
210
195
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
196
|
+
// Start the pull loop on the first HTML chunk to stream RSC
|
|
197
|
+
// data chunks alongside the HTML. The __timber_f init script is
|
|
198
|
+
// already in <head> (via flightInitScript), so no bootstrap needed.
|
|
214
199
|
if (isFirst) {
|
|
215
|
-
transform.push(bootstrapBuf);
|
|
216
200
|
pullLoop(transform);
|
|
217
201
|
}
|
|
218
202
|
callback();
|
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
|
/**
|
|
@@ -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
|
});
|
|
@@ -12,6 +12,7 @@ import { logRenderError } from '#/server/logger.js';
|
|
|
12
12
|
import type { ManifestSegmentNode } from '#/server/route-matcher.js';
|
|
13
13
|
import { DenySignal, RenderError } from '#/server/primitives.js';
|
|
14
14
|
import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
|
|
15
|
+
import { flightInitScript } from '#/server/flight-scripts.js';
|
|
15
16
|
import { renderDenyPage } from '#/server/deny-renderer.js';
|
|
16
17
|
import type { LayoutEntry } from '#/server/deny-renderer.js';
|
|
17
18
|
import type { NavContext } from '#/server/ssr-entry.js';
|
|
@@ -125,7 +126,7 @@ export async function renderErrorPage(
|
|
|
125
126
|
searchParams: Object.fromEntries(new URL(req.url).searchParams),
|
|
126
127
|
statusCode: status,
|
|
127
128
|
responseHeaders,
|
|
128
|
-
headHtml:
|
|
129
|
+
headHtml: flightInitScript(),
|
|
129
130
|
bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
|
|
130
131
|
rscStream: inlineStream,
|
|
131
132
|
cookies: getCookiesForSsr(),
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
|
|
16
|
+
import { flightInitScript } from '#/server/flight-scripts.js';
|
|
16
17
|
import type { LayoutEntry } from '#/server/deny-renderer.js';
|
|
17
18
|
import { renderDenyPage } from '#/server/deny-renderer.js';
|
|
18
19
|
import type { RouteMatch } from '#/server/pipeline.js';
|
|
@@ -105,7 +106,14 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
105
106
|
searchParams: Object.fromEntries(new URL(req.url).searchParams),
|
|
106
107
|
statusCode: 200,
|
|
107
108
|
responseHeaders,
|
|
108
|
-
headHtml:
|
|
109
|
+
headHtml:
|
|
110
|
+
headHtml +
|
|
111
|
+
clientBootstrap.preloadLinks +
|
|
112
|
+
segmentScript +
|
|
113
|
+
paramsScript +
|
|
114
|
+
// Initialize __timber_f in <head> so it exists before any streaming
|
|
115
|
+
// chunk scripts arrive in <body>. See flight-scripts.ts, LOCAL-415.
|
|
116
|
+
(clientJsDisabled ? '' : flightInitScript()),
|
|
109
117
|
bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
|
|
110
118
|
// Skip RSC inline stream when client JS is disabled — no client to hydrate.
|
|
111
119
|
rscStream: clientJsDisabled ? undefined : inlineStream,
|
|
@@ -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,
|