From 8415fbb53790e91040b1bf49342880046e228a1c Mon Sep 17 00:00:00 2001 From: Christoph Nakazawa Date: Sat, 14 Nov 2026 11:26:00 +0900 Subject: [PATCH] Generate authentication files before dev server hooks. --- docs/guide/auth.md | 2 + packages/void/src/plugin-routing.ts | 15 ++- .../integration/auth-schema-startup.test.ts | 125 ++++++++++++++++++ .../void/test/unit/plugin-routing.test.ts | 58 +++++--- 4 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 packages/void/test/integration/auth-schema-startup.test.ts diff --git a/docs/guide/auth.md b/docs/guide/auth.md index c302951b2..090042ea2 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -240,6 +240,8 @@ export default defineAuth(({ defaults }) => ({ `defaults` already includes Void's conventions. Extend it explicitly instead of expecting a deep merge. +In `auth.ts`, load helpers that import `@auth-schema` inside the hook that uses them, with `await import('./server/users')`. This lets `void prepare` generate the authentication schema on a fresh clone. The same applies when a helper imports your app's database schema and that schema references authentication tables. + For the full set of available options, see the official [Better Auth options reference](https://www.better-auth.com/docs/reference/options). ## Database and Migrations diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 97caf3639..60406db41 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -300,8 +300,7 @@ export function routingPlugin( } reloadFileRules(); - // Auth compile options are set up lazily in buildStart (after env plugin - // has run its config hook and the auth config file path is known). + // Resolve auth after config hooks have run and the auth config path is known. function resolveAuthCompileOptions(): void { if (!options.isAuthEnabled() && !authConfigPath) { compileOptions.auth = null; @@ -967,7 +966,7 @@ export function routingPlugin( } }, - configureServer(devServer) { + async configureServer(devServer) { const authEnvironment = devServer.environments[AUTH_CONFIG_ENVIRONMENT]; authConfigEnvironment = authEnvironment && isRunnableDevEnvironment(authEnvironment) ? authEnvironment : undefined; @@ -1155,6 +1154,16 @@ export function routingPlugin( watcher.on('change', handleRulesFileChange); watcher.on('unlink', handleRulesFileChange); } + + // Later configureServer hooks can load app modules before buildStart. + // Their imports may already reference the generated authentication tables. + if (options.isAuthEnabled()) { + await regenerateInOrder(async () => { + await rescan(); + resolveAuthCompileOptions(); + await writeAuthDatabaseFiles(); + }); + } }, }; } diff --git a/packages/void/test/integration/auth-schema-startup.test.ts b/packages/void/test/integration/auth-schema-startup.test.ts new file mode 100644 index 000000000..c86cb1ab9 --- /dev/null +++ b/packages/void/test/integration/auth-schema-startup.test.ts @@ -0,0 +1,125 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import spawn from 'cross-spawn'; +import { afterEach, expect, it } from 'vite-plus/test'; +import { + createIsolatedPlayground, + killChild, + startServer, + type IsolatedPlayground, +} from '../../../../test/integration-helpers.ts'; + +const repository = join(import.meta.dirname, '../../../..'); +let fixture: IsolatedPlayground; +let child: ReturnType | undefined; + +afterEach(async () => { + if (child) { + await killChild(child); + child = undefined; + } + fixture?.cleanup(); +}); + +function freshAuthApp() { + fixture = createIsolatedPlayground(repository, 'playground/kitchen-sink'); + for (const path of [ + 'pages', + 'routes', + 'middleware', + 'db', + 'crons', + 'queues', + 'email', + 'lib', + 'env.ts', + 'index.html', + ]) { + rmSync(join(fixture.root, path), { recursive: true, force: true }); + } + const write = (path: string, source: string) => { + const file = join(fixture.root, path); + mkdirSync(join(file, '..'), { recursive: true }); + writeFileSync(file, source); + }; + write( + 'vite.config.ts', + "import { voidPlugin } from 'void'; export default { plugins: [voidPlugin()], resolve: { tsconfigPaths: true } };", + ); + write('void.config.ts', "export default { auth: { providers: ['email'] } };"); + write( + 'tsconfig.json', + JSON.stringify({ + extends: './.void/tsconfig.json', + compilerOptions: { paths: { '@auth-schema': ['./.void/better-auth-schema.ts'] } }, + include: ['**/*.ts'], + }), + ); + write( + 'auth.ts', + `import { defineAuth } from 'void/auth'; +export default defineAuth(({ defaults }) => ({ + ...defaults, + user: { additionalFields: { nickname: { type: 'string', required: false } } }, + databaseHooks: { user: { create: { after: async () => { + const { authFields } = await import('./lib/users'); + authFields(); + } } } }, +}));`, + ); + write( + 'lib/users.ts', + "import { userTable } from '@auth-schema'; export const authFields = () => Object.keys(userTable);", + ); + write( + 'db/schema.ts', + `import { userTable } from '@auth-schema'; +import { sqliteTable, text } from 'void/schema-d1'; +export const postTable = sqliteTable('post', { + id: text('id').primaryKey(), + authorId: text('author_id').references(() => userTable.id), +});`, + ); + write( + 'routes/index.ts', + `import 'void/db'; +import { postTable } from '../db/schema'; +import { authFields } from '../lib/users'; +export const GET = () => Response.json({ fields: authFields(), author: postTable.authorId.name });`, + ); +} + +it('prepares a fresh app whose auth hooks lazily import the generated schema', () => { + freshAuthApp(); + execFileSync(process.execPath, [join(repository, 'packages/void/dist/cli/cli.mjs'), 'prepare'], { + cwd: fixture.root, + stdio: 'pipe', + timeout: 30_000, + }); + expect(readFileSync(join(fixture.root, '.void/better-auth-schema.ts'), 'utf8')).toContain( + '"nickname"', + ); +}); + +it('starts a fresh Cloudflare app with auth table imports without running prepare first', async () => { + freshAuthApp(); + const server = await startServer( + (port) => { + child = spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: fixture.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, VOID_INSPECTOR_PORT: 'false' }, + }); + return child; + }, + 30_000, + { expectBody: 'nickname' }, + ); + const response = await fetch(server.base); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + fields: expect.arrayContaining(['nickname']), + author: 'author_id', + }); +}); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index ab434cc92..23b340fb8 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -118,13 +118,13 @@ function fakeDevServer( return { server, invalidated, restart, emit, logError }; } -function startDevServer( +async function startDevServer( plugin: RoutingPlugin, moduleIds?: Array, extraEnvironments?: Record, ) { const dev = fakeDevServer(moduleIds, extraEnvironments); - (getHookHandler(plugin.configureServer) as (server: unknown) => void)(dev.server); + await (getHookHandler(plugin.configureServer) as (server: unknown) => Promise)(dev.server); return dev; } @@ -188,6 +188,26 @@ describe('routingPlugin', () => { expect(database).toContain('requireRuntimeBinding("AUTH_DB")'); }); + it('writes auth files before later configureServer hooks can load the Worker', async () => { + const root = createTempRoot(); + writeFileSync( + join(root, 'auth.ts'), + "export type {} from 'void/auth';\nexport default { user: { additionalFields: { nickname: { type: 'string' } } } };", + ); + const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); + resolveConfig(plugin, 'serve'); + const dev = fakeDevServer(); + + await (getHookHandler(plugin.configureServer) as (server: unknown) => Promise)( + dev.server, + ); + + expect(readFileSync(join(root, '.void', 'better-auth-schema.ts'), 'utf8')).toContain( + '"nickname"', + ); + expect(existsSync(join(root, '.void', 'better-auth-db.ts'))).toBe(true); + }); + it('resolves extensionless and Vite alias imports in auth.ts during a build', async () => { const root = createTempRoot(); mkdirSync(join(root, 'lib')); @@ -384,7 +404,7 @@ describe('routingPlugin', () => { }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { emit, restart } = startDevServer(plugin); + const { emit, restart } = await startDevServer(plugin); restart.mockImplementation(() => runBuildStart(plugin)); const addedPath = join(root, 'routes', addedRoute); @@ -405,7 +425,7 @@ describe('routingPlugin', () => { ); const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); - const { emit, invalidated } = startDevServer(plugin, PAGE_MODULE_IDS); + const { emit, invalidated } = await startDevServer(plugin, PAGE_MODULE_IDS); writeFileSync( join(root, 'pages', 'test.tsx'), @@ -429,7 +449,7 @@ describe('routingPlugin', () => { const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); resolveConfig(plugin, 'serve', plugins); - const { emit, invalidated, logError } = startDevServer(plugin, PAGE_MODULE_IDS); + const { emit, invalidated, logError } = await startDevServer(plugin, PAGE_MODULE_IDS); // Moving about.tsx to about/index.tsx adds the new file before the old one goes. const indexPath = join(root, 'pages', 'about', 'index.tsx'); @@ -463,7 +483,7 @@ describe('routingPlugin', () => { runConfig(plugin, { command: 'serve', mode: 'development' }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { emit, logError } = startDevServer(plugin, PAGE_MODULE_IDS); + const { emit, logError } = await startDevServer(plugin, PAGE_MODULE_IDS); const typesPath = join(root, '.void', 'routes.d.ts'); const before = readFileSync(typesPath, 'utf8'); expect(before).toContain('pages/home.dev.server.ts'); @@ -490,7 +510,7 @@ describe('routingPlugin', () => { const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { emit, invalidated } = startDevServer(plugin, PAGE_MODULE_IDS); + const { emit, invalidated } = await startDevServer(plugin, PAGE_MODULE_IDS); writeFileSync(pagePath, 'export default function Index() { return

Updated

; }'); await emit('change', pagePath); @@ -519,7 +539,7 @@ describe('routingPlugin', () => { const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { emit, invalidated } = startDevServer(plugin, PAGE_MODULE_IDS); + const { emit, invalidated } = await startDevServer(plugin, PAGE_MODULE_IDS); writeFileSync( serverPath, @@ -562,7 +582,9 @@ describe('routingPlugin', () => { const typesPath = join(root, '.void', 'routes.d.ts'); const previousTypes = readFileSync(typesPath, 'utf-8'); - const { server, emit, invalidated, logError } = startDevServer(plugin, ['virtual:void-routes']); + const { server, emit, invalidated, logError } = await startDevServer(plugin, [ + 'virtual:void-routes', + ]); expect(previousTypes).not.toContain('POST: {'); @@ -624,7 +646,7 @@ describe('routingPlugin', () => { const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const { emit, invalidated } = await startDevServer(plugin, ['virtual:void-routes']); writeFileSync(envPath, 'export default { example: true };'); await emit('change', envPath); @@ -645,7 +667,9 @@ describe('routingPlugin', () => { const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { server, emit, invalidated, logError } = startDevServer(plugin, ['virtual:void-routes']); + const { server, emit, invalidated, logError } = await startDevServer(plugin, [ + 'virtual:void-routes', + ]); vi.spyOn( server.environments.void_worker.moduleGraph, 'invalidateModule', @@ -673,7 +697,7 @@ describe('routingPlugin', () => { const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const { emit, invalidated } = await startDevServer(plugin, ['virtual:void-routes']); const typesPath = join(root, '.void', 'routes.d.ts'); const types = readFileSync(typesPath, 'utf8'); @@ -701,7 +725,7 @@ describe('routingPlugin', () => { ); const plugin = routingPlugin(root, { isAuthEnabled: () => true, getApplicationAnalysis }); resolveConfig(plugin, 'serve'); - const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const { emit, invalidated } = await startDevServer(plugin, ['virtual:void-routes']); if (overlap === 'another save') { await runBuildStart(plugin); } @@ -768,7 +792,7 @@ describe('routingPlugin', () => { const plugin = createRoutingPlugin(root, { isAuthEnabled: () => true }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const { emit, invalidated } = await startDevServer(plugin, ['virtual:void-routes']); writeFileSync(configPath, 'export default ' + JSON.stringify(config)); writeFileSync(routePath, 'export const GET = () => new Response("updated");'); await emit('change', routePath); @@ -797,7 +821,7 @@ describe('routingPlugin', () => { const plugin = createRoutingPlugin(root, { target, isAuthEnabled: () => auth }); resolveConfig(plugin, 'serve'); await runBuildStart(plugin); - const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const { emit, invalidated } = await startDevServer(plugin, ['virtual:void-routes']); const outputs = files.map((file) => { const path = join(root, '.void', file); const content = readFileSync(path, 'utf8'); @@ -969,7 +993,7 @@ describe('routingPlugin', () => { isAuthEnabled: () => false, onDevAssetRoutingPolicyChange: onPolicyChange, }); - const { emit, invalidated } = startDevServer(plugin, ['virtual:void-routes']); + const { emit, invalidated } = await startDevServer(plugin, ['virtual:void-routes']); const routePath = join(root, 'src', 'routes', 'api.ts'); writeFileSync(routePath, 'export const GET = () => new Response("ok");\n'); @@ -988,7 +1012,7 @@ describe('routingPlugin', () => { mkdirSync(join(root, 'src', 'pages'), { recursive: true }); const plugin = createRoutingPlugin(root, { isAuthEnabled: () => false }); - const { emit, invalidated } = startDevServer(plugin, PAGE_MODULE_IDS); + const { emit, invalidated } = await startDevServer(plugin, PAGE_MODULE_IDS); const pagePath = join(root, 'src', 'pages', 'test.tsx'); writeFileSync(pagePath, 'export default function Test() { return null; }');