@timber-js/app 0.2.0-alpha.121 → 0.2.0-alpha.123
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/{actions-Dy8vOjFt.js → actions-ZlcKx0H9.js} +5 -5
- package/dist/_chunks/actions-ZlcKx0H9.js.map +1 -0
- package/dist/_chunks/{build-output-helper-B1n0sUqj.js → build-output-helper-DYOWsPjZ.js} +47 -6
- package/dist/_chunks/build-output-helper-DYOWsPjZ.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-4HcQIziG.js +2 -0
- package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js → cli-schema-sync-VcCf1TFY.js} +17 -3
- package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js.map → cli-schema-sync-VcCf1TFY.js.map} +1 -1
- package/dist/_chunks/{define-CPVQaWbx.js → define-C0V_eKiW.js} +6 -18
- package/dist/_chunks/define-C0V_eKiW.js.map +1 -0
- package/dist/_chunks/{handler-store-CcwNX-gk.js → handler-store-bIAstrxQ.js} +1 -1
- package/dist/_chunks/{handler-store-CcwNX-gk.js.map → handler-store-bIAstrxQ.js.map} +1 -1
- package/dist/_chunks/{merge-search-params-az6ZiBeU.js → merge-search-params-Cm1j99HC.js} +1 -1
- package/dist/_chunks/{merge-search-params-az6ZiBeU.js.map → merge-search-params-Cm1j99HC.js.map} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-DESG8uk5.js} +32 -19
- package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -0
- package/dist/_chunks/{navigation-root-DoRTeBm5.js → navigation-root-Ds_hDtRW.js} +2 -2
- package/dist/_chunks/{navigation-root-DoRTeBm5.js.map → navigation-root-Ds_hDtRW.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-Bf8eC6q6.js → resolve-schema-DU2nbVOi.js} +5 -1
- package/dist/_chunks/resolve-schema-DU2nbVOi.js.map +1 -0
- package/dist/_chunks/{router-ref-CpvjBqdu.js → router-ref-B2-2kIIC.js} +2 -2
- package/dist/_chunks/{router-ref-CpvjBqdu.js.map → router-ref-B2-2kIIC.js.map} +1 -1
- package/dist/_chunks/{segment-classify-CnfLLvir.js → segment-classify-BjfuctV2.js} +1 -1
- package/dist/_chunks/{segment-classify-CnfLLvir.js.map → segment-classify-BjfuctV2.js.map} +1 -1
- package/dist/_chunks/{segment-context-EsfQpS--.js → segment-context-Bg0pFbLT.js} +1 -1
- package/dist/_chunks/{segment-context-EsfQpS--.js.map → segment-context-Bg0pFbLT.js.map} +1 -1
- package/dist/_chunks/{ssr-data-m9-cNEIw.js → ssr-data-DVX8S7vj.js} +1 -1
- package/dist/_chunks/{ssr-data-m9-cNEIw.js.map → ssr-data-DVX8S7vj.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-CtIAjqK0.js → use-segment-params-Du7u25cK.js} +3 -8
- package/dist/_chunks/{use-segment-params-CtIAjqK0.js.map → use-segment-params-Du7u25cK.js.map} +1 -1
- package/dist/_chunks/{validation-BMU5MPzP.js → validation-AfEFM5ze.js} +1 -1
- package/dist/_chunks/{validation-BMU5MPzP.js.map → validation-AfEFM5ze.js.map} +1 -1
- package/dist/_chunks/{walkers-DNp9tHyV.js → walkers-tkw7sDlj.js} +12 -9
- package/dist/_chunks/walkers-tkw7sDlj.js.map +1 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +48 -15
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +67 -32
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +9 -5
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +8 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/shallow-url.d.ts +10 -0
- package/dist/client/shallow-url.d.ts.map +1 -0
- package/dist/cookies/index.js +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +102 -10
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/params/resolve-schema.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +18 -0
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +5 -4
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/segment-params/define.d.ts +0 -6
- package/dist/segment-params/define.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/actions.d.ts +3 -8
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +6 -0
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.d.ts +1 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +15 -6
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +23 -7
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +4 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tree-match.d.ts +12 -4
- package/dist/server/tree-match.d.ts.map +1 -1
- package/dist/server/types.d.ts +2 -2
- package/package.json +6 -7
- package/src/adapters/build-output-helper.ts +67 -5
- package/src/cli-schema-sync.ts +25 -4
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +9 -10
- package/src/client/browser-entry/index.ts +1 -1
- package/src/client/browser-entry/post-hydration.ts +12 -6
- package/src/client/index.ts +1 -5
- package/src/client/link.tsx +34 -21
- package/src/client/router.ts +74 -25
- package/src/client/rsc-fetch.ts +20 -5
- package/src/client/shallow-url.ts +22 -0
- package/src/index.ts +2 -0
- package/src/params/resolve-schema.ts +15 -0
- package/src/plugins/build-manifest.ts +57 -5
- package/src/plugins/cloudflare-wasm.ts +103 -0
- package/src/plugins/dev-server.ts +4 -2
- package/src/plugins/routing.ts +1 -1
- package/src/routing/codegen-shared.ts +8 -5
- package/src/routing/codegen.ts +5 -0
- package/src/routing/link-codegen.ts +1 -1
- package/src/segment-params/define.ts +2 -29
- package/src/server/actions.ts +4 -14
- package/src/server/build-manifest.ts +7 -0
- package/src/server/internal.ts +7 -1
- package/src/server/metadata-routes.ts +44 -19
- package/src/server/pipeline-phases.ts +8 -0
- package/src/server/request-context.ts +0 -4
- package/src/server/route-element-builder.ts +57 -11
- package/src/server/route-matcher.ts +81 -49
- package/src/server/rsc-entry/index.ts +7 -1
- package/src/server/rsc-entry/render-route.ts +7 -1
- package/src/server/ssr-entry.ts +6 -0
- package/src/server/tree-match.ts +23 -11
- package/src/server/types.ts +2 -2
- package/dist/_chunks/actions-Dy8vOjFt.js.map +0 -1
- package/dist/_chunks/build-output-helper-B1n0sUqj.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-DHQLjFu3.js +0 -2
- package/dist/_chunks/define-CPVQaWbx.js.map +0 -1
- package/dist/_chunks/metadata-routes-Cjmvi3rQ.js.map +0 -1
- package/dist/_chunks/resolve-schema-Bf8eC6q6.js.map +0 -1
- package/dist/_chunks/walkers-DNp9tHyV.js.map +0 -1
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isStandardSchema,
|
|
21
21
|
isCodec,
|
|
22
22
|
} from '../schema-bridge.js';
|
|
23
|
+
import { useSegmentParams as clientUseSegmentParams } from '../client/use-segment-params.js';
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Server-only ALS reference for .get()
|
|
@@ -28,28 +29,6 @@ import {
|
|
|
28
29
|
// Same pattern as search-params: eagerly registered at server startup
|
|
29
30
|
// to avoid dynamic imports that lose ALS context. See TIM-523.
|
|
30
31
|
let _getSegmentParamsFn: (() => Record<string, string | string[]>) | undefined;
|
|
31
|
-
let _useSegmentParamsHook: (() => Record<string, string | string[]>) | undefined;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Register the client useSegmentParams hook.
|
|
35
|
-
* Called by client entry at module load time to avoid require() at call time.
|
|
36
|
-
* @internal
|
|
37
|
-
*/
|
|
38
|
-
export function _registerUseSegmentParams(hook: () => Record<string, string | string[]>): void {
|
|
39
|
-
_useSegmentParamsHook = hook;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Self-register on the client when the barrel hasn't been imported yet.
|
|
43
|
-
// Handles the edge case where a client component imports only from params.ts
|
|
44
|
-
// without importing anything from @timber-js/app/client (which runs the
|
|
45
|
-
// registration). The dynamic import resolves before React hydration in Vite.
|
|
46
|
-
if (typeof window !== 'undefined' && !_useSegmentParamsHook) {
|
|
47
|
-
import('../client/use-segment-params.js').then((mod) => {
|
|
48
|
-
if (!_useSegmentParamsHook) {
|
|
49
|
-
_useSegmentParamsHook = mod.useSegmentParams;
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
32
|
|
|
54
33
|
/**
|
|
55
34
|
* Register the getSegmentParams function. Called once at module load time
|
|
@@ -311,13 +290,7 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
|
|
|
311
290
|
// Client hook that reads typed params from the navigation context.
|
|
312
291
|
// Delegates to the existing useSegmentParams hook from use-segment-params.ts.
|
|
313
292
|
function useSegmentParams(): T {
|
|
314
|
-
|
|
315
|
-
throw new Error(
|
|
316
|
-
'[timber] segmentParams.useSegmentParams() requires @timber-js/app/client to be loaded. ' +
|
|
317
|
-
'This hook can only be used in client components.'
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
const raw = _useSegmentParamsHook();
|
|
293
|
+
const raw = clientUseSegmentParams();
|
|
321
294
|
// Params are already coerced by the server pipeline before being sent
|
|
322
295
|
// to the client — return as-is. Re-parsing would break non-idempotent
|
|
323
296
|
// codecs (e.g., z.coerce.number() on an already-parsed number). See TIM-574.
|
package/src/server/actions.ts
CHANGED
|
@@ -60,21 +60,11 @@ export interface ActionHandlerResult {
|
|
|
60
60
|
// (the previous module-level global was vulnerable to cross-request pollution).
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
* @internal —
|
|
63
|
+
* Run `fn` inside a revalidation ALS scope.
|
|
64
|
+
* @internal — for tests that call revalidatePath/Tag directly without executeAction().
|
|
65
65
|
*/
|
|
66
|
-
export function
|
|
67
|
-
|
|
68
|
-
// directly without going through executeAction().
|
|
69
|
-
revalidationAls.enterWith(state);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Clear the revalidation state after action execution.
|
|
74
|
-
* @internal — kept for test compatibility.
|
|
75
|
-
*/
|
|
76
|
-
export function _clearRevalidationState(): void {
|
|
77
|
-
revalidationAls.enterWith(undefined as unknown as RevalidationState);
|
|
66
|
+
export function _runWithRevalidationState<T>(state: RevalidationState, fn: () => T): T {
|
|
67
|
+
return revalidationAls.run(state, fn);
|
|
78
68
|
}
|
|
79
69
|
|
|
80
70
|
/**
|
|
@@ -35,6 +35,12 @@ export interface BuildManifest {
|
|
|
35
35
|
modulepreload: Record<string, string[]>;
|
|
36
36
|
/** Map from input file path to font assets used by that module. */
|
|
37
37
|
fonts: Record<string, ManifestFontEntry[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Cache-busting hashes for metadata route files (opengraph-image, etc.).
|
|
40
|
+
* Map from file path (relative to project root) to short content hash.
|
|
41
|
+
* In dev mode this is empty — a startup nonce is used instead.
|
|
42
|
+
*/
|
|
43
|
+
metadataRouteHashes?: Record<string, string>;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
/** Empty build manifest used in dev mode. */
|
|
@@ -43,6 +49,7 @@ export const EMPTY_BUILD_MANIFEST: BuildManifest = {
|
|
|
43
49
|
js: {},
|
|
44
50
|
modulepreload: {},
|
|
45
51
|
fonts: {},
|
|
52
|
+
metadataRouteHashes: {},
|
|
46
53
|
};
|
|
47
54
|
|
|
48
55
|
/** Segment shape expected by collectRouteCss (matches ManifestSegmentNode). */
|
package/src/server/internal.ts
CHANGED
|
@@ -114,7 +114,13 @@ export {
|
|
|
114
114
|
getMetadataRouteAutoLink,
|
|
115
115
|
METADATA_ROUTE_CONVENTIONS,
|
|
116
116
|
} from './metadata-routes.js';
|
|
117
|
-
export type {
|
|
117
|
+
export type {
|
|
118
|
+
MetadataRouteInfo,
|
|
119
|
+
MetadataRouteType,
|
|
120
|
+
AutoLinkTag,
|
|
121
|
+
AutoLinkLink,
|
|
122
|
+
AutoLinkMeta,
|
|
123
|
+
} from './metadata-routes.js';
|
|
118
124
|
|
|
119
125
|
// ── Server Actions (pipeline internals) ──────────────────────────────────
|
|
120
126
|
export { executeAction, buildNoJsResponse, isRscActionRequest } from './actions.js';
|
|
@@ -29,7 +29,6 @@ export type MetadataRouteType =
|
|
|
29
29
|
| 'favicon'
|
|
30
30
|
| 'icon'
|
|
31
31
|
| 'opengraph-image'
|
|
32
|
-
| 'twitter-image'
|
|
33
32
|
| 'apple-icon';
|
|
34
33
|
|
|
35
34
|
// ─── Convention Table ────────────────────────────────────────────────────────
|
|
@@ -84,7 +83,7 @@ export const METADATA_ROUTE_CONVENTIONS: Record<
|
|
|
84
83
|
contentType: 'image/x-icon',
|
|
85
84
|
nestable: false,
|
|
86
85
|
staticExtensions: ['ico'],
|
|
87
|
-
dynamicExtensions: [],
|
|
86
|
+
dynamicExtensions: ['ts', 'tsx'],
|
|
88
87
|
servePath: 'favicon.ico',
|
|
89
88
|
},
|
|
90
89
|
'icon': {
|
|
@@ -103,14 +102,7 @@ export const METADATA_ROUTE_CONVENTIONS: Record<
|
|
|
103
102
|
dynamicExtensions: ['ts', 'tsx'],
|
|
104
103
|
servePath: 'opengraph-image',
|
|
105
104
|
},
|
|
106
|
-
|
|
107
|
-
type: 'twitter-image',
|
|
108
|
-
contentType: 'image/*',
|
|
109
|
-
nestable: true,
|
|
110
|
-
staticExtensions: ['png', 'jpg'],
|
|
111
|
-
dynamicExtensions: ['ts', 'tsx'],
|
|
112
|
-
servePath: 'twitter-image',
|
|
113
|
-
},
|
|
105
|
+
|
|
114
106
|
'apple-icon': {
|
|
115
107
|
type: 'apple-icon',
|
|
116
108
|
contentType: 'image/*',
|
|
@@ -225,26 +217,59 @@ export function getMetadataRouteServePath(type: MetadataRouteType): string {
|
|
|
225
217
|
throw new Error(`[timber] Unknown metadata route type: ${type}`);
|
|
226
218
|
}
|
|
227
219
|
|
|
220
|
+
const METADATA_SERVE_PATHS = new Set(
|
|
221
|
+
Object.values(METADATA_ROUTE_CONVENTIONS).map((c) => c.servePath)
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
export function isMetadataRouteServePath(pathname: string): boolean {
|
|
225
|
+
const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);
|
|
226
|
+
return METADATA_SERVE_PATHS.has(lastSegment);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A <link> auto-link tag. */
|
|
230
|
+
export interface AutoLinkLink {
|
|
231
|
+
tag: 'link';
|
|
232
|
+
rel: string;
|
|
233
|
+
href: string;
|
|
234
|
+
type?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A <meta> auto-link tag. */
|
|
238
|
+
export interface AutoLinkMeta {
|
|
239
|
+
tag: 'meta';
|
|
240
|
+
property?: string;
|
|
241
|
+
name?: string;
|
|
242
|
+
content: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export type AutoLinkTag = AutoLinkLink | AutoLinkMeta;
|
|
246
|
+
|
|
228
247
|
/**
|
|
229
248
|
* Get the auto-link tags to inject into <head> for metadata route files
|
|
230
249
|
* discovered in a segment.
|
|
231
250
|
*
|
|
251
|
+
* Returns link tags for icon/apple-icon/manifest, and meta tags for
|
|
252
|
+
* opengraph-image (emits both og:image and twitter:image). Returns null
|
|
253
|
+
* for types that don't auto-link (favicon, sitemap, robots).
|
|
254
|
+
*
|
|
232
255
|
* @param type - The metadata route type
|
|
233
256
|
* @param href - The resolved URL path to the metadata route
|
|
234
|
-
* @returns
|
|
257
|
+
* @returns Tag descriptor(s) for the <head>, or null if no auto-link
|
|
235
258
|
*/
|
|
236
|
-
export function getMetadataRouteAutoLink(
|
|
237
|
-
type: MetadataRouteType,
|
|
238
|
-
href: string
|
|
239
|
-
): { rel: string; href: string; type?: string } | null {
|
|
259
|
+
export function getMetadataRouteAutoLink(type: MetadataRouteType, href: string): AutoLinkTag[] {
|
|
240
260
|
switch (type) {
|
|
241
261
|
case 'icon':
|
|
242
|
-
return { rel: 'icon', href };
|
|
262
|
+
return [{ tag: 'link', rel: 'icon', href }];
|
|
243
263
|
case 'apple-icon':
|
|
244
|
-
return { rel: 'apple-touch-icon', href };
|
|
264
|
+
return [{ tag: 'link', rel: 'apple-touch-icon', href }];
|
|
245
265
|
case 'manifest':
|
|
246
|
-
return { rel: 'manifest', href };
|
|
266
|
+
return [{ tag: 'link', rel: 'manifest', href }];
|
|
267
|
+
case 'opengraph-image':
|
|
268
|
+
return [
|
|
269
|
+
{ tag: 'meta', property: 'og:image', content: href },
|
|
270
|
+
{ tag: 'meta', name: 'twitter:image', content: href },
|
|
271
|
+
];
|
|
247
272
|
default:
|
|
248
|
-
return
|
|
273
|
+
return [];
|
|
249
274
|
}
|
|
250
275
|
}
|
|
@@ -232,6 +232,7 @@ export async function handleRequest(
|
|
|
232
232
|
return await serveStaticMetadataFile(metaMatch);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
setSegmentParams(metaMatch.segmentParams);
|
|
235
236
|
const mod = await loadModule<{ default?: MetadataHandler }>(metaMatch.file);
|
|
236
237
|
if (typeof mod.default !== 'function') {
|
|
237
238
|
if (isDebug()) {
|
|
@@ -247,6 +248,13 @@ export async function handleRequest(
|
|
|
247
248
|
// outer Server-Timing writer can append without hitting an
|
|
248
249
|
// immutable header bag (e.g. user returns Response.redirect()).
|
|
249
250
|
if (handlerResult instanceof Response) {
|
|
251
|
+
if (method === 'HEAD') {
|
|
252
|
+
return new Response(null, {
|
|
253
|
+
status: handlerResult.status,
|
|
254
|
+
statusText: handlerResult.statusText,
|
|
255
|
+
headers: new Headers(handlerResult.headers),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
250
258
|
return cloneWithMutableHeaders(handlerResult);
|
|
251
259
|
}
|
|
252
260
|
// Otherwise, serialize based on content type. The type discriminator
|
|
@@ -23,10 +23,6 @@ import { isDebug } from './debug.js';
|
|
|
23
23
|
// Re-export the ALS for framework-internal consumers that need direct access.
|
|
24
24
|
export { requestContextAls };
|
|
25
25
|
|
|
26
|
-
// No fallback needed — we use enterWith() instead of run() to ensure
|
|
27
|
-
// the ALS context persists for the entire request lifecycle including
|
|
28
|
-
// async stream consumption by React's renderToReadableStream.
|
|
29
|
-
|
|
30
26
|
// ─── Public API ───────────────────────────────────────────────────────────
|
|
31
27
|
|
|
32
28
|
/**
|
|
@@ -17,14 +17,22 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { createElement } from 'react';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
20
21
|
|
|
21
22
|
import { withSpan } from './tracing.js';
|
|
22
23
|
import type { RouteMatch } from './pipeline.js';
|
|
23
24
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
24
25
|
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
25
|
-
import type { HeadElement as MetadataHeadElement } from './metadata.js';
|
|
26
26
|
import type { Metadata } from './types.js';
|
|
27
27
|
import { METADATA_ROUTE_CONVENTIONS, getMetadataRouteAutoLink } from './metadata-routes.js';
|
|
28
|
+
|
|
29
|
+
// In dev mode, use a per-startup nonce for metadata route cache busting
|
|
30
|
+
// instead of per-file content hashes (avoids rehashing on every request).
|
|
31
|
+
let _devNonce: string | undefined;
|
|
32
|
+
function getDevNonce(): string {
|
|
33
|
+
_devNonce ??= randomUUID().slice(0, 8);
|
|
34
|
+
return _devNonce;
|
|
35
|
+
}
|
|
28
36
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
29
37
|
import { AccessGate } from './access-gate.js';
|
|
30
38
|
import {
|
|
@@ -215,7 +223,8 @@ export async function buildRouteElement(
|
|
|
215
223
|
req: Request,
|
|
216
224
|
match: RouteMatch,
|
|
217
225
|
interception?: InterceptionContext,
|
|
218
|
-
clientStateTree?: Set<string> | null
|
|
226
|
+
clientStateTree?: Set<string> | null,
|
|
227
|
+
metadataRouteHashes?: Record<string, string>
|
|
219
228
|
): Promise<RouteElementResult> {
|
|
220
229
|
const segments = match.segments;
|
|
221
230
|
|
|
@@ -309,8 +318,20 @@ export async function buildRouteElement(
|
|
|
309
318
|
const resolvedMetadata = resolveMetadata(metadataEntries);
|
|
310
319
|
const headElements = renderMetadataToElements(resolvedMetadata);
|
|
311
320
|
|
|
312
|
-
// Auto-link metadata route files (icon, apple-icon, manifest)
|
|
321
|
+
// Auto-link metadata route files (icon, apple-icon, manifest, opengraph-image).
|
|
322
|
+
// opengraph-image emits both og:image and twitter:image (no separate twitter-image convention).
|
|
323
|
+
// Skip OG auto-linking when the user already declared images in metadata.
|
|
313
324
|
// See design/16-metadata.md §"Auto-Linking"
|
|
325
|
+
const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);
|
|
326
|
+
const requestUrl = new URL(req.url);
|
|
327
|
+
const requestPathname = requestUrl.pathname;
|
|
328
|
+
// In dev mode, use the request origin so OG URLs resolve to localhost.
|
|
329
|
+
// In production, use metadataBase (the canonical domain).
|
|
330
|
+
const ogBase =
|
|
331
|
+
process.env.NODE_ENV !== 'production'
|
|
332
|
+
? new URL(requestUrl.origin)
|
|
333
|
+
: resolvedMetadata.metadataBase;
|
|
334
|
+
|
|
314
335
|
for (const segment of segments) {
|
|
315
336
|
if (!segment.metadataRoutes) continue;
|
|
316
337
|
for (const baseName of Object.keys(segment.metadataRoutes)) {
|
|
@@ -318,14 +339,39 @@ export async function buildRouteElement(
|
|
|
318
339
|
if (!convention) continue;
|
|
319
340
|
// Non-nestable routes only auto-link from root
|
|
320
341
|
if (!convention.nestable && segment.urlPath !== '/') continue;
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
342
|
+
// Skip auto-linking if user already declared the image in metadata
|
|
343
|
+
if (convention.type === 'opengraph-image' && hasUserOgImage) continue;
|
|
344
|
+
// Build the href using the actual request path (not the pattern with [param]).
|
|
345
|
+
// For nestable routes, the metadata route sits under the same resolved path
|
|
346
|
+
// as the page. For root-only routes, use '/'.
|
|
347
|
+
const resolvedPrefix = convention.nestable
|
|
348
|
+
? requestPathname === '/'
|
|
349
|
+
? ''
|
|
350
|
+
: requestPathname
|
|
351
|
+
: '';
|
|
352
|
+
let href = `${resolvedPrefix}/${convention.servePath}`;
|
|
353
|
+
// Append cache-bust query param for image metadata routes
|
|
354
|
+
if (convention.type === 'opengraph-image') {
|
|
355
|
+
const metaFile = segment.metadataRoutes[baseName];
|
|
356
|
+
const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;
|
|
357
|
+
const cacheBust = fileHash ?? getDevNonce();
|
|
358
|
+
href = `${href}?${cacheBust}`;
|
|
359
|
+
}
|
|
360
|
+
// Resolve to absolute URL for og:image/twitter:image (social crawlers need full URLs)
|
|
361
|
+
if (ogBase && convention.type === 'opengraph-image') {
|
|
362
|
+
href = new URL(href, ogBase).toString();
|
|
363
|
+
}
|
|
364
|
+
for (const autoLink of getMetadataRouteAutoLink(convention.type, href)) {
|
|
365
|
+
if (autoLink.tag === 'link') {
|
|
366
|
+
const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };
|
|
367
|
+
if (autoLink.type) attrs.type = autoLink.type;
|
|
368
|
+
headElements.push({ tag: 'link', attrs });
|
|
369
|
+
} else {
|
|
370
|
+
const attrs: Record<string, string> = { content: autoLink.content };
|
|
371
|
+
if (autoLink.property) attrs.property = autoLink.property;
|
|
372
|
+
if (autoLink.name) attrs.name = autoLink.name;
|
|
373
|
+
headElements.push({ tag: 'meta', attrs });
|
|
374
|
+
}
|
|
329
375
|
}
|
|
330
376
|
}
|
|
331
377
|
}
|
|
@@ -121,73 +121,105 @@ export interface MetadataRouteMatch {
|
|
|
121
121
|
* that should be served from disk rather than executed as a handler.
|
|
122
122
|
*/
|
|
123
123
|
isStatic: boolean;
|
|
124
|
+
/** Extracted segment params from dynamic segments in the URL prefix. */
|
|
125
|
+
segmentParams: Record<string, string | string[]>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Reverse lookup: serve path → convention base name
|
|
129
|
+
const SERVE_PATH_TO_CONVENTION = new Map<string, string>();
|
|
130
|
+
for (const [baseName, convention] of Object.entries(METADATA_ROUTE_CONVENTIONS)) {
|
|
131
|
+
SERVE_PATH_TO_CONVENTION.set(convention.servePath, baseName);
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
/**
|
|
127
135
|
* Create a metadata route matcher from a manifest.
|
|
128
136
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
137
|
+
* Uses dynamic tree matching to resolve metadata routes, supporting
|
|
138
|
+
* dynamic segments like /docs/[slug]/opengraph-image.
|
|
131
139
|
*
|
|
132
140
|
* See design/16-metadata.md §"Metadata Routes"
|
|
133
141
|
*/
|
|
134
142
|
export function createMetadataRouteMatcher(
|
|
135
143
|
manifest: ManifestRoot
|
|
136
144
|
): (pathname: string) => MetadataRouteMatch | null {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
return (pathname: string) => matchMetadataPathname(manifest.root, pathname);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Match a pathname against metadata routes in the segment tree.
|
|
150
|
+
*
|
|
151
|
+
* Checks if the last URL part is a known metadata route serve path,
|
|
152
|
+
* then walks the tree to find the segment that owns the route.
|
|
153
|
+
*/
|
|
154
|
+
function matchMetadataPathname(
|
|
155
|
+
root: ManifestSegmentNode,
|
|
156
|
+
pathname: string
|
|
157
|
+
): MetadataRouteMatch | null {
|
|
158
|
+
const parts = pathname === '/' ? [] : pathname.slice(1).split('/');
|
|
159
|
+
if (parts.length === 0) return null;
|
|
160
|
+
|
|
161
|
+
const lastPart = parts[parts.length - 1];
|
|
162
|
+
const baseName = SERVE_PATH_TO_CONVENTION.get(lastPart);
|
|
163
|
+
if (!baseName) return null;
|
|
164
|
+
|
|
165
|
+
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
166
|
+
if (!convention) return null;
|
|
167
|
+
|
|
168
|
+
// The prefix parts identify which segment owns this metadata route.
|
|
169
|
+
// For root-level routes (e.g. /sitemap.xml), prefix is empty → root segment.
|
|
170
|
+
const prefixParts = parts.slice(0, -1);
|
|
171
|
+
|
|
172
|
+
let segment: ManifestSegmentNode;
|
|
173
|
+
let params: Record<string, string | string[]>;
|
|
174
|
+
|
|
175
|
+
if (prefixParts.length === 0) {
|
|
176
|
+
segment = root;
|
|
177
|
+
params = {};
|
|
178
|
+
} else {
|
|
179
|
+
const result = matchUrlParts(root, prefixParts, { requireTerminal: false });
|
|
180
|
+
if (!result) return null;
|
|
181
|
+
segment = result.chain[result.chain.length - 1];
|
|
182
|
+
params = result.params;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Non-nestable routes (robots, manifest, favicon) only serve from root
|
|
186
|
+
if (!convention.nestable && segment !== root) return null;
|
|
140
187
|
|
|
141
|
-
|
|
188
|
+
const file = findMetadataRouteInSegment(segment, baseName);
|
|
189
|
+
if (!file) return null;
|
|
190
|
+
|
|
191
|
+
const ext = file.filePath.slice(file.filePath.lastIndexOf('.') + 1);
|
|
192
|
+
const isStatic = isStaticMetadataExtension(baseName, ext);
|
|
193
|
+
const contentType = isStatic
|
|
194
|
+
? resolveStaticContentType(convention.contentType, ext)
|
|
195
|
+
: convention.contentType;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
type: convention.type,
|
|
199
|
+
contentType,
|
|
200
|
+
file,
|
|
201
|
+
segment,
|
|
202
|
+
isStatic,
|
|
203
|
+
segmentParams: params,
|
|
204
|
+
};
|
|
142
205
|
}
|
|
143
206
|
|
|
144
207
|
/**
|
|
145
|
-
*
|
|
208
|
+
* Find a metadata route file in a segment, including transparent group children.
|
|
209
|
+
* Groups don't add URL depth, so their metadata routes are reachable at the parent's path.
|
|
146
210
|
*/
|
|
147
|
-
function
|
|
211
|
+
function findMetadataRouteInSegment(
|
|
148
212
|
node: ManifestSegmentNode,
|
|
149
|
-
|
|
150
|
-
):
|
|
151
|
-
if (node.metadataRoutes) {
|
|
152
|
-
|
|
153
|
-
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
154
|
-
if (!convention) continue;
|
|
155
|
-
|
|
156
|
-
// Non-nestable routes (robots, manifest, favicon) only serve from root
|
|
157
|
-
if (!convention.nestable && node.urlPath !== '/') continue;
|
|
158
|
-
|
|
159
|
-
// Build the serve pathname: segment urlPath + serve path
|
|
160
|
-
const prefix = node.urlPath === '/' ? '' : node.urlPath;
|
|
161
|
-
const pathname = `${prefix}/${convention.servePath}`;
|
|
162
|
-
|
|
163
|
-
// Determine if this is a static file based on its extension.
|
|
164
|
-
// Static files (.xml, .txt, .png, .ico, etc.) are served from disk.
|
|
165
|
-
// Dynamic files (.ts, .tsx) export a handler function.
|
|
166
|
-
const ext = file.filePath.slice(file.filePath.lastIndexOf('.') + 1);
|
|
167
|
-
const isStatic = isStaticMetadataExtension(baseName, ext);
|
|
168
|
-
|
|
169
|
-
// Resolve generic content types (e.g. image/*) to concrete MIME types
|
|
170
|
-
// based on the file extension for static files.
|
|
171
|
-
const contentType = isStatic
|
|
172
|
-
? resolveStaticContentType(convention.contentType, ext)
|
|
173
|
-
: convention.contentType;
|
|
174
|
-
|
|
175
|
-
map.set(pathname, {
|
|
176
|
-
type: convention.type,
|
|
177
|
-
contentType,
|
|
178
|
-
file,
|
|
179
|
-
segment: node,
|
|
180
|
-
isStatic,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
213
|
+
baseName: string
|
|
214
|
+
): ManifestFile | null {
|
|
215
|
+
if (node.metadataRoutes?.[baseName]) {
|
|
216
|
+
return node.metadataRoutes[baseName];
|
|
183
217
|
}
|
|
184
|
-
|
|
185
218
|
for (const child of node.children) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
for (const slotNode of Object.values(node.slots)) {
|
|
191
|
-
collectMetadataRoutes(slotNode, map);
|
|
219
|
+
if (child.segmentType === 'group') {
|
|
220
|
+
const found = findMetadataRouteInSegment(child, baseName);
|
|
221
|
+
if (found) return found;
|
|
222
|
+
}
|
|
192
223
|
}
|
|
224
|
+
return null;
|
|
193
225
|
}
|
|
@@ -444,7 +444,13 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
444
444
|
// AccessGate → getSegmentParams() throws because segmentParams
|
|
445
445
|
// is never set. See TIM-667.
|
|
446
446
|
setSegmentParams(revalidateMatch.segmentParams);
|
|
447
|
-
const routeResult = await buildRouteElement(
|
|
447
|
+
const routeResult = await buildRouteElement(
|
|
448
|
+
revalidateReq,
|
|
449
|
+
revalidateMatch,
|
|
450
|
+
undefined,
|
|
451
|
+
null,
|
|
452
|
+
(buildManifest as BuildManifest).metadataRouteHashes
|
|
453
|
+
);
|
|
448
454
|
return {
|
|
449
455
|
element: routeResult.element,
|
|
450
456
|
headElements: routeResult.headElements,
|
|
@@ -108,7 +108,13 @@ export async function renderRoute(
|
|
|
108
108
|
let routeResult;
|
|
109
109
|
const _buildStart = performance.now();
|
|
110
110
|
try {
|
|
111
|
-
routeResult = await buildRouteElement(
|
|
111
|
+
routeResult = await buildRouteElement(
|
|
112
|
+
req,
|
|
113
|
+
match,
|
|
114
|
+
interception,
|
|
115
|
+
clientStateTree,
|
|
116
|
+
buildManifest.metadataRouteHashes
|
|
117
|
+
);
|
|
112
118
|
} catch (error) {
|
|
113
119
|
// Param coercion failed — render the custom 404 page (status files / not-found).
|
|
114
120
|
// Previously returned a bare Response(null, { status: 404 }) which bypassed
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -18,6 +18,12 @@ import config from 'virtual:timber-config';
|
|
|
18
18
|
// during SSR can serialize flat `Partial<T>` values via registry lookup.
|
|
19
19
|
// @ts-expect-error — virtual module provided by timber-routing plugin
|
|
20
20
|
import 'virtual:timber-search-params-registry';
|
|
21
|
+
// TIM-949: Populate the codec registry so server-rendered <Link> components
|
|
22
|
+
// use codec.serialize() instead of String() fallback for URL interpolation.
|
|
23
|
+
// @ts-expect-error — virtual module provided by timber-routing plugin
|
|
24
|
+
import schemaCodecs from 'virtual:timber-schema';
|
|
25
|
+
import { setLinkCodecs } from '../params/codec-registry.js';
|
|
26
|
+
setLinkCodecs(schemaCodecs);
|
|
21
27
|
import {
|
|
22
28
|
createFromReadableStream,
|
|
23
29
|
createFromNodeStream,
|
package/src/server/tree-match.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface TreeMatchResult<TFile> {
|
|
|
27
27
|
params: Record<string, string | string[]>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export interface TreeMatchOptions {
|
|
31
|
+
/**
|
|
32
|
+
* When true (default), only match if the leaf node has a page or route
|
|
33
|
+
* handler. Set to false for metadata route matching, where we just need
|
|
34
|
+
* to find the owning segment regardless of whether it has a page.
|
|
35
|
+
*/
|
|
36
|
+
requireTerminal?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
/**
|
|
31
40
|
* Match URL path parts against a segment tree.
|
|
32
41
|
*
|
|
@@ -34,9 +43,9 @@ export interface TreeMatchResult<TFile> {
|
|
|
34
43
|
* ordering globally across group boundaries. Returns the matched
|
|
35
44
|
* segment chain and collected params, or null if no route matches.
|
|
36
45
|
*
|
|
37
|
-
*
|
|
38
|
-
* the leaf.
|
|
39
|
-
*
|
|
46
|
+
* By default, a match requires a terminal node (one with `page` or
|
|
47
|
+
* `route`) at the leaf. Pass `{ requireTerminal: false }` to match
|
|
48
|
+
* any segment (used by metadata route matching).
|
|
40
49
|
*
|
|
41
50
|
* @param root The root segment node to start matching from.
|
|
42
51
|
* @param parts URL path parts (e.g. ['blog', 'hello-world']).
|
|
@@ -44,12 +53,14 @@ export interface TreeMatchResult<TFile> {
|
|
|
44
53
|
*/
|
|
45
54
|
export function matchUrlParts<TFile>(
|
|
46
55
|
root: SegmentNode<TFile>,
|
|
47
|
-
parts: string[]
|
|
56
|
+
parts: string[],
|
|
57
|
+
options?: TreeMatchOptions
|
|
48
58
|
): TreeMatchResult<TFile> | null {
|
|
49
59
|
const chain: SegmentNode<TFile>[] = [];
|
|
50
60
|
const params: Record<string, string | string[]> = {};
|
|
61
|
+
const requireTerminal = options?.requireTerminal ?? true;
|
|
51
62
|
|
|
52
|
-
if (matchNode(root, parts, 0, chain, params)) {
|
|
63
|
+
if (matchNode(root, parts, 0, chain, params, requireTerminal)) {
|
|
53
64
|
return { chain, params };
|
|
54
65
|
}
|
|
55
66
|
return null;
|
|
@@ -107,19 +118,20 @@ function matchNode<TFile>(
|
|
|
107
118
|
parts: string[],
|
|
108
119
|
index: number,
|
|
109
120
|
chain: SegmentNode<TFile>[],
|
|
110
|
-
params: Record<string, string | string[]
|
|
121
|
+
params: Record<string, string | string[]>,
|
|
122
|
+
requireTerminal: boolean
|
|
111
123
|
): boolean {
|
|
112
124
|
chain.push(node);
|
|
113
125
|
|
|
114
|
-
// All parts consumed — check if this node is terminal
|
|
126
|
+
// All parts consumed — check if this node is terminal (or any node if !requireTerminal)
|
|
115
127
|
if (index >= parts.length) {
|
|
116
|
-
if (isTerminal(node)) return true;
|
|
128
|
+
if (!requireTerminal || isTerminal(node)) return true;
|
|
117
129
|
|
|
118
130
|
// Groups don't consume URL segments, so a group child's page can
|
|
119
131
|
// match at the same URL depth. Recurse into groups transparently.
|
|
120
132
|
for (const child of node.children) {
|
|
121
133
|
if (child.segmentType === 'group') {
|
|
122
|
-
if (matchNode(child, parts, index, chain, params)) {
|
|
134
|
+
if (matchNode(child, parts, index, chain, params, requireTerminal)) {
|
|
123
135
|
return true;
|
|
124
136
|
}
|
|
125
137
|
}
|
|
@@ -149,7 +161,7 @@ function matchNode<TFile>(
|
|
|
149
161
|
for (const { child, groupChain } of effective) {
|
|
150
162
|
if (child.segmentType === 'static' && child.segmentName === part) {
|
|
151
163
|
chain.push(...groupChain);
|
|
152
|
-
if (matchNode(child, parts, index + 1, chain, params)) {
|
|
164
|
+
if (matchNode(child, parts, index + 1, chain, params, requireTerminal)) {
|
|
153
165
|
return true;
|
|
154
166
|
}
|
|
155
167
|
chain.length -= groupChain.length;
|
|
@@ -162,7 +174,7 @@ function matchNode<TFile>(
|
|
|
162
174
|
chain.push(...groupChain);
|
|
163
175
|
const prev = params[child.paramName];
|
|
164
176
|
params[child.paramName] = part;
|
|
165
|
-
if (matchNode(child, parts, index + 1, chain, params)) {
|
|
177
|
+
if (matchNode(child, parts, index + 1, chain, params, requireTerminal)) {
|
|
166
178
|
return true;
|
|
167
179
|
}
|
|
168
180
|
// Backtrack
|
package/src/server/types.ts
CHANGED
|
@@ -176,7 +176,7 @@ export namespace MetadataRoute {
|
|
|
176
176
|
* This is a structural union — the pipeline dispatches on the shape at
|
|
177
177
|
* request time (see `pipeline-phases.ts`):
|
|
178
178
|
* - `Response` → returned as-is (e.g. `ImageResponse`, `Response.redirect()`).
|
|
179
|
-
* Used by `icon.tsx`, `opengraph-image.tsx`,
|
|
179
|
+
* Used by `icon.tsx`, `opengraph-image.tsx`,
|
|
180
180
|
* `apple-icon.tsx`.
|
|
181
181
|
* - `string` → written as the response body verbatim. Used by `robots.ts`
|
|
182
182
|
* and by any handler that wants to emit a pre-serialized payload.
|
|
@@ -192,7 +192,7 @@ export type MetadataResult = Response | string | MetadataRoute.Sitemap | Metadat
|
|
|
192
192
|
/**
|
|
193
193
|
* Default export signature for a dynamic metadata route file
|
|
194
194
|
* (`sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.tsx`,
|
|
195
|
-
* `opengraph-image.tsx`, `
|
|
195
|
+
* `opengraph-image.tsx`, `apple-icon.tsx`).
|
|
196
196
|
*
|
|
197
197
|
* Parameterize `TResult` to narrow the return type for a specific route:
|
|
198
198
|
*
|