diff --git a/packages/void/src/auth/config.ts b/packages/void/src/auth/config.ts index 181da39d7..9dd85ea06 100644 --- a/packages/void/src/auth/config.ts +++ b/packages/void/src/auth/config.ts @@ -1,6 +1,12 @@ import { existsSync, readFileSync } from 'node:fs'; -import { pathToFileURL } from 'node:url'; -import { isRunnableDevEnvironment, parseSync, type ViteDevServer } from 'vite'; +import { + type AliasOptions, + isRunnableDevEnvironment, + parseSync, + runnerImport, + type ViteDevServer, +} from 'vite'; +import { discoverSchema } from '../drizzle/discover.ts'; import { readProjectPaths, type ProjectPaths } from '../project-paths.ts'; import type { VoidAuthConfig } from '../runtime/auth.ts'; import type { AstNode, AstProgram } from '../shared/ast.ts'; @@ -53,26 +59,72 @@ 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 { +/** + * The dev server's importer for the auth config: its auth config environment, or + * a one-off runner with the server's aliases when it has no such environment. + */ +export function devAuthConfigImporter(server: ViteDevServer): ModuleImporter { const environment = server.environments[AUTH_CONFIG_ENVIRONMENT]; return environment && isRunnableDevEnvironment(environment) ? (path) => environment.runner.import(path) - : undefined; + : (path) => { + const { root, mode, resolve } = server.config; + return viteAuthConfigImporter(root, mode, resolve)(path); + }; +} + +/** + * Builds and CLI commands evaluate the auth config once in a throwaway module + * runner, so its imports resolve as in dev: without file extensions and through + * the app's aliases. A native `import()` resolves neither. + */ +export function viteAuthConfigImporter( + root: string, + mode: string | undefined, + resolve: { alias?: AliasOptions; tsconfigPaths?: boolean } | undefined, +): ModuleImporter { + return async (path) => { + const { module } = await runnerImport>(path, { + root, + mode, + logLevel: 'silent', + resolve: { alias: resolve?.alias, tsconfigPaths: resolve?.tsconfigPaths }, + }); + return module; + }; } /** - * 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. + * The importer for commands outside Vite, such as `void prepare`. It does not load + * the app's Vite config: evaluating it constructs the app's plugins, and Void's + * write generated files and Cloudflare config when constructed. Imports resolve + * without extensions, through tsconfig `paths`, and through Void's `@schema` + * alias; an alias set only in vite.config resolves in dev and builds. */ +export function projectAuthConfigImporter(root: string): ModuleImporter { + return (path) => { + const schema = discoverSchema(root); + return viteAuthConfigImporter(root, 'development', { + alias: schema ? { '@schema': schema.path } : undefined, + tsconfigPaths: true, + })(path); + }; +} + +/** Evaluate the auth config through the importer of the current command. */ export async function loadVoidAuthConfig( configPath: string | null, - importModule: ModuleImporter = (path) => import(pathToFileURL(path).href), + importModule: ModuleImporter, ): Promise { if (!configPath) { return undefined; } const mod = await importModule(configPath); - return (mod.default ?? mod.auth ?? mod.config) as VoidAuthConfig | undefined; + // The Worker imports only the default export. + if (!Object.hasOwn(mod, 'default')) { + throw new Error( + `${configPath} has no default export. Export the auth config with \`export default defineAuth(...)\`.`, + ); + } + return mod.default as VoidAuthConfig | undefined; } diff --git a/packages/void/src/auth/node.ts b/packages/void/src/auth/node.ts index c739401ec..4ce1e442e 100644 --- a/packages/void/src/auth/node.ts +++ b/packages/void/src/auth/node.ts @@ -11,7 +11,12 @@ 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, type ModuleImporter } from './config.ts'; +import { + findVoidAuthConfig, + loadVoidAuthConfig, + projectAuthConfigImporter, + type ModuleImporter, +} from './config.ts'; import { resolveVoidAuthMigrationSchema } from './drizzle.ts'; export async function runLocalVoidAuthMigrations( @@ -28,7 +33,10 @@ export async function runLocalVoidAuthMigrations( /** The dev server's runner, so a restarted server sees the current config. */ importModule?: ModuleImporter, ): Promise { - const authConfig = await loadVoidAuthConfig(configPath, importModule); + const authConfig = await loadVoidAuthConfig( + configPath, + importModule ?? projectAuthConfigImporter(root), + ); const runtime = { dialect, providers: readConfig(root).auth?.providers ?? null, diff --git a/packages/void/src/codegen/prepare.ts b/packages/void/src/codegen/prepare.ts index 0c3b80cdf..e6e780c37 100644 --- a/packages/void/src/codegen/prepare.ts +++ b/packages/void/src/codegen/prepare.ts @@ -2,7 +2,7 @@ import { existsSync, rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'pathslash'; import { analyzeApplication } from '../application-routing.ts'; -import { loadVoidAuthConfig } from '../auth/config.ts'; +import { loadVoidAuthConfig, projectAuthConfigImporter } from '../auth/config.ts'; import { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } from '../auth/drizzle.ts'; import { getDatabaseCasing, readConfig } from '../config.ts'; import { discoverSchema } from '../drizzle/discover.ts'; @@ -88,7 +88,7 @@ export async function prepareProject(root: string): Promise; const schemaPath = join(outDir, 'better-auth-schema.ts'); diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index e39a5b581..9860cd858 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -23,7 +23,11 @@ import { isGeneratedAssetHeaders, resolveAssetWorkerBypass, } from './asset-worker-bypass.ts'; -import { AUTH_CONFIG_ENVIRONMENT, loadVoidAuthConfig } from './auth/config.ts'; +import { + AUTH_CONFIG_ENVIRONMENT, + loadVoidAuthConfig, + viteAuthConfigImporter, +} 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'; @@ -342,7 +346,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug try { return await loadVoidAuthConfig( configPath, - environment && ((path) => environment.runner.import(path)), + environment + ? (path) => environment.runner.import(path) + : viteAuthConfigImporter(root, resolvedConfig?.mode, resolvedConfig?.resolve), ); } catch (error) { authConfigStale = true; diff --git a/packages/void/test/unit/auth-config-loader.test.ts b/packages/void/test/unit/auth-config-loader.test.ts index 5b89442b6..175422781 100644 --- a/packages/void/test/unit/auth-config-loader.test.ts +++ b/packages/void/test/unit/auth-config-loader.test.ts @@ -1,12 +1,15 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createRunnableDevEnvironment, createServer } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { AUTH_CONFIG_ENVIRONMENT, devAuthConfigImporter, loadVoidAuthConfig, + projectAuthConfigImporter, } from '../../src/auth/config.ts'; const tempDirs: Array = []; @@ -30,23 +33,95 @@ describe('loadVoidAuthConfig', () => { expect(importModule).not.toHaveBeenCalled(); }); - 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 the default export', async () => { + const dir = createTempDir(); + const path = join(dir, 'auth.ts'); + writeFileSync(path, "export default { marker: 'default' };"); + expect(await loadVoidAuthConfig(path, projectAuthConfigImporter(dir))).toEqual({ + marker: 'default', + }); + }); + + // The Worker imports only the default export. + it.each(['auth', 'config'])('rejects a config with only an `%s` export', async (name) => { + const dir = createTempDir(); + const path = join(dir, 'auth.ts'); + writeFileSync(path, `export const ${name} = { marker: 'named' };`); + await expect(loadVoidAuthConfig(path, projectAuthConfigImporter(dir))).rejects.toThrow( + `${path} has no default export. Export the auth config with \`export default defineAuth(...)\`.`, + ); }); + // Vitest resolves the native `import()` itself; plain Node is what `void prepare` runs in. + // `void prepare` boots no Vite: it never evaluates vite.config, whose plugins write files + // when constructed. `@fields` comes from tsconfig `paths`, `@schema` from Void's own alias. + it('resolves extensionless, tsconfig paths and @schema imports in void prepare without vite.config', () => { + const root = createTempDir(); + mkdirSync(join(root, 'lib')); + mkdirSync(join(root, 'fields')); + mkdirSync(join(root, 'db')); + const marker = join(root, 'vite-config-loaded'); + writeFileSync( + join(root, 'vite.config.ts'), + [ + "import { writeFileSync } from 'node:fs';", + `writeFileSync(${JSON.stringify(marker)}, '');`, + 'export default {};', + ].join('\n'), + ); + writeFileSync( + join(root, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { paths: { '@fields/*': ['./fields/*'] } } }), + ); + writeFileSync( + join(root, 'auth.ts'), + [ + "import type { VoidAuthConfig } from 'void/auth';", + "import { nickname } from './lib/nickname';", + "import { pronouns } from '@fields/pronouns';", + "import { website } from '@schema';", + 'export default { user: { additionalFields: { nickname, pronouns, website } } } satisfies VoidAuthConfig;', + ].join('\n'), + ); + writeFileSync( + join(root, 'lib', 'nickname.ts'), + "export const nickname = { type: 'string', required: false } as const;", + ); + writeFileSync( + join(root, 'fields', 'pronouns.ts'), + "export const pronouns = { type: 'string', required: false } as const;", + ); + writeFileSync( + join(root, 'db', 'schema.ts'), + "export const website = { type: 'string', required: false } as const;", + ); + + execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + 'const { prepareProject } = await import(process.argv[1]); await prepareProject(process.argv[2]);', + fileURLToPath(new URL('../../src/codegen/prepare.ts', import.meta.url)), + root, + ], + { stdio: 'pipe' }, + ); + + const schema = readFileSync(join(root, '.void', 'better-auth-schema.ts'), 'utf8'); + expect(schema).toContain('nickname'); + expect(schema).toContain('pronouns'); + expect(schema).toContain('website'); + expect(existsSync(marker)).toBe(false); + }, 30_000); + it('evaluates the config through the module importer it is given', async () => { - const importModule = vi.fn(async () => ({ auth: { marker: 'runner' } })); + const importModule = vi.fn(async () => ({ default: { marker: 'runner' } })); expect(await loadVoidAuthConfig('/app/auth.ts', importModule)).toEqual({ marker: 'runner' }); expect(importModule).toHaveBeenCalledWith('/app/auth.ts'); }); - it("imports through the dev server's auth config environment when it has one", async () => { + it("imports through the dev server's auth config environment, or with its aliases", async () => { const dir = createTempDir(); const path = join(dir, 'auth.ts'); writeFileSync(path, "export default { marker: 'first' };"); @@ -64,19 +139,25 @@ describe('loadVoidAuthConfig', () => { }, }); try { - const importModule = devAuthConfigImporter(server)!; + const importModule = devAuthConfigImporter(server); expect(await loadVoidAuthConfig(path, importModule)).toEqual({ marker: 'first' }); } finally { await server.close(); } + mkdirSync(join(dir, 'fields')); + writeFileSync(join(dir, 'fields', 'marker.ts'), "export const marker = 'alias';"); + writeFileSync(path, "import { marker } from '@fields/marker';\nexport default { marker };"); const plain = await createServer({ root: dir, configFile: false, logLevel: 'silent', + resolve: { alias: { '@fields': join(dir, 'fields') } }, server: { middlewareMode: true, ws: false, watch: null }, }); try { - expect(devAuthConfigImporter(plain)).toBeUndefined(); + expect(await loadVoidAuthConfig(path, devAuthConfigImporter(plain))).toEqual({ + marker: 'alias', + }); } finally { await plain.close(); } diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 51fd7cd82..32b8607ef 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -174,6 +174,44 @@ describe('routingPlugin', () => { ); }); + it('resolves extensionless and Vite alias imports in auth.ts during a build', async () => { + const root = createTempRoot(); + mkdirSync(join(root, 'lib')); + mkdirSync(join(root, 'fields')); + writeFileSync( + join(root, 'auth.ts'), + [ + "import type { VoidAuthConfig } from 'void/auth';", + "import { nickname } from './lib/nickname';", + "import { pronouns } from '@fields/pronouns';", + 'export default { user: { additionalFields: { nickname, pronouns } } } satisfies VoidAuthConfig;', + ].join('\n'), + ); + writeFileSync( + join(root, 'lib', 'nickname.ts'), + "export const nickname = { type: 'string', required: false } as const;", + ); + writeFileSync( + join(root, 'fields', 'pronouns.ts'), + "export const pronouns = { type: 'string', required: false } as const;", + ); + + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); + (getHookHandler(plugin.configResolved) as (config: ResolvedConfig) => void)({ + build: { assetsDir: 'assets' }, + command: 'build', + mode: 'production', + plugins: [], + root, + resolve: { alias: [{ find: '@fields', replacement: join(root, 'fields') }] }, + } as unknown as ResolvedConfig); + await runBuildStart(plugin); + + const schema = readFileSync(join(root, '.void', 'better-auth-schema.ts'), 'utf8'); + expect(schema).toContain('nickname'); + expect(schema).toContain('pronouns'); + }); + it('bakes explicit page opt-outs into the native ISR policy', async () => { const root = createTempRoot(); mkdirSync(join(root, 'pages'), { recursive: true });