@timber-js/app 0.2.0-alpha.121 → 0.2.0-alpha.122

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.
Files changed (89) hide show
  1. package/dist/_chunks/actions-Dy8vOjFt.js.map +1 -1
  2. package/dist/_chunks/{build-output-helper-B1n0sUqj.js → build-output-helper-DYOWsPjZ.js} +47 -6
  3. package/dist/_chunks/build-output-helper-DYOWsPjZ.js.map +1 -0
  4. package/dist/_chunks/cli-schema-sync-4HcQIziG.js +2 -0
  5. package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js → cli-schema-sync-VcCf1TFY.js} +17 -3
  6. package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js.map → cli-schema-sync-VcCf1TFY.js.map} +1 -1
  7. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-DESG8uk5.js} +32 -19
  8. package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -0
  9. package/dist/_chunks/{resolve-schema-Bf8eC6q6.js → resolve-schema-cURaYvaD.js} +5 -1
  10. package/dist/_chunks/resolve-schema-cURaYvaD.js.map +1 -0
  11. package/dist/_chunks/{segment-classify-CnfLLvir.js → segment-classify-BjfuctV2.js} +1 -1
  12. package/dist/_chunks/{segment-classify-CnfLLvir.js.map → segment-classify-BjfuctV2.js.map} +1 -1
  13. package/dist/_chunks/{walkers-DNp9tHyV.js → walkers-tkw7sDlj.js} +12 -9
  14. package/dist/_chunks/walkers-tkw7sDlj.js.map +1 -0
  15. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  16. package/dist/adapters/cloudflare.js +1 -1
  17. package/dist/adapters/nitro.js +1 -1
  18. package/dist/cli-schema-sync.d.ts.map +1 -1
  19. package/dist/cli.js +1 -1
  20. package/dist/client/index.js +1 -1
  21. package/dist/client/index.js.map +1 -1
  22. package/dist/client/link.d.ts +9 -5
  23. package/dist/client/link.d.ts.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +101 -9
  26. package/dist/index.js.map +1 -1
  27. package/dist/params/index.js +1 -1
  28. package/dist/params/resolve-schema.d.ts.map +1 -1
  29. package/dist/plugins/build-manifest.d.ts.map +1 -1
  30. package/dist/plugins/cloudflare-wasm.d.ts +18 -0
  31. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -0
  32. package/dist/plugins/dev-server.d.ts.map +1 -1
  33. package/dist/routing/codegen-shared.d.ts +5 -4
  34. package/dist/routing/codegen-shared.d.ts.map +1 -1
  35. package/dist/routing/index.js +3 -3
  36. package/dist/server/actions.d.ts +3 -8
  37. package/dist/server/actions.d.ts.map +1 -1
  38. package/dist/server/build-manifest.d.ts +6 -0
  39. package/dist/server/build-manifest.d.ts.map +1 -1
  40. package/dist/server/internal.d.ts +1 -1
  41. package/dist/server/internal.d.ts.map +1 -1
  42. package/dist/server/internal.js +13 -4
  43. package/dist/server/internal.js.map +1 -1
  44. package/dist/server/metadata-routes.d.ts +23 -7
  45. package/dist/server/metadata-routes.d.ts.map +1 -1
  46. package/dist/server/pipeline-phases.d.ts.map +1 -1
  47. package/dist/server/request-context.d.ts.map +1 -1
  48. package/dist/server/route-element-builder.d.ts +1 -1
  49. package/dist/server/route-element-builder.d.ts.map +1 -1
  50. package/dist/server/route-matcher.d.ts +4 -2
  51. package/dist/server/route-matcher.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  53. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  54. package/dist/server/ssr-entry.d.ts.map +1 -1
  55. package/dist/server/tree-match.d.ts +12 -4
  56. package/dist/server/tree-match.d.ts.map +1 -1
  57. package/dist/server/types.d.ts +2 -2
  58. package/package.json +1 -1
  59. package/src/adapters/build-output-helper.ts +67 -5
  60. package/src/cli-schema-sync.ts +25 -4
  61. package/src/client/browser-entry/index.ts +1 -1
  62. package/src/client/link.tsx +9 -9
  63. package/src/index.ts +2 -0
  64. package/src/params/resolve-schema.ts +15 -0
  65. package/src/plugins/build-manifest.ts +57 -5
  66. package/src/plugins/cloudflare-wasm.ts +103 -0
  67. package/src/plugins/dev-server.ts +4 -2
  68. package/src/plugins/routing.ts +1 -1
  69. package/src/routing/codegen-shared.ts +8 -5
  70. package/src/routing/codegen.ts +5 -0
  71. package/src/routing/link-codegen.ts +1 -1
  72. package/src/server/actions.ts +4 -14
  73. package/src/server/build-manifest.ts +7 -0
  74. package/src/server/internal.ts +7 -1
  75. package/src/server/metadata-routes.ts +44 -19
  76. package/src/server/pipeline-phases.ts +8 -0
  77. package/src/server/request-context.ts +0 -4
  78. package/src/server/route-element-builder.ts +57 -11
  79. package/src/server/route-matcher.ts +81 -49
  80. package/src/server/rsc-entry/index.ts +7 -1
  81. package/src/server/rsc-entry/render-route.ts +7 -1
  82. package/src/server/ssr-entry.ts +6 -0
  83. package/src/server/tree-match.ts +23 -11
  84. package/src/server/types.ts +2 -2
  85. package/dist/_chunks/build-output-helper-B1n0sUqj.js.map +0 -1
  86. package/dist/_chunks/cli-schema-sync-DHQLjFu3.js +0 -2
  87. package/dist/_chunks/metadata-routes-Cjmvi3rQ.js.map +0 -1
  88. package/dist/_chunks/resolve-schema-Bf8eC6q6.js.map +0 -1
  89. package/dist/_chunks/walkers-DNp9tHyV.js.map +0 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * timber-cloudflare-wasm — Handle WASM modules for Cloudflare Workers builds.
3
+ *
4
+ * Cloudflare Workers can natively import `.wasm` files as compiled
5
+ * WebAssembly.Module. But Rolldown can't load `.wasm` files at build time.
6
+ *
7
+ * This plugin bridges the gap:
8
+ * 1. Adds the `workerd` resolve condition so libraries like @takumi-rs/wasm
9
+ * resolve to their Cloudflare-native entry (bare `.wasm` import)
10
+ * 2. Externalizes `.wasm` imports so Rolldown skips them
11
+ * 3. Copies the WASM files to the build output so wrangler can find them
12
+ *
13
+ * Only active for Cloudflare adapter production builds.
14
+ */
15
+
16
+ import { basename, resolve, dirname } from 'node:path';
17
+ import { copyFile, mkdir } from 'node:fs/promises';
18
+ import type { Plugin, ResolvedConfig } from 'vite';
19
+ import type { PluginContext } from '../plugin-context.js';
20
+
21
+ function isCloudflareAdapter(ctx: PluginContext): boolean {
22
+ const adapter = ctx.config.adapter as { name?: string } | undefined;
23
+ return adapter?.name === 'cloudflare';
24
+ }
25
+
26
+ export function timberCloudflareWasm(ctx: PluginContext): Plugin[] {
27
+ // Track resolved WASM file paths so we can copy them to the output
28
+ const wasmFiles = new Map<string, string>();
29
+
30
+ const plugin: Plugin = {
31
+ name: 'timber-cloudflare-wasm',
32
+
33
+ configResolved(resolved: ResolvedConfig) {
34
+ if (resolved.command !== 'build') return;
35
+ if (!isCloudflareAdapter(ctx)) return;
36
+
37
+ // Prepend `workerd` to resolve conditions for server environments
38
+ // so @takumi-rs/wasm/auto resolves to the Cloudflare-native entry
39
+ // (bare WASM import) instead of the Vite entry (?url + fetch).
40
+ // Must happen in configResolved because the RSC plugin sets its own
41
+ // conditions during config — merging via config() would lose them.
42
+ for (const envName of ['rsc', 'ssr']) {
43
+ const env = resolved.environments[envName];
44
+ if (!env) continue;
45
+ const conditions = env.resolve.conditions;
46
+ if (!conditions.includes('workerd')) {
47
+ conditions.unshift('workerd');
48
+ }
49
+ }
50
+ },
51
+
52
+ resolveId: {
53
+ order: 'pre',
54
+ async handler(source, importer) {
55
+ if (!source.endsWith('.wasm')) return;
56
+
57
+ let absolutePath: string;
58
+ if (source.startsWith('.') && importer) {
59
+ absolutePath = resolve(dirname(importer), source);
60
+ } else {
61
+ return;
62
+ }
63
+
64
+ const filename = basename(absolutePath);
65
+ wasmFiles.set(filename, absolutePath);
66
+
67
+ return { id: './' + filename, external: true };
68
+ },
69
+ },
70
+
71
+ async writeBundle(options, bundle) {
72
+ if (wasmFiles.size === 0) return;
73
+
74
+ const outDir = options.dir;
75
+ if (!outDir) return;
76
+
77
+ // Find which output chunks reference WASM files and copy the WASM
78
+ // to the same directory so the relative imports resolve correctly.
79
+ const targetDirs = new Set<string>();
80
+ for (const chunk of Object.values(bundle)) {
81
+ if (chunk.type !== 'chunk') continue;
82
+ for (const filename of wasmFiles.keys()) {
83
+ if (chunk.code.includes(filename)) {
84
+ targetDirs.add(dirname(resolve(outDir, chunk.fileName)));
85
+ }
86
+ }
87
+ }
88
+
89
+ if (targetDirs.size === 0) return;
90
+
91
+ const copies: Promise<void>[] = [];
92
+ for (const dir of targetDirs) {
93
+ await mkdir(dir, { recursive: true });
94
+ for (const [filename, absolutePath] of wasmFiles) {
95
+ copies.push(copyFile(absolutePath, resolve(dir, filename)));
96
+ }
97
+ }
98
+ await Promise.all(copies);
99
+ },
100
+ };
101
+
102
+ return [plugin];
103
+ }
@@ -31,6 +31,7 @@ import {
31
31
  } from '../dev-tools/error-page.js';
32
32
  import { addVirtualModuleContext } from '../config-validation.js';
33
33
  import { compressResponse } from '../server/compress.js';
34
+ import { isMetadataRouteServePath } from '../server/metadata-routes.js';
34
35
 
35
36
  // ─── Constants ────────────────────────────────────────────────────────────
36
37
 
@@ -165,8 +166,9 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
165
166
  return;
166
167
  }
167
168
 
168
- // Pass through static asset requests
169
- if (isAssetRequest(url)) {
169
+ // Pass through static asset requests — but not metadata routes like
170
+ // /favicon.ico that match asset extensions but are handled by timber.
171
+ if (isAssetRequest(url) && !isMetadataRouteServePath(url)) {
170
172
  next();
171
173
  return;
172
174
  }
@@ -68,7 +68,7 @@ const SEGMENT_PREFIX = '\0$segment:';
68
68
  * File convention names we track for changes that require manifest regeneration.
69
69
  */
70
70
  const ROUTE_FILE_PATTERNS =
71
- /\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
71
+ /\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|apple-icon)\./;
72
72
 
73
73
  /**
74
74
  * Create the timber-routing Vite plugin.
@@ -82,13 +82,16 @@ export function buildCodecChainType(
82
82
 
83
83
  /**
84
84
  * Build a TypeScript type expression for a single param using the global
85
- * schema's InferCodecType. Falls back to `fallback` when schema doesn't
86
- * declare this param.
85
+ * schema's InferCodecType. Falls back to the param's base type when
86
+ * schema doesn't declare this param (schemas are opt-in per key).
87
87
  *
88
- * Generates: `InferCodecType<typeof schema.segmentParams['[paramName]']>`
88
+ * Generates a conditional that checks key existence:
89
+ * `[bracketName] extends keyof typeof schema.segmentParams ? InferCodecType<...> : fallback`
89
90
  */
90
- export function buildSchemaParamType(p: ParamEntry): string {
91
- return `InferCodecType<typeof schema.segmentParams[${JSON.stringify(p.bracketName)}]>`;
91
+ export function buildSchemaParamType(p: ParamEntry, fallback?: string): string {
92
+ const key = JSON.stringify(p.bracketName);
93
+ const fb = fallback ?? p.type;
94
+ return `${key} extends keyof typeof schema['segmentParams'] ? InferCodecType<typeof schema.segmentParams[${key}]> : ${fb}`;
92
95
  }
93
96
 
94
97
  /**
@@ -350,6 +350,11 @@ function formatDeclarationFile(
350
350
  " [K in keyof typeof schema['segmentParams']]: typeof schema['segmentParams'][K] extends SegmentParamCodec ? true : ['Error: value for', K, 'is not a valid Codec or Standard Schema'];"
351
351
  );
352
352
  lines.push('};');
353
+ // Generic constraint enforces that all values are `true` — if any value is
354
+ // an error tuple, TS reports a real diagnostic instead of silently computing it.
355
+ lines.push(
356
+ 'type _EnforceSchemaValues<_T extends Record<string, true> = _AssertSchemaValues> = true;'
357
+ );
353
358
  lines.push('');
354
359
  }
355
360
 
@@ -83,7 +83,7 @@ export function formatLinkParamsType(
83
83
  const fields = params.map((p) => {
84
84
  const fallback = p.type === 'string' ? 'string | number' : p.type;
85
85
  const codecType = hasSchema
86
- ? buildSchemaParamType(p)
86
+ ? buildSchemaParamType(p, fallback)
87
87
  : buildCodecChainType(p, importBase, fallback);
88
88
  return `${p.name}: ${codecType}`;
89
89
  });
@@ -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
- * Set the revalidation state for the current action execution.
64
- * @internal — kept for test compatibility; prefer executeAction() which uses ALS.
63
+ * Run `fn` inside a revalidation ALS scope.
64
+ * @internal — for tests that call revalidatePath/Tag directly without executeAction().
65
65
  */
66
- export function _setRevalidationState(state: RevalidationState): void {
67
- // Enter ALS scope — this is only used by tests that call revalidatePath/Tag
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). */
@@ -114,7 +114,13 @@ export {
114
114
  getMetadataRouteAutoLink,
115
115
  METADATA_ROUTE_CONVENTIONS,
116
116
  } from './metadata-routes.js';
117
- export type { MetadataRouteInfo, MetadataRouteType } from './metadata-routes.js';
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
- 'twitter-image': {
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 An object with tag/attrs for the <head>, or null if no auto-link
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 null;
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) from segments.
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
- // Build the href: segment urlPath + serve path
322
- const prefix = segment.urlPath === '/' ? '' : segment.urlPath;
323
- const href = `${prefix}/${convention.servePath}`;
324
- const autoLink = getMetadataRouteAutoLink(convention.type, href);
325
- if (autoLink) {
326
- const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };
327
- if (autoLink.type) attrs.type = autoLink.type;
328
- headElements.push({ tag: 'link', attrs } as MetadataHeadElement);
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
- * Walks the segment tree and builds a map from serve paths to handler modules.
130
- * Metadata routes are matched by exact pathname (e.g., /sitemap.xml, /blog/sitemap.xml).
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
- // Build a static lookup map: pathname → match info
138
- const routeMap = new Map<string, MetadataRouteMatch>();
139
- collectMetadataRoutes(manifest.root, routeMap);
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
- return (pathname: string) => routeMap.get(pathname) ?? null;
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
- * Recursively collect metadata routes from the segment tree into a lookup map.
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 collectMetadataRoutes(
211
+ function findMetadataRouteInSegment(
148
212
  node: ManifestSegmentNode,
149
- map: Map<string, MetadataRouteMatch>
150
- ): void {
151
- if (node.metadataRoutes) {
152
- for (const [baseName, file] of Object.entries(node.metadataRoutes)) {
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
- collectMetadataRoutes(child, map);
187
- }
188
-
189
- // Also check inside group segments (they're transparent for URL paths)
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(revalidateReq, revalidateMatch);
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,