From 3e2422039cb13f847eee82dfddafcda71d0c8c6b Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Thu, 5 Nov 2026 02:08:10 +0900 Subject: [PATCH] fix(config): stop pointing users at the legacy void.json Projects configure Void through `void.config.ts`, but several places still named `void.json`, a file those projects don't have: - `sourceDir` errors from `readProjectPaths()`, which framework adapters and many CLI commands use (`readConfig()` already named the right file) - the error for source conventions that exist in both the project root and `sourceDir` - the `[cause]` line printed under every `void.config.ts` validation error, because `validateConfig()` kept the error from before it replaced the file name - `VoidAssetRewriteError` - `origin` labels in the dev `X-Void-Routing` header and routing lint warnings, which the rewrites guide already documents as `void.config.ts#routing.rewrites` - the file tree on the App Types docs page The `sourceDir` and conflict errors now name the project's actual config file, so projects that still use `void.json` keep seeing the right name. `validateConfig()` no longer attaches the original error as `cause`. `VoidAssetRewriteError` and the `origin` labels aren't given the file name, so they always say `void.config.ts`. `VoidAssetRewriteError` also linked to `void.dev`, which doesn't host the docs. It now links to `void.cloud`. Tests: cover the `sourceDir` and conflict errors for both config files, and what an invalid `void.config.ts` prints. The `void.config.ts` cases fail on `main`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8F8mb12HvQFWAKMEqYLme --- docs/.vitepress/theme/VoidAppFileTree.vue | 2 +- packages/void/src/config-loader.ts | 5 ++ packages/void/src/config.ts | 9 ++-- packages/void/src/project-paths.ts | 18 +++---- packages/void/src/runtime/handler.ts | 2 +- packages/void/test/unit/compile.test.ts | 14 ++--- packages/void/test/unit/config.test.ts | 52 ++++++++++++++----- .../test/unit/lint-duplicate-sources.test.ts | 16 +++--- .../test/unit/merge-routing-rules.test.ts | 6 +-- packages/void/test/unit/package.test.ts | 4 +- packages/void/test/unit/project-paths.test.ts | 42 +++++++++++++++ 11 files changed, 122 insertions(+), 48 deletions(-) diff --git a/docs/.vitepress/theme/VoidAppFileTree.vue b/docs/.vitepress/theme/VoidAppFileTree.vue index 46f5b8a72..72608ec3d 100644 --- a/docs/.vitepress/theme/VoidAppFileTree.vue +++ b/docs/.vitepress/theme/VoidAppFileTree.vue @@ -40,7 +40,7 @@ function buildItems(ext: string): Array { { name: 'crons/', collapsed: true, children: [{ name: 'daily-cleanup.ts' }] }, { name: 'queues/', collapsed: true, children: [{ name: 'email-sender.ts' }] }, { name: 'vite.config.ts' }, - { name: 'void.json' }, + { name: 'void.config.ts' }, ]; } diff --git a/packages/void/src/config-loader.ts b/packages/void/src/config-loader.ts index aa5d754ba..289d83305 100644 --- a/packages/void/src/config-loader.ts +++ b/packages/void/src/config-loader.ts @@ -8,6 +8,11 @@ export const TYPED_CONFIG_FILE = 'void.config.ts'; export type LoadedVoidConfig = { source: string; value: unknown }; +/** The file to name in messages about a project's settings. Only legacy projects use JSON. */ +export function getConfigFileName(root: string): string { + return existsSync(join(root, LEGACY_CONFIG_FILE)) ? LEGACY_CONFIG_FILE : TYPED_CONFIG_FILE; +} + /** Evaluate a TypeScript config without requiring Void to be installed in the target project. */ export function loadTypedVoidConfigFile(path: string): unknown { const sourceEntry = fileURLToPath(new URL('./config-entry.ts', import.meta.url)); diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index 327d1e992..5483f559e 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -427,7 +427,8 @@ export function validateConfig(config: unknown, source: string = CONFIG_FILE): V return validateConfigObject(config); } catch (error) { if (source !== CONFIG_FILE && error instanceof Error) { - throw new Error(error.message.replaceAll(CONFIG_FILE, source), { cause: error }); + // No `cause`: Node would print its message too, which names the placeholder. + throw new Error(error.message.replaceAll(CONFIG_FILE, source)); } throw error; } @@ -450,7 +451,7 @@ function validateConfigObject(obj: unknown): VoidConfig { if (typeof obj.sourceDir !== 'string') { throw new Error(`config: "sourceDir" in ${CONFIG_FILE} must be a string.`); } - validateSourceDir(obj.sourceDir); + validateSourceDir(obj.sourceDir, CONFIG_FILE); } // Validate target @@ -1172,7 +1173,7 @@ export function convertRedirectsConfig( return undefined; } - const origin = 'void.json#routing.redirects'; + const origin = 'void.config.ts#routing.redirects'; const rules: Array = []; for (const [rawSource, dest] of Object.entries(redirects)) { const parsed = parseHostPrefixedSource(rawSource); @@ -1231,7 +1232,7 @@ function convertRewriteRules( return undefined; } - const origin = `void.json#routing.${field}`; + const origin = `void.config.ts#routing.${field}`; const rules: Array = []; for (const [rawSource, destination] of Object.entries(entries)) { const parsed = parseHostPrefixedSource(rawSource); diff --git a/packages/void/src/project-paths.ts b/packages/void/src/project-paths.ts index ff9d38af6..daf3a7aae 100644 --- a/packages/void/src/project-paths.ts +++ b/packages/void/src/project-paths.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { isAbsolute, join, normalize } from 'pathslash'; -import { loadVoidConfig } from './config-loader.ts'; +import { getConfigFileName, loadVoidConfig, TYPED_CONFIG_FILE } from './config-loader.ts'; import type { VoidConfig } from './config.ts'; const SOURCE_CONVENTION_DIRECTORIES = [ @@ -50,9 +50,9 @@ export type ProjectPaths = { envFileCandidates: ReadonlyArray; }; -export function validateSourceDir(value: string): string { +export function validateSourceDir(value: string, configFile: string = TYPED_CONFIG_FILE): string { if (value.trim() !== value || value.length === 0) { - throw new Error('config: "sourceDir" in void.json must be a non-empty relative directory.'); + throw new Error(`config: "sourceDir" in ${configFile} must be a non-empty relative directory.`); } if ( value === '.' || @@ -63,24 +63,24 @@ export function validateSourceDir(value: string): string { isAbsolute(value) ) { throw new Error( - `config: "sourceDir" path '${value}' in void.json must be a relative directory.`, + `config: "sourceDir" path '${value}' in ${configFile} must be a relative directory.`, ); } const normalized = normalize(value).replace(/\/+$/, ''); if (normalized === '' || normalized === '.') { throw new Error( - `config: "sourceDir" path '${value}' in void.json must be a relative directory.`, + `config: "sourceDir" path '${value}' in ${configFile} must be a relative directory.`, ); } return normalized; } -function getSourceDir(config?: VoidConfig): string { - return config?.sourceDir ? validateSourceDir(config.sourceDir) : ''; +function getSourceDir(root: string, config?: VoidConfig): string { + return config?.sourceDir ? validateSourceDir(config.sourceDir, getConfigFileName(root)) : ''; } export function resolveProjectPaths(root: string, config?: VoidConfig): ProjectPaths { - const sourceDir = getSourceDir(config); + const sourceDir = getSourceDir(root, config); const sourceRoot = sourceDir ? join(root, sourceDir) : root; const sourceRelative = (path: string) => (sourceDir ? `${sourceDir}/${path}` : path); @@ -142,6 +142,6 @@ export function assertNoRootSourceConventionConflicts(paths: ProjectPaths): void return; } throw new Error( - `sourceDir: void.json sets "sourceDir": "${paths.sourceDir}", but convention path(s) also exist at the project root: ${conflicts.join(', ')}. Move them under "${paths.sourceDir}" or remove the root copy.`, + `sourceDir: ${getConfigFileName(paths.root)} sets "sourceDir": "${paths.sourceDir}", but convention path(s) also exist at the project root: ${conflicts.join(', ')}. Move them under "${paths.sourceDir}" or remove the root copy.`, ); } diff --git a/packages/void/src/runtime/handler.ts b/packages/void/src/runtime/handler.ts index 0724201bf..b44b5902d 100644 --- a/packages/void/src/runtime/handler.ts +++ b/packages/void/src/runtime/handler.ts @@ -22,7 +22,7 @@ export class VoidAssetRewriteError extends Error { override name = 'VoidAssetRewriteError'; constructor(destination: string) { super( - `c.rewrite() cannot re-dispatch to a static asset path (${JSON.stringify(destination)}). Runtime rewrites only reach routes and SSR entries inside the worker — static asset handlers run in front of the worker. To rewrite into an asset, use a static \`routing.rewrites\` rule in void.json or a \`200!\` entry in _redirects. See https://void.dev/guide/edge/rewrites#runtime-rewrites-cannot-reach-static-assets`, + `c.rewrite() cannot re-dispatch to a static asset path (${JSON.stringify(destination)}). Runtime rewrites only reach routes and SSR entries inside the worker — static asset handlers run in front of the worker. To rewrite into an asset, use a static \`routing.rewrites\` rule in void.config.ts or a \`200!\` entry in _redirects. See https://void.cloud/guide/edge/rewrites#runtime-rewrites-cannot-reach-static-assets`, ); } } diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 3d7ae18e2..f7aec8da6 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -422,14 +422,14 @@ describe('c.rewrite runtime wrapper', () => { destination: '/fallback.html', status: 200, force: false, - origin: 'void.json#routing.fallbacks', + origin: 'void.config.ts#routing.fallbacks', }, { source: '/docs/*', destination: '/rewrite-target', status: 200, force: true, - origin: 'void.json#routing.rewrites', + origin: 'void.config.ts#routing.rewrites', }, ]; const { always: preAsset, fallbacks: postAsset } = splitRedirectRulesByForce(allRules); @@ -588,9 +588,9 @@ describe('c.rewrite runtime wrapper', () => { expect(caught).toBeInstanceOf(VoidAssetRewriteError); expect((caught as Error).name).toBe('VoidAssetRewriteError'); expect((caught as Error).message).toContain(destination); - expect((caught as Error).message).toContain('routing.rewrites'); + expect((caught as Error).message).toContain('`routing.rewrites` rule in void.config.ts'); expect((caught as Error).message).toContain( - 'https://void.dev/guide/edge/rewrites#runtime-rewrites-cannot-reach-static-assets', + 'https://void.cloud/guide/edge/rewrites#runtime-rewrites-cannot-reach-static-assets', ); }, ); @@ -617,14 +617,14 @@ describe('default-target dev parity', () => { source: '/old', destination: '/new', status: 301, - origin: 'void.json#routing.redirects', + origin: 'void.config.ts#routing.redirects', }, { source: '/rewrite', destination: '/new', status: 200, force: true, - origin: 'void.json#routing.rewrites', + origin: 'void.config.ts#routing.rewrites', }, ]; const headerRules = [{ pattern: '/*', headers: [['x-test', '1']] as Array<[string, string]> }]; @@ -634,7 +634,7 @@ describe('default-target dev parity', () => { destination: '/docs.html', status: 200, force: false, - origin: 'void.json#routing.fallbacks', + origin: 'void.config.ts#routing.fallbacks', }, ]; diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index f0b84af25..1889ae968 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -1,5 +1,6 @@ import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { inspect } from 'node:util'; import { describe, it, expect, vi, beforeEach } from 'vite-plus/test'; vi.mock('../../src/log', () => ({ @@ -130,6 +131,21 @@ describe('readConfig', () => { cleanup(); }); + it('prints only void.config.ts when a typed config is invalid', () => { + setup(); + writeFileSync(join(tmpDir, 'void.config.ts'), 'export default { routing: { isr: 1 } };\n'); + let error: unknown; + try { + readConfig(tmpDir); + } catch (caught) { + error = caught; + } + // inspect() is what prints the `[cause]` line under an uncaught error. + expect(inspect(error)).toContain('"routing.isr" in void.config.ts must be a boolean'); + expect(inspect(error)).not.toContain('void.json'); + cleanup(); + }); + it('accepts an explicit Cloudflare deploy mode', () => { setup(JSON.stringify({ deploy: { cloudflare: { mode: 'atomic' } } })); expect(readConfig(tmpDir).deploy?.cloudflare?.mode).toBe('atomic'); @@ -971,7 +987,7 @@ describe('routing rules: host-prefixed sources', () => { destination: 'https://foo.com/:splat', status: 302, host: 'www.foo.com', - origin: 'void.json#routing.redirects', + origin: 'void.config.ts#routing.redirects', }, ]); cleanup(); @@ -1294,14 +1310,24 @@ describe('convertRedirectsConfig', () => { it('converts string destinations with default 302', () => { const result = convertRedirectsConfig({ '/old': '/new' }); expect(result).toEqual([ - { source: '/old', destination: '/new', status: 302, origin: 'void.json#routing.redirects' }, + { + source: '/old', + destination: '/new', + status: 302, + origin: 'void.config.ts#routing.redirects', + }, ]); }); it('converts object destinations with explicit status', () => { const result = convertRedirectsConfig({ '/old': { to: '/new', status: 301 } }); expect(result).toEqual([ - { source: '/old', destination: '/new', status: 301, origin: 'void.json#routing.redirects' }, + { + source: '/old', + destination: '/new', + status: 301, + origin: 'void.config.ts#routing.redirects', + }, ]); }); @@ -1311,12 +1337,12 @@ describe('convertRedirectsConfig', () => { '/c/*': { to: '/d/:splat', status: 308 }, }); expect(result).toEqual([ - { source: '/a', destination: '/b', status: 302, origin: 'void.json#routing.redirects' }, + { source: '/a', destination: '/b', status: 302, origin: 'void.config.ts#routing.redirects' }, { source: '/c/*', destination: '/d/:splat', status: 308, - origin: 'void.json#routing.redirects', + origin: 'void.config.ts#routing.redirects', }, ]); }); @@ -1339,7 +1365,7 @@ describe('convertRewritesConfig', () => { destination: '/en/docs', status: 200, force: true, - origin: 'void.json#routing.rewrites', + origin: 'void.config.ts#routing.rewrites', }, ]); }); @@ -1355,14 +1381,14 @@ describe('convertRewritesConfig', () => { destination: '/en', status: 200, force: true, - origin: 'void.json#routing.rewrites', + origin: 'void.config.ts#routing.rewrites', }, { source: '/docs/*', destination: '/en/docs/:splat', status: 200, force: true, - origin: 'void.json#routing.rewrites', + origin: 'void.config.ts#routing.rewrites', }, ]); }); @@ -1385,7 +1411,7 @@ describe('convertFallbacksConfig', () => { destination: '/index.html', status: 200, force: false, - origin: 'void.json#routing.fallbacks', + origin: 'void.config.ts#routing.fallbacks', }, ]); }); @@ -1401,14 +1427,14 @@ describe('convertFallbacksConfig', () => { destination: '/api/fallback', status: 200, force: false, - origin: 'void.json#routing.fallbacks', + origin: 'void.config.ts#routing.fallbacks', }, { source: '/*', destination: '/index.html', status: 200, force: false, - origin: 'void.json#routing.fallbacks', + origin: 'void.config.ts#routing.fallbacks', }, ]); }); @@ -1441,7 +1467,7 @@ describe('lintDestinationSplats', () => { const msg = warnMock.mock.calls[0][0]; expect(msg).toContain('/old/*'); expect(msg).toContain('/new/*'); - expect(msg).toContain('void.json#routing.redirects'); + expect(msg).toContain('void.config.ts#routing.redirects'); }); it('emits a single aggregated warning covering all affected rules', async () => { @@ -1467,7 +1493,7 @@ describe('lintDestinationSplats', () => { const rules = convertFallbacksConfig({ '/docs/*': '/docs/*' }); lintDestinationSplats(rules, 'fallbacks (post-asset)'); expect(warnMock).toHaveBeenCalledOnce(); - expect(warnMock.mock.calls[0][0]).toContain('void.json#routing.fallbacks'); + expect(warnMock.mock.calls[0][0]).toContain('void.config.ts#routing.fallbacks'); }); }); diff --git a/packages/void/test/unit/lint-duplicate-sources.test.ts b/packages/void/test/unit/lint-duplicate-sources.test.ts index 284a9e665..07bf6c4f5 100644 --- a/packages/void/test/unit/lint-duplicate-sources.test.ts +++ b/packages/void/test/unit/lint-duplicate-sources.test.ts @@ -34,8 +34,8 @@ describe('lintDuplicateSources', () => { it('emits no warning when all sources are unique', () => { const rules: Array = [ rule('/docs/*', '_redirects:1'), - rule('/api/*', 'void.json#routing.rewrites'), - rule('/blog/*', 'void.json#routing.redirects', { status: 302 }), + rule('/api/*', 'void.config.ts#routing.rewrites'), + rule('/blog/*', 'void.config.ts#routing.redirects', { status: 302 }), ]; lintDuplicateSources(rules, 'redirects'); @@ -52,8 +52,8 @@ describe('lintDuplicateSources', () => { it('emits a single aggregated warning naming the duplicate source and every origin', () => { const rules: Array = [ rule('/docs/*', '_redirects:12'), - rule('/api/*', 'void.json#routing.redirects', { status: 302 }), - rule('/docs/*', 'void.json#routing.rewrites'), + rule('/api/*', 'void.config.ts#routing.redirects', { status: 302 }), + rule('/docs/*', 'void.config.ts#routing.rewrites'), ]; lintDuplicateSources(rules, 'redirects/rewrites (pre-asset)'); @@ -63,7 +63,7 @@ describe('lintDuplicateSources', () => { expect(msg).toContain('redirects/rewrites (pre-asset)'); expect(msg).toContain('/docs/*'); expect(msg).toContain('_redirects:12'); - expect(msg).toContain('void.json#routing.rewrites'); + expect(msg).toContain('void.config.ts#routing.rewrites'); // The non-duplicated /api/* source must not appear expect(msg).not.toContain('/api/*'); }); @@ -71,9 +71,9 @@ describe('lintDuplicateSources', () => { it('aggregates multiple duplicate sources into one message', () => { const rules: Array = [ rule('/docs/*', '_redirects:12'), - rule('/docs/*', 'void.json#routing.rewrites'), - rule('/api/*', 'void.json#routing.rewrites'), - rule('/api/*', 'void.json#routing.rewrites'), + rule('/docs/*', 'void.config.ts#routing.rewrites'), + rule('/api/*', 'void.config.ts#routing.rewrites'), + rule('/api/*', 'void.config.ts#routing.rewrites'), rule('/blog/*', '_redirects:20'), ]; diff --git a/packages/void/test/unit/merge-routing-rules.test.ts b/packages/void/test/unit/merge-routing-rules.test.ts index a2e16583e..bce1912ad 100644 --- a/packages/void/test/unit/merge-routing-rules.test.ts +++ b/packages/void/test/unit/merge-routing-rules.test.ts @@ -95,7 +95,7 @@ describe('mergeRoutingRules', () => { }); expect(result.redirectRules).toHaveLength(2); - expect(result.redirectRules[0].origin).toBe('void.json#routing.rewrites'); + expect(result.redirectRules[0].origin).toBe('void.config.ts#routing.rewrites'); expect(result.redirectRules[0].destination).toBe('/handbook/:splat'); expect(result.redirectRules[1].origin).toMatch(/^_redirects:/); expect(result.redirectRules[1].destination).toBe('/en/docs/:splat'); @@ -160,12 +160,12 @@ describe('mergeRoutingRules', () => { expect(result.redirectRules[0]).toMatchObject({ source: '/x', destination: '/from-redirect', - origin: 'void.json#routing.redirects', + origin: 'void.config.ts#routing.redirects', }); expect(result.redirectRules[1]).toMatchObject({ source: '/x', destination: '/from-rewrite', - origin: 'void.json#routing.rewrites', + origin: 'void.config.ts#routing.rewrites', }); }); diff --git a/packages/void/test/unit/package.test.ts b/packages/void/test/unit/package.test.ts index c10334049..08ef1dd1b 100644 --- a/packages/void/test/unit/package.test.ts +++ b/packages/void/test/unit/package.test.ts @@ -221,7 +221,7 @@ describe('resolveSpaFallbackChain', () => { destination: '/index.html', status: 200, force: false, - origin: 'void.json#routing.fallbacks', + origin: 'void.config.ts#routing.fallbacks', }, ]; const result = resolveSpaFallbackChain('spa', userRules); @@ -242,7 +242,7 @@ describe('resolveSpaFallbackChain', () => { destination: '/index.html', status: 200, force: false, - origin: 'void.json#routing.fallbacks', + origin: 'void.config.ts#routing.fallbacks', }, ]; const result = resolveSpaFallbackChain('spa', userRules); diff --git a/packages/void/test/unit/project-paths.test.ts b/packages/void/test/unit/project-paths.test.ts index 174f8ba37..c64c560fc 100644 --- a/packages/void/test/unit/project-paths.test.ts +++ b/packages/void/test/unit/project-paths.test.ts @@ -155,6 +155,48 @@ describe('project path resolution', () => { ).toThrow(/routes, db/); }); + describe.each(['void.config.ts', 'void.json'] as const)('errors from %s', (file) => { + function writeConfigFile(config: Record) { + const contents = JSON.stringify(config); + writeFileSync( + join(root, file), + file === 'void.json' ? contents : `export default ${contents};\n`, + ); + } + + it.each([ + [' src', `config: "sourceDir" in ${file} must be a non-empty relative directory.`], + ['../src', `config: "sourceDir" path '../src' in ${file} must be a relative directory.`], + ['./', `config: "sourceDir" path './' in ${file} must be a relative directory.`], + ])('names the file when sourceDir is %j', (sourceDir, message) => { + writeConfigFile({ sourceDir }); + expect(() => readProjectPaths(root)).toThrow(message); + expect(() => readConfig(root)).toThrow(message); + }); + + it('names the file when source conventions conflict', () => { + writeConfigFile({ sourceDir: 'src' }); + mkdirSync(join(root, 'routes'), { recursive: true }); + mkdirSync(join(root, 'src', 'routes'), { recursive: true }); + + expect(() => assertNoRootSourceConventionConflicts(readProjectPaths(root))).toThrow( + `sourceDir: ${file} sets "sourceDir": "src"`, + ); + }); + }); + + it('names void.config.ts when no config file exists', () => { + mkdirSync(join(root, 'routes'), { recursive: true }); + mkdirSync(join(root, 'src', 'routes'), { recursive: true }); + + expect(() => resolveProjectPaths(root, { sourceDir: '../src' })).toThrow( + `config: "sourceDir" path '../src' in void.config.ts must be a relative directory.`, + ); + expect(() => + assertNoRootSourceConventionConflicts(resolveProjectPaths(root, { sourceDir: 'src' })), + ).toThrow('sourceDir: void.config.ts sets "sourceDir": "src"'); + }); + it('does not treat project-root config and public files as source conflicts', () => { writeConfig({ sourceDir: 'src' }); mkdirSync(join(root, 'public'), { recursive: true });