From 68f23e5ec631ffbac2b07158b3a58fc11470bd3f Mon Sep 17 00:00:00 2001 From: karthic Date: Thu, 12 Nov 2026 11:27:46 +0530 Subject: [PATCH] Hint at tsconfig path aliases when void.config.ts fails to load, closes #800. Move the alias detection from the env.ts loader into a shared host helper, accept jiti's MODULE_NOT_FOUND code, and document that void.config.ts only supports relative imports and package names. Co-Authored-By: Claude Opus 5.5 --- docs/reference/config.md | 2 + packages/void/src/config-loader.ts | 8 +-- packages/void/src/host/alias-hint.ts | 63 +++++++++++++++++++ packages/void/src/host/load-user-env.ts | 49 +-------------- packages/void/test/unit/config-loader.test.ts | 61 ++++++++++++++++++ 5 files changed, 133 insertions(+), 50 deletions(-) create mode 100644 packages/void/src/host/alias-hint.ts create mode 100644 packages/void/test/unit/config-loader.test.ts diff --git a/docs/reference/config.md b/docs/reference/config.md index 38d767cfc..e8e941734 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -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 diff --git a/packages/void/src/config-loader.ts b/packages/void/src/config-loader.ts index 289d83305..284e456e4 100644 --- a/packages/void/src/config-loader.ts +++ b/packages/void/src/config-loader.ts @@ -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'; @@ -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 }); } } diff --git a/packages/void/src/host/alias-hint.ts b/packages/void/src/host/alias-hint.ts new file mode 100644 index 000000000..1a3da461f --- /dev/null +++ b/packages/void/src/host/alias-hint.ts @@ -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.` + ); +} diff --git a/packages/void/src/host/load-user-env.ts b/packages/void/src/host/load-user-env.ts index ec2f87953..e3aceaad3 100644 --- a/packages/void/src/host/load-user-env.ts +++ b/packages/void/src/host/load-user-env.ts @@ -15,45 +15,10 @@ * 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 { // Counter + timestamp so back-to-back HMR reloads in the same millisecond // still produce distinct URLs (Node's ESM loader caches by URL). @@ -61,16 +26,8 @@ export async function loadUserEnvFile(envFile: string): Promise { 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); } } diff --git a/packages/void/test/unit/config-loader.test.ts b/packages/void/test/unit/config-loader.test.ts new file mode 100644 index 000000000..1584a223f --- /dev/null +++ b/packages/void/test/unit/config-loader.test.ts @@ -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 = []; + +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/); + }); +});