Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions packages/void/src/plugin-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
}
},
};
}
125 changes: 125 additions & 0 deletions packages/void/test/integration/auth-schema-startup.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spawn> | 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',
});
});
58 changes: 41 additions & 17 deletions packages/void/test/unit/plugin-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,13 @@ function fakeDevServer(
return { server, invalidated, restart, emit, logError };
}

function startDevServer(
async function startDevServer(
plugin: RoutingPlugin,
moduleIds?: Array<string>,
extraEnvironments?: Record<string, unknown>,
) {
const dev = fakeDevServer(moduleIds, extraEnvironments);
(getHookHandler(plugin.configureServer) as (server: unknown) => void)(dev.server);
await (getHookHandler(plugin.configureServer) as (server: unknown) => Promise<void>)(dev.server);
return dev;
}

Expand Down Expand Up @@ -188,6 +188,26 @@ describe('routingPlugin', () => {
expect(database).toContain('requireRuntimeBinding<D1Database>("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<void>)(
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'));
Expand Down Expand Up @@ -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);
Expand All @@ -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'),
Expand All @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -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 <h1>Updated</h1>; }');
await emit('change', pagePath);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: {');

Expand Down Expand Up @@ -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);
Expand All @@ -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',
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -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; }');
Expand Down
Loading