Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/.vitepress/theme/VoidAppFileTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function buildItems(ext: string): Array<FileTreeItem> {
{ 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' },
];
}
</script>
Expand Down
5 changes: 5 additions & 0 deletions packages/void/src/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
9 changes: 5 additions & 4 deletions packages/void/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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<DevRedirectRule> = [];
for (const [rawSource, dest] of Object.entries(redirects)) {
const parsed = parseHostPrefixedSource(rawSource);
Expand Down Expand Up @@ -1231,7 +1232,7 @@ function convertRewriteRules(
return undefined;
}

const origin = `void.json#routing.${field}`;
const origin = `void.config.ts#routing.${field}`;
const rules: Array<DevRedirectRule> = [];
for (const [rawSource, destination] of Object.entries(entries)) {
const parsed = parseHostPrefixedSource(rawSource);
Expand Down
18 changes: 9 additions & 9 deletions packages/void/src/project-paths.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -50,9 +50,9 @@ export type ProjectPaths = {
envFileCandidates: ReadonlyArray<string>;
};

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 === '.' ||
Expand All @@ -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);

Expand Down Expand Up @@ -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.`,
);
}
2 changes: 1 addition & 1 deletion packages/void/src/runtime/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
}
}
Expand Down
14 changes: 7 additions & 7 deletions packages/void/test/unit/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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',
);
},
);
Expand All @@ -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]> }];
Expand All @@ -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',
},
];

Expand Down
52 changes: 39 additions & 13 deletions packages/void/test/unit/config.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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',
},
]);
});

Expand All @@ -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',
},
]);
});
Expand All @@ -1339,7 +1365,7 @@ describe('convertRewritesConfig', () => {
destination: '/en/docs',
status: 200,
force: true,
origin: 'void.json#routing.rewrites',
origin: 'void.config.ts#routing.rewrites',
},
]);
});
Expand All @@ -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',
},
]);
});
Expand All @@ -1385,7 +1411,7 @@ describe('convertFallbacksConfig', () => {
destination: '/index.html',
status: 200,
force: false,
origin: 'void.json#routing.fallbacks',
origin: 'void.config.ts#routing.fallbacks',
},
]);
});
Expand All @@ -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',
},
]);
});
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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');
});
});

Expand Down
16 changes: 8 additions & 8 deletions packages/void/test/unit/lint-duplicate-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ describe('lintDuplicateSources', () => {
it('emits no warning when all sources are unique', () => {
const rules: Array<DevRedirectRule> = [
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');
Expand All @@ -52,8 +52,8 @@ describe('lintDuplicateSources', () => {
it('emits a single aggregated warning naming the duplicate source and every origin', () => {
const rules: Array<DevRedirectRule> = [
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)');
Expand All @@ -63,17 +63,17 @@ 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/*');
});

it('aggregates multiple duplicate sources into one message', () => {
const rules: Array<DevRedirectRule> = [
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'),
];

Expand Down
6 changes: 3 additions & 3 deletions packages/void/test/unit/merge-routing-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
});
});

Expand Down
Loading