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: 2 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export default defineConfig({

TypeScript provides editor completion and checks. Void validates the config again when it runs, including values computed at runtime.

Use relative imports or package names inside `void.config.ts`. TypeScript path aliases such as `@/lib/theme` are not supported.

The shorter JSON fragments below show individual fields to place inside `defineConfig({ ... })`.

## Fields
Expand Down
8 changes: 4 additions & 4 deletions packages/void/src/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { createJiti } from 'jiti';
import { join } from 'pathslash';
import { aliasHint } from './host/alias-hint.ts';

export const LEGACY_CONFIG_FILE = 'void.json';
export const TYPED_CONFIG_FILE = 'void.config.ts';
Expand Down Expand Up @@ -51,10 +52,9 @@ export function loadVoidConfig(root: string): LoadedVoidConfig | null {
try {
return { source: TYPED_CONFIG_FILE, value: loadTypedVoidConfigFile(typedPath) };
} catch (error) {
throw new Error(
`config: Could not load ${TYPED_CONFIG_FILE}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
const base = `config: Could not load ${TYPED_CONFIG_FILE}: ${error instanceof Error ? error.message : String(error)}`;
const hint = aliasHint(error, TYPED_CONFIG_FILE);
throw new Error(hint ? `${base}\n\n${hint}` : base, { cause: error });
}
}

Expand Down
63 changes: 63 additions & 0 deletions packages/void/src/host/alias-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Detects tsconfig path aliases in module-resolution errors from files that
* Void loads outside Vite (`env.ts` through Node's `import()`, `void.config.ts`
* through jiti), neither of which resolves tsconfig `paths`.
*
* Node-only (lives under `host/`). Must not be imported from anything
* that ships in worker bundles.
*/

/** Returns the failed specifier from a Node or jiti module-resolution error, or `null`. */
export function extractFailedSpecifier(err: unknown): string | null {
if (!(err instanceof Error)) {
return null;
}
// `import()` reports `ERR_MODULE_NOT_FOUND`; jiti loads through `require`,
// which reports `MODULE_NOT_FOUND`.
const code = (err as NodeJS.ErrnoException).code;
if (
code !== 'ERR_MODULE_NOT_FOUND' &&
code !== 'MODULE_NOT_FOUND' &&
code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED'
) {
return null;
}
// Node's message shapes: `Cannot find package '@/foo' imported from ...`
// or `Cannot find module '@/foo' imported from ...`.
const match = /Cannot find (?:package|module) '([^']+)'/.exec(err.message);
return match ? (match[1] ?? null) : null;
}

/** A specifier is alias-shaped if it isn't relative, absolute, a node: URL, or a valid bare package name. */
export function looksLikeAlias(specifier: string): boolean {
if (
specifier.startsWith('./') ||
specifier.startsWith('../') ||
specifier.startsWith('/') ||
specifier.startsWith('node:') ||
specifier.startsWith('file:')
) {
return false;
}
// Bare scoped package: `@scope/name[/subpath]` where scope and name are valid.
// Alias forms like `@/foo` fail this because the scope is empty.
if (specifier.startsWith('@')) {
return !/^@[^/]+\/[^/]+/.test(specifier);
}
// Tilde and plain `#` prefixes are common alias conventions that aren't
// valid bare packages.
return specifier.startsWith('~') || specifier.startsWith('#');
}

/** The alias hint for a failed load of `file`, or `null` when the error isn't an alias import. */
export function aliasHint(err: unknown, file: string): string | null {
const specifier = extractFailedSpecifier(err);
if (!specifier || !looksLikeAlias(specifier)) {
return null;
}
return (
`Hint: '${specifier}' looks like a tsconfig path alias. ` +
`${file} does not resolve tsconfig "paths" — ` +
`use a relative import (e.g. "./shared/...") instead.`
);
}
49 changes: 3 additions & 46 deletions packages/void/src/host/load-user-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,62 +15,19 @@
* that ships in worker bundles.
*/
import { pathToFileURL } from 'node:url';
import { aliasHint } from './alias-hint.ts';

let loadCounter = 0;

/** Returns the failed specifier from a Node module-resolution error, or `null`. */
function extractFailedSpecifier(err: unknown): string | null {
if (!(err instanceof Error)) {
return null;
}
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
return null;
}
// Node's message shapes: `Cannot find package '@/foo' imported from ...`
// or `Cannot find module '@/foo' imported from ...`.
const match = /Cannot find (?:package|module) '([^']+)'/.exec(err.message);
return match ? (match[1] ?? null) : null;
}

/** A specifier is alias-shaped if it isn't relative, absolute, a node: URL, or a valid bare package name. */
function looksLikeAlias(specifier: string): boolean {
if (
specifier.startsWith('./') ||
specifier.startsWith('../') ||
specifier.startsWith('/') ||
specifier.startsWith('node:') ||
specifier.startsWith('file:')
) {
return false;
}
// Bare scoped package: `@scope/name[/subpath]` where scope and name are valid.
// Alias forms like `@/foo` fail this because the scope is empty.
if (specifier.startsWith('@')) {
return !/^@[^/]+\/[^/]+/.test(specifier);
}
// Tilde and plain `#` prefixes are common alias conventions that aren't
// valid bare packages.
return specifier.startsWith('~') || specifier.startsWith('#');
}

export async function loadUserEnvFile(envFile: string): Promise<void> {
// Counter + timestamp so back-to-back HMR reloads in the same millisecond
// still produce distinct URLs (Node's ESM loader caches by URL).
const url = pathToFileURL(envFile).href + `?t=${Date.now()}-${++loadCounter}`;
try {
await import(/* @vite-ignore */ url);
} catch (err) {
const specifier = extractFailedSpecifier(err);
const base = `env: Failed to load '${envFile}': '${(err as Error).message}'.`;
if (specifier && looksLikeAlias(specifier)) {
throw new Error(
`${base}\n\n` +
`Hint: '${specifier}' looks like a tsconfig path alias. ` +
`env.ts is loaded directly by Node and does not resolve tsconfig "paths" — ` +
`use a relative import (e.g. "./shared/...") instead.`,
);
}
throw new Error(base);
const hint = aliasHint(err, 'env.ts');
throw new Error(hint ? `${base}\n\n${hint}` : base);
}
}
61 changes: 61 additions & 0 deletions packages/void/test/unit/config-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vite-plus/test';
import { loadVoidConfig } from '../../src/config-loader.ts';

/**
* Coverage for the void.config.ts loader. jiti only aliases `void/config`,
* so tsconfig path aliases fail to resolve — they get detected and produce
* the same hint as env.ts.
*/

const roots: Array<string> = [];

function createTempProject(config: string): string {
const root = mkdtempSync(join(tmpdir(), 'void-config-loader-'));
roots.push(root);
writeFileSync(join(root, 'void.config.ts'), config);
return root;
}

afterEach(() => {
for (const root of roots.splice(0, roots.length)) {
rmSync(root, { recursive: true, force: true });
}
});

describe('loadVoidConfig', () => {
it('loads void.config.ts with relative imports', () => {
const root = createTempProject(
'import { defineConfig } from "void/config";\n' +
'import { SOURCE_DIR } from "./shared.ts";\n' +
'export default defineConfig({ sourceDir: SOURCE_DIR });\n',
);
writeFileSync(join(root, 'shared.ts'), 'export const SOURCE_DIR = "src";\n');

expect(loadVoidConfig(root)).toEqual({
source: 'void.config.ts',
value: { sourceDir: 'src' },
});
});

it('gives a targeted hint when void.config.ts imports through a tsconfig path alias', () => {
const root = createTempProject(
'import { defineConfig } from "void/config";\n' +
'import { SOURCE_DIR } from "@/lib/shared";\n' +
'export default defineConfig({ sourceDir: SOURCE_DIR });\n',
);

expect(() => loadVoidConfig(root)).toThrow(
/Could not load void\.config\.ts.*Hint: '@\/lib\/shared' looks like a tsconfig path alias.*use a relative import/s,
);
});

it('does not add the alias hint for a genuine missing bare package', () => {
const root = createTempProject('import "@definitely-not-installed/pkg";\nexport default {};\n');

expect(() => loadVoidConfig(root)).toThrow(/Could not load void\.config\.ts/);
expect(() => loadVoidConfig(root)).not.toThrow(/tsconfig path alias/);
});
});