Skip to content
Open
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: 1 addition & 1 deletion docs/guide/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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:

Expand Down
28 changes: 26 additions & 2 deletions packages/void/src/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { parseSync } from 'vite';
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';
Expand Down Expand Up @@ -43,12 +43,36 @@ export function findVoidAuthConfig(
return null;
}

/**
* 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 const AUTH_CONFIG_ENVIRONMENT = 'void_auth_config';

export type ModuleImporter = (path: string) => Promise<Record<string, unknown>>;

/** 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<VoidAuthConfig | undefined> {
if (!configPath) {
return undefined;
}
const mod = await import(pathToFileURL(configPath).href);
const mod = await importModule(configPath);
return (mod.default ?? mod.auth ?? mod.config) as VoidAuthConfig | undefined;
}
6 changes: 4 additions & 2 deletions packages/void/src/auth/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<void> {
const authConfig = await loadVoidAuthConfig(configPath);
const authConfig = await loadVoidAuthConfig(configPath, importModule);
const runtime = {
dialect,
providers: readConfig(root).auth?.providers ?? null,
Expand Down
2 changes: 2 additions & 0 deletions packages/void/src/plugin-migration.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
Expand Down
90 changes: 82 additions & 8 deletions packages/void/src/plugin-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
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 {
Expand All @@ -16,7 +23,7 @@ import {
isGeneratedAssetHeaders,
resolveAssetWorkerBypass,
} from './asset-worker-bypass.ts';
import { loadVoidAuthConfig } 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';
Expand Down Expand Up @@ -144,6 +151,10 @@ 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)));
let authConfigEnvironment: RunnableDevEnvironment | undefined;
// 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);
Expand Down Expand Up @@ -318,6 +329,26 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
return parts.join('|');
}

async function loadAuthConfig(configPath: string | null) {
const environment = authConfigEnvironment;
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();
}
authConfigStale = false;
try {
return await loadVoidAuthConfig(
configPath,
environment && ((path) => environment.runner.import(path)),
);
} catch (error) {
authConfigStale = true;
throw error;
}
}

async function writeAuthDatabaseFiles(): Promise<void> {
if (!compileOptions.auth) {
return;
Expand All @@ -329,7 +360,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
const authRuntime: VoidAuthRuntimeOptions = {
dialect: compileOptions.auth.dialect,
providers: compileOptions.auth.providers,
userConfig: await loadVoidAuthConfig(compileOptions.auth.authConfigPath),
userConfig: await loadAuthConfig(compileOptions.auth.authConfigPath),
};
const local = resolvedConfig?.command === 'serve' ? loadLocalEnv(root) : {};
const env = { ...local, ...process.env } as Record<string, unknown>;
Expand Down Expand Up @@ -387,7 +418,11 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
}

function isPotentialAuthConfigPath(path: string): boolean {
return authConfigPaths.has(normalizePath(path));
const normalized = normalizePath(path);
return (
authConfigPaths.has(normalized) ||
(authConfigEnvironment?.moduleGraph.getModulesByFile(normalized)?.size ?? 0) > 0
);
}

function isPotentialEnvFilePath(path: string): boolean {
Expand Down Expand Up @@ -603,6 +638,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);
Expand All @@ -622,12 +676,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 {
Expand Down Expand Up @@ -841,13 +895,32 @@ 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);
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) ||
Expand All @@ -856,7 +929,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
isInDirectory(normalizedPath, normalizedQueuesDir) ||
isInDirectory(normalizedPath, normalizedEmailDir) ||
isInDirectory(normalizedPath, normalizedPagesDir) ||
isPotentialAuthConfigPath(normalizedPath) ||
authConfigChanged ||
authImportCreated ||
isPotentialEnvFilePath(normalizedPath);
if (!sourceConventionChanged && !ssrEntryAbsPaths.has(normalizedPath)) {
return;
Expand All @@ -869,7 +943,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
// not the client — so we must iterate all environments.
const forceRegeneration =
event !== 'change' ||
isPotentialAuthConfigPath(normalizedPath) ||
authConfigChanged ||
isPotentialEnvFilePath(normalizedPath) ||
ssrEntryAbsPaths.has(normalizedPath);

Expand Down
84 changes: 84 additions & 0 deletions packages/void/test/unit/auth-config-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
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<string> = [];

function createTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'void-auth-config-'));
tempDirs.push(dir);
return dir;
}

describe('loadVoidAuthConfig', () => {
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop()!, { recursive: true, force: true });
}
});

it('returns undefined without a config file', async () => {
const importModule = vi.fn();
expect(await loadVoidAuthConfig(null, importModule)).toBeUndefined();
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('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("imports through the dev server's auth config environment when it has one", async () => {
const dir = createTempDir();
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) },
},
},
});
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 },
});
try {
expect(devAuthConfigImporter(plain)).toBeUndefined();
} finally {
await plain.close();
}
});
});
2 changes: 1 addition & 1 deletion packages/void/test/unit/local-d1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async function runGeneratedMigrationPlugin(plugins: ReturnType<typeof voidPlugin
const plugin = plugins.find(({ name }) => name === 'void:migrations');
expect(plugin).toBeTruthy();
const configureServer = hookHandler(plugin?.configureServer);
await (configureServer as (server: unknown) => Promise<void>).call({}, {});
await (configureServer as (server: unknown) => Promise<void>).call({}, { environments: {} });
}

afterEach(() => {
Expand Down
Loading
Loading