@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
|
@@ -63,6 +63,17 @@ export function toBracketKey(segmentType: string, paramName: string): string {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
const CATCH_ALL_RE = /^\[\.\.\..+\]$/;
|
|
67
|
+
const OPTIONAL_CATCH_ALL_RE = /^\[\[\.\.\..+\]\]$/;
|
|
68
|
+
|
|
69
|
+
function isCatchAllKey(key: string): boolean {
|
|
70
|
+
return CATCH_ALL_RE.test(key) || OPTIONAL_CATCH_ALL_RE.test(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isOptionalCatchAllKey(key: string): boolean {
|
|
74
|
+
return OPTIONAL_CATCH_ALL_RE.test(key);
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
/**
|
|
67
78
|
* Resolve the global schema's segmentParams into a map of bracket-keyed
|
|
68
79
|
* param name → resolved Codec.
|
|
@@ -81,6 +92,23 @@ export function resolveSchemaCodecs(
|
|
|
81
92
|
const resolved: Record<string, Codec<unknown>> = Object.create(null);
|
|
82
93
|
|
|
83
94
|
for (const [bracketName, value] of Object.entries(segmentParams)) {
|
|
95
|
+
if (isCatchAllKey(bracketName) && isCodec(value) && !('__catchAll' in value)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[timber] Schema error: '${bracketName}' is a catch-all segment and requires an array codec ` +
|
|
98
|
+
`(e.g. codec.catchAll(codec.string)). Got a scalar codec.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
isOptionalCatchAllKey(bracketName) &&
|
|
103
|
+
isCodec(value) &&
|
|
104
|
+
'__catchAll' in value &&
|
|
105
|
+
!('__optional' in value)
|
|
106
|
+
) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[timber] Schema error: '${bracketName}' is an optional catch-all segment and requires codec.optionalCatchAll() ` +
|
|
109
|
+
`(e.g. codec.optionalCatchAll(codec.string)). Got a non-optional catch-all codec.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
84
112
|
resolved[bracketName] = resolveCodec(bracketName, value);
|
|
85
113
|
}
|
|
86
114
|
|
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
* Design docs: 18-build-system.md §"Build Manifest", 02-rendering-pipeline.md §"Early Hints"
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
20
21
|
import type { Plugin, ResolvedConfig } from 'vite';
|
|
21
22
|
import type { PluginContext } from '../plugin-context.js';
|
|
22
23
|
import type { BuildManifest } from '../server/build-manifest.js';
|
|
24
|
+
import type { SegmentNode, RouteFile } from '../routing/types.js';
|
|
25
|
+
import { isDynamicMetadataExtension } from '../server/metadata-routes.js';
|
|
23
26
|
|
|
24
27
|
// Rollup types used by generateBundle hook — imported from vite which re-exports them.
|
|
25
28
|
// We define minimal interfaces here to avoid a direct 'rollup' dependency.
|
|
@@ -84,7 +87,7 @@ export function parseViteManifest(
|
|
|
84
87
|
modulepreload[inputPath] = collectTransitiveDeps(inputPath, viteManifest, base);
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
return { css, js, modulepreload, fonts: {} };
|
|
90
|
+
return { css, js, modulepreload, fonts: {}, metadataRouteHashes: {} };
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/**
|
|
@@ -197,7 +200,7 @@ export function buildManifestFromBundle(
|
|
|
197
200
|
css['_global'] = allCss;
|
|
198
201
|
}
|
|
199
202
|
|
|
200
|
-
return { css, js, modulepreload, fonts: {} };
|
|
203
|
+
return { css, js, modulepreload, fonts: {}, metadataRouteHashes: {} };
|
|
201
204
|
}
|
|
202
205
|
|
|
203
206
|
/**
|
|
@@ -228,6 +231,50 @@ function collectTransitiveBundleDeps(
|
|
|
228
231
|
return result;
|
|
229
232
|
}
|
|
230
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Walk the route tree and compute short content hashes for metadata route files
|
|
236
|
+
* (opengraph-image, icon, apple-icon) used for cache-busting
|
|
237
|
+
* the auto-linked URLs in <head>.
|
|
238
|
+
*/
|
|
239
|
+
function computeMetadataRouteHashes(node: SegmentNode<RouteFile>): Record<string, string> {
|
|
240
|
+
const hashes: Record<string, string> = {};
|
|
241
|
+
|
|
242
|
+
if (node.metadataRoutes) {
|
|
243
|
+
for (const [baseName, file] of Object.entries(node.metadataRoutes)) {
|
|
244
|
+
if (!isDynamicMetadataExtension(baseName, file.extension)) {
|
|
245
|
+
// For static files, hash the file content
|
|
246
|
+
try {
|
|
247
|
+
const content = readFileSync(file.filePath);
|
|
248
|
+
const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
249
|
+
hashes[file.filePath] = hash;
|
|
250
|
+
} catch {
|
|
251
|
+
// File unreadable — skip hash, DEV_NONCE fallback applies
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
// For dynamic files (.ts/.tsx), hash the source file as a reasonable
|
|
255
|
+
// approximation — the output depends on runtime data too, but the
|
|
256
|
+
// source change is the best build-time signal we have.
|
|
257
|
+
try {
|
|
258
|
+
const content = readFileSync(file.filePath);
|
|
259
|
+
const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
|
|
260
|
+
hashes[file.filePath] = hash;
|
|
261
|
+
} catch {
|
|
262
|
+
// File unreadable — skip
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const child of node.children) {
|
|
269
|
+
Object.assign(hashes, computeMetadataRouteHashes(child));
|
|
270
|
+
}
|
|
271
|
+
for (const slot of Object.values(node.slots)) {
|
|
272
|
+
Object.assign(hashes, computeMetadataRouteHashes(slot));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return hashes;
|
|
276
|
+
}
|
|
277
|
+
|
|
231
278
|
/**
|
|
232
279
|
* Create the timber-build-manifest Vite plugin.
|
|
233
280
|
*
|
|
@@ -278,7 +325,7 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
|
|
|
278
325
|
'// Auto-generated build manifest — do not edit.',
|
|
279
326
|
'// Dev mode: empty manifest (Vite HMR handles CSS/JS).',
|
|
280
327
|
'',
|
|
281
|
-
'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {} };',
|
|
328
|
+
'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {}, metadataRouteHashes: {} };',
|
|
282
329
|
'',
|
|
283
330
|
'export default manifest;',
|
|
284
331
|
].join('\n');
|
|
@@ -292,7 +339,7 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
|
|
|
292
339
|
'// Auto-generated build manifest — do not edit.',
|
|
293
340
|
'// Production: reads manifest from globalThis (set by _timber-manifest-init.js).',
|
|
294
341
|
'',
|
|
295
|
-
'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {} };',
|
|
342
|
+
'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {}, metadataRouteHashes: {} };',
|
|
296
343
|
'',
|
|
297
344
|
'export default manifest;',
|
|
298
345
|
].join('\n');
|
|
@@ -309,6 +356,11 @@ export function timberBuildManifest(ctx: PluginContext): Plugin {
|
|
|
309
356
|
if (envName === 'client') {
|
|
310
357
|
ctx.buildManifest = buildManifestFromBundle(bundle, resolvedBase, ctx.root);
|
|
311
358
|
|
|
359
|
+
// Compute cache-busting hashes for metadata route files
|
|
360
|
+
if (ctx.routeTree) {
|
|
361
|
+
ctx.buildManifest.metadataRouteHashes = computeMetadataRouteHashes(ctx.routeTree.root);
|
|
362
|
+
}
|
|
363
|
+
|
|
312
364
|
// When client JavaScript is disabled, strip JS chunks from the bundle
|
|
313
365
|
// so Rollup never writes them to disk. CSS assets are preserved —
|
|
314
366
|
// they're still needed for server-rendered HTML.
|
|
@@ -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
|
@@ -20,11 +20,7 @@ import {
|
|
|
20
20
|
writeSegmentTypes,
|
|
21
21
|
type SegmentInfo,
|
|
22
22
|
} from '../routing/segment-codegen.js';
|
|
23
|
-
import {
|
|
24
|
-
collectDynamicSegments,
|
|
25
|
-
findSchemaFile,
|
|
26
|
-
parseExistingSchemaKeys,
|
|
27
|
-
} from '../cli-schema-sync.js';
|
|
23
|
+
import { validateSchemaAgainstRoutes } from '../routing/schema-validation.js';
|
|
28
24
|
|
|
29
25
|
import {
|
|
30
26
|
lintConventions,
|
|
@@ -72,7 +68,7 @@ const SEGMENT_PREFIX = '\0$segment:';
|
|
|
72
68
|
* File convention names we track for changes that require manifest regeneration.
|
|
73
69
|
*/
|
|
74
70
|
const ROUTE_FILE_PATTERNS =
|
|
75
|
-
/\/(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)\./;
|
|
76
72
|
|
|
77
73
|
/**
|
|
78
74
|
* Create the timber-routing Vite plugin.
|
|
@@ -157,10 +153,10 @@ export function timberRouting(ctx: PluginContext): Plugin {
|
|
|
157
153
|
dirToRoute = buildDirToRouteMap(ctx.routeTree, ctx.appDir);
|
|
158
154
|
writeCodegen(ctx);
|
|
159
155
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
156
|
+
// Validate schema keys against filesystem segments.
|
|
157
|
+
// Warns on stale keys, bracket form mismatches, and missing keys.
|
|
162
158
|
// See design/41-global-params.md §Auto-Scaffolding
|
|
163
|
-
|
|
159
|
+
warnSchemaValidation(ctx);
|
|
164
160
|
|
|
165
161
|
// Lint conventions (empty app, missing methods, missing root layout)
|
|
166
162
|
const conventionWarnings = lintConventions(ctx.routeTree, ctx.appDir);
|
|
@@ -346,34 +342,35 @@ export function timberRouting(ctx: PluginContext): Plugin {
|
|
|
346
342
|
* When the route tree changes, any environment that has imported the manifest
|
|
347
343
|
* needs to be invalidated so the next request/transform picks up the new tree.
|
|
348
344
|
*/
|
|
349
|
-
/** Set of
|
|
350
|
-
const
|
|
345
|
+
/** Set of warning keys we've already emitted — avoid repeating on every rescan. */
|
|
346
|
+
const _warnedKeys = new Set<string>();
|
|
351
347
|
|
|
352
348
|
/**
|
|
353
|
-
*
|
|
354
|
-
*
|
|
349
|
+
* Validate schema keys against the filesystem route tree.
|
|
350
|
+
* Warns on stale keys, bracket form mismatches, and missing keys.
|
|
351
|
+
* Uses the already-scanned route tree instead of re-scanning.
|
|
355
352
|
*/
|
|
356
|
-
function
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
353
|
+
function warnSchemaValidation(ctx: PluginContext): void {
|
|
354
|
+
if (!ctx.routeTree) return;
|
|
355
|
+
const warnings = validateSchemaAgainstRoutes(ctx.routeTree, ctx.appDir);
|
|
356
|
+
|
|
357
|
+
for (const w of warnings) {
|
|
358
|
+
const warnKey = `${w.type}:${w.key}`;
|
|
359
|
+
if (_warnedKeys.has(warnKey)) continue;
|
|
360
|
+
_warnedKeys.add(warnKey);
|
|
361
|
+
|
|
362
|
+
switch (w.type) {
|
|
363
|
+
case 'stale-key':
|
|
364
|
+
console.warn(`[timber] ${w.message}`);
|
|
365
|
+
break;
|
|
366
|
+
case 'bracket-mismatch':
|
|
367
|
+
console.warn(`[timber] ${w.message}`);
|
|
368
|
+
break;
|
|
369
|
+
case 'missing-key':
|
|
370
|
+
console.warn(`[timber] ${w.message}`);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
377
374
|
}
|
|
378
375
|
|
|
379
376
|
function invalidateManifest(server: ViteDevServer): void {
|
|
@@ -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
|
@@ -321,10 +321,40 @@ function formatDeclarationFile(
|
|
|
321
321
|
? './' + relative(importBase, join(appDir, 'schema')).replace(/\\/g, '/')
|
|
322
322
|
: '../app/schema';
|
|
323
323
|
lines.push(`import type schema from '${schemaImport}';`);
|
|
324
|
-
lines.push("import type { InferCodecType } from '@timber-js/app/params';");
|
|
324
|
+
lines.push("import type { InferCodecType, SegmentParamCodec } from '@timber-js/app/params';");
|
|
325
325
|
lines.push('');
|
|
326
|
-
//
|
|
327
|
-
|
|
326
|
+
// Collect all unique bracket keys from routes for key validation
|
|
327
|
+
const allBracketKeys = new Set<string>();
|
|
328
|
+
for (const route of routes) {
|
|
329
|
+
for (const p of route.params) {
|
|
330
|
+
allBracketKeys.add(p.bracketName);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Augment the ValidDynamicSegmentKeysRegistry so defineSchema constrains keys
|
|
335
|
+
// to known filesystem segments. Unknown keys produce inline TS errors.
|
|
336
|
+
if (allBracketKeys.size > 0) {
|
|
337
|
+
lines.push("declare module '@timber-js/app/params' {");
|
|
338
|
+
lines.push(' interface ValidDynamicSegmentKeysRegistry {');
|
|
339
|
+
for (const key of [...allBracketKeys].sort()) {
|
|
340
|
+
lines.push(` ${JSON.stringify(key)}: true;`);
|
|
341
|
+
}
|
|
342
|
+
lines.push(' }');
|
|
343
|
+
lines.push('}');
|
|
344
|
+
lines.push('');
|
|
345
|
+
}
|
|
346
|
+
// Type-level validation: each schema value must be a Codec or Standard Schema.
|
|
347
|
+
// Running `pnpm run typecheck` will surface errors for invalid values.
|
|
348
|
+
lines.push('type _AssertSchemaValues = {');
|
|
349
|
+
lines.push(
|
|
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
|
+
);
|
|
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
|
+
);
|
|
328
358
|
lines.push('');
|
|
329
359
|
}
|
|
330
360
|
|
|
@@ -350,6 +380,16 @@ function formatDeclarationFile(
|
|
|
350
380
|
}
|
|
351
381
|
|
|
352
382
|
lines.push(' }');
|
|
383
|
+
|
|
384
|
+
// When a global schema exists, emit segment param types inside the
|
|
385
|
+
// @timber-js/app module augmentation so that declare module blocks
|
|
386
|
+
// for @timber-js/app/server and @timber-js/app/client can reference
|
|
387
|
+
// AllSegmentParams via `import type { AllSegmentParams } from '@timber-js/app'`.
|
|
388
|
+
if (hasSchema) {
|
|
389
|
+
lines.push('');
|
|
390
|
+
lines.push(...formatSchemaTypes(routes).map((l) => ` ${l}`));
|
|
391
|
+
}
|
|
392
|
+
|
|
353
393
|
lines.push('}');
|
|
354
394
|
lines.push('');
|
|
355
395
|
|
|
@@ -359,13 +399,20 @@ function formatDeclarationFile(
|
|
|
359
399
|
// Generate @timber-js/app/server augmentation — typed getSegmentParams
|
|
360
400
|
if (dynamicRoutes.length > 0) {
|
|
361
401
|
lines.push("declare module '@timber-js/app/server' {");
|
|
402
|
+
if (hasSchema) {
|
|
403
|
+
lines.push(" import type { AllSegmentParams } from '@timber-js/app'");
|
|
404
|
+
}
|
|
362
405
|
for (const route of dynamicRoutes) {
|
|
363
406
|
const paramsType = formatParamsType(route.params, importBase, hasSchema);
|
|
364
407
|
lines.push(
|
|
365
408
|
` export function getSegmentParams(segmentPath: '${route.segmentPath}'): ${paramsType}`
|
|
366
409
|
);
|
|
367
410
|
}
|
|
368
|
-
lines.push(
|
|
411
|
+
lines.push(
|
|
412
|
+
hasSchema
|
|
413
|
+
? ' export function getSegmentParams(): Partial<AllSegmentParams>'
|
|
414
|
+
: ' export function getSegmentParams(): Record<string, string | string[]>'
|
|
415
|
+
);
|
|
369
416
|
lines.push('}');
|
|
370
417
|
lines.push('');
|
|
371
418
|
}
|
|
@@ -377,6 +424,9 @@ function formatDeclarationFile(
|
|
|
377
424
|
lines.push(
|
|
378
425
|
" import type { SearchParamsDefinition, SetParams, QueryStatesOptions, SearchParamCodec } from '@timber-js/app/search-params'"
|
|
379
426
|
);
|
|
427
|
+
if (hasSchema) {
|
|
428
|
+
lines.push(" import type { AllSegmentParams } from '@timber-js/app'");
|
|
429
|
+
}
|
|
380
430
|
lines.push('');
|
|
381
431
|
|
|
382
432
|
// useSegmentParams overloads
|
|
@@ -387,7 +437,11 @@ function formatDeclarationFile(
|
|
|
387
437
|
` export function useSegmentParams(segmentPath: '${route.segmentPath}'): ${paramsType}`
|
|
388
438
|
);
|
|
389
439
|
}
|
|
390
|
-
lines.push(
|
|
440
|
+
lines.push(
|
|
441
|
+
hasSchema
|
|
442
|
+
? ' export function useSegmentParams(): Partial<AllSegmentParams>'
|
|
443
|
+
: ' export function useSegmentParams(): Record<string, string | string[]>'
|
|
444
|
+
);
|
|
391
445
|
lines.push('');
|
|
392
446
|
}
|
|
393
447
|
|
package/src/routing/index.ts
CHANGED
|
@@ -16,3 +16,8 @@ export { classifyUrlSegment, classifySegment } from './segment-classify.js';
|
|
|
16
16
|
export type { UrlSegment, SegmentClassification } from './segment-classify.js';
|
|
17
17
|
export { collectLeafRoutes } from './walkers.js';
|
|
18
18
|
export type { LeafRoute, CollectLeafRoutesOptions } from './walkers.js';
|
|
19
|
+
export {
|
|
20
|
+
validateSchemaAgainstRoutes,
|
|
21
|
+
collectDynamicSegmentsFromTree,
|
|
22
|
+
} from './schema-validation.js';
|
|
23
|
+
export type { SchemaWarning } from './schema-validation.js';
|
|
@@ -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/routing/scanner.ts
CHANGED
|
@@ -106,6 +106,10 @@ export function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTre
|
|
|
106
106
|
// e.g., /[id]/items/[id] — same param name in ancestor and descendant
|
|
107
107
|
validateDuplicateParamNames(tree.root);
|
|
108
108
|
|
|
109
|
+
// Validate: detect same param name used with different bracket forms
|
|
110
|
+
// e.g., [id] in one route and [...id] in another
|
|
111
|
+
validateUniqueBracketForms(tree.root);
|
|
112
|
+
|
|
109
113
|
return tree;
|
|
110
114
|
}
|
|
111
115
|
|
|
@@ -457,6 +461,60 @@ function walkForDuplicateParams(node: SegmentNode, seen: Map<string, string>): v
|
|
|
457
461
|
}
|
|
458
462
|
}
|
|
459
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Validate that each param name uses a single bracket form across the entire route tree.
|
|
466
|
+
*
|
|
467
|
+
* Different bracket forms produce different types:
|
|
468
|
+
* [id] → string
|
|
469
|
+
* [...id] → string[]
|
|
470
|
+
* [[...id]] → string[] | undefined
|
|
471
|
+
*
|
|
472
|
+
* If the same param name appears with multiple forms, the no-argument
|
|
473
|
+
* getSegmentParams() / useSegmentParams() return type becomes ambiguous.
|
|
474
|
+
*
|
|
475
|
+
* Same name + same form across different routes is fine and expected.
|
|
476
|
+
*/
|
|
477
|
+
function validateUniqueBracketForms(root: SegmentNode): void {
|
|
478
|
+
const forms = new Map<string, { segmentType: SegmentType; urlPath: string }>();
|
|
479
|
+
collectBracketForms(root, forms);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function collectBracketForms(
|
|
483
|
+
node: SegmentNode,
|
|
484
|
+
forms: Map<string, { segmentType: SegmentType; urlPath: string }>
|
|
485
|
+
): void {
|
|
486
|
+
if (node.paramName && node.segmentType !== 'static' && node.segmentType !== 'group') {
|
|
487
|
+
const existing = forms.get(node.paramName);
|
|
488
|
+
if (existing && existing.segmentType !== node.segmentType) {
|
|
489
|
+
const bracketExamples: Record<string, string> = {
|
|
490
|
+
'dynamic': `[${node.paramName}]`,
|
|
491
|
+
'catch-all': `[...${node.paramName}]`,
|
|
492
|
+
'optional-catch-all': `[[...${node.paramName}]]`,
|
|
493
|
+
};
|
|
494
|
+
throw new Error(
|
|
495
|
+
`[timber] Param name '${node.paramName}' uses multiple bracket forms.\n` +
|
|
496
|
+
` ${bracketExamples[existing.segmentType] ?? existing.segmentType} at: ${existing.urlPath}\n` +
|
|
497
|
+
` ${bracketExamples[node.segmentType] ?? node.segmentType} at: ${node.urlPath || '/'}\n` +
|
|
498
|
+
` Each param name must use a single bracket form across all routes.\n` +
|
|
499
|
+
` Rename one of the segments to fix this (e.g., [${node.paramName}] → [${node.paramName}Id]).`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (!existing) {
|
|
503
|
+
forms.set(node.paramName, {
|
|
504
|
+
segmentType: node.segmentType,
|
|
505
|
+
urlPath: node.urlPath || '/',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const child of node.children) {
|
|
511
|
+
collectBracketForms(child, forms);
|
|
512
|
+
}
|
|
513
|
+
for (const slotNode of Object.values(node.slots)) {
|
|
514
|
+
collectBracketForms(slotNode, forms);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
460
518
|
/**
|
|
461
519
|
* Find a fixed-extension file (proxy.ts) in a directory.
|
|
462
520
|
*/
|