@timber-js/app 0.2.0-alpha.190 → 0.2.0-alpha.192
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{actions-35jnMdeJ.js → actions-BjbbNRFN.js} +3 -3
- package/dist/_chunks/{actions-35jnMdeJ.js.map → actions-BjbbNRFN.js.map} +1 -1
- package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
- package/dist/_chunks/als-slots-BEEIPKYm.js.map +1 -1
- package/dist/_chunks/{cache-api-DjNrIWRR.js → cache-api-DllJ-Lyw.js} +2 -2
- package/dist/_chunks/{cache-api-DjNrIWRR.js.map → cache-api-DllJ-Lyw.js.map} +1 -1
- package/dist/_chunks/{cli-check-CpmN7Nh-.js → cli-check-DJZDc22E.js} +3 -3
- package/dist/_chunks/{cli-check-CpmN7Nh-.js.map → cli-check-DJZDc22E.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-CGMp_Psg.js → cli-schema-sync-D4_AZVUb.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-CGMp_Psg.js.map → cli-schema-sync-D4_AZVUb.js.map} +1 -1
- package/dist/_chunks/{convention-lint-kXsgc_-7.js → convention-lint-CpteIpTm.js} +2 -2
- package/dist/_chunks/{convention-lint-kXsgc_-7.js.map → convention-lint-CpteIpTm.js.map} +1 -1
- package/dist/_chunks/{logger-D8xJZXIN.js → logger-CH7IcMmg.js} +1 -16
- package/dist/_chunks/{logger-D8xJZXIN.js.map → logger-CH7IcMmg.js.map} +1 -1
- package/dist/_chunks/{scanner-C8b0Gcw3.js → scanner-CAietmj4.js} +2 -2
- package/dist/_chunks/{scanner-C8b0Gcw3.js.map → scanner-CAietmj4.js.map} +1 -1
- package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -1
- package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
- package/dist/_chunks/slot-params-BCTmZkQB.js.map +1 -1
- package/dist/_chunks/ssr-data-14MXm7Pj.js.map +1 -1
- package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -1
- package/dist/_chunks/{walkers-RzN6AFjr.js → walkers-BsVLmD1S.js} +2 -2
- package/dist/_chunks/{walkers-RzN6AFjr.js.map → walkers-BsVLmD1S.js.map} +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/internal.js.map +1 -1
- package/dist/client/params-context.d.ts +2 -1
- package/dist/client/params-context.d.ts.map +1 -1
- package/dist/client/ssr-data.d.ts +2 -1
- package/dist/client/ssr-data.d.ts.map +1 -1
- package/dist/client/state.d.ts +3 -2
- package/dist/client/state.d.ts.map +1 -1
- package/dist/client/use-segment-params.d.ts +5 -4
- package/dist/client/use-segment-params.d.ts.map +1 -1
- package/dist/config-types.d.ts +24 -6
- package/dist/config-types.d.ts.map +1 -1
- package/dist/index.js +35 -32
- package/dist/index.js.map +1 -1
- package/dist/plugins/mdx.d.ts +17 -4
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/routing/segment-keys.d.ts +2 -1
- package/dist/routing/segment-keys.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +10 -5
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/chain-url-parts.d.ts +2 -1
- package/dist/server/chain-url-parts.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +3 -3
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-collector.d.ts +40 -18
- package/dist/server/metadata-collector.d.ts.map +1 -1
- package/dist/server/param-coercion.d.ts +2 -1
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +3 -2
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/prebuilt/cache-key.d.ts +2 -1
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
- package/dist/server/prebuilt/payload-source.d.ts +2 -1
- package/dist/server/prebuilt/payload-source.d.ts.map +1 -1
- package/dist/server/prebuilt/synthetic-store.d.ts +2 -1
- package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
- package/dist/server/prebuilt-builder.d.ts +4 -3
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/prebuilt-runtime.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +6 -5
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/sitemap-generator.d.ts +2 -1
- package/dist/server/sitemap-generator.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-bridge-types.d.ts +5 -3
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/types.d.ts +2 -1
- package/dist/server/types.d.ts.map +1 -1
- package/dist/shared/als-slots.d.ts +3 -2
- package/dist/shared/als-slots.d.ts.map +1 -1
- package/dist/shared/param-value.d.ts +25 -1
- package/dist/shared/param-value.d.ts.map +1 -1
- package/dist/shared/payload-root.d.ts +2 -1
- package/dist/shared/payload-root.d.ts.map +1 -1
- package/dist/shared/slot-params.d.ts +4 -3
- package/dist/shared/slot-params.d.ts.map +1 -1
- package/docs/api/34-api-config.mdx +9 -8
- package/docs/more/04b-mdx.mdx +38 -18
- package/package.json +7 -3
- package/src/client/params-context.ts +2 -2
- package/src/client/ssr-data.ts +2 -1
- package/src/client/state.ts +3 -2
- package/src/client/use-segment-params.ts +6 -5
- package/src/config-types.ts +27 -7
- package/src/plugins/mdx.ts +69 -35
- package/src/plugins/server-bundle.ts +2 -1
- package/src/routing/segment-keys.ts +5 -2
- package/src/server/action-handler.ts +106 -8
- package/src/server/als-registry.ts +10 -5
- package/src/server/chain-url-parts.ts +2 -1
- package/src/server/index.ts +4 -0
- package/src/server/metadata-collector.ts +114 -40
- package/src/server/param-coercion.ts +7 -7
- package/src/server/pipeline-phases.ts +3 -1
- package/src/server/pipeline.ts +3 -2
- package/src/server/prebuilt/cache-key.ts +2 -1
- package/src/server/prebuilt/payload-source.ts +2 -1
- package/src/server/prebuilt/synthetic-store.ts +2 -1
- package/src/server/prebuilt-builder.ts +6 -5
- package/src/server/prebuilt-runtime.ts +5 -3
- package/src/server/request-context.ts +6 -8
- package/src/server/route-element-builder.ts +18 -40
- package/src/server/sitemap-generator.ts +7 -9
- package/src/server/slot-resolver.ts +2 -1
- package/src/server/ssr-bridge-types.ts +6 -3
- package/src/server/types.ts +2 -1
- package/src/shared/als-slots.ts +3 -1
- package/src/shared/param-value.ts +44 -7
- package/src/shared/payload-root.ts +2 -1
- package/src/shared/slot-params.ts +11 -12
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '../rsc-runtime/rsc.js';
|
|
22
22
|
|
|
23
23
|
import { validateCsrf, type CsrfConfig } from './csrf.js';
|
|
24
|
+
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
24
25
|
import { executeAction, type RevalidateRenderer } from './actions.js';
|
|
25
26
|
import { runWithRequestContext, setMutableCookieContext } from './request-context.js';
|
|
26
27
|
import { getSetCookieHeaders, getCookiesForSsr } from './cookie-context.js';
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
import type { FormFlashData } from './form-flash.js';
|
|
37
38
|
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
38
39
|
import { logActionError } from './logger.js';
|
|
40
|
+
import { withTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
39
41
|
import { RSC_CONTENT_TYPE } from '../shared/rsc-media-type.js';
|
|
40
42
|
|
|
41
43
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
@@ -287,25 +289,107 @@ async function handleRscAction(
|
|
|
287
289
|
'Content-Type': RSC_CONTENT_TYPE,
|
|
288
290
|
};
|
|
289
291
|
|
|
290
|
-
let payload: unknown;
|
|
291
292
|
if (result.revalidation) {
|
|
292
293
|
// Wrapper object — Next.js-style pattern: action result + element tree
|
|
293
294
|
// serialized together so React Flight handles both in one stream.
|
|
294
|
-
|
|
295
|
+
const revalidationPayload = {
|
|
295
296
|
_action: result.actionResult,
|
|
296
297
|
// The payload root, not a bare tree: it carries the revalidated
|
|
297
298
|
// route's params too, and the client publishes them (TIM-1297).
|
|
298
299
|
_tree: result.revalidation.payload,
|
|
299
300
|
};
|
|
301
|
+
|
|
302
|
+
// TIM-1366: access.ts deny signals fire during serialization (inside
|
|
303
|
+
// AccessGate during renderToReadableStream), not during buildRouteElement.
|
|
304
|
+
// Buffer the stream and track errors so we can drop the revalidation
|
|
305
|
+
// cleanly instead of shipping a broken flight stream. Access runs
|
|
306
|
+
// exactly once — during the real render — preserving TIM-1363.
|
|
307
|
+
let serializationSignal:
|
|
308
|
+
| { kind: 'deny'; status: number }
|
|
309
|
+
| { kind: 'redirect'; location: string; status: number }
|
|
310
|
+
| { kind: 'error' }
|
|
311
|
+
| undefined;
|
|
312
|
+
const rscStream = renderToReadableStream(revalidationPayload, {
|
|
313
|
+
signal: req.signal,
|
|
314
|
+
onError(error: unknown) {
|
|
315
|
+
// ??= keeps the first signal (shallowest AccessGate in top-down render).
|
|
316
|
+
if (error instanceof DenySignal) {
|
|
317
|
+
serializationSignal ??= { kind: 'deny', status: error.status };
|
|
318
|
+
} else if (error instanceof RedirectSignal) {
|
|
319
|
+
serializationSignal ??= {
|
|
320
|
+
kind: 'redirect',
|
|
321
|
+
location: error.location,
|
|
322
|
+
status: error.status,
|
|
323
|
+
};
|
|
324
|
+
} else {
|
|
325
|
+
console.error('[timber] revalidation serialization error:', error);
|
|
326
|
+
serializationSignal ??= { kind: 'error' };
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Drain the stream to detect any signals before committing the response.
|
|
332
|
+
// Timeout-guarded: the element tree may contain async server components
|
|
333
|
+
// with stalled fetches. 30s matches the default renderTimeoutMs.
|
|
334
|
+
const reader = rscStream.getReader();
|
|
335
|
+
const chunks: Uint8Array[] = [];
|
|
336
|
+
const drainTimeoutMs = 30_000;
|
|
337
|
+
try {
|
|
338
|
+
for (;;) {
|
|
339
|
+
const { done, value } = await withTimeout(
|
|
340
|
+
reader.read(),
|
|
341
|
+
drainTimeoutMs,
|
|
342
|
+
'revalidation stream drain timed out'
|
|
343
|
+
);
|
|
344
|
+
if (done) break;
|
|
345
|
+
chunks.push(value);
|
|
346
|
+
if (serializationSignal) {
|
|
347
|
+
reader.cancel().catch(() => {});
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} catch (drainError) {
|
|
352
|
+
reader.cancel().catch(() => {});
|
|
353
|
+
if (drainError instanceof RenderTimeoutError) {
|
|
354
|
+
console.error('[timber] revalidatePath dropped — stream drain timed out');
|
|
355
|
+
const fallbackStream = renderToReadableStream(result.actionResult);
|
|
356
|
+
return new Response(fallbackStream, { status: 200, headers });
|
|
357
|
+
}
|
|
358
|
+
throw drainError;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (serializationSignal) {
|
|
362
|
+
if (serializationSignal.kind === 'redirect') {
|
|
363
|
+
const redirectPayload = {
|
|
364
|
+
_redirect: serializationSignal.location,
|
|
365
|
+
_status: serializationSignal.status,
|
|
366
|
+
};
|
|
367
|
+
const redirectStream = renderToReadableStream(redirectPayload);
|
|
368
|
+
return new Response(redirectStream, {
|
|
369
|
+
status: 200,
|
|
370
|
+
headers: {
|
|
371
|
+
'Content-Type': RSC_CONTENT_TYPE,
|
|
372
|
+
'X-Timber-Redirect': serializationSignal.location,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
// Deny or render error: drop the revalidation tree, ship the action result alone.
|
|
377
|
+
if (serializationSignal.kind === 'deny') {
|
|
378
|
+
console.error(
|
|
379
|
+
`[timber] revalidatePath dropped — access denied during render (status ${serializationSignal.status})`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const fallbackStream = renderToReadableStream(result.actionResult);
|
|
383
|
+
return new Response(fallbackStream, { status: 200, headers });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Clean serialization — return the buffered bytes with revalidation header.
|
|
300
387
|
headers['X-Timber-Revalidation'] = '1';
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// during client rendering. No X-Timber-Head header needed. See TIM-1151.
|
|
304
|
-
} else {
|
|
305
|
-
payload = result.actionResult;
|
|
388
|
+
const buffered = concatUint8Arrays(chunks);
|
|
389
|
+
return new Response(buffered.buffer as ArrayBuffer, { status: 200, headers });
|
|
306
390
|
}
|
|
307
391
|
|
|
308
|
-
const rscStream = renderToReadableStream(
|
|
392
|
+
const rscStream = renderToReadableStream(result.actionResult);
|
|
309
393
|
|
|
310
394
|
return new Response(rscStream, {
|
|
311
395
|
status: 200,
|
|
@@ -313,6 +397,20 @@ async function handleRscAction(
|
|
|
313
397
|
});
|
|
314
398
|
}
|
|
315
399
|
|
|
400
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
function concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {
|
|
403
|
+
let total = 0;
|
|
404
|
+
for (const c of chunks) total += c.byteLength;
|
|
405
|
+
const out = new Uint8Array(total);
|
|
406
|
+
let offset = 0;
|
|
407
|
+
for (const c of chunks) {
|
|
408
|
+
out.set(c, offset);
|
|
409
|
+
offset += c.byteLength;
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
|
|
316
414
|
// ─── No-JS Form Action ───────────────────────────────────────────────────
|
|
317
415
|
|
|
318
416
|
/**
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import '#server-only-guard';
|
|
35
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
35
36
|
import type { SlotParamsRecord } from '../shared/slot-params.js';
|
|
36
37
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
37
38
|
import type { DebugComponentEntry } from './rsc-entry/helpers.js';
|
|
@@ -94,7 +95,7 @@ export interface RequestContextStore {
|
|
|
94
95
|
*
|
|
95
96
|
* See design/07-routing.md §"params.ts — Convention File for Typed Params"
|
|
96
97
|
*/
|
|
97
|
-
segmentParams?:
|
|
98
|
+
segmentParams?: CoercedParams;
|
|
98
99
|
/**
|
|
99
100
|
* The matched segment path (e.g. '/(browse)/[artistSlug]/[year]').
|
|
100
101
|
* Includes route groups and parallel slots — uniquely identifies the
|
|
@@ -152,11 +153,15 @@ export interface RequestContextStore {
|
|
|
152
153
|
/**
|
|
153
154
|
* Metadata entries collected during render by MetadataCollector.
|
|
154
155
|
* Populated per-segment in tree order (root→page) during
|
|
155
|
-
* renderToReadableStream.
|
|
156
|
-
*
|
|
157
|
-
*
|
|
156
|
+
* renderToReadableStream. Entries may contain promises for dynamic
|
|
157
|
+
* metadata (resolved concurrently with page rendering, TIM-1367).
|
|
158
|
+
* MetadataHead awaits all entries before merging.
|
|
159
|
+
* See TIM-1363, TIM-1367, design/16-metadata.md.
|
|
158
160
|
*/
|
|
159
|
-
_metadataEntries?:
|
|
161
|
+
_metadataEntries?: Array<{
|
|
162
|
+
metadata: import('./types.js').Metadata | Promise<import('./types.js').Metadata | null>;
|
|
163
|
+
isPage: boolean;
|
|
164
|
+
}>;
|
|
160
165
|
}
|
|
161
166
|
|
|
162
167
|
/** A single outgoing cookie entry in the cookie jar. */
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
24
24
|
import type { RouteMatch } from './pipeline.js';
|
|
25
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
25
26
|
import { effectiveUrlSegment } from '../routing/segment-classify.js';
|
|
26
27
|
import { computeInterceptedBase } from '../routing/interception.js';
|
|
27
28
|
|
|
@@ -51,7 +52,7 @@ import { computeInterceptedBase } from '../routing/interception.js';
|
|
|
51
52
|
*/
|
|
52
53
|
export function extractUrlParts(
|
|
53
54
|
segments: ManifestSegmentNode[],
|
|
54
|
-
mainParams:
|
|
55
|
+
mainParams: CoercedParams
|
|
55
56
|
): string[] {
|
|
56
57
|
const parts: string[] = [];
|
|
57
58
|
for (const node of segments) {
|
package/src/server/index.ts
CHANGED
|
@@ -64,4 +64,8 @@ export { revalidatePath, revalidateTag } from './actions';
|
|
|
64
64
|
// Design doc: design/17-logging.md §"trace_id is Always Set"
|
|
65
65
|
export { getTraceId, getSpanId, withSpan, addSpanEvent } from './tracing';
|
|
66
66
|
|
|
67
|
+
// Coerced param types — the post-coercion domain for segment params.
|
|
68
|
+
// TIM-1347: these replaced the previous `Record<string, string | string[]>` lie.
|
|
69
|
+
export type { CoercedParamValue, CoercedParams } from '../shared/param-value';
|
|
70
|
+
|
|
67
71
|
// Segment params types — re-exported for convenience.
|
|
@@ -6,12 +6,21 @@
|
|
|
6
6
|
* the page level, merging all collected entries and rendering Float-
|
|
7
7
|
* hoisted head elements.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* MetadataCollector is synchronous: it starts dynamic metadata() calls
|
|
10
|
+
* without awaiting and pushes the promise into the entries array, then
|
|
11
|
+
* returns children immediately. This lets React descend into the page
|
|
12
|
+
* subtree while metadata I/O runs concurrently (TIM-1367). A sibling
|
|
13
|
+
* MetadataSettle component awaits the promise so errors surface at the
|
|
14
|
+
* collector's tree position (caught by that segment's error boundary).
|
|
13
15
|
*
|
|
14
|
-
*
|
|
16
|
+
* MetadataHead is async: it awaits all entry promises before merging.
|
|
17
|
+
* Ordering is guaranteed by tree position — ancestor collectors push
|
|
18
|
+
* before MetadataHead renders.
|
|
19
|
+
*
|
|
20
|
+
* AccessGate denial prevents MetadataCollector from rendering, so
|
|
21
|
+
* metadata() never starts for denied segments (TIM-1027).
|
|
22
|
+
*
|
|
23
|
+
* See TIM-1363, TIM-1367, design/16-metadata.md.
|
|
15
24
|
*/
|
|
16
25
|
|
|
17
26
|
import { createElement, Fragment } from 'react';
|
|
@@ -27,11 +36,41 @@ import { collectMetadataRouteHeadElements } from './metadata-routes.js';
|
|
|
27
36
|
|
|
28
37
|
// ─── Request-scoped metadata entry store ────────────────────────────────
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Internal entry type that supports deferred (promise) metadata for
|
|
41
|
+
* concurrent resolution (TIM-1367). Dynamic metadata pushes a promise;
|
|
42
|
+
* static metadata pushes the resolved object directly.
|
|
43
|
+
*/
|
|
44
|
+
interface DeferredMetadataEntry {
|
|
45
|
+
metadata: Metadata | Promise<Metadata | null>;
|
|
46
|
+
isPage: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getDeferredMetadataEntries(): DeferredMetadataEntry[] {
|
|
31
50
|
const store = requestContextAls.getStore();
|
|
32
51
|
if (!store) return [];
|
|
33
52
|
store._metadataEntries ??= [];
|
|
34
|
-
return store._metadataEntries;
|
|
53
|
+
return store._metadataEntries as DeferredMetadataEntry[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── MetadataSettle ─────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
interface MetadataSettleProps {
|
|
59
|
+
promise: Promise<Metadata | null>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tiny async server component rendered as a sibling of the collector's
|
|
64
|
+
* children. Its sole job is error ownership: awaiting the metadata
|
|
65
|
+
* promise so that rejections surface at THIS tree position (caught by
|
|
66
|
+
* the owning segment's error boundary, not MetadataHead's).
|
|
67
|
+
*
|
|
68
|
+
* Shell settlement (onSettled) is handled by the collector attaching
|
|
69
|
+
* directly to the promise — unconditional on render progress.
|
|
70
|
+
*/
|
|
71
|
+
async function MetadataSettle(props: MetadataSettleProps): Promise<ReactNode> {
|
|
72
|
+
await props.promise;
|
|
73
|
+
return null;
|
|
35
74
|
}
|
|
36
75
|
|
|
37
76
|
// ─── MetadataCollector ──────────────────────────────────────────────────
|
|
@@ -45,36 +84,47 @@ export interface MetadataCollectorProps {
|
|
|
45
84
|
}
|
|
46
85
|
|
|
47
86
|
/**
|
|
48
|
-
*
|
|
49
|
-
* render. Placed between AccessGate and the layout/page. When
|
|
87
|
+
* Synchronous server component that collects one segment's metadata
|
|
88
|
+
* during render. Placed between AccessGate and the layout/page. When
|
|
50
89
|
* AccessGate denies, this component never renders, so dynamic
|
|
51
|
-
* metadata() never
|
|
90
|
+
* metadata() never starts for denied segments (TIM-1027).
|
|
52
91
|
*
|
|
53
92
|
* Static metadata objects are pushed synchronously (no side effects).
|
|
54
|
-
* Dynamic metadata functions are awaited
|
|
93
|
+
* Dynamic metadata functions are started (not awaited) and pushed as
|
|
94
|
+
* promises — React descends into children immediately while the I/O
|
|
95
|
+
* runs concurrently (TIM-1367). Shell settlement (onSettled) is
|
|
96
|
+
* attached directly to the promise so it fires unconditionally. A
|
|
97
|
+
* sibling MetadataSettle component awaits the promise for error
|
|
98
|
+
* ownership only.
|
|
55
99
|
*/
|
|
56
|
-
export
|
|
100
|
+
export function MetadataCollector(props: MetadataCollectorProps): ReactNode {
|
|
57
101
|
const { metadataExport, segmentName, isPage, onSettled, children } = props;
|
|
58
102
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
if (typeof metadataExport === 'function') {
|
|
104
|
+
const promise = withSpan('timber.metadata', { 'timber.segment': segmentName }, () =>
|
|
105
|
+
(metadataExport as () => Promise<Metadata>)()
|
|
106
|
+
).then((v) => v ?? null);
|
|
107
|
+
// Suppress unhandled rejection — MetadataSettle and MetadataHead
|
|
108
|
+
// both attach handlers, but a fast-rejecting promise can fire
|
|
109
|
+
// Node's unhandledRejection before they do.
|
|
110
|
+
promise.catch(() => {});
|
|
111
|
+
// Shell settlement: unconditional on render progress. If React
|
|
112
|
+
// never reaches MetadataSettle (e.g., parent error boundary fires
|
|
113
|
+
// first), the HTTP status still commits on time.
|
|
114
|
+
if (onSettled)
|
|
115
|
+
promise.then(
|
|
116
|
+
() => onSettled(),
|
|
117
|
+
() => onSettled()
|
|
118
|
+
);
|
|
69
119
|
|
|
70
|
-
|
|
71
|
-
getMetadataEntries().push({ metadata, isPage });
|
|
72
|
-
}
|
|
120
|
+
getDeferredMetadataEntries().push({ metadata: promise, isPage });
|
|
73
121
|
|
|
74
|
-
return children;
|
|
75
|
-
} finally {
|
|
76
|
-
onSettled?.();
|
|
122
|
+
return createElement(Fragment, null, children, createElement(MetadataSettle, { promise }));
|
|
77
123
|
}
|
|
124
|
+
|
|
125
|
+
getDeferredMetadataEntries().push({ metadata: metadataExport, isPage });
|
|
126
|
+
onSettled?.();
|
|
127
|
+
return children;
|
|
78
128
|
}
|
|
79
129
|
|
|
80
130
|
// ─── MetadataHead ───────────────────────────────────────────────────────
|
|
@@ -83,21 +133,46 @@ export interface MetadataHeadProps {
|
|
|
83
133
|
segments: ManifestSegmentNode[];
|
|
84
134
|
requestUrl: string;
|
|
85
135
|
metadataRouteHashes?: Record<string, string>;
|
|
86
|
-
children: ReactNode;
|
|
87
136
|
}
|
|
88
137
|
|
|
89
138
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
139
|
+
* Async server component that awaits all deferred metadata promises,
|
|
140
|
+
* merges entries, auto-links metadata routes, and renders Float-
|
|
141
|
+
* hoisted head elements.
|
|
142
|
+
*
|
|
143
|
+
* Rendered as a SIBLING of the page element (not a wrapper) so React
|
|
144
|
+
* processes both concurrently — the page starts rendering immediately
|
|
145
|
+
* while MetadataHead awaits metadata promises. Both resolve before
|
|
146
|
+
* onShellReady (they're outside Suspense), but they overlap. Float
|
|
147
|
+
* hoists the head elements to <head> regardless of tree position.
|
|
93
148
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
149
|
+
* Ordering is guaranteed by tree position: every MetadataCollector
|
|
150
|
+
* is an ancestor of the Fragment containing MetadataHead, so its
|
|
151
|
+
* synchronous push into the entries array happens-before MetadataHead
|
|
152
|
+
* executes.
|
|
153
|
+
*
|
|
154
|
+
* See TIM-1367 for the concurrent resolution design.
|
|
97
155
|
*/
|
|
98
|
-
export function MetadataHead(props: MetadataHeadProps): ReactNode {
|
|
99
|
-
const { segments, requestUrl, metadataRouteHashes
|
|
100
|
-
const
|
|
156
|
+
export async function MetadataHead(props: MetadataHeadProps): Promise<ReactNode> {
|
|
157
|
+
const { segments, requestUrl, metadataRouteHashes } = props;
|
|
158
|
+
const deferredEntries = getDeferredMetadataEntries();
|
|
159
|
+
|
|
160
|
+
// Resolve all deferred metadata concurrently. Rejected entries are
|
|
161
|
+
// dropped — their errors are surfaced by MetadataSettle at the
|
|
162
|
+
// owning segment's error boundary, not here.
|
|
163
|
+
const settled = await Promise.allSettled(
|
|
164
|
+
deferredEntries.map((e) =>
|
|
165
|
+
e.metadata instanceof Promise ? e.metadata : Promise.resolve(e.metadata)
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
const entries: SegmentMetadataEntry[] = [];
|
|
169
|
+
for (let i = 0; i < settled.length; i++) {
|
|
170
|
+
const s = settled[i];
|
|
171
|
+
if (s.status === 'fulfilled' && s.value) {
|
|
172
|
+
entries.push({ metadata: s.value, isPage: deferredEntries[i].isPage });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
101
176
|
const resolved = resolveMetadata(entries);
|
|
102
177
|
const headElements = renderMetadataToElements(resolved);
|
|
103
178
|
|
|
@@ -110,6 +185,5 @@ export function MetadataHead(props: MetadataHeadProps): ReactNode {
|
|
|
110
185
|
);
|
|
111
186
|
headElements.push(...autoLinked);
|
|
112
187
|
|
|
113
|
-
|
|
114
|
-
return createElement(Fragment, null, metadataElement, children);
|
|
188
|
+
return headElementsToReact(headElements);
|
|
115
189
|
}
|
|
@@ -17,7 +17,7 @@ import type { SegmentType } from '../routing/types.js';
|
|
|
17
17
|
import { effectiveUrlSegment } from '../routing/segment-classify.js';
|
|
18
18
|
import { toBracketKey } from '../params/resolve-schema.js';
|
|
19
19
|
import type { RouteMatch } from './pipeline.js';
|
|
20
|
-
import { normalizeParamValue } from '../shared/param-value.js';
|
|
20
|
+
import { normalizeParamValue, type CoercedParams } from '../shared/param-value.js';
|
|
21
21
|
import { ParamCoercionError } from './route-element-builder.js';
|
|
22
22
|
import { isDebug } from './debug.js';
|
|
23
23
|
|
|
@@ -65,11 +65,11 @@ export function coerceSlotParams(
|
|
|
65
65
|
interceptedSegmentName?: string;
|
|
66
66
|
}>,
|
|
67
67
|
rawParams: Record<string, string | string[]>
|
|
68
|
-
):
|
|
68
|
+
): CoercedParams {
|
|
69
69
|
const globalCodecs = _globalCodecs;
|
|
70
70
|
if (!globalCodecs) return rawParams;
|
|
71
71
|
|
|
72
|
-
const result:
|
|
72
|
+
const result: CoercedParams = Object.create(null);
|
|
73
73
|
for (const key of Object.keys(rawParams)) {
|
|
74
74
|
result[key] = rawParams[key];
|
|
75
75
|
}
|
|
@@ -102,10 +102,10 @@ export function coerceSlotParams(
|
|
|
102
102
|
}
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
105
|
-
result[key] = normalizeParamValue(parsed, key)
|
|
105
|
+
result[key] = normalizeParamValue(parsed, key);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
return result
|
|
108
|
+
return result;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
@@ -134,13 +134,13 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
|
134
134
|
// Unconditionally install a null-prototype target so the invariant
|
|
135
135
|
// "match.segmentParams is null-prototype" holds from the first line,
|
|
136
136
|
// regardless of whether any segment has a codec.
|
|
137
|
-
const mergeTarget:
|
|
137
|
+
const mergeTarget: CoercedParams = Object.create(null);
|
|
138
138
|
for (const key of Object.keys(match.segmentParams)) {
|
|
139
139
|
if (key !== '__proto__') {
|
|
140
140
|
mergeTarget[key] = match.segmentParams[key as keyof typeof match.segmentParams];
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
-
match.segmentParams = mergeTarget
|
|
143
|
+
match.segmentParams = mergeTarget;
|
|
144
144
|
|
|
145
145
|
// TIM-931/TIM-936: When a global schema is available, use it for coercion
|
|
146
146
|
// instead of per-segment params.ts files. The global codec map is keyed
|
|
@@ -528,7 +528,9 @@ export async function handleRequest(
|
|
|
528
528
|
// Snapshot raw params before coercion — slot resolution needs the
|
|
529
529
|
// original string values to reconstruct URL parts for tree matching.
|
|
530
530
|
// Coerced params may have been transformed by codecs.
|
|
531
|
-
|
|
531
|
+
// segmentParams is typed CoercedParams but is still raw strings here
|
|
532
|
+
// (coercion runs below). The snapshot preserves the pre-coercion shape.
|
|
533
|
+
match.rawSegmentParams = { ...match.segmentParams } as Record<string, string | string[]>;
|
|
532
534
|
try {
|
|
533
535
|
await coerceSegmentParams(match);
|
|
534
536
|
} catch (error) {
|
package/src/server/pipeline.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* and design/17-logging.md §"Production Logging"
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
18
19
|
import type { ProxyExport } from './proxy.js';
|
|
19
20
|
import type { MiddlewareFn } from './middleware-runner.js';
|
|
20
21
|
import { runWithTimingCollector, getServerTimingHeader } from './server-timing.js';
|
|
@@ -54,8 +55,8 @@ import { isDebug } from './debug.js';
|
|
|
54
55
|
export interface RouteMatch {
|
|
55
56
|
/** The matched segment chain from root to leaf. */
|
|
56
57
|
segments: ManifestSegmentNode[];
|
|
57
|
-
/** Extracted segment params (catch-all segments produce string[]). */
|
|
58
|
-
segmentParams:
|
|
58
|
+
/** Extracted segment params (catch-all segments produce string[]). Post-coercion, values may be non-string (TIM-1347). */
|
|
59
|
+
segmentParams: CoercedParams;
|
|
59
60
|
/**
|
|
60
61
|
* Raw segment params before codec coercion. Always string or string[].
|
|
61
62
|
* Used by slot resolution to reconstruct URL parts — coerced params may
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import { createHash } from 'node:crypto';
|
|
44
|
+
import type { CoercedParams } from '../../shared/param-value.js';
|
|
44
45
|
|
|
45
46
|
class UnkeyableValueError extends Error {}
|
|
46
47
|
|
|
@@ -260,7 +261,7 @@ export function splitSlotProps(
|
|
|
260
261
|
*/
|
|
261
262
|
export function computeComponentCacheKey(
|
|
262
263
|
props: Record<string, unknown>,
|
|
263
|
-
params:
|
|
264
|
+
params: CoercedParams
|
|
264
265
|
): string | null {
|
|
265
266
|
let canonical: string;
|
|
266
267
|
try {
|
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { initTagIndex, resetTagIndex } from './overlay.js';
|
|
20
|
+
import type { CoercedParams } from '../../shared/param-value.js';
|
|
20
21
|
|
|
21
22
|
export interface PrebuiltManifestEntry {
|
|
22
23
|
/** Segment params this entry was captured under. */
|
|
23
|
-
params:
|
|
24
|
+
params: CoercedParams;
|
|
24
25
|
/** Payload path relative to the prebuilt/ directory. */
|
|
25
26
|
file: string;
|
|
26
27
|
/** Payload size in bytes (build report / diagnostics). */
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import type { RequestContextStore } from '../als-registry.js';
|
|
38
|
+
import type { CoercedParams } from '../../shared/param-value.js';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Thrown when a prerendered component touches per-request context at
|
|
@@ -79,7 +80,7 @@ export interface BuildTimeStoreHandle {
|
|
|
79
80
|
*/
|
|
80
81
|
export function createBuildTimeStore(
|
|
81
82
|
componentId: string,
|
|
82
|
-
segmentParams:
|
|
83
|
+
segmentParams: CoercedParams
|
|
83
84
|
): BuildTimeStoreHandle {
|
|
84
85
|
const violations: BuildTimeContextError[] = [];
|
|
85
86
|
const handle: BuildTimeStoreHandle = {
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
import { createElement } from 'react';
|
|
46
46
|
|
|
47
47
|
import { requestContextAls } from './als-registry.js';
|
|
48
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
48
49
|
import { ParamCoercionError } from './route-element-builder.js';
|
|
49
50
|
import { SlotOutlet } from '../client/slot-outlet.js';
|
|
50
51
|
import { coerceSegmentParams } from './param-coercion.js';
|
|
@@ -76,8 +77,8 @@ export interface CapturedEntry {
|
|
|
76
77
|
componentId: string;
|
|
77
78
|
/** `computeComponentCacheKey(callSiteProps, coercedParams)` — names the .flight file. */
|
|
78
79
|
cacheKey: string;
|
|
79
|
-
/** The
|
|
80
|
-
params:
|
|
80
|
+
/** The param combo — manifest metadata, not the hash input. */
|
|
81
|
+
params: CoercedParams;
|
|
81
82
|
/** Call-site props used for this entry (empty object for zero-prop usages). */
|
|
82
83
|
props?: Record<string, unknown>;
|
|
83
84
|
bytes: Uint8Array;
|
|
@@ -91,7 +92,7 @@ export interface CapturedEntry {
|
|
|
91
92
|
export interface CaptureIssue {
|
|
92
93
|
componentId: string;
|
|
93
94
|
routeUrlPath: string;
|
|
94
|
-
params:
|
|
95
|
+
params: CoercedParams;
|
|
95
96
|
message: string;
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -219,7 +220,7 @@ async function loadLazyChunk(chunkFile: string): Promise<void> {
|
|
|
219
220
|
|
|
220
221
|
// ─── Param enumeration ────────────────────────────────────────────────────
|
|
221
222
|
|
|
222
|
-
type ParamCombo =
|
|
223
|
+
type ParamCombo = CoercedParams;
|
|
223
224
|
|
|
224
225
|
/**
|
|
225
226
|
* Resolve the param combos to capture for a route. Static routes have
|
|
@@ -333,7 +334,7 @@ interface EntryOutcome {
|
|
|
333
334
|
async function renderEntry(
|
|
334
335
|
registration: PrebuiltRegistration,
|
|
335
336
|
props: Record<string, unknown>,
|
|
336
|
-
coercedParams:
|
|
337
|
+
coercedParams: CoercedParams,
|
|
337
338
|
timeoutMs: number,
|
|
338
339
|
isLayout: boolean
|
|
339
340
|
): Promise<EntryOutcome> {
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
import { createElement } from 'react';
|
|
37
37
|
|
|
38
38
|
import { requestContextAls, type RequestContextStore } from './als-registry.js';
|
|
39
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
40
|
+
import type { SlotParamsRecord } from '../shared/slot-params.js';
|
|
39
41
|
import { computeComponentCacheKey, splitSlotProps, tryCanonicalize } from './prebuilt/cache-key.js';
|
|
40
42
|
import {
|
|
41
43
|
isChildrenSlotOutletElement,
|
|
@@ -172,9 +174,9 @@ const registry = new Map<string, PrebuiltRegistration>();
|
|
|
172
174
|
* aliases, …) is unkeyable here too, and this guard can never throw.
|
|
173
175
|
*/
|
|
174
176
|
function resolveKeyParams(store: {
|
|
175
|
-
segmentParams?:
|
|
176
|
-
slotParams?:
|
|
177
|
-
}):
|
|
177
|
+
segmentParams?: CoercedParams;
|
|
178
|
+
slotParams?: SlotParamsRecord;
|
|
179
|
+
}): CoercedParams | null {
|
|
178
180
|
const main = store.segmentParams ?? {};
|
|
179
181
|
const slots = store.slotParams;
|
|
180
182
|
if (!slots || Object.keys(slots).length === 0) return main;
|