@timber-js/app 0.2.0-alpha.190 → 0.2.0-alpha.191
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/index.js +4 -4
- 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/package.json +1 -1
- 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/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
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*
|
|
18
18
|
* See design/41-global-params.md §"Params in a parallel slot".
|
|
19
19
|
*/
|
|
20
|
+
import type { CoercedParams } from './param-value.js';
|
|
20
21
|
/** Slot tree path → that slot's own coerced params. */
|
|
21
|
-
export type SlotParamsRecord = Record<string,
|
|
22
|
+
export type SlotParamsRecord = Record<string, CoercedParams>;
|
|
22
23
|
/**
|
|
23
24
|
* The params a slot sees: the main route's as a base, the slot's own on top.
|
|
24
25
|
*
|
|
@@ -34,7 +35,7 @@ export type SlotParamsRecord = Record<string, Record<string, string | string[]>>
|
|
|
34
35
|
* a function instead of `undefined` — in the one param record that was built
|
|
35
36
|
* by merging. See design/13-security.md #36c.
|
|
36
37
|
*/
|
|
37
|
-
export declare function mergeSlotParams(mainParams:
|
|
38
|
+
export declare function mergeSlotParams(mainParams: CoercedParams, slotParams: CoercedParams): CoercedParams;
|
|
38
39
|
/**
|
|
39
40
|
* Resolve the params for a segment path against a published slot map.
|
|
40
41
|
*
|
|
@@ -47,5 +48,5 @@ export declare function mergeSlotParams(mainParams: Record<string, string | stri
|
|
|
47
48
|
* JSON, so a segment path of `constructor` or `toString` would otherwise
|
|
48
49
|
* resolve to an inherited member of `Object.prototype`.
|
|
49
50
|
*/
|
|
50
|
-
export declare function resolveSegmentParams(mainParams:
|
|
51
|
+
export declare function resolveSegmentParams(mainParams: CoercedParams, slotParams: SlotParamsRecord | null | undefined, segmentPath: string | undefined): CoercedParams;
|
|
51
52
|
//# sourceMappingURL=slot-params.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slot-params.d.ts","sourceRoot":"","sources":["../../src/shared/slot-params.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,
|
|
1
|
+
{"version":3,"file":"slot-params.d.ts","sourceRoot":"","sources":["../../src/shared/slot-params.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,uDAAuD;AACvD,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;AAE7D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,aAAa,EACzB,UAAU,EAAE,aAAa,GACxB,aAAa,CAEf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,aAAa,EACzB,UAAU,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,EAC/C,WAAW,EAAE,MAAM,GAAG,SAAS,GAC9B,aAAa,CAGf"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.191",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import React, { createElement, useMemo, use } from 'react';
|
|
34
34
|
import { _setCurrentParams, _setCurrentSlotParams } from './state.js';
|
|
35
|
-
import { toNullProtoRecord } from '../shared/param-value.js';
|
|
35
|
+
import { toNullProtoRecord, type CoercedParams } from '../shared/param-value.js';
|
|
36
36
|
import { readPublishedParams, type PublishedParams } from '../shared/payload-root.js';
|
|
37
37
|
import type { SlotParamsRecord } from '../shared/slot-params.js';
|
|
38
38
|
|
|
@@ -103,7 +103,7 @@ export function useParamsContext(): ParamsContextValue | null {
|
|
|
103
103
|
// ─── Provider ────────────────────────────────────────────────────
|
|
104
104
|
|
|
105
105
|
interface ParamsProviderProps {
|
|
106
|
-
params:
|
|
106
|
+
params: CoercedParams;
|
|
107
107
|
slotParams: SlotParamsRecord | null;
|
|
108
108
|
children?: React.ReactNode;
|
|
109
109
|
}
|
package/src/client/ssr-data.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
_setSsrDataProvider,
|
|
29
29
|
_setCurrentSsrData,
|
|
30
30
|
} from './state.js';
|
|
31
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
31
32
|
import type { SlotParamsRecord } from '../shared/slot-params.js';
|
|
32
33
|
|
|
33
34
|
// ─── Types ────────────────────────────────────────────────────────
|
|
@@ -40,7 +41,7 @@ export interface SsrData {
|
|
|
40
41
|
/** The request's cookies as name→value pairs */
|
|
41
42
|
cookies: Map<string, string>;
|
|
42
43
|
/** The request's route params (e.g. { id: '123' }) */
|
|
43
|
-
params:
|
|
44
|
+
params: CoercedParams;
|
|
44
45
|
/**
|
|
45
46
|
* Per-slot params keyed by slot tree path, absent when the request rendered
|
|
46
47
|
* no slot with params of its own (TIM-1285).
|
package/src/client/state.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* §"Singleton State Registry".
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
22
23
|
import type { SlotParamsRecord } from '../shared/slot-params.js';
|
|
23
24
|
import type { RouterInstance } from './router-types.js';
|
|
24
25
|
import type { SsrData } from './ssr-data.js';
|
|
@@ -54,9 +55,9 @@ export function _setCurrentSsrData(data: SsrData | undefined): void {
|
|
|
54
55
|
// ─── Route Params (from use-segment-params.ts) ──────────────────────────────────────
|
|
55
56
|
|
|
56
57
|
/** Current route params snapshot — replaced (not mutated) on each navigation. */
|
|
57
|
-
export let currentParams:
|
|
58
|
+
export let currentParams: CoercedParams = {};
|
|
58
59
|
|
|
59
|
-
export function _setCurrentParams(params:
|
|
60
|
+
export function _setCurrentParams(params: CoercedParams): void {
|
|
60
61
|
currentParams = params;
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* Design doc: design/09-typescript.md §"Typed Routes"
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
33
34
|
import type { Routes } from '../index.js';
|
|
34
35
|
import { getSsrData } from './ssr-data.js';
|
|
35
36
|
import {
|
|
@@ -61,7 +62,7 @@ export function subscribe(callback: () => void): () => void {
|
|
|
61
62
|
* Get the current params snapshot (module-level fallback).
|
|
62
63
|
* Used by tests and by the hook when called outside a React component.
|
|
63
64
|
*/
|
|
64
|
-
export function getSnapshot():
|
|
65
|
+
export function getSnapshot(): CoercedParams {
|
|
65
66
|
return currentParams;
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -84,7 +85,7 @@ export function getSnapshot(): Record<string, string | string[]> {
|
|
|
84
85
|
* During SSR, params are also available via getSsrData().params
|
|
85
86
|
* (ALS-backed).
|
|
86
87
|
*/
|
|
87
|
-
export function setCurrentParams(params:
|
|
88
|
+
export function setCurrentParams(params: CoercedParams): void {
|
|
88
89
|
_setCurrentParams(params);
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -140,9 +141,9 @@ export function notifyParamsListeners(): void {
|
|
|
140
141
|
*/
|
|
141
142
|
export function useSegmentParams<R extends keyof Routes>(
|
|
142
143
|
segmentPath: R
|
|
143
|
-
): Routes[R] extends { segmentParams: infer P } ? P :
|
|
144
|
-
export function useSegmentParams(segmentPath?: string):
|
|
145
|
-
export function useSegmentParams(segmentPath?: string):
|
|
144
|
+
): Routes[R] extends { segmentParams: infer P } ? P : CoercedParams;
|
|
145
|
+
export function useSegmentParams(segmentPath?: string): CoercedParams;
|
|
146
|
+
export function useSegmentParams(segmentPath?: string): CoercedParams {
|
|
146
147
|
// Try the client-owned provider first. It sits above everything a navigation
|
|
147
148
|
// renders, so any component on the page — initial document, full navigation,
|
|
148
149
|
// or a layout the server skipped — has one above it. Absent during SSR,
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
* `ManifestSegmentNode` (request time) — keys depend only on the
|
|
23
23
|
* URL path and the segment classification, never on file payloads.
|
|
24
24
|
*/
|
|
25
|
+
|
|
26
|
+
import type { CoercedParams } from '../shared/param-value.js';
|
|
27
|
+
|
|
25
28
|
export interface SegmentKeyInput {
|
|
26
29
|
urlPath: string;
|
|
27
30
|
segmentName?: string;
|
|
@@ -170,14 +173,14 @@ export function computeSlotContentKey(
|
|
|
170
173
|
slotKey: string,
|
|
171
174
|
ownerParts: string[],
|
|
172
175
|
entryFile: string | null,
|
|
173
|
-
slotParams:
|
|
176
|
+
slotParams: CoercedParams
|
|
174
177
|
): string {
|
|
175
178
|
const owner = ownerParts.join('/');
|
|
176
179
|
const entry = entryFile ?? '\x01';
|
|
177
180
|
const paramKeys = Object.keys(slotParams).sort();
|
|
178
181
|
const paramParts = paramKeys.map((k) => {
|
|
179
182
|
const v = slotParams[k];
|
|
180
|
-
return Array.isArray(v) ? `${k}=${v.join('\x02')}` : `${k}=${v}`;
|
|
183
|
+
return Array.isArray(v) ? `${k}=${v.map(String).join('\x02')}` : `${k}=${String(v)}`;
|
|
181
184
|
});
|
|
182
185
|
return [slotKey, owner, entry, ...paramParts].join('\0');
|
|
183
186
|
}
|
|
@@ -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) {
|