From 7316fcea21e783b1e2a251ea92f6008f28651991 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 00:16:30 +0800 Subject: [PATCH 1/4] fix(auth): reload the auth config and its local imports in dev A native import() keeps the first evaluation of auth.ts, and of every local file it imports, for the life of the process, so the dev server kept regenerating auth files from the config it saw first. The loader still uses Node's own import, so module rules are unchanged (type stripping, top-level await, --conditions; no tsconfig read, NODE_ENV untouched). A resolve hook gives each load a fresh query on the entry and on every local ES module it imports. Packages load once. A load whose files are all unchanged reuses the previous result. Node keeps CommonJS files and anything reached through require() cached by path. They are reported, and the dev server warns that saving one needs a restart. The watcher also reloads on saves to the config's local imports, retries after a failed load, and catches a file saved while a load runs. Fixes #550 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/auth.md | 2 +- packages/void/src/auth/config.ts | 34 +- packages/void/src/auth/fresh-import.ts | 210 +++++++++++ packages/void/src/plugin-routing.ts | 81 ++++- .../void/test/unit/auth-config-loader.test.ts | 336 ++++++++++++++++++ .../void/test/unit/plugin-routing.test.ts | 112 +++++- 6 files changed, 756 insertions(+), 19 deletions(-) create mode 100644 packages/void/src/auth/fresh-import.ts create mode 100644 packages/void/test/unit/auth-config-loader.test.ts diff --git a/docs/guide/auth.md b/docs/guide/auth.md index e66866cf2..8cd766ef2 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -186,7 +186,7 @@ For the full set of available options, see the official [Better Auth options ref ## Database and Migrations -Auth tables live alongside your app's tables. During local development, Void creates them automatically. +Auth tables live alongside your app's tables. During local development, Void creates them automatically. While the dev server runs, saving `auth.ts`, or a local ES module it imports, updates the auth schema Void generates for your app. Files loaded as CommonJS or with `require()` need a dev server restart, and Void logs a warning when one changes. New auth tables and columns are created in the local database the next time the dev server starts. Void platforms create auth tables automatically. For a direct Cloudflare deploy, include them in your checked-in migrations: diff --git a/packages/void/src/auth/config.ts b/packages/void/src/auth/config.ts index c18d64339..e8a0cfa2f 100644 --- a/packages/void/src/auth/config.ts +++ b/packages/void/src/auth/config.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; import { parseSync } from 'vite'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import type { VoidAuthConfig } from '../runtime/auth.ts'; import type { AstNode, AstProgram } from '../shared/ast.ts'; +import { importFresh } from './fresh-import.ts'; function isVoidAuthConfigSource(code: string, filename: string): boolean { if (!code.includes('void/auth')) { @@ -43,12 +43,34 @@ export function findVoidAuthConfig( return null; } -export async function loadVoidAuthConfig( +export type LoadedVoidAuthConfig = { + config: VoidAuthConfig | undefined; + /** Local files the config imported, the config file excluded. */ + dependencies: Array; + /** Local files, the config included, whose edits only a restart picks up. */ + restartRequired: Array; +}; + +/** + * Evaluate the auth config from its current source, and the current source of + * the local files it imports, so a dev server picks up saved edits. + */ +export async function loadVoidAuthConfigWithDependencies( configPath: string | null, -): Promise { +): Promise { if (!configPath) { - return undefined; + return { config: undefined, dependencies: [], restartRequired: [] }; } - const mod = await import(pathToFileURL(configPath).href); - return (mod.default ?? mod.auth ?? mod.config) as VoidAuthConfig | undefined; + const { module, dependencies, restartRequired } = await importFresh(configPath); + return { + config: (module.default ?? module.auth ?? module.config) as VoidAuthConfig | undefined, + dependencies, + restartRequired, + }; +} + +export async function loadVoidAuthConfig( + configPath: string | null, +): Promise { + return (await loadVoidAuthConfigWithDependencies(configPath)).config; } diff --git a/packages/void/src/auth/fresh-import.ts b/packages/void/src/auth/fresh-import.ts new file mode 100644 index 000000000..651f4cb05 --- /dev/null +++ b/packages/void/src/auth/fresh-import.ts @@ -0,0 +1,210 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import * as nodeModule from 'node:module'; +import { isAbsolute as isWindowsAbsolute } from 'node:path/win32'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * Native `import()` keeps the first evaluation of a file, and of every file it + * imports, for the life of the process. A resolve hook gives each fresh load + * its own `?void-fresh-import=` on the entry and on every local ES module + * it imports, so Node evaluates those again with the same module rules as a + * plain import (type stripping, top-level await, `--conditions`). Packages keep + * their single cached evaluation. + * + * CommonJS files, and anything reached through `require()`, stay cached by file + * path: dropping their `require.cache` entries breaks the ES module views Node + * already built of them. They are reported as `restartRequired`. + * + * Old local module instances stay in memory, so a load whose files are all + * unchanged returns the previous result instead of evaluating again. + */ + +const LOAD_PARAM = 'void-fresh-import'; + +type ActiveLoad = { + /** The entry's real path, as the hook resolved it. */ + entry?: string; + /** Every local file the load reached, the entry included. */ + files: Set; + /** Signature of each dependency, taken before Node read it. */ + signatures: Array; + /** Files Node loaded as CommonJS or through `require()`. */ + cached: Set; +}; + +export type FreshImport = { + module: Record; + /** Local files the entry imported, the entry excluded. */ + dependencies: Array; + /** + * Local files, the entry included, whose edits need a restart: Node loaded + * them as CommonJS or through `require()` and keeps their first evaluation. + */ + restartRequired: Array; +}; + +const activeLoads = new Map(); +const lastLoads = new Map(); +let hooksRegistered = false; +let lastLoadId = 0; +// One load at a time: concurrent loads that share a CommonJS file trip Node's loader. +let queue: Promise = Promise.resolve(); + +/** A content hash: a same-size save within one mtime tick still counts as a change. */ +function fileSignature(path: string): string { + let source: Buffer; + try { + source = readFileSync(path); + } catch { + return `${path}:missing`; + } + return `${path}:${createHash('sha1').update(source).digest('hex')}`; +} + +/** Relative, absolute and `#` subpath imports name the app's own files. */ +function isLocalSpecifier(specifier: string): boolean { + return ( + specifier.startsWith('.') || + specifier.startsWith('/') || + specifier.startsWith('file:') || + specifier.startsWith('#') || + // `require('C:\\app\\helper.cjs')` on Windows. + isWindowsAbsolute(specifier) + ); +} + +function localPath(url: string): string | undefined { + if (!url.startsWith('file:')) { + return undefined; + } + const parsed = new URL(url); + if (parsed.pathname.includes('/node_modules/')) { + return undefined; + } + parsed.searchParams.delete(LOAD_PARAM); + return fileURLToPath(parsed); +} + +function loadIdOf(url: string): string | undefined { + const id = new URL(url).searchParams.get(LOAD_PARAM); + if (id) { + return id; + } + // `require()` inside a CommonJS file reports its parent without the query. + const path = localPath(url); + for (const [loadId, load] of activeLoads) { + if (path && load.files.has(path)) { + return loadId; + } + } + return undefined; +} + +/** False where Node's module hooks are missing (Bun, Deno). */ +function registerFreshImportHooks(): boolean { + if (hooksRegistered) { + return true; + } + const registerHooks = (nodeModule as Partial).registerHooks; + if (typeof registerHooks !== 'function' || 'Bun' in globalThis || 'Deno' in globalThis) { + return false; + } + hooksRegistered = true; + registerHooks({ + resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + if (activeLoads.size === 0) { + return result; + } + const entryId = specifier.startsWith('file:') + ? new URL(specifier).searchParams.get(LOAD_PARAM) + : null; + const id = + entryId ?? + (context.parentURL && isLocalSpecifier(specifier) + ? loadIdOf(context.parentURL) + : undefined); + const load = id ? activeLoads.get(id) : undefined; + const path = localPath(result.url); + if (!id || !load || !path) { + return result; + } + if (entryId) { + load.entry = path; + } else if (!load.files.has(path)) { + // Taken before Node reads the file, so a save during the load counts as a change. + load.signatures.push(fileSignature(path)); + } + load.files.add(path); + if (context.conditions?.includes('require') || result.format?.startsWith('commonjs')) { + load.cached.add(path); + return result; + } + const url = new URL(result.url); + url.searchParams.set(LOAD_PARAM, id); + return { ...result, url: url.href }; + }, + load(url, context, nextLoad) { + const result = nextLoad(url, context); + // A `.js` file's format is only known once Node reads its package.json or source. + const id = activeLoads.size > 0 ? new URL(url).searchParams.get(LOAD_PARAM) : null; + const path = localPath(url); + if (id && path && result.format?.startsWith('commonjs')) { + activeLoads.get(id)?.cached.add(path); + } + return result; + }, + }); + return true; +} + +async function load(path: string): Promise { + const last = lastLoads.get(path); + if (last && [path, ...last.result.dependencies].map(fileSignature).join('|') === last.signature) { + return last.result; + } + + if (!registerFreshImportHooks()) { + const module = (await import(pathToFileURL(path).href)) as Record; + return { module, dependencies: [], restartRequired: [path] }; + } + const id = String(++lastLoadId); + const active: ActiveLoad = { files: new Set(), signatures: [], cached: new Set() }; + activeLoads.set(id, active); + const entrySignature = fileSignature(path); + const url = pathToFileURL(path); + url.searchParams.set(LOAD_PARAM, id); + try { + const module = (await import(url.href)) as Record; + // A cached CommonJS file does not run its `require()` calls again, so keep + // what earlier loads of this entry reached through it. + const restartRequired = new Set([...(last?.result.restartRequired ?? []), ...active.cached]); + const entry = active.entry ?? path; + if (restartRequired.delete(entry)) { + restartRequired.add(path); + } + const dependencies = [...active.files].filter((file) => file !== entry); + for (const file of restartRequired) { + if (file !== path && !dependencies.includes(file)) { + dependencies.push(file); + active.signatures.push(fileSignature(file)); + } + } + const result = { module, dependencies, restartRequired: [...restartRequired] }; + lastLoads.set(path, { result, signature: [entrySignature, ...active.signatures].join('|') }); + return result; + } finally { + activeLoads.delete(id); + } +} + +/** + * Import `path` with its current source and the current source of every local + * ES module it imports. Returns the previous result when none of its files changed. + */ +export function importFresh(path: string): Promise { + const result = queue.then(() => load(path)); + queue = result.catch(() => undefined); + return result; +} diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index f7050ce46..dd8909a0c 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative, resolve } from 'pathslash'; @@ -16,7 +24,7 @@ import { isGeneratedAssetHeaders, resolveAssetWorkerBypass, } from './asset-worker-bypass.ts'; -import { loadVoidAuthConfig } from './auth/config.ts'; +import { loadVoidAuthConfigWithDependencies, type LoadedVoidAuthConfig } from './auth/config.ts'; import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts'; import { lintRoutingPhases, mergeRoutingRules } from './cli/headers.ts'; import { stampClientAssetCachePolicy } from './client-asset-manifest.ts'; @@ -69,6 +77,15 @@ const RESOLVE_ID_FILTER = const LOAD_ID_FILTER = /^virtual:void-(routes|client-worker(?::(react|vue|svelte|solid))?|client-browser(?::(react|vue|svelte|solid))?)$/; +const SCRIPT_EXTENSIONS = ['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', '.json']; + +function isScriptPath(path: string): boolean { + return ( + !path.includes('/node_modules/') && + SCRIPT_EXTENSIONS.some((extension) => path.endsWith(extension)) + ); +} + type RoutingPluginOptions = { getApplicationAnalysis: (environment: RouteEnvironment, refresh?: boolean) => ApplicationAnalysis; isAuthEnabled: () => boolean; @@ -144,6 +161,14 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const pagesDir = paths.pagesDir; const ssrDir = paths.ssrDir; const authConfigPaths = new Set(paths.authConfigCandidates.map((path) => normalizePath(path))); + // Local files the auth config imported when it was last loaded. + let authConfigDependencies = new Set(); + // Those of them Node keeps cached (CommonJS, `require()`) until a restart. + let authConfigRestartFiles = new Set(); + // A failed load may stop at a missing or broken file it never reported. + let authConfigLoadFailed = false; + // A file saved during a load may be one the load is about to report. + let authConfigLoading = false; const envFilePaths = new Set(paths.envFileCandidates.map((path) => normalizePath(path))); const normalizedRoutesDir = normalizePath(routesDir); const normalizedMiddlewareDir = normalizePath(middlewareDir); @@ -320,16 +345,33 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug async function writeAuthDatabaseFiles(): Promise { if (!compileOptions.auth) { + authConfigDependencies = new Set(); + authConfigRestartFiles = new Set(); + authConfigLoadFailed = false; return; } // Better Auth's schema helpers load zod and most of better-auth; only auth apps need them. const { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } = await import('./auth/drizzle.ts'); + let loaded: LoadedVoidAuthConfig; + authConfigLoading = true; + try { + loaded = await loadVoidAuthConfigWithDependencies(compileOptions.auth.authConfigPath); + } catch (error) { + authConfigLoadFailed = true; + throw error; + } finally { + authConfigLoading = false; + } + authConfigLoadFailed = false; + // A helper the config imports changes the generated schema like the config does. + authConfigDependencies = new Set(loaded.dependencies.map((path) => normalizePath(path))); + authConfigRestartFiles = new Set(loaded.restartRequired.map((path) => normalizePath(path))); const authRuntime: VoidAuthRuntimeOptions = { dialect: compileOptions.auth.dialect, providers: compileOptions.auth.providers, - userConfig: await loadVoidAuthConfig(compileOptions.auth.authConfigPath), + userConfig: loaded.config, }; const local = resolvedConfig?.command === 'serve' ? loadLocalEnv(root) : {}; const env = { ...local, ...process.env } as Record; @@ -387,7 +429,32 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } function isPotentialAuthConfigPath(path: string): boolean { - return authConfigPaths.has(normalizePath(path)); + const normalized = normalizePath(path); + if (authConfigPaths.has(normalized)) { + return true; + } + if ((authConfigLoadFailed || authConfigLoading) && isScriptPath(normalized)) { + // Creating or fixing the file the last load stopped at must retry it. + return true; + } + return isAuthConfigFile(authConfigDependencies, normalized); + } + + function isAuthConfigFile(files: Set, normalized: string): boolean { + if (files.size === 0) { + return false; + } + if (files.has(normalized)) { + return true; + } + // The loader reports real paths; the watcher reports them under the root as + // written, which can go through a symlink. + try { + return files.has(normalizePath(realpathSync(normalized))); + } catch { + // A deleted file keeps the path the watcher gave. + return false; + } } function isPotentialEnvFilePath(path: string): boolean { @@ -848,6 +915,12 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const handleFileChange = async (path: string, event: 'add' | 'change' | 'unlink') => { const normalizedPath = normalizePath(path); invalidateSourceAnalysis(normalizedPath); + if (event === 'change' && isAuthConfigFile(authConfigRestartFiles, normalizedPath)) { + devServer.config.logger.warn( + `auth: ${relative(root, normalizedPath)} is loaded as CommonJS or with require(); restart the dev server to apply its changes.`, + { timestamp: true }, + ); + } const sourceConventionChanged = isInDirectory(normalizedPath, normalizedRoutesDir) || diff --git a/packages/void/test/unit/auth-config-loader.test.ts b/packages/void/test/unit/auth-config-loader.test.ts new file mode 100644 index 000000000..db7a64b07 --- /dev/null +++ b/packages/void/test/unit/auth-config-loader.test.ts @@ -0,0 +1,336 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { loadVoidAuthConfig, loadVoidAuthConfigWithDependencies } from '../../src/auth/config.ts'; + +const tempDirs: Array = []; + +function createTempDir(): string { + // The loader reports real paths (macOS temp dirs sit behind /var -> /private/var). + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'void-auth-config-'))); + tempDirs.push(dir); + return dir; +} + +function writeFiles(dir: string, files: Record): void { + for (const [file, source] of Object.entries(files)) { + mkdirSync(dirname(join(dir, file)), { recursive: true }); + writeFileSync(join(dir, file), source); + } +} + +type Step = { write?: Record; load: string; together?: number }; +type StepResult = + | { value: unknown; dependencies: Array; restartRequired: Array; reused: boolean } + | { error: string }; + +const CONFIG_URL = pathToFileURL( + join(import.meta.dirname, '..', '..', 'src', 'auth', 'config.ts'), +).href; + +// Vitest routes `import()` through its own module runner, so the Node resolve +// hook the loader relies on only runs in a plain Node process. +const DRIVER = ` +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +const [, moduleUrl, dir, stepsJson] = process.argv; +const { loadVoidAuthConfigWithDependencies } = await import(moduleUrl); +const toRelative = (paths) => paths.map((path) => relative(dir, path).split(sep).join('/')).sort(); +const results = []; +let previous; +for (const step of JSON.parse(stepsJson)) { + for (const [file, source] of Object.entries(step.write ?? {})) { + mkdirSync(dirname(join(dir, file)), { recursive: true }); + writeFileSync(join(dir, file), source); + } + const loads = Array.from({ length: step.together ?? 1 }, () => + loadVoidAuthConfigWithDependencies(join(dir, step.load)), + ); + for (const outcome of await Promise.allSettled(loads)) { + if (outcome.status === 'rejected') { + results.push({ error: String(outcome.reason?.message ?? outcome.reason).split('\\n')[0] }); + continue; + } + const { config, dependencies, restartRequired } = outcome.value; + results.push({ + value: typeof config === 'function' ? config() : config, + dependencies: toRelative(dependencies), + restartRequired: toRelative(restartRequired), + reused: config === previous, + }); + previous = config; + } +} +process.stdout.write(JSON.stringify(results)); +`; + +function runSteps(dir: string, steps: Array): Array { + const env = { ...process.env }; + delete env.NODE_ENV; + const result = spawnSync( + process.execPath, + ['--input-type=module', '--eval', DRIVER, CONFIG_URL, dir, JSON.stringify(steps)], + { cwd: dir, encoding: 'utf8', env, timeout: 30_000 }, + ); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as Array; +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +describe('loadVoidAuthConfig', () => { + it('returns undefined without a config file', async () => { + expect(await loadVoidAuthConfig(null)).toBeUndefined(); + expect(await loadVoidAuthConfigWithDependencies(null)).toEqual({ + config: undefined, + dependencies: [], + restartRequired: [], + }); + }); + + it.each([ + ['default', "export default { marker: 'default' };", 'default'], + ['auth', "export const auth = { marker: 'auth' };", 'auth'], + ['config', "export const config = { marker: 'config' };", 'config'], + ])('reads the %s export', async (_name, source, marker) => { + const path = join(createTempDir(), 'auth.ts'); + writeFileSync(path, source); + expect(await loadVoidAuthConfig(path)).toEqual({ marker }); + }); + + it('reads edits to the config and to the local files it imports', () => { + const dir = createTempDir(); + const pkg = (name: string) => ({ + [`node_modules/${name}/package.json`]: JSON.stringify({ + name, + type: 'module', + exports: './index.js', + }), + [`node_modules/${name}/index.js`]: `globalThis.${name}Runs = (globalThis.${name}Runs ?? 0) + 1;\nexport const runs = () => globalThis.${name}Runs;`, + }); + writeFiles(dir, { + 'package.json': JSON.stringify({ + type: 'module', + imports: { '#lib/*': './lib/*', '#dep': 'dep' }, + }), + // Before `void prepare` writes `.void/tsconfig.json`; Node never reads it. + 'tsconfig.json': JSON.stringify({ extends: './.void/tsconfig.json' }), + ...pkg('pkg'), + ...pkg('dep'), + 'helper.ts': "export const field: string = 'nickname';", + 'lib/sub.ts': "export const sub = 'sub-1';", + 'auth.ts': [ + "import { runs } from 'pkg';", + "import { runs as depRuns } from '#dep';", + "import { field } from './helper.ts';", + "import { sub } from '#lib/sub.ts';", + 'export default () => ({ field, sub, runs: [runs(), depRuns()], env: process.env.NODE_ENV ?? "unset" });', + ].join('\n'), + }); + + const results = runSteps(dir, [ + { load: 'auth.ts' }, + { write: { 'helper.ts': "export const field: string = 'displayName';" }, load: 'auth.ts' }, + { write: { 'lib/sub.ts': "export const sub = 'sub-2';" }, load: 'auth.ts' }, + { + write: { + 'auth.ts': [ + "import { runs } from 'pkg';", + "import { field } from './helper.ts';", + "export default () => ({ field: field + '!', runs: [runs()] });", + ].join('\n'), + }, + load: 'auth.ts', + }, + ]); + + const loaded = (value: unknown, dependencies = ['helper.ts', 'lib/sub.ts']) => ({ + value, + dependencies, + restartRequired: [], + reused: false, + }); + // Packages, including one reached through a `#` import, run once. + expect(results).toEqual([ + loaded({ field: 'nickname', sub: 'sub-1', runs: [1, 1], env: 'unset' }), + loaded({ field: 'displayName', sub: 'sub-1', runs: [1, 1], env: 'unset' }), + loaded({ field: 'displayName', sub: 'sub-2', runs: [1, 1], env: 'unset' }), + loaded({ field: 'displayName!', runs: [1] }, ['helper.ts']), + ]); + }); + + it('keeps CommonJS files at their first evaluation and reports them', () => { + const dir = createTempDir(); + writeFiles(dir, { + 'package.json': JSON.stringify({ type: 'commonjs' }), + 'base.cjs': "module.exports = { base: 'base-1' };", + // A re-export, which Node reads ahead of running the file. + 'spread.cjs': "module.exports = { ...require('./base.cjs'), spread: true };", + 'esm.mts': "export const esm: string = 'esm-1';", + 'bridge.cjs': "module.exports = { esm: require('./esm.mts').esm };", + 'auth.mts': [ + "import spread from './spread.cjs';", + "import bridge from './bridge.cjs';", + "export const marker = 'marker-1';", + 'export default { ...spread, ...bridge, marker };', + ].join('\n'), + 'auth.cts': "module.exports = { base: require('./base.cjs').base };", + }); + + const results = runSteps(dir, [ + { load: 'auth.mts' }, + { + write: { + 'base.cjs': "module.exports = { base: 'base-2' };", + 'esm.mts': "export const esm: string = 'esm-2';", + 'auth.mts': [ + "import spread from './spread.cjs';", + "import bridge from './bridge.cjs';", + "export const marker = 'marker-2';", + 'export default { ...spread, ...bridge, marker };', + ].join('\n'), + }, + load: 'auth.mts', + }, + { load: 'auth.cts' }, + ]); + + const restartRequired = ['base.cjs', 'bridge.cjs', 'esm.mts', 'spread.cjs']; + expect(results).toEqual([ + { + value: { base: 'base-1', spread: true, esm: 'esm-1', marker: 'marker-1' }, + dependencies: restartRequired, + restartRequired, + reused: false, + }, + { + value: { base: 'base-1', spread: true, esm: 'esm-1', marker: 'marker-2' }, + dependencies: restartRequired, + restartRequired, + reused: false, + }, + { + value: { base: 'base-1' }, + dependencies: ['base.cjs'], + restartRequired: ['auth.cts', 'base.cjs'], + reused: false, + }, + ]); + }); + + it('reports a CommonJS .js helper in a package without a type', () => { + const dir = createTempDir(); + writeFiles(dir, { + 'package.json': '{}', + 'helper.js': "module.exports = { helper: 'helper-1' };", + 'auth.mts': "import helper from './helper.js';\nexport default { ...helper };", + }); + + const results = runSteps(dir, [ + { load: 'auth.mts' }, + { write: { 'helper.js': "module.exports = { helper: 'helper-2' };" }, load: 'auth.mts' }, + ]); + + const loaded = { dependencies: ['helper.js'], restartRequired: ['helper.js'], reused: false }; + expect(results).toEqual([ + { value: { helper: 'helper-1' }, ...loaded }, + { value: { helper: 'helper-1' }, ...loaded }, + ]); + }); + + it('recovers after a syntax error and after a missing import is created', () => { + const dir = createTempDir(); + writeFiles(dir, { + 'helper.ts': "export const field = 'nickname';", + 'auth.ts': "import { field } from './helper.ts';\nexport default { field };", + }); + + const results = runSteps(dir, [ + { load: 'auth.ts' }, + { write: { 'helper.ts': 'export const field = {' }, load: 'auth.ts' }, + { write: { 'helper.ts': "export const field = 'fixed';" }, load: 'auth.ts' }, + { + write: { 'auth.ts': "import { extra } from './extra.ts';\nexport default { extra };" }, + load: 'auth.ts', + }, + { write: { 'extra.ts': "export const extra = 'created';" }, load: 'auth.ts' }, + ]); + + expect(results[0]).toMatchObject({ value: { field: 'nickname' } }); + expect(results[1]).toHaveProperty('error'); + expect(results[2]).toMatchObject({ value: { field: 'fixed' } }); + expect(results[3]).toHaveProperty('error'); + expect(results[4]).toMatchObject({ value: { extra: 'created' }, dependencies: ['extra.ts'] }); + }); + + it('reuses the last config while none of its files changed', () => { + const dir = createTempDir(); + writeFiles(dir, { + 'helper.ts': [ + 'globalThis.helperRuns = (globalThis.helperRuns ?? 0) + 1;', + "export const field = 'nickname';", + ].join('\n'), + 'auth.mts': [ + "import { field } from './helper.ts';", + 'const awaited = await Promise.resolve(field);', + 'export default () => ({ awaited, runs: globalThis.helperRuns, dir: import.meta.dirname });', + ].join('\n'), + }); + + const results = runSteps(dir, [ + { load: 'auth.mts' }, + { load: 'auth.mts' }, + { + write: { 'helper.ts': "globalThis.helperRuns += 1;\nexport const field = 'next';" }, + load: 'auth.mts', + }, + ]); + + const loaded = (value: unknown, reused: boolean) => ({ + value, + dependencies: ['helper.ts'], + restartRequired: [], + reused, + }); + expect(results).toEqual([ + loaded({ awaited: 'nickname', runs: 1, dir }, false), + loaded({ awaited: 'nickname', runs: 1, dir }, true), + loaded({ awaited: 'next', runs: 2, dir }, false), + ]); + }); + + it('loads one config at a time when called together', () => { + const dir = createTempDir(); + writeFiles(dir, { + 'package.json': JSON.stringify({ type: 'commonjs' }), + 'inner.cjs': "module.exports = 'inner';", + 'helper.cjs': "module.exports = require('./inner.cjs');", + 'auth.mts': "import helper from './helper.cjs';\nexport default { helper };", + }); + + const results = runSteps(dir, [{ load: 'auth.mts', together: 2 }]); + + expect(results).toEqual([ + { + value: { helper: 'inner' }, + dependencies: ['helper.cjs', 'inner.cjs'], + restartRequired: ['helper.cjs', 'inner.cjs'], + reused: false, + }, + { + value: { helper: 'inner' }, + dependencies: ['helper.cjs', 'inner.cjs'], + restartRequired: ['helper.cjs', 'inner.cjs'], + reused: true, + }, + ]); + }); +}); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 975b73bfd..e654713a3 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -1,6 +1,14 @@ -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import type { ConfigEnv, ResolvedConfig, UserConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; @@ -66,9 +74,10 @@ function fakeDevServer(moduleIds: Array = []) { const invalidated: Array = []; const restart = vi.fn(async () => {}); const logError = vi.fn(); + const logWarn = vi.fn(); const modules = new Map(moduleIds.map((id) => [id, { id }])); const server = { - config: { logger: { error: logError } }, + config: { logger: { error: logError, warn: logWarn } }, watcher: { on(event: string, handler: (path: string) => Promise | void) { handlers.set(event, [...(handlers.get(event) ?? []), handler]); @@ -94,7 +103,7 @@ function fakeDevServer(moduleIds: Array = []) { await handler(path); } } - return { server, invalidated, restart, emit, logError }; + return { server, invalidated, restart, emit, logError, logWarn }; } function startDevServer(plugin: RoutingPlugin, moduleIds?: Array) { @@ -433,7 +442,7 @@ describe('routingPlugin', () => { rmSync(typesPath); mkdirSync(typesPath); } else if (failure === 'auth generation') { - vi.spyOn(authConfig, 'loadVoidAuthConfig').mockRejectedValueOnce( + vi.spyOn(authConfig, 'loadVoidAuthConfigWithDependencies').mockRejectedValueOnce( new Error('Transient auth generation failure'), ); } else { @@ -575,10 +584,10 @@ describe('routingPlugin', () => { started = resolve; }); let resume!: () => void; - const pause = new Promise((resolve) => { - resume = () => resolve(undefined); + const pause = new Promise((resolve) => { + resume = () => resolve({ config: undefined, dependencies: [], restartRequired: [] }); }); - vi.spyOn(authConfig, 'loadVoidAuthConfig').mockImplementationOnce(() => { + vi.spyOn(authConfig, 'loadVoidAuthConfigWithDependencies').mockImplementationOnce(() => { started(); return pause; }); @@ -608,6 +617,93 @@ describe('routingPlugin', () => { expect(invalidated).toEqual(['virtual:void-routes']); }); + it('regenerates auth files when the auth config or a file it imports changes', async () => { + const root = createTempRoot(); + const routePath = join(root, 'routes', 'health.ts'); + writeFileSync(routePath, 'export const GET = () => new Response();'); + const authPath = readProjectPaths(root).authConfigCandidates[0]!; + const helperPath = join(dirname(authPath), 'auth-fields.ts'); + const createdPath = join(dirname(authPath), 'auth-created.ts'); + const legacyPath = join(dirname(authPath), 'auth-legacy.cjs'); + const latePath = join(dirname(authPath), 'auth-late.ts'); + writeFileSync(authPath, "export type {} from 'void/auth';"); + writeFileSync(helperPath, ''); + writeFileSync(legacyPath, ''); + let dependencies = [helperPath, legacyPath]; + let pause: Promise | undefined; + // Evaluating real files is covered by auth-config-loader.test.ts; vitest's + // module runner bypasses the Node hook the loader uses. + let field: string | Error = 'nickname'; + const load = vi + .spyOn(authConfig, 'loadVoidAuthConfigWithDependencies') + .mockImplementation(async () => { + await pause; + if (field instanceof Error) { + throw field; + } + return { + config: { user: { additionalFields: { [field]: { type: 'string' } } } }, + dependencies: dependencies.map((path) => realpathSync(path)), + restartRequired: [realpathSync(legacyPath)], + }; + }); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const schemaPath = join(root, '.void', 'better-auth-schema.ts'); + expect(readFileSync(schemaPath, 'utf8')).toContain('"nickname"'); + const { emit, logError, logWarn } = startDevServer(plugin, ['virtual:void-routes']); + + field = 'bio'; + await emit('change', authPath); + expect(readFileSync(schemaPath, 'utf8')).toContain('"bio"'); + + field = 'displayName'; + await emit('change', helperPath); + expect(readFileSync(schemaPath, 'utf8')).toContain('"displayName"'); + + // Other source files do not reload the config. + const loads = load.mock.calls.length; + await emit('change', routePath); + expect(load).toHaveBeenCalledTimes(loads); + + // After a failed load, a file it never reached (one being created) retries it. + field = new Error("Cannot find module './auth-created.ts'"); + await emit('change', helperPath); + expect(logError).toHaveBeenCalledTimes(1); + field = 'avatar'; + writeFileSync(createdPath, ''); + await emit('add', createdPath); + expect(readFileSync(schemaPath, 'utf8')).toContain('"avatar"'); + + // Once it loads again, only its own files count. + await emit('change', routePath); + expect(load).toHaveBeenCalledTimes(loads + 2); + + // Node keeps CommonJS files cached, so their edits ask for a restart. + await emit('change', legacyPath); + expect(logWarn).toHaveBeenCalledWith( + expect.stringContaining('auth-legacy.cjs is loaded as CommonJS'), + expect.anything(), + ); + + // A file saved while a load runs, which that load then reports, reloads again. + let finish = () => {}; + pause = new Promise((resolve) => { + finish = resolve; + }); + writeFileSync(latePath, ''); + const loading = emit('change', authPath); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(loads + 3)); + dependencies = [helperPath, latePath]; + const lateSave = emit('change', latePath); + pause = undefined; + finish(); + await Promise.all([loading, lateSave]); + expect(load).toHaveBeenCalledTimes(loads + 4); + }); + it.each([ { change: 'providers', From a806b25e059317d94d92d4c89ace1e88cbf96163 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 00:16:32 +0800 Subject: [PATCH 2/4] test(platform): give the platform status browser test time to launch Chromium Under a full parallel run, launching Chromium can pass the default timeout. The sibling browser tests already allow 30 seconds. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/test/integration/platform-status.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platform/packages/api/test/integration/platform-status.test.ts b/platform/packages/api/test/integration/platform-status.test.ts index 829015590..30d1ff38b 100644 --- a/platform/packages/api/test/integration/platform-status.test.ts +++ b/platform/packages/api/test/integration/platform-status.test.ts @@ -326,4 +326,6 @@ it.each([390, 1280])( await browser.close(); } }, + // Launching Chromium alone can take longer than the default under a full parallel run. + 30_000, ); From 834ecce4c2f789a7c381d215a51dd3d381442c55 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 12:21:24 +0800 Subject: [PATCH 3/4] refactor(auth): reload the dev auth config through a Vite environment Replace the process-wide Node resolve hook with a dev-only Vite environment, `void_auth_config`: a Node module runner with HMR off. The dev server loads auth.ts through it; Vite's module graph lists the files the config imports, so a save of any of them invalidates those modules and the config is read again. Builds and CLI commands keep the plain import() from before. - Start-up auth migrations load through the same runner, so a restarted dev server also creates new auth tables and columns. - After a failed load, or a save during a load, the next script save retries from a clean environment. The retry runs apart from the save, so a config that still fails does not stop page reloads. - Vite's update pass skips the environment, so saves log no page reload that nothing receives. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/auth.md | 2 +- packages/void/src/auth/config.ts | 52 +-- packages/void/src/auth/fresh-import.ts | 210 ----------- packages/void/src/auth/node.ts | 6 +- packages/void/src/plugin-migration.ts | 2 + packages/void/src/plugin-routing.ts | 194 ++++++---- .../void/test/unit/auth-config-loader.test.ts | 354 +++--------------- packages/void/test/unit/local-d1.test.ts | 2 +- .../void/test/unit/plugin-migration.test.ts | 34 +- .../void/test/unit/plugin-routing.test.ts | 300 ++++++++++----- 10 files changed, 446 insertions(+), 710 deletions(-) delete mode 100644 packages/void/src/auth/fresh-import.ts diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 8cd766ef2..fac1aa526 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -186,7 +186,7 @@ For the full set of available options, see the official [Better Auth options ref ## Database and Migrations -Auth tables live alongside your app's tables. During local development, Void creates them automatically. While the dev server runs, saving `auth.ts`, or a local ES module it imports, updates the auth schema Void generates for your app. Files loaded as CommonJS or with `require()` need a dev server restart, and Void logs a warning when one changes. New auth tables and columns are created in the local database the next time the dev server starts. +Auth tables live alongside your app's tables. During local development, Void creates them automatically. While the dev server runs, saving `auth.ts`, or a local file it imports, updates the auth schema Void generates for your app. New auth tables and columns are created in the local database the next time the dev server starts. Void platforms create auth tables automatically. For a direct Cloudflare deploy, include them in your checked-in migrations: diff --git a/packages/void/src/auth/config.ts b/packages/void/src/auth/config.ts index e8a0cfa2f..181da39d7 100644 --- a/packages/void/src/auth/config.ts +++ b/packages/void/src/auth/config.ts @@ -1,9 +1,9 @@ import { existsSync, readFileSync } from 'node:fs'; -import { parseSync } from 'vite'; +import { pathToFileURL } from 'node:url'; +import { isRunnableDevEnvironment, parseSync, type ViteDevServer } from 'vite'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import type { VoidAuthConfig } from '../runtime/auth.ts'; import type { AstNode, AstProgram } from '../shared/ast.ts'; -import { importFresh } from './fresh-import.ts'; function isVoidAuthConfigSource(code: string, filename: string): boolean { if (!code.includes('void/auth')) { @@ -43,34 +43,36 @@ export function findVoidAuthConfig( return null; } -export type LoadedVoidAuthConfig = { - config: VoidAuthConfig | undefined; - /** Local files the config imported, the config file excluded. */ - dependencies: Array; - /** Local files, the config included, whose edits only a restart picks up. */ - restartRequired: Array; -}; - /** - * Evaluate the auth config from its current source, and the current source of - * the local files it imports, so a dev server picks up saved edits. + * The dev server evaluates the auth config in this Node environment. A native + * `import()` would keep the first evaluation for the life of the process; the + * module runner reads the config again once its files are invalidated, and the + * module graph lists the local files the config imports. */ -export async function loadVoidAuthConfigWithDependencies( - configPath: string | null, -): Promise { - if (!configPath) { - return { config: undefined, dependencies: [], restartRequired: [] }; - } - const { module, dependencies, restartRequired } = await importFresh(configPath); - return { - config: (module.default ?? module.auth ?? module.config) as VoidAuthConfig | undefined, - dependencies, - restartRequired, - }; +export const AUTH_CONFIG_ENVIRONMENT = 'void_auth_config'; + +export type ModuleImporter = (path: string) => Promise>; + +/** The dev server's importer for the auth config, when it has the environment. */ +export function devAuthConfigImporter(server: ViteDevServer): ModuleImporter | undefined { + const environment = server.environments[AUTH_CONFIG_ENVIRONMENT]; + return environment && isRunnableDevEnvironment(environment) + ? (path) => environment.runner.import(path) + : undefined; } +/** + * Evaluate the auth config. A native `import()` keeps its first evaluation for + * the life of the process, which suits one-off commands; the dev server passes + * its module runner. + */ export async function loadVoidAuthConfig( configPath: string | null, + importModule: ModuleImporter = (path) => import(pathToFileURL(path).href), ): Promise { - return (await loadVoidAuthConfigWithDependencies(configPath)).config; + if (!configPath) { + return undefined; + } + const mod = await importModule(configPath); + return (mod.default ?? mod.auth ?? mod.config) as VoidAuthConfig | undefined; } diff --git a/packages/void/src/auth/fresh-import.ts b/packages/void/src/auth/fresh-import.ts deleted file mode 100644 index 651f4cb05..000000000 --- a/packages/void/src/auth/fresh-import.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import * as nodeModule from 'node:module'; -import { isAbsolute as isWindowsAbsolute } from 'node:path/win32'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -/** - * Native `import()` keeps the first evaluation of a file, and of every file it - * imports, for the life of the process. A resolve hook gives each fresh load - * its own `?void-fresh-import=` on the entry and on every local ES module - * it imports, so Node evaluates those again with the same module rules as a - * plain import (type stripping, top-level await, `--conditions`). Packages keep - * their single cached evaluation. - * - * CommonJS files, and anything reached through `require()`, stay cached by file - * path: dropping their `require.cache` entries breaks the ES module views Node - * already built of them. They are reported as `restartRequired`. - * - * Old local module instances stay in memory, so a load whose files are all - * unchanged returns the previous result instead of evaluating again. - */ - -const LOAD_PARAM = 'void-fresh-import'; - -type ActiveLoad = { - /** The entry's real path, as the hook resolved it. */ - entry?: string; - /** Every local file the load reached, the entry included. */ - files: Set; - /** Signature of each dependency, taken before Node read it. */ - signatures: Array; - /** Files Node loaded as CommonJS or through `require()`. */ - cached: Set; -}; - -export type FreshImport = { - module: Record; - /** Local files the entry imported, the entry excluded. */ - dependencies: Array; - /** - * Local files, the entry included, whose edits need a restart: Node loaded - * them as CommonJS or through `require()` and keeps their first evaluation. - */ - restartRequired: Array; -}; - -const activeLoads = new Map(); -const lastLoads = new Map(); -let hooksRegistered = false; -let lastLoadId = 0; -// One load at a time: concurrent loads that share a CommonJS file trip Node's loader. -let queue: Promise = Promise.resolve(); - -/** A content hash: a same-size save within one mtime tick still counts as a change. */ -function fileSignature(path: string): string { - let source: Buffer; - try { - source = readFileSync(path); - } catch { - return `${path}:missing`; - } - return `${path}:${createHash('sha1').update(source).digest('hex')}`; -} - -/** Relative, absolute and `#` subpath imports name the app's own files. */ -function isLocalSpecifier(specifier: string): boolean { - return ( - specifier.startsWith('.') || - specifier.startsWith('/') || - specifier.startsWith('file:') || - specifier.startsWith('#') || - // `require('C:\\app\\helper.cjs')` on Windows. - isWindowsAbsolute(specifier) - ); -} - -function localPath(url: string): string | undefined { - if (!url.startsWith('file:')) { - return undefined; - } - const parsed = new URL(url); - if (parsed.pathname.includes('/node_modules/')) { - return undefined; - } - parsed.searchParams.delete(LOAD_PARAM); - return fileURLToPath(parsed); -} - -function loadIdOf(url: string): string | undefined { - const id = new URL(url).searchParams.get(LOAD_PARAM); - if (id) { - return id; - } - // `require()` inside a CommonJS file reports its parent without the query. - const path = localPath(url); - for (const [loadId, load] of activeLoads) { - if (path && load.files.has(path)) { - return loadId; - } - } - return undefined; -} - -/** False where Node's module hooks are missing (Bun, Deno). */ -function registerFreshImportHooks(): boolean { - if (hooksRegistered) { - return true; - } - const registerHooks = (nodeModule as Partial).registerHooks; - if (typeof registerHooks !== 'function' || 'Bun' in globalThis || 'Deno' in globalThis) { - return false; - } - hooksRegistered = true; - registerHooks({ - resolve(specifier, context, nextResolve) { - const result = nextResolve(specifier, context); - if (activeLoads.size === 0) { - return result; - } - const entryId = specifier.startsWith('file:') - ? new URL(specifier).searchParams.get(LOAD_PARAM) - : null; - const id = - entryId ?? - (context.parentURL && isLocalSpecifier(specifier) - ? loadIdOf(context.parentURL) - : undefined); - const load = id ? activeLoads.get(id) : undefined; - const path = localPath(result.url); - if (!id || !load || !path) { - return result; - } - if (entryId) { - load.entry = path; - } else if (!load.files.has(path)) { - // Taken before Node reads the file, so a save during the load counts as a change. - load.signatures.push(fileSignature(path)); - } - load.files.add(path); - if (context.conditions?.includes('require') || result.format?.startsWith('commonjs')) { - load.cached.add(path); - return result; - } - const url = new URL(result.url); - url.searchParams.set(LOAD_PARAM, id); - return { ...result, url: url.href }; - }, - load(url, context, nextLoad) { - const result = nextLoad(url, context); - // A `.js` file's format is only known once Node reads its package.json or source. - const id = activeLoads.size > 0 ? new URL(url).searchParams.get(LOAD_PARAM) : null; - const path = localPath(url); - if (id && path && result.format?.startsWith('commonjs')) { - activeLoads.get(id)?.cached.add(path); - } - return result; - }, - }); - return true; -} - -async function load(path: string): Promise { - const last = lastLoads.get(path); - if (last && [path, ...last.result.dependencies].map(fileSignature).join('|') === last.signature) { - return last.result; - } - - if (!registerFreshImportHooks()) { - const module = (await import(pathToFileURL(path).href)) as Record; - return { module, dependencies: [], restartRequired: [path] }; - } - const id = String(++lastLoadId); - const active: ActiveLoad = { files: new Set(), signatures: [], cached: new Set() }; - activeLoads.set(id, active); - const entrySignature = fileSignature(path); - const url = pathToFileURL(path); - url.searchParams.set(LOAD_PARAM, id); - try { - const module = (await import(url.href)) as Record; - // A cached CommonJS file does not run its `require()` calls again, so keep - // what earlier loads of this entry reached through it. - const restartRequired = new Set([...(last?.result.restartRequired ?? []), ...active.cached]); - const entry = active.entry ?? path; - if (restartRequired.delete(entry)) { - restartRequired.add(path); - } - const dependencies = [...active.files].filter((file) => file !== entry); - for (const file of restartRequired) { - if (file !== path && !dependencies.includes(file)) { - dependencies.push(file); - active.signatures.push(fileSignature(file)); - } - } - const result = { module, dependencies, restartRequired: [...restartRequired] }; - lastLoads.set(path, { result, signature: [entrySignature, ...active.signatures].join('|') }); - return result; - } finally { - activeLoads.delete(id); - } -} - -/** - * Import `path` with its current source and the current source of every local - * ES module it imports. Returns the previous result when none of its files changed. - */ -export function importFresh(path: string): Promise { - const result = queue.then(() => load(path)); - queue = result.catch(() => undefined); - return result; -} diff --git a/packages/void/src/auth/node.ts b/packages/void/src/auth/node.ts index 161519fc1..c739401ec 100644 --- a/packages/void/src/auth/node.ts +++ b/packages/void/src/auth/node.ts @@ -11,7 +11,7 @@ import { } from '../runtime/auth-migrations.ts'; import type { VoidAuthDialect, VoidAuthEnv } from '../runtime/better-auth-shared.ts'; import { loadLocalEnv } from '../shared/env.ts'; -import { findVoidAuthConfig, loadVoidAuthConfig } from './config.ts'; +import { findVoidAuthConfig, loadVoidAuthConfig, type ModuleImporter } from './config.ts'; import { resolveVoidAuthMigrationSchema } from './drizzle.ts'; export async function runLocalVoidAuthMigrations( @@ -25,8 +25,10 @@ export async function runLocalVoidAuthMigrations( * shell value counts as unset. */ databaseUrl?: string, + /** The dev server's runner, so a restarted server sees the current config. */ + importModule?: ModuleImporter, ): Promise { - const authConfig = await loadVoidAuthConfig(configPath); + const authConfig = await loadVoidAuthConfig(configPath, importModule); const runtime = { dialect, providers: readConfig(root).auth?.providers ?? null, diff --git a/packages/void/src/plugin-migration.ts b/packages/void/src/plugin-migration.ts index 220feb1b2..4138f3d67 100644 --- a/packages/void/src/plugin-migration.ts +++ b/packages/void/src/plugin-migration.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'pathslash'; import type { Plugin } from 'vite'; +import { devAuthConfigImporter } from './auth/config.ts'; import type { DatabaseDialect } from './config.ts'; import { voidLog, voidError } from './log.ts'; import { afterServerReady } from './plugin-dev-config.ts'; @@ -52,6 +53,7 @@ export function migrationPlugin( databaseId, auth?.configPath, databaseUrl, + devAuthConfigImporter(server), ); }; // MySQL runs Better Auth's migrations under the migration lock of the database they change. diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index dd8909a0c..38458fd58 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -1,16 +1,15 @@ -import { - existsSync, - mkdirSync, - readFileSync, - realpathSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative, resolve } from 'pathslash'; -import { normalizePath, type Plugin } from 'vite'; +import { + createRunnableDevEnvironment, + isRunnableDevEnvironment, + normalizePath, + type Plugin, + type ResolvedConfig, + type RunnableDevEnvironment, +} from 'vite'; import type { ApplicationAnalysis } from './application-analysis.ts'; import { completeApplicationAnalysis } from './application-routing.ts'; import { @@ -24,7 +23,7 @@ import { isGeneratedAssetHeaders, resolveAssetWorkerBypass, } from './asset-worker-bypass.ts'; -import { loadVoidAuthConfigWithDependencies, type LoadedVoidAuthConfig } from './auth/config.ts'; +import { AUTH_CONFIG_ENVIRONMENT, loadVoidAuthConfig } from './auth/config.ts'; import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts'; import { lintRoutingPhases, mergeRoutingRules } from './cli/headers.ts'; import { stampClientAssetCachePolicy } from './client-asset-manifest.ts'; @@ -161,13 +160,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const pagesDir = paths.pagesDir; const ssrDir = paths.ssrDir; const authConfigPaths = new Set(paths.authConfigCandidates.map((path) => normalizePath(path))); - // Local files the auth config imported when it was last loaded. - let authConfigDependencies = new Set(); - // Those of them Node keeps cached (CommonJS, `require()`) until a restart. - let authConfigRestartFiles = new Set(); - // A failed load may stop at a missing or broken file it never reported. - let authConfigLoadFailed = false; - // A file saved during a load may be one the load is about to report. + let authConfigEnvironment: RunnableDevEnvironment | undefined; + // Set when the last load failed, or a script was saved while a load ran: the + // file that fixes it may be one the module graph never got, so the next + // script save retries and the retry starts from a clean environment. + let authConfigRetry = false; let authConfigLoading = false; const envFilePaths = new Set(paths.envFileCandidates.map((path) => normalizePath(path))); const normalizedRoutesDir = normalizePath(routesDir); @@ -343,35 +340,57 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return parts.join('|'); } - async function writeAuthDatabaseFiles(): Promise { - if (!compileOptions.auth) { - authConfigDependencies = new Set(); - authConfigRestartFiles = new Set(); - authConfigLoadFailed = false; - return; + async function loadAuthConfig(configPath: string | null) { + const environment = authConfigEnvironment; + if (authConfigRetry && environment) { + // An import that failed stays in the graph under its unresolved specifier. + environment.moduleGraph.invalidateAll(); + environment.runner.clearCache(); } - - // Better Auth's schema helpers load zod and most of better-auth; only auth apps need them. - const { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } = - await import('./auth/drizzle.ts'); - let loaded: LoadedVoidAuthConfig; + authConfigRetry = false; authConfigLoading = true; try { - loaded = await loadVoidAuthConfigWithDependencies(compileOptions.auth.authConfigPath); + return await loadVoidAuthConfig( + configPath, + environment && ((path) => environment.runner.import(path)), + ); } catch (error) { - authConfigLoadFailed = true; + authConfigRetry = true; throw error; } finally { authConfigLoading = false; } - authConfigLoadFailed = false; - // A helper the config imports changes the generated schema like the config does. - authConfigDependencies = new Set(loaded.dependencies.map((path) => normalizePath(path))); - authConfigRestartFiles = new Set(loaded.restartRequired.map((path) => normalizePath(path))); + } + + /** Retry a failed auth config load: whether it loads now, or undefined when nothing failed. */ + async function retryAuthConfig(logger: import('vite').Logger): Promise { + if (!authConfigRetry || !compileOptions.auth) { + return undefined; + } + try { + await loadAuthConfig(compileOptions.auth.authConfigPath); + return true; + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error), { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + return false; + } + } + + async function writeAuthDatabaseFiles(): Promise { + if (!compileOptions.auth) { + return; + } + + // Better Auth's schema helpers load zod and most of better-auth; only auth apps need them. + const { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } = + await import('./auth/drizzle.ts'); const authRuntime: VoidAuthRuntimeOptions = { dialect: compileOptions.auth.dialect, providers: compileOptions.auth.providers, - userConfig: loaded.config, + userConfig: await loadAuthConfig(compileOptions.auth.authConfigPath), }; const local = resolvedConfig?.command === 'serve' ? loadLocalEnv(root) : {}; const env = { ...local, ...process.env } as Record; @@ -430,31 +449,32 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug function isPotentialAuthConfigPath(path: string): boolean { const normalized = normalizePath(path); - if (authConfigPaths.has(normalized)) { - return true; - } - if ((authConfigLoadFailed || authConfigLoading) && isScriptPath(normalized)) { - // Creating or fixing the file the last load stopped at must retry it. - return true; - } - return isAuthConfigFile(authConfigDependencies, normalized); + return ( + authConfigPaths.has(normalized) || + (authConfigEnvironment?.moduleGraph.getModulesByFile(normalized)?.size ?? 0) > 0 + ); } - function isAuthConfigFile(files: Set, normalized: string): boolean { - if (files.size === 0) { - return false; - } - if (files.has(normalized)) { - return true; + /** A script save that may fix a failed load, or that a running load may import. */ + function isAuthConfigRetryPath(path: string): boolean { + return ( + compileOptions.auth !== null && + (authConfigRetry || authConfigLoading) && + isScriptPath(path) && + !isPotentialAuthConfigPath(path) + ); + } + + /** Drop the evaluated auth config when one of its files changes. */ + function invalidateAuthConfig(path: string): void { + const modules = authConfigEnvironment?.moduleGraph.getModulesByFile(path); + if (!authConfigEnvironment || !modules?.size) { + return; } - // The loader reports real paths; the watcher reports them under the root as - // written, which can go through a symlink. - try { - return files.has(normalizePath(realpathSync(normalized))); - } catch { - // A deleted file keeps the path the watcher gave. - return false; + for (const module of modules) { + authConfigEnvironment.moduleGraph.invalidateModule(module); } + authConfigEnvironment.runner.clearCache(); } function isPotentialEnvFilePath(path: string): boolean { @@ -670,6 +690,25 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug config(_, env) { previewing = env.isPreview === true; routeEnvironment = env.command === 'serve' && !env.isPreview ? 'dev' : 'prod'; + // Dev only, so builds never see the environment. + const authConfigEnvironmentConfig = + routeEnvironment === 'dev' + ? { + environments: { + [AUTH_CONFIG_ENVIRONMENT]: { + consumer: 'server' as const, + dev: { + createEnvironment: (name: string, config: ResolvedConfig) => + // Void invalidates it on saves; no HMR client to run. + createRunnableDevEnvironment(name, config, { + hot: false, + runnerOptions: { hmr: false }, + }), + }, + }, + }, + } + : {}; compileOptions.hasLive = options.live !== undefined; compileOptions.managedDispatch = !options.target && env.command === 'build' && Boolean(process.env.VOID_DEPLOY_PROJECT_ID); @@ -689,12 +728,12 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } }); - return { resolve: { alias: resolvedAliases } }; + return { ...authConfigEnvironmentConfig, resolve: { alias: resolvedAliases } }; } const entries = validateSsrEntries(root, paths); if (env.command !== 'build') { - return; + return authConfigEnvironmentConfig; } return { @@ -908,18 +947,29 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug }, }, + hotUpdate() { + // Void invalidates this environment itself; Vite's update pass would + // only log a page reload that nothing receives. + if (this.environment.name === AUTH_CONFIG_ENVIRONMENT) { + return []; + } + }, + configureServer(devServer) { + const authEnvironment = devServer.environments[AUTH_CONFIG_ENVIRONMENT]; + authConfigEnvironment = + authEnvironment && isRunnableDevEnvironment(authEnvironment) ? authEnvironment : undefined; + // Watch the source convention directories for file changes const watcher = devServer.watcher; const handleFileChange = async (path: string, event: 'add' | 'change' | 'unlink') => { const normalizedPath = normalizePath(path); invalidateSourceAnalysis(normalizedPath); - if (event === 'change' && isAuthConfigFile(authConfigRestartFiles, normalizedPath)) { - devServer.config.logger.warn( - `auth: ${relative(root, normalizedPath)} is loaded as CommonJS or with require(); restart the dev server to apply its changes.`, - { timestamp: true }, - ); + invalidateAuthConfig(normalizedPath); + const authRetry = isAuthConfigRetryPath(normalizedPath); + if (authRetry && authConfigLoading) { + authConfigRetry = true; } const sourceConventionChanged = @@ -930,6 +980,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug isInDirectory(normalizedPath, normalizedEmailDir) || isInDirectory(normalizedPath, normalizedPagesDir) || isPotentialAuthConfigPath(normalizedPath) || + authRetry || isPotentialEnvFilePath(normalizedPath); if (!sourceConventionChanged && !ssrEntryAbsPaths.has(normalizedPath)) { return; @@ -937,11 +988,18 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const shouldRestart = await regenerateInOrder(async () => { invalidateSourceAnalysis(normalizedPath); + // The retry runs apart from this save, so a config that still fails + // does not stop the rest of it (a page reload, say). + const authRetried = authRetry + ? await retryAuthConfig(devServer.config.logger) + : undefined; + const authRecovered = authRetried === true; // Re-scan and invalidate the virtual module in ALL environments. // The route table virtual module lives in the workerd environment, // not the client — so we must iterate all environments. const forceRegeneration = event !== 'change' || + authRecovered || isPotentialAuthConfigPath(normalizedPath) || isPotentialEnvFilePath(normalizedPath) || ssrEntryAbsPaths.has(normalizedPath); @@ -995,7 +1053,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return true; } - if (routeInputsChanged) { + // Generated files would fail on the same error; the next save retries. + const regenerate = routeInputsChanged && authRetried !== false; + if (regenerate) { await writeAuthDatabaseFiles(); writeRouteTypes(); writeQueueTypes(); @@ -1028,7 +1088,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } } const mod = env.moduleGraph.getModuleById(VIRTUAL_ROUTES_ID); - if (mod && routeInputsChanged) { + if (mod && regenerate) { env.moduleGraph.invalidateModule(mod); if (structural) { env.hot.send({ type: 'full-reload' }); @@ -1043,7 +1103,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug devServer.environments.client?.hot.send({ type: 'full-reload' }); } - if (routeInputsChanged) { + if (regenerate) { generatedRouteInputs = nextRouteInputs; generatedFiles = generatedFilesSignature(); } diff --git a/packages/void/test/unit/auth-config-loader.test.ts b/packages/void/test/unit/auth-config-loader.test.ts index db7a64b07..5b89442b6 100644 --- a/packages/void/test/unit/auth-config-loader.test.ts +++ b/packages/void/test/unit/auth-config-loader.test.ts @@ -1,99 +1,33 @@ -import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; -import { loadVoidAuthConfig, loadVoidAuthConfigWithDependencies } from '../../src/auth/config.ts'; +import { join } from 'node:path'; +import { createRunnableDevEnvironment, createServer } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + AUTH_CONFIG_ENVIRONMENT, + devAuthConfigImporter, + loadVoidAuthConfig, +} from '../../src/auth/config.ts'; const tempDirs: Array = []; function createTempDir(): string { - // The loader reports real paths (macOS temp dirs sit behind /var -> /private/var). - const dir = realpathSync(mkdtempSync(join(tmpdir(), 'void-auth-config-'))); + const dir = mkdtempSync(join(tmpdir(), 'void-auth-config-')); tempDirs.push(dir); return dir; } -function writeFiles(dir: string, files: Record): void { - for (const [file, source] of Object.entries(files)) { - mkdirSync(dirname(join(dir, file)), { recursive: true }); - writeFileSync(join(dir, file), source); - } -} - -type Step = { write?: Record; load: string; together?: number }; -type StepResult = - | { value: unknown; dependencies: Array; restartRequired: Array; reused: boolean } - | { error: string }; - -const CONFIG_URL = pathToFileURL( - join(import.meta.dirname, '..', '..', 'src', 'auth', 'config.ts'), -).href; - -// Vitest routes `import()` through its own module runner, so the Node resolve -// hook the loader relies on only runs in a plain Node process. -const DRIVER = ` -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative, sep } from 'node:path'; -const [, moduleUrl, dir, stepsJson] = process.argv; -const { loadVoidAuthConfigWithDependencies } = await import(moduleUrl); -const toRelative = (paths) => paths.map((path) => relative(dir, path).split(sep).join('/')).sort(); -const results = []; -let previous; -for (const step of JSON.parse(stepsJson)) { - for (const [file, source] of Object.entries(step.write ?? {})) { - mkdirSync(dirname(join(dir, file)), { recursive: true }); - writeFileSync(join(dir, file), source); - } - const loads = Array.from({ length: step.together ?? 1 }, () => - loadVoidAuthConfigWithDependencies(join(dir, step.load)), - ); - for (const outcome of await Promise.allSettled(loads)) { - if (outcome.status === 'rejected') { - results.push({ error: String(outcome.reason?.message ?? outcome.reason).split('\\n')[0] }); - continue; +describe('loadVoidAuthConfig', () => { + afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); } - const { config, dependencies, restartRequired } = outcome.value; - results.push({ - value: typeof config === 'function' ? config() : config, - dependencies: toRelative(dependencies), - restartRequired: toRelative(restartRequired), - reused: config === previous, - }); - previous = config; - } -} -process.stdout.write(JSON.stringify(results)); -`; - -function runSteps(dir: string, steps: Array): Array { - const env = { ...process.env }; - delete env.NODE_ENV; - const result = spawnSync( - process.execPath, - ['--input-type=module', '--eval', DRIVER, CONFIG_URL, dir, JSON.stringify(steps)], - { cwd: dir, encoding: 'utf8', env, timeout: 30_000 }, - ); - expect(result.error).toBeUndefined(); - expect(result.status, result.stderr).toBe(0); - return JSON.parse(result.stdout) as Array; -} - -afterEach(() => { - while (tempDirs.length > 0) { - rmSync(tempDirs.pop()!, { recursive: true, force: true }); - } -}); + }); -describe('loadVoidAuthConfig', () => { it('returns undefined without a config file', async () => { - expect(await loadVoidAuthConfig(null)).toBeUndefined(); - expect(await loadVoidAuthConfigWithDependencies(null)).toEqual({ - config: undefined, - dependencies: [], - restartRequired: [], - }); + const importModule = vi.fn(); + expect(await loadVoidAuthConfig(null, importModule)).toBeUndefined(); + expect(importModule).not.toHaveBeenCalled(); }); it.each([ @@ -106,231 +40,45 @@ describe('loadVoidAuthConfig', () => { expect(await loadVoidAuthConfig(path)).toEqual({ marker }); }); - it('reads edits to the config and to the local files it imports', () => { - const dir = createTempDir(); - const pkg = (name: string) => ({ - [`node_modules/${name}/package.json`]: JSON.stringify({ - name, - type: 'module', - exports: './index.js', - }), - [`node_modules/${name}/index.js`]: `globalThis.${name}Runs = (globalThis.${name}Runs ?? 0) + 1;\nexport const runs = () => globalThis.${name}Runs;`, - }); - writeFiles(dir, { - 'package.json': JSON.stringify({ - type: 'module', - imports: { '#lib/*': './lib/*', '#dep': 'dep' }, - }), - // Before `void prepare` writes `.void/tsconfig.json`; Node never reads it. - 'tsconfig.json': JSON.stringify({ extends: './.void/tsconfig.json' }), - ...pkg('pkg'), - ...pkg('dep'), - 'helper.ts': "export const field: string = 'nickname';", - 'lib/sub.ts': "export const sub = 'sub-1';", - 'auth.ts': [ - "import { runs } from 'pkg';", - "import { runs as depRuns } from '#dep';", - "import { field } from './helper.ts';", - "import { sub } from '#lib/sub.ts';", - 'export default () => ({ field, sub, runs: [runs(), depRuns()], env: process.env.NODE_ENV ?? "unset" });', - ].join('\n'), - }); - - const results = runSteps(dir, [ - { load: 'auth.ts' }, - { write: { 'helper.ts': "export const field: string = 'displayName';" }, load: 'auth.ts' }, - { write: { 'lib/sub.ts': "export const sub = 'sub-2';" }, load: 'auth.ts' }, - { - write: { - 'auth.ts': [ - "import { runs } from 'pkg';", - "import { field } from './helper.ts';", - "export default () => ({ field: field + '!', runs: [runs()] });", - ].join('\n'), - }, - load: 'auth.ts', - }, - ]); - - const loaded = (value: unknown, dependencies = ['helper.ts', 'lib/sub.ts']) => ({ - value, - dependencies, - restartRequired: [], - reused: false, - }); - // Packages, including one reached through a `#` import, run once. - expect(results).toEqual([ - loaded({ field: 'nickname', sub: 'sub-1', runs: [1, 1], env: 'unset' }), - loaded({ field: 'displayName', sub: 'sub-1', runs: [1, 1], env: 'unset' }), - loaded({ field: 'displayName', sub: 'sub-2', runs: [1, 1], env: 'unset' }), - loaded({ field: 'displayName!', runs: [1] }, ['helper.ts']), - ]); + it('evaluates the config through the module importer it is given', async () => { + const importModule = vi.fn(async () => ({ auth: { marker: 'runner' } })); + expect(await loadVoidAuthConfig('/app/auth.ts', importModule)).toEqual({ marker: 'runner' }); + expect(importModule).toHaveBeenCalledWith('/app/auth.ts'); }); - it('keeps CommonJS files at their first evaluation and reports them', () => { + it("imports through the dev server's auth config environment when it has one", async () => { const dir = createTempDir(); - writeFiles(dir, { - 'package.json': JSON.stringify({ type: 'commonjs' }), - 'base.cjs': "module.exports = { base: 'base-1' };", - // A re-export, which Node reads ahead of running the file. - 'spread.cjs': "module.exports = { ...require('./base.cjs'), spread: true };", - 'esm.mts': "export const esm: string = 'esm-1';", - 'bridge.cjs': "module.exports = { esm: require('./esm.mts').esm };", - 'auth.mts': [ - "import spread from './spread.cjs';", - "import bridge from './bridge.cjs';", - "export const marker = 'marker-1';", - 'export default { ...spread, ...bridge, marker };', - ].join('\n'), - 'auth.cts': "module.exports = { base: require('./base.cjs').base };", - }); - - const results = runSteps(dir, [ - { load: 'auth.mts' }, - { - write: { - 'base.cjs': "module.exports = { base: 'base-2' };", - 'esm.mts': "export const esm: string = 'esm-2';", - 'auth.mts': [ - "import spread from './spread.cjs';", - "import bridge from './bridge.cjs';", - "export const marker = 'marker-2';", - 'export default { ...spread, ...bridge, marker };', - ].join('\n'), + const path = join(dir, 'auth.ts'); + writeFileSync(path, "export default { marker: 'first' };"); + const server = await createServer({ + root: dir, + configFile: false, + logLevel: 'silent', + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, watch: null }, + environments: { + [AUTH_CONFIG_ENVIRONMENT]: { + consumer: 'server', + dev: { createEnvironment: (name, config) => createRunnableDevEnvironment(name, config) }, }, - load: 'auth.mts', - }, - { load: 'auth.cts' }, - ]); - - const restartRequired = ['base.cjs', 'bridge.cjs', 'esm.mts', 'spread.cjs']; - expect(results).toEqual([ - { - value: { base: 'base-1', spread: true, esm: 'esm-1', marker: 'marker-1' }, - dependencies: restartRequired, - restartRequired, - reused: false, }, - { - value: { base: 'base-1', spread: true, esm: 'esm-1', marker: 'marker-2' }, - dependencies: restartRequired, - restartRequired, - reused: false, - }, - { - value: { base: 'base-1' }, - dependencies: ['base.cjs'], - restartRequired: ['auth.cts', 'base.cjs'], - reused: false, - }, - ]); - }); - - it('reports a CommonJS .js helper in a package without a type', () => { - const dir = createTempDir(); - writeFiles(dir, { - 'package.json': '{}', - 'helper.js': "module.exports = { helper: 'helper-1' };", - 'auth.mts': "import helper from './helper.js';\nexport default { ...helper };", - }); - - const results = runSteps(dir, [ - { load: 'auth.mts' }, - { write: { 'helper.js': "module.exports = { helper: 'helper-2' };" }, load: 'auth.mts' }, - ]); - - const loaded = { dependencies: ['helper.js'], restartRequired: ['helper.js'], reused: false }; - expect(results).toEqual([ - { value: { helper: 'helper-1' }, ...loaded }, - { value: { helper: 'helper-1' }, ...loaded }, - ]); - }); - - it('recovers after a syntax error and after a missing import is created', () => { - const dir = createTempDir(); - writeFiles(dir, { - 'helper.ts': "export const field = 'nickname';", - 'auth.ts': "import { field } from './helper.ts';\nexport default { field };", - }); - - const results = runSteps(dir, [ - { load: 'auth.ts' }, - { write: { 'helper.ts': 'export const field = {' }, load: 'auth.ts' }, - { write: { 'helper.ts': "export const field = 'fixed';" }, load: 'auth.ts' }, - { - write: { 'auth.ts': "import { extra } from './extra.ts';\nexport default { extra };" }, - load: 'auth.ts', - }, - { write: { 'extra.ts': "export const extra = 'created';" }, load: 'auth.ts' }, - ]); - - expect(results[0]).toMatchObject({ value: { field: 'nickname' } }); - expect(results[1]).toHaveProperty('error'); - expect(results[2]).toMatchObject({ value: { field: 'fixed' } }); - expect(results[3]).toHaveProperty('error'); - expect(results[4]).toMatchObject({ value: { extra: 'created' }, dependencies: ['extra.ts'] }); - }); - - it('reuses the last config while none of its files changed', () => { - const dir = createTempDir(); - writeFiles(dir, { - 'helper.ts': [ - 'globalThis.helperRuns = (globalThis.helperRuns ?? 0) + 1;', - "export const field = 'nickname';", - ].join('\n'), - 'auth.mts': [ - "import { field } from './helper.ts';", - 'const awaited = await Promise.resolve(field);', - 'export default () => ({ awaited, runs: globalThis.helperRuns, dir: import.meta.dirname });', - ].join('\n'), }); - - const results = runSteps(dir, [ - { load: 'auth.mts' }, - { load: 'auth.mts' }, - { - write: { 'helper.ts': "globalThis.helperRuns += 1;\nexport const field = 'next';" }, - load: 'auth.mts', - }, - ]); - - const loaded = (value: unknown, reused: boolean) => ({ - value, - dependencies: ['helper.ts'], - restartRequired: [], - reused, - }); - expect(results).toEqual([ - loaded({ awaited: 'nickname', runs: 1, dir }, false), - loaded({ awaited: 'nickname', runs: 1, dir }, true), - loaded({ awaited: 'next', runs: 2, dir }, false), - ]); - }); - - it('loads one config at a time when called together', () => { - const dir = createTempDir(); - writeFiles(dir, { - 'package.json': JSON.stringify({ type: 'commonjs' }), - 'inner.cjs': "module.exports = 'inner';", - 'helper.cjs': "module.exports = require('./inner.cjs');", - 'auth.mts': "import helper from './helper.cjs';\nexport default { helper };", + try { + const importModule = devAuthConfigImporter(server)!; + expect(await loadVoidAuthConfig(path, importModule)).toEqual({ marker: 'first' }); + } finally { + await server.close(); + } + const plain = await createServer({ + root: dir, + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true, ws: false, watch: null }, }); - - const results = runSteps(dir, [{ load: 'auth.mts', together: 2 }]); - - expect(results).toEqual([ - { - value: { helper: 'inner' }, - dependencies: ['helper.cjs', 'inner.cjs'], - restartRequired: ['helper.cjs', 'inner.cjs'], - reused: false, - }, - { - value: { helper: 'inner' }, - dependencies: ['helper.cjs', 'inner.cjs'], - restartRequired: ['helper.cjs', 'inner.cjs'], - reused: true, - }, - ]); + try { + expect(devAuthConfigImporter(plain)).toBeUndefined(); + } finally { + await plain.close(); + } }); }); diff --git a/packages/void/test/unit/local-d1.test.ts b/packages/void/test/unit/local-d1.test.ts index 2538155cd..0a8099786 100644 --- a/packages/void/test/unit/local-d1.test.ts +++ b/packages/void/test/unit/local-d1.test.ts @@ -41,7 +41,7 @@ async function runGeneratedMigrationPlugin(plugins: ReturnType name === 'void:migrations'); expect(plugin).toBeTruthy(); const configureServer = hookHandler(plugin?.configureServer); - await (configureServer as (server: unknown) => Promise).call({}, {}); + await (configureServer as (server: unknown) => Promise).call({}, { environments: {} }); } afterEach(() => { diff --git a/packages/void/test/unit/plugin-migration.test.ts b/packages/void/test/unit/plugin-migration.test.ts index 6c0a7cb5d..094a8be9f 100644 --- a/packages/void/test/unit/plugin-migration.test.ts +++ b/packages/void/test/unit/plugin-migration.test.ts @@ -40,10 +40,17 @@ vi.mock('mysql2/promise', () => ({ })); const authFailure = vi.hoisted(() => ({ error: undefined as Error | undefined })); +const authImporters = vi.hoisted((): Array => []); + +vi.mock('../../src/auth/config.ts', async (importOriginal) => ({ + ...(await importOriginal()), + devAuthConfigImporter: (server: { authImporter?: unknown }) => server.authImporter, +})); vi.mock('../../src/auth/node.ts', () => ({ runLocalVoidAuthMigrations: async (...args: Array) => { mysqlQueries.push('Better Auth migrations'); + authImporters.push(args[6]); const databaseUrl = args[5]; mysqlSteps.push( `auth ${typeof databaseUrl === 'string' ? new URL(databaseUrl).pathname.slice(1) : '(no URL)'}`, @@ -78,16 +85,19 @@ afterEach(() => { } }); -async function runConfigureServer(plugin: ReturnType) { +async function runConfigureServer( + plugin: ReturnType, + server: Record = { environments: {} }, +) { const hook = plugin.configureServer as ConfigureServerHook | undefined; if (!hook) { return; } if (typeof hook === 'function') { - await hook.call({}, {}); + await hook.call({}, server); return; } - await hook.handler.call({}, {}); + await hook.handler.call({}, server); } describe('migrationPlugin persistence roots', () => { @@ -113,6 +123,24 @@ describe('migrationPlugin persistence roots', () => { }); }); +describe('migrationPlugin Better Auth config', () => { + it("loads the auth config through the dev server's runner, so a restarted server reads it again", async () => { + const root = createProjectRoot(); + const authImporter = vi.fn(); + authImporters.length = 0; + + await runConfigureServer( + migrationPlugin(root, 'sqlite', 'local', join(root, '.void'), { + enabled: true, + configPath: null, + }), + { environments: {}, authImporter }, + ); + + expect(authImporters).toEqual([authImporter]); + }); +}); + describe('migrationPlugin with MySQL and Better Auth', () => { // Better Auth reads the schema, then adds what is missing, so two runs at once both add it. // An app without migration files reads DATABASE_URL like Better Auth: the shell before .env. diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index e654713a3..67f744aa9 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -9,7 +9,13 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import type { ConfigEnv, ResolvedConfig, UserConfig } from 'vite'; +import { + createServer, + type ConfigEnv, + type ResolvedConfig, + type RunnableDevEnvironment, + type UserConfig, +} from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; import { GENERATED_ASSET_HEADERS_MARKER } from '../../src/asset-worker-bypass.ts'; @@ -69,21 +75,24 @@ async function runBuildStart(plugin: RoutingPlugin): Promise { await (getHookHandler(plugin.buildStart) as () => Promise).call({} as never); } -function fakeDevServer(moduleIds: Array = []) { +function fakeDevServer( + moduleIds: Array = [], + extraEnvironments: Record = {}, +) { const handlers = new Map Promise | void>>(); const invalidated: Array = []; const restart = vi.fn(async () => {}); const logError = vi.fn(); - const logWarn = vi.fn(); const modules = new Map(moduleIds.map((id) => [id, { id }])); const server = { - config: { logger: { error: logError, warn: logWarn } }, + config: { logger: { error: logError } }, watcher: { on(event: string, handler: (path: string) => Promise | void) { handlers.set(event, [...(handlers.get(event) ?? []), handler]); }, }, environments: { + ...extraEnvironments, void_worker: { moduleGraph: { getModuleById(id: string) { @@ -103,11 +112,15 @@ function fakeDevServer(moduleIds: Array = []) { await handler(path); } } - return { server, invalidated, restart, emit, logError, logWarn }; + return { server, invalidated, restart, emit, logError }; } -function startDevServer(plugin: RoutingPlugin, moduleIds?: Array) { - const dev = fakeDevServer(moduleIds); +function startDevServer( + plugin: RoutingPlugin, + moduleIds?: Array, + extraEnvironments?: Record, +) { + const dev = fakeDevServer(moduleIds, extraEnvironments); (getHookHandler(plugin.configureServer) as (server: unknown) => void)(dev.server); return dev; } @@ -442,7 +455,7 @@ describe('routingPlugin', () => { rmSync(typesPath); mkdirSync(typesPath); } else if (failure === 'auth generation') { - vi.spyOn(authConfig, 'loadVoidAuthConfigWithDependencies').mockRejectedValueOnce( + vi.spyOn(authConfig, 'loadVoidAuthConfig').mockRejectedValueOnce( new Error('Transient auth generation failure'), ); } else { @@ -584,10 +597,10 @@ describe('routingPlugin', () => { started = resolve; }); let resume!: () => void; - const pause = new Promise((resolve) => { - resume = () => resolve({ config: undefined, dependencies: [], restartRequired: [] }); + const pause = new Promise((resolve) => { + resume = () => resolve(undefined); }); - vi.spyOn(authConfig, 'loadVoidAuthConfigWithDependencies').mockImplementationOnce(() => { + vi.spyOn(authConfig, 'loadVoidAuthConfig').mockImplementationOnce(() => { started(); return pause; }); @@ -617,93 +630,6 @@ describe('routingPlugin', () => { expect(invalidated).toEqual(['virtual:void-routes']); }); - it('regenerates auth files when the auth config or a file it imports changes', async () => { - const root = createTempRoot(); - const routePath = join(root, 'routes', 'health.ts'); - writeFileSync(routePath, 'export const GET = () => new Response();'); - const authPath = readProjectPaths(root).authConfigCandidates[0]!; - const helperPath = join(dirname(authPath), 'auth-fields.ts'); - const createdPath = join(dirname(authPath), 'auth-created.ts'); - const legacyPath = join(dirname(authPath), 'auth-legacy.cjs'); - const latePath = join(dirname(authPath), 'auth-late.ts'); - writeFileSync(authPath, "export type {} from 'void/auth';"); - writeFileSync(helperPath, ''); - writeFileSync(legacyPath, ''); - let dependencies = [helperPath, legacyPath]; - let pause: Promise | undefined; - // Evaluating real files is covered by auth-config-loader.test.ts; vitest's - // module runner bypasses the Node hook the loader uses. - let field: string | Error = 'nickname'; - const load = vi - .spyOn(authConfig, 'loadVoidAuthConfigWithDependencies') - .mockImplementation(async () => { - await pause; - if (field instanceof Error) { - throw field; - } - return { - config: { user: { additionalFields: { [field]: { type: 'string' } } } }, - dependencies: dependencies.map((path) => realpathSync(path)), - restartRequired: [realpathSync(legacyPath)], - }; - }); - - const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); - resolveConfig(plugin, 'serve'); - await runBuildStart(plugin); - const schemaPath = join(root, '.void', 'better-auth-schema.ts'); - expect(readFileSync(schemaPath, 'utf8')).toContain('"nickname"'); - const { emit, logError, logWarn } = startDevServer(plugin, ['virtual:void-routes']); - - field = 'bio'; - await emit('change', authPath); - expect(readFileSync(schemaPath, 'utf8')).toContain('"bio"'); - - field = 'displayName'; - await emit('change', helperPath); - expect(readFileSync(schemaPath, 'utf8')).toContain('"displayName"'); - - // Other source files do not reload the config. - const loads = load.mock.calls.length; - await emit('change', routePath); - expect(load).toHaveBeenCalledTimes(loads); - - // After a failed load, a file it never reached (one being created) retries it. - field = new Error("Cannot find module './auth-created.ts'"); - await emit('change', helperPath); - expect(logError).toHaveBeenCalledTimes(1); - field = 'avatar'; - writeFileSync(createdPath, ''); - await emit('add', createdPath); - expect(readFileSync(schemaPath, 'utf8')).toContain('"avatar"'); - - // Once it loads again, only its own files count. - await emit('change', routePath); - expect(load).toHaveBeenCalledTimes(loads + 2); - - // Node keeps CommonJS files cached, so their edits ask for a restart. - await emit('change', legacyPath); - expect(logWarn).toHaveBeenCalledWith( - expect.stringContaining('auth-legacy.cjs is loaded as CommonJS'), - expect.anything(), - ); - - // A file saved while a load runs, which that load then reports, reloads again. - let finish = () => {}; - pause = new Promise((resolve) => { - finish = resolve; - }); - writeFileSync(latePath, ''); - const loading = emit('change', authPath); - await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(loads + 3)); - dependencies = [helperPath, latePath]; - const lateSave = emit('change', latePath); - pause = undefined; - finish(); - await Promise.all([loading, lateSave]); - expect(load).toHaveBeenCalledTimes(loads + 4); - }); - it.each([ { change: 'providers', @@ -774,6 +700,184 @@ describe('routingPlugin', () => { }, ); + it.each([ + { command: 'serve' as const, isPreview: false, declared: true }, + { command: 'serve' as const, isPreview: true, declared: false }, + { command: 'build' as const, isPreview: false, declared: false }, + ])( + 'declares the auth config environment for $command (preview $isPreview): $declared', + ({ command, isPreview, declared }) => { + const plugin = createRoutingPlugin(createTempRoot(), { isAuthEnabled: () => false }); + const config = ( + getHookHandler(plugin.config) as (config: UserConfig, env: ConfigEnv) => UserConfig | void + )({}, { command, mode: 'development', isPreview }); + expect(Object.keys(config?.environments ?? {})).toEqual(declared ? ['void_auth_config'] : []); + }, + ); + + it('leaves the auth config environment out of Vite hot updates', () => { + const plugin = createRoutingPlugin(createTempRoot(), { isAuthEnabled: () => true }); + const hotUpdate = getHookHandler(plugin.hotUpdate) as (this: { + environment: { name: string }; + }) => unknown; + expect(hotUpdate.call({ environment: { name: 'void_auth_config' } })).toEqual([]); + expect(hotUpdate.call({ environment: { name: 'void_worker' } })).toBeUndefined(); + }); + + it('regenerates auth files when the auth config or a file it imports changes', async () => { + // Vite's module graph keys files by real path, as the watcher reports them for a real root; + // `.native` also expands Windows short names, as Vite does. + const root = realpathSync.native(createTempRoot()); + const routePath = join(root, 'routes', 'health.ts'); + writeFileSync(routePath, 'export const GET = () => new Response();'); + const authPath = readProjectPaths(root).authConfigCandidates[0]!; + const helperPath = join(dirname(authPath), 'auth-fields.ts'); + const extraPath = join(dirname(authPath), 'auth-extra.ts'); + // The type-only export makes the file an auth config without loading void/auth. + const authSource = (imports: string, fields: string) => + "export type {} from 'void/auth';\n" + + imports + + '\nexport default ({ defaults }) => ({ ...defaults, user: { additionalFields: { ' + + fields + + ' } } });'; + const helperImport = "import { field } from './auth-fields.ts';"; + writeFileSync(helperPath, "export const field = 'nickname';"); + writeFileSync(authPath, authSource(helperImport, "[field]: { type: 'string' }")); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); + // A real dev server, so Vite runs its own watcher handling for the auth + // environment; events are sent by hand. + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + plugins: [ + plugin, + // Holds back Vite's own handling of each save, so recovery cannot rely on it. + { + name: 'slow-watch-change', + async watchChange() { + await new Promise((resolve) => setTimeout(resolve, 100)); + }, + }, + ], + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, ws: false, watch: null, preTransformRequests: false }, + }); + try { + const logError = vi.spyOn(server.config.logger, 'error'); + const environment = server.environments.void_auth_config as RunnableDevEnvironment; + const imports = vi.spyOn(environment.runner, 'import'); + const schemaPath = join(root, '.void', 'better-auth-schema.ts'); + const schema = () => readFileSync(schemaPath, 'utf8'); + const save = (event: 'add' | 'change', path: string, source: string) => { + writeFileSync(path, source); + server.watcher.emit(event, path); + }; + await vi.waitFor(() => expect(schema()).toContain('"nickname"')); + + save( + 'change', + authPath, + authSource(helperImport, "[field]: { type: 'string' }, bio: { type: 'string' }"), + ); + await vi.waitFor(() => expect(schema()).toContain('"bio"')); + + save('change', helperPath, "export const field = 'displayName';"); + await vi.waitFor(() => expect(schema()).toContain('"displayName"')); + expect(schema()).not.toContain('"nickname"'); + + // A save outside the config's files does not load it again. + const loads = imports.mock.calls.length; + writeFileSync(routePath, 'export const GET = () => new Response("updated");'); + server.watcher.emit('change', routePath); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(imports).toHaveBeenCalledTimes(loads); + + // A syntax error in a helper is reported, and fixing it regenerates. + save('change', helperPath, 'export const field ='); + await vi.waitFor(() => expect(logError).toHaveBeenCalledTimes(1)); + save('change', helperPath, "export const field = 'avatar';"); + await vi.waitFor(() => expect(schema()).toContain('"avatar"')); + + // A load that fails on a missing file retries once the file is created. + save( + 'change', + authPath, + authSource("import { extra } from './auth-extra.ts';", "[extra]: { type: 'string' }"), + ); + await vi.waitFor(() => expect(logError).toHaveBeenCalledTimes(2)); + save('add', extraPath, "export const extra = 'created';"); + await vi.waitFor(() => expect(schema()).toContain('"created"')); + } finally { + await server.close(); + } + }); + + it('reloads the auth config for a file saved while it loads', async () => { + const root = createTempRoot(); + writeFileSync(join(root, 'routes', 'health.ts'), 'export const GET = () => new Response();'); + const authPath = readProjectPaths(root).authConfigCandidates[0]!; + const latePath = join(dirname(authPath), 'auth-late.ts'); + writeFileSync(authPath, "export type {} from 'void/auth';"); + // Exists, but not yet part of the config: the load in flight is about to import it. + writeFileSync(latePath, ''); + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const { emit } = startDevServer(plugin, ['virtual:void-routes']); + + let started!: () => void; + const entered = new Promise((resolve) => { + started = resolve; + }); + let resume!: () => void; + const pause = new Promise((resolve) => { + resume = () => resolve(undefined); + }); + const load = vi.spyOn(authConfig, 'loadVoidAuthConfig').mockImplementationOnce(() => { + started(); + return pause; + }); + const loading = emit('change', authPath); + await entered; + writeFileSync(latePath, 'export const late = 1;'); + const lateSave = emit('change', latePath); + resume(); + await Promise.all([loading, lateSave]); + expect(load.mock.calls.length).toBeGreaterThan(1); + }); + + it('still reloads the page for an island save while the auth config fails to load', async () => { + const root = createTempRoot(); + writeFileSync(join(root, 'routes', 'health.ts'), 'export const GET = () => new Response();'); + mkdirSync(join(root, 'pages'), { recursive: true }); + const islandPath = join(root, 'pages', 'about.island.tsx'); + writeFileSync(islandPath, 'export default function Page() {}\n'); + const authPath = readProjectPaths(root).authConfigCandidates[0]!; + writeFileSync(authPath, "export type {} from 'void/auth';"); + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); + resolveConfig(plugin, 'serve'); + await runBuildStart(plugin); + const clientReload = vi.fn(); + const { emit, logError } = startDevServer(plugin, ['virtual:void-routes'], { + client: { + moduleGraph: { getModuleById: () => undefined, invalidateModule() {} }, + hot: { send: clientReload }, + }, + }); + + vi.spyOn(authConfig, 'loadVoidAuthConfig').mockRejectedValue(new Error('auth: broken')); + await emit('change', authPath); + expect(logError).toHaveBeenCalledTimes(1); + + writeFileSync(islandPath, 'export default function Page() { return null; }\n'); + await emit('change', islandPath); + // The retry reports the error again, and the island still reloads. + expect(logError).toHaveBeenCalledTimes(2); + expect(clientReload).toHaveBeenCalledWith({ type: 'full-reload' }); + }); + it('rescans sourceDir route changes during dev', async () => { const root = createBareTempRoot(); writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'src' })); From 5fbd01ad56184909e22d2d153b35017539b6fee5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 14:18:21 +0800 Subject: [PATCH 4/4] refactor(auth): reload the dev auth config with one stale flag Replace the retry state (retry and loading flags, a script extension list, a second load path and the gating around it) with one flag. A save of a file in the auth environment's module graph, or a failed load, marks the config stale; the next load invalidates the whole graph first. After a failed load, a created file triggers that load, as the missing import may now exist. The runner checks each import against the server graph, so clearing the runner cache is not needed. Loads run in order, so a save during a load is picked up by the load queued after it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/plugin-routing.ts | 101 ++++-------------- .../void/test/unit/plugin-routing.test.ts | 64 ----------- 2 files changed, 21 insertions(+), 144 deletions(-) diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 38458fd58..40858dc3d 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -76,15 +76,6 @@ const RESOLVE_ID_FILTER = const LOAD_ID_FILTER = /^virtual:void-(routes|client-worker(?::(react|vue|svelte|solid))?|client-browser(?::(react|vue|svelte|solid))?)$/; -const SCRIPT_EXTENSIONS = ['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', '.json']; - -function isScriptPath(path: string): boolean { - return ( - !path.includes('/node_modules/') && - SCRIPT_EXTENSIONS.some((extension) => path.endsWith(extension)) - ); -} - type RoutingPluginOptions = { getApplicationAnalysis: (environment: RouteEnvironment, refresh?: boolean) => ApplicationAnalysis; isAuthEnabled: () => boolean; @@ -161,11 +152,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const ssrDir = paths.ssrDir; const authConfigPaths = new Set(paths.authConfigCandidates.map((path) => normalizePath(path))); let authConfigEnvironment: RunnableDevEnvironment | undefined; - // Set when the last load failed, or a script was saved while a load ran: the - // file that fixes it may be one the module graph never got, so the next - // script save retries and the retry starts from a clean environment. - let authConfigRetry = false; - let authConfigLoading = false; + // Set when a file the auth config loaded changes, or a load fails, so the + // next load starts from an empty module graph. + let authConfigStale = false; const envFilePaths = new Set(paths.envFileCandidates.map((path) => normalizePath(path))); const normalizedRoutesDir = normalizePath(routesDir); const normalizedMiddlewareDir = normalizePath(middlewareDir); @@ -342,40 +331,21 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug async function loadAuthConfig(configPath: string | null) { const environment = authConfigEnvironment; - if (authConfigRetry && environment) { - // An import that failed stays in the graph under its unresolved specifier. + if (authConfigStale && environment) { + // The runner checks each import against the graph. Invalidate all of + // it: a failed import stays in it under the specifier it could not + // resolve, not under a file path. environment.moduleGraph.invalidateAll(); - environment.runner.clearCache(); } - authConfigRetry = false; - authConfigLoading = true; + authConfigStale = false; try { return await loadVoidAuthConfig( configPath, environment && ((path) => environment.runner.import(path)), ); } catch (error) { - authConfigRetry = true; + authConfigStale = true; throw error; - } finally { - authConfigLoading = false; - } - } - - /** Retry a failed auth config load: whether it loads now, or undefined when nothing failed. */ - async function retryAuthConfig(logger: import('vite').Logger): Promise { - if (!authConfigRetry || !compileOptions.auth) { - return undefined; - } - try { - await loadAuthConfig(compileOptions.auth.authConfigPath); - return true; - } catch (error) { - logger.error(error instanceof Error ? error.message : String(error), { - error: error instanceof Error ? error : undefined, - timestamp: true, - }); - return false; } } @@ -455,28 +425,6 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug ); } - /** A script save that may fix a failed load, or that a running load may import. */ - function isAuthConfigRetryPath(path: string): boolean { - return ( - compileOptions.auth !== null && - (authConfigRetry || authConfigLoading) && - isScriptPath(path) && - !isPotentialAuthConfigPath(path) - ); - } - - /** Drop the evaluated auth config when one of its files changes. */ - function invalidateAuthConfig(path: string): void { - const modules = authConfigEnvironment?.moduleGraph.getModulesByFile(path); - if (!authConfigEnvironment || !modules?.size) { - return; - } - for (const module of modules) { - authConfigEnvironment.moduleGraph.invalidateModule(module); - } - authConfigEnvironment.runner.clearCache(); - } - function isPotentialEnvFilePath(path: string): boolean { return envFilePaths.has(normalizePath(path)); } @@ -966,11 +914,13 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const handleFileChange = async (path: string, event: 'add' | 'change' | 'unlink') => { const normalizedPath = normalizePath(path); invalidateSourceAnalysis(normalizedPath); - invalidateAuthConfig(normalizedPath); - const authRetry = isAuthConfigRetryPath(normalizedPath); - if (authRetry && authConfigLoading) { - authConfigRetry = true; + const authConfigChanged = isPotentialAuthConfigPath(normalizedPath); + if (authConfigChanged) { + authConfigStale = true; } + // After a failed load, a new file may be the import that was missing. + const authImportCreated = + event === 'add' && authConfigStale && compileOptions.auth !== null; const sourceConventionChanged = isInDirectory(normalizedPath, normalizedRoutesDir) || @@ -979,8 +929,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug isInDirectory(normalizedPath, normalizedQueuesDir) || isInDirectory(normalizedPath, normalizedEmailDir) || isInDirectory(normalizedPath, normalizedPagesDir) || - isPotentialAuthConfigPath(normalizedPath) || - authRetry || + authConfigChanged || + authImportCreated || isPotentialEnvFilePath(normalizedPath); if (!sourceConventionChanged && !ssrEntryAbsPaths.has(normalizedPath)) { return; @@ -988,19 +938,12 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug const shouldRestart = await regenerateInOrder(async () => { invalidateSourceAnalysis(normalizedPath); - // The retry runs apart from this save, so a config that still fails - // does not stop the rest of it (a page reload, say). - const authRetried = authRetry - ? await retryAuthConfig(devServer.config.logger) - : undefined; - const authRecovered = authRetried === true; // Re-scan and invalidate the virtual module in ALL environments. // The route table virtual module lives in the workerd environment, // not the client — so we must iterate all environments. const forceRegeneration = event !== 'change' || - authRecovered || - isPotentialAuthConfigPath(normalizedPath) || + authConfigChanged || isPotentialEnvFilePath(normalizedPath) || ssrEntryAbsPaths.has(normalizedPath); @@ -1053,9 +996,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug return true; } - // Generated files would fail on the same error; the next save retries. - const regenerate = routeInputsChanged && authRetried !== false; - if (regenerate) { + if (routeInputsChanged) { await writeAuthDatabaseFiles(); writeRouteTypes(); writeQueueTypes(); @@ -1088,7 +1029,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug } } const mod = env.moduleGraph.getModuleById(VIRTUAL_ROUTES_ID); - if (mod && regenerate) { + if (mod && routeInputsChanged) { env.moduleGraph.invalidateModule(mod); if (structural) { env.hot.send({ type: 'full-reload' }); @@ -1103,7 +1044,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug devServer.environments.client?.hot.send({ type: 'full-reload' }); } - if (regenerate) { + if (routeInputsChanged) { generatedRouteInputs = nextRouteInputs; generatedFiles = generatedFilesSignature(); } diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 67f744aa9..94cd85282 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -814,70 +814,6 @@ describe('routingPlugin', () => { } }); - it('reloads the auth config for a file saved while it loads', async () => { - const root = createTempRoot(); - writeFileSync(join(root, 'routes', 'health.ts'), 'export const GET = () => new Response();'); - const authPath = readProjectPaths(root).authConfigCandidates[0]!; - const latePath = join(dirname(authPath), 'auth-late.ts'); - writeFileSync(authPath, "export type {} from 'void/auth';"); - // Exists, but not yet part of the config: the load in flight is about to import it. - writeFileSync(latePath, ''); - const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); - resolveConfig(plugin, 'serve'); - await runBuildStart(plugin); - const { emit } = startDevServer(plugin, ['virtual:void-routes']); - - let started!: () => void; - const entered = new Promise((resolve) => { - started = resolve; - }); - let resume!: () => void; - const pause = new Promise((resolve) => { - resume = () => resolve(undefined); - }); - const load = vi.spyOn(authConfig, 'loadVoidAuthConfig').mockImplementationOnce(() => { - started(); - return pause; - }); - const loading = emit('change', authPath); - await entered; - writeFileSync(latePath, 'export const late = 1;'); - const lateSave = emit('change', latePath); - resume(); - await Promise.all([loading, lateSave]); - expect(load.mock.calls.length).toBeGreaterThan(1); - }); - - it('still reloads the page for an island save while the auth config fails to load', async () => { - const root = createTempRoot(); - writeFileSync(join(root, 'routes', 'health.ts'), 'export const GET = () => new Response();'); - mkdirSync(join(root, 'pages'), { recursive: true }); - const islandPath = join(root, 'pages', 'about.island.tsx'); - writeFileSync(islandPath, 'export default function Page() {}\n'); - const authPath = readProjectPaths(root).authConfigCandidates[0]!; - writeFileSync(authPath, "export type {} from 'void/auth';"); - const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); - resolveConfig(plugin, 'serve'); - await runBuildStart(plugin); - const clientReload = vi.fn(); - const { emit, logError } = startDevServer(plugin, ['virtual:void-routes'], { - client: { - moduleGraph: { getModuleById: () => undefined, invalidateModule() {} }, - hot: { send: clientReload }, - }, - }); - - vi.spyOn(authConfig, 'loadVoidAuthConfig').mockRejectedValue(new Error('auth: broken')); - await emit('change', authPath); - expect(logError).toHaveBeenCalledTimes(1); - - writeFileSync(islandPath, 'export default function Page() { return null; }\n'); - await emit('change', islandPath); - // The retry reports the error again, and the island still reloads. - expect(logError).toHaveBeenCalledTimes(2); - expect(clientReload).toHaveBeenCalledWith({ type: 'full-reload' }); - }); - it('rescans sourceDir route changes during dev', async () => { const root = createBareTempRoot(); writeFileSync(join(root, 'void.json'), JSON.stringify({ sourceDir: 'src' }));