@timber-js/app 0.2.0-alpha.120 → 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-Cw-7NfJJ.js → actions-Dy8vOjFt.js} +6 -6
- package/dist/_chunks/actions-Dy8vOjFt.js.map +1 -0
- package/dist/_chunks/{build-output-helper-DhxiT18k.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-VcCf1TFY.js +738 -0
- package/dist/_chunks/cli-schema-sync-VcCf1TFY.js.map +1 -0
- package/dist/_chunks/{codec-registry-V7ARyeLN.js → codec-registry-G9hD5LOq.js} +1 -1
- package/dist/_chunks/{codec-registry-V7ARyeLN.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
- package/dist/_chunks/{define-zQX6e1tN.js → define-CPVQaWbx.js} +3 -3
- package/dist/_chunks/{define-zQX6e1tN.js.map → define-CPVQaWbx.js.map} +1 -1
- package/dist/_chunks/{define-8zQ8Vm7x.js → define-jeHDXZGq.js} +3 -3
- package/dist/_chunks/{define-8zQ8Vm7x.js.map → define-jeHDXZGq.js.map} +1 -1
- package/dist/_chunks/{format-CR6v1pWE.js → format-BjN9HLu9.js} +1 -1
- package/dist/_chunks/{format-CR6v1pWE.js.map → format-BjN9HLu9.js.map} +1 -1
- package/dist/_chunks/{handler-store-iqnt2ghN.js → handler-store-CcwNX-gk.js} +1 -1
- package/dist/_chunks/{handler-store-iqnt2ghN.js.map → handler-store-CcwNX-gk.js.map} +1 -1
- package/dist/_chunks/{merge-search-params-CwxJzBLS.js → merge-search-params-az6ZiBeU.js} +1 -1
- package/dist/_chunks/{merge-search-params-CwxJzBLS.js.map → merge-search-params-az6ZiBeU.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-DKsJ5Di7.js → navigation-root-DoRTeBm5.js} +2 -2
- package/dist/_chunks/{navigation-root-DKsJ5Di7.js.map → navigation-root-DoRTeBm5.js.map} +1 -1
- package/dist/_chunks/{registry-Dp_hJL4s.js → registry-BI9cgCt7.js} +1 -1
- package/dist/_chunks/{registry-Dp_hJL4s.js.map → registry-BI9cgCt7.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-23Mc8aLi.js → resolve-schema-cURaYvaD.js} +15 -3
- package/dist/_chunks/resolve-schema-cURaYvaD.js.map +1 -0
- package/dist/_chunks/{router-ref-sektkr-t.js → router-ref-CpvjBqdu.js} +2 -2
- package/dist/_chunks/{router-ref-sektkr-t.js.map → router-ref-CpvjBqdu.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-eL5vQDSA.js → schema-bridge-o0SwFtXW.js} +1 -1
- package/dist/_chunks/{schema-bridge-eL5vQDSA.js.map → schema-bridge-o0SwFtXW.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-Bg0pFbLT.js → segment-context-EsfQpS--.js} +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-EsfQpS--.js.map} +1 -1
- package/dist/_chunks/{ssr-data-BO5OvEvg.js → ssr-data-m9-cNEIw.js} +1 -1
- package/dist/_chunks/{ssr-data-BO5OvEvg.js.map → ssr-data-m9-cNEIw.js.map} +1 -1
- package/dist/_chunks/{stale-reload-BCPRG0Cv.js → stale-reload-BN2kcE06.js} +1 -1
- package/dist/_chunks/{stale-reload-BCPRG0Cv.js.map → stale-reload-BN2kcE06.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D9SBlAfp.js → use-query-states-C9G6LP0F.js} +2 -2
- package/dist/_chunks/{use-query-states-D9SBlAfp.js.map → use-query-states-C9G6LP0F.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-CskiK-59.js → use-segment-params-CtIAjqK0.js} +2 -2
- package/dist/_chunks/{use-segment-params-CskiK-59.js.map → use-segment-params-CtIAjqK0.js.map} +1 -1
- package/dist/_chunks/{validation-Bm3Je-09.js → validation-BMU5MPzP.js} +1 -1
- package/dist/_chunks/{validation-Bm3Je-09.js.map → validation-BMU5MPzP.js.map} +1 -1
- package/dist/_chunks/{walkers-DgOXkTts.js → walkers-tkw7sDlj.js} +125 -12
- 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/error-boundary.js +2 -2
- package/dist/client/index.js +10 -10
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +7 -7
- package/dist/client/link.d.ts +9 -5
- package/dist/client/link.d.ts.map +1 -1
- package/dist/codec.d.ts +18 -0
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +30 -1
- package/dist/codec.js.map +1 -1
- package/dist/cookies/index.js +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +127 -26
- package/dist/index.js.map +1 -1
- package/dist/params/define-schema.d.ts +32 -4
- package/dist/params/define-schema.d.ts.map +1 -1
- package/dist/params/index.d.ts +1 -1
- package/dist/params/index.d.ts.map +1 -1
- package/dist/params/index.js +2 -2
- package/dist/params/index.js.map +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/plugins/routing.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.d.ts +2 -0
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +4 -4
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/schema-validation.d.ts +35 -0
- package/dist/routing/schema-validation.d.ts.map +1 -0
- package/dist/search-params/index.js +3 -3
- 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 +2 -2
- 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 +7 -6
- 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/index.ts +1 -1
- package/src/client/link.tsx +9 -9
- package/src/codec.ts +51 -1
- package/src/index.ts +2 -0
- package/src/params/define-schema.ts +46 -5
- package/src/params/index.ts +7 -1
- package/src/params/resolve-schema.ts +28 -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 +31 -34
- package/src/routing/codegen-shared.ts +8 -5
- package/src/routing/codegen.ts +59 -5
- package/src/routing/index.ts +5 -0
- package/src/routing/link-codegen.ts +1 -1
- package/src/routing/scanner.ts +58 -0
- package/src/routing/schema-validation.ts +149 -0
- 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/LICENSE +0 -8
- package/dist/_chunks/actions-Cw-7NfJJ.js.map +0 -1
- package/dist/_chunks/build-output-helper-DhxiT18k.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-XV0I47Gj.js +0 -2
- package/dist/_chunks/cli-schema-sync-q2wB0DyJ.js +0 -358
- package/dist/_chunks/cli-schema-sync-q2wB0DyJ.js.map +0 -1
- package/dist/_chunks/metadata-routes-Cjmvi3rQ.js.map +0 -1
- package/dist/_chunks/resolve-schema-23Mc8aLi.js.map +0 -1
- package/dist/_chunks/scanner-DmfoB7iK.js +0 -336
- package/dist/_chunks/scanner-DmfoB7iK.js.map +0 -1
- package/dist/_chunks/walkers-DgOXkTts.js.map +0 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema key/value validation against the filesystem route tree.
|
|
3
|
+
*
|
|
4
|
+
* Runs at codegen time (build + dev) to catch mismatches between
|
|
5
|
+
* app/schema.ts keys and the actual dynamic segments on disk.
|
|
6
|
+
*
|
|
7
|
+
* Design doc: design/41-global-params.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import type { RouteTree, SegmentNode } from './types.js';
|
|
12
|
+
import { findSchemaFile, parseExistingSchemaKeys } from '../cli-schema-sync.js';
|
|
13
|
+
import { classifyUrlSegment } from './segment-classify.js';
|
|
14
|
+
|
|
15
|
+
export interface SchemaWarning {
|
|
16
|
+
type: 'stale-key' | 'missing-key' | 'bracket-mismatch';
|
|
17
|
+
key: string;
|
|
18
|
+
message: string;
|
|
19
|
+
filesystemKey?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collect all unique bracket-keyed dynamic segment names from a route tree.
|
|
24
|
+
* Returns entries like '[artistSlug]', '[...slug]', '[[...topic]]'.
|
|
25
|
+
*/
|
|
26
|
+
export function collectDynamicSegmentsFromTree(tree: RouteTree): Set<string> {
|
|
27
|
+
const segments = new Set<string>();
|
|
28
|
+
walkTree(tree.root, segments);
|
|
29
|
+
return segments;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walkTree(node: SegmentNode, segments: Set<string>): void {
|
|
33
|
+
if (node.paramName && node.segmentName) {
|
|
34
|
+
const classified = classifyUrlSegment(node.segmentName);
|
|
35
|
+
if (classified.kind !== 'static') {
|
|
36
|
+
segments.add(node.segmentName);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const child of node.children) {
|
|
40
|
+
walkTree(child, segments);
|
|
41
|
+
}
|
|
42
|
+
for (const slot of Object.values(node.slots)) {
|
|
43
|
+
walkTree(slot, segments);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract the param name from a bracket key.
|
|
49
|
+
* '[id]' → 'id', '[...slug]' → 'slug', '[[...topic]]' → 'topic'
|
|
50
|
+
*/
|
|
51
|
+
function extractParamName(bracketKey: string): string | null {
|
|
52
|
+
const seg = classifyUrlSegment(bracketKey);
|
|
53
|
+
if (seg.kind === 'static') return null;
|
|
54
|
+
return seg.name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate schema keys against the filesystem route tree.
|
|
59
|
+
*
|
|
60
|
+
* Returns warnings for:
|
|
61
|
+
* - Stale keys: schema keys that don't match any filesystem segment
|
|
62
|
+
* - Bracket form mismatches: same param name, different bracket form
|
|
63
|
+
* - Missing keys: filesystem segments not registered in schema (informational)
|
|
64
|
+
*/
|
|
65
|
+
export function validateSchemaAgainstRoutes(tree: RouteTree, appDir: string): SchemaWarning[] {
|
|
66
|
+
const schemaPath = findSchemaFile(appDir);
|
|
67
|
+
if (!schemaPath) return [];
|
|
68
|
+
|
|
69
|
+
const content = readFileSync(schemaPath, 'utf-8');
|
|
70
|
+
const schemaKeys = parseExistingSchemaKeys(content);
|
|
71
|
+
|
|
72
|
+
if (schemaKeys.length === 0) return [];
|
|
73
|
+
|
|
74
|
+
const filesystemSegments = collectDynamicSegmentsFromTree(tree);
|
|
75
|
+
|
|
76
|
+
return validateKeys(schemaKeys, filesystemSegments);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Core validation: compare schema keys against filesystem segments.
|
|
81
|
+
* Exported separately for testing without filesystem dependencies.
|
|
82
|
+
*/
|
|
83
|
+
export function validateKeys(
|
|
84
|
+
schemaKeys: string[],
|
|
85
|
+
filesystemSegments: Set<string>
|
|
86
|
+
): SchemaWarning[] {
|
|
87
|
+
const warnings: SchemaWarning[] = [];
|
|
88
|
+
|
|
89
|
+
// Build a map of param name → bracket form for filesystem segments
|
|
90
|
+
const fsParamToKey = new Map<string, string>();
|
|
91
|
+
for (const fsKey of filesystemSegments) {
|
|
92
|
+
const name = extractParamName(fsKey);
|
|
93
|
+
if (name) {
|
|
94
|
+
fsParamToKey.set(name, fsKey);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build a set of schema param names for the missing-key check
|
|
99
|
+
const schemaParamNames = new Set<string>();
|
|
100
|
+
|
|
101
|
+
for (const schemaKey of schemaKeys) {
|
|
102
|
+
const schemaParamName = extractParamName(schemaKey);
|
|
103
|
+
if (!schemaParamName) continue;
|
|
104
|
+
|
|
105
|
+
schemaParamNames.add(schemaParamName);
|
|
106
|
+
|
|
107
|
+
if (filesystemSegments.has(schemaKey)) {
|
|
108
|
+
// Exact match — no issue
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if the param name exists but with a different bracket form
|
|
113
|
+
const fsKey = fsParamToKey.get(schemaParamName);
|
|
114
|
+
if (fsKey) {
|
|
115
|
+
warnings.push({
|
|
116
|
+
type: 'bracket-mismatch',
|
|
117
|
+
key: schemaKey,
|
|
118
|
+
filesystemKey: fsKey,
|
|
119
|
+
message:
|
|
120
|
+
`Schema key '${schemaKey}' does not match filesystem bracket form '${fsKey}'. ` +
|
|
121
|
+
`Update the schema key to '${fsKey}'.`,
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
warnings.push({
|
|
125
|
+
type: 'stale-key',
|
|
126
|
+
key: schemaKey,
|
|
127
|
+
message:
|
|
128
|
+
`Schema key '${schemaKey}' does not match any dynamic segment in the filesystem. ` +
|
|
129
|
+
`Remove it from app/schema.ts or create the corresponding route segment.`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check for filesystem segments not in schema (informational)
|
|
135
|
+
for (const fsKey of filesystemSegments) {
|
|
136
|
+
const name = extractParamName(fsKey);
|
|
137
|
+
if (name && !schemaParamNames.has(name)) {
|
|
138
|
+
warnings.push({
|
|
139
|
+
type: 'missing-key',
|
|
140
|
+
key: fsKey,
|
|
141
|
+
message:
|
|
142
|
+
`Dynamic segment '${fsKey}' has no codec in app/schema.ts (defaults to string). ` +
|
|
143
|
+
`Run: timber schema sync`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return warnings;
|
|
149
|
+
}
|
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,
|