@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.
- package/dist/_chunks/actions-Dy8vOjFt.js.map +1 -1
- 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/{metadata-routes-Cjmvi3rQ.js → metadata-routes-DESG8uk5.js} +32 -19
- package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Bf8eC6q6.js → resolve-schema-cURaYvaD.js} +5 -1
- package/dist/_chunks/resolve-schema-cURaYvaD.js.map +1 -0
- 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/{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/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +9 -5
- package/dist/client/link.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -9
- 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/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/internal.d.ts +1 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +13 -4
- 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 +1 -1
- package/src/adapters/build-output-helper.ts +67 -5
- package/src/cli-schema-sync.ts +25 -4
- package/src/client/browser-entry/index.ts +1 -1
- package/src/client/link.tsx +9 -9
- 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/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/build-output-helper-B1n0sUqj.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-DHQLjFu3.js +0 -2
- 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
|
@@ -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
|
-
|
|
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
|
}
|
package/src/plugins/routing.ts
CHANGED
|
@@ -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|
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/routing/codegen.ts
CHANGED
|
@@ -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
|
});
|
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,
|