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: 1 addition & 1 deletion docs/guide/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ import { getSession, getUser, requireAuth } from 'void/auth';
- `getSession()` returns `{ user, session } | null`
- `requireAuth(c)` returns the authenticated user or throws `401`

Import `AuthUser` and `AuthSession` from `void/auth` for type annotations. See their fields in the [API reference](../reference/api/auth.md#authuser).
Import `AuthUser` and `AuthSession` from `void/auth` for type annotations. They include the fields your Better Auth plugins and `user.additionalFields` add, such as `role` from `admin()`. See their fields in the [API reference](../reference/api/auth.md#authuser).

## Behavior

Expand Down
8 changes: 6 additions & 2 deletions docs/reference/api/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function requireAuth(c: CloudContext): AuthUser;

## `AuthUser` {#authuser}

Better Auth user shape re-exported by Void.
Better Auth user shape re-exported by Void. It also includes the fields that Better Auth plugins and `user.additionalFields` in `auth.ts` add, for example `role` from `admin()` or a custom `plan` field. Your tsconfig needs the generated Void types for this; see [Type Safety setup](../../guide/type-safety.md#setup).

```ts
interface AuthUser {
Expand All @@ -95,7 +95,7 @@ interface AuthUser {

## `AuthSession` {#authsession}

Better Auth session shape re-exported by Void.
Better Auth session shape re-exported by Void. It also includes the session fields that Better Auth plugins in `auth.ts` add, for example `impersonatedBy` from `admin()`.

```ts
interface AuthSession {
Expand All @@ -110,6 +110,10 @@ interface AuthSession {
}
```

::: tip Types inside `auth.ts`
`AuthUser` and `AuthSession` are built from `auth.ts`. Inside `auth.ts` and the files it imports, don't use them, or values from `getUser()`, `getSession()` and `requireAuth()`, to type a hook's parameters or returned data. TypeScript reports a circular reference and the extra fields are lost. Let Better Auth infer those types, or use its own `User` and `Session` types. Better Auth callbacks such as `sendResetPassword` pass its base `User`, without plugin fields.
:::

## `AuthState` {#authstate}

Combined authenticated request state:
Expand Down
71 changes: 71 additions & 0 deletions packages/void/src/codegen/auth-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { existsSync, rmSync } from 'node:fs';
import { join, relative } from 'pathslash';
import { writeFileIfChanged } from './write-if-changed.ts';

export type AuthTypesResult = {
dts: string;
};

/**
* Generate `.void/auth.d.ts` content. Fills `void/auth`'s `VoidAuthTypes`
* registry with Better Auth inference from the app's `defineAuth` config
* (plugins, `user.additionalFields`, etc.), which `AuthUser` and `AuthSession`
* read from.
*
* Uses `typeof import(...)` on the auth config file so TypeScript handles the
* inference — codegen never analyses types itself. `better-auth` is reached
* through `void/auth`'s `InferAuthSession`, because apps usually do not depend
* on `better-auth` directly and could not resolve it from `.void/`.
*
* @param authFileAbs Absolute path to the user's auth config file.
* @param outDir Absolute path to the project's `.void/` directory.
*/
export function generateAuthTypes(authFileAbs: string, outDir: string): AuthTypesResult {
const importPath = relative(outDir, authFileAbs);
const lines: Array<string> = [];

lines.push('// Auto-generated by void — do not edit');
lines.push('export {}');
lines.push('');
lines.push('import type { InferAuthSession } from "void/auth";');
lines.push('');
lines.push(
`type _VoidAuthSession = InferAuthSession<typeof import("${importPath}")["default"]>;`,
);
lines.push('');
lines.push('declare module "void/auth" {');
lines.push(' interface VoidAuthTypes {');
lines.push(' user: _VoidAuthSession["user"];');
lines.push(' session: _VoidAuthSession["session"];');
lines.push(' }');
lines.push('}');
lines.push('');

return { dts: lines.join('\n') };
}

export function authTypesPath(outDir: string): string {
return join(outDir, 'auth.d.ts');
}

/** Remove `.void/auth.d.ts` when auth is disabled or the config file is gone. */
export function removeAuthTypesIfPresent(outDir: string): boolean {
const filePath = authTypesPath(outDir);
if (!existsSync(filePath)) {
return false;
}
rmSync(filePath, { force: true });
return true;
}

/**
* Write or remove `.void/auth.d.ts` from the current auth config path.
* Returns whether the file on disk changed.
*/
export function syncAuthTypesFile(authConfigPath: string | null, outDir: string): boolean {
if (!authConfigPath) {
return removeAuthTypesIfPresent(outDir);
}
const { dts } = generateAuthTypes(authConfigPath, outDir);
return writeFileIfChanged(authTypesPath(outDir), dts);
}
8 changes: 8 additions & 0 deletions packages/void/src/codegen/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { generateVoidAuthDatabaseModule, generateVoidAuthDrizzleSchema } from '.
import { getDatabaseCasing, readConfig } from '../config.ts';
import { discoverSchema } from '../drizzle/discover.ts';
import { loadLocalEnv } from '../shared/env.ts';
import { generateAuthTypes } from './auth-types.ts';
import { generateDbDts } from './db-types.ts';
import { findEnvFileForPaths, generateEnvTypes } from './env-types.ts';
import { ensureProjectTsconfig } from './project-tsconfig.ts';
Expand Down Expand Up @@ -55,6 +56,13 @@ export async function prepareProject(root: string): Promise<PrepareProjectResult
removeGeneratedFile(outDir, 'env.d.ts', removed);
}

if (authConfigPath) {
const { dts } = generateAuthTypes(authConfigPath, outDir);
writeGeneratedFile(outDir, 'auth.d.ts', dts, written);
} else {
removeGeneratedFile(outDir, 'auth.d.ts', removed);
}

const hasRouteTypes =
scan.routes.length > 0 || (scan.websockets?.length ?? 0) > 0 || scan.pages.pages.length > 0;
if (hasRouteTypes) {
Expand Down
26 changes: 25 additions & 1 deletion packages/void/src/codegen/project-tsconfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join, relative } from 'pathslash';
import { readProjectPaths, type ProjectPaths } from '../project-paths.ts';
import { writeFileIfChanged } from './write-if-changed.ts';
Expand Down Expand Up @@ -39,12 +39,14 @@ export function createProjectTsconfig(
const includeQueues = existsSync(pathsConfig.queuesDir);
const includeMd = existsSync(join(outDir, 'md.d.ts'));
const includeEnv = existsSync(join(outDir, 'env.d.ts'));
const includeAuth = existsSync(join(outDir, 'auth.d.ts'));
const files = [
...(includeRoutes ? ['routes.d.ts'] : []),
...(hasDb ? ['db.d.ts'] : []),
...(includeQueues ? ['queues.d.ts'] : []),
...(includeMd ? ['md.d.ts'] : []),
...(includeEnv ? ['env.d.ts'] : []),
...(includeAuth ? ['auth.d.ts'] : []),
];

const tsconfig: ProjectTsconfig = { files };
Expand Down Expand Up @@ -108,3 +110,25 @@ export function ensureProjectTsconfig(
JSON.stringify(tsconfig, null, 2) + '\n',
);
}

/**
* Add or drop `auth.d.ts` in an existing .void/tsconfig.json to match whether
* `.void/auth.d.ts` exists, keeping every other entry as written. The dev/build
* routing plugin uses this after it writes or removes auth.d.ts, so it does not
* recompute the routes/db choices the Drizzle plugin or `void prepare` made.
* A missing or unreadable tsconfig is left for those writers to create.
*/
export function syncAuthTypesInProjectTsconfig(root: string): boolean {
const outDir = join(root, '.void');
const tsconfigPath = join(outDir, 'tsconfig.json');
let tsconfig: ProjectTsconfig;
try {
tsconfig = JSON.parse(readFileSync(tsconfigPath, 'utf8')) as ProjectTsconfig;
} catch {
return false;
}
const files = Array.isArray(tsconfig.files) ? tsconfig.files : [];
const others = files.filter((file) => file !== 'auth.d.ts');
tsconfig.files = existsSync(join(outDir, 'auth.d.ts')) ? [...others, 'auth.d.ts'] : others;
return writeFileIfChanged(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n');
}
16 changes: 15 additions & 1 deletion packages/void/src/plugin-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import {
import { resolveDistDir, resolveWorkerDirName } from './cli/build-output-dir.ts';
import { lintRoutingPhases, mergeRoutingRules } from './cli/headers.ts';
import { stampClientAssetCachePolicy } from './client-asset-manifest.ts';
import { removeAuthTypesIfPresent, syncAuthTypesFile } from './codegen/auth-types.ts';
import { findEnvFileForPaths } from './codegen/env-types.ts';
import { syncAuthTypesInProjectTsconfig } from './codegen/project-tsconfig.ts';
import { generateQueueTypes } from './codegen/queue-types.ts';
import { generateRouteTypes } from './codegen/route-types.ts';
import { writeFileIfChanged } from './codegen/write-if-changed.ts';
Expand Down Expand Up @@ -321,7 +323,13 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
'routes.d.ts',
'queues.d.ts',
...(options.target ? ['app.ts', 'index.ts'] : ['entry.ts']),
...(compileOptions.auth ? ['better-auth-db.ts', 'better-auth-schema.ts'] : []),
...(compileOptions.auth
? [
'better-auth-db.ts',
'better-auth-schema.ts',
...(compileOptions.auth.authConfigPath ? ['auth.d.ts'] : []),
]
: []),
];
const parts: Array<string> = [];
for (const file of files) {
Expand Down Expand Up @@ -358,6 +366,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug

async function writeAuthDatabaseFiles(): Promise<void> {
if (!compileOptions.auth) {
if (removeAuthTypesIfPresent(outDir)) {
syncAuthTypesInProjectTsconfig(root);
}
return;
}

Expand All @@ -382,6 +393,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
drizzleAdapterPath,
}),
);

syncAuthTypesFile(compileOptions.auth.authConfigPath, outDir);
syncAuthTypesInProjectTsconfig(root);
}

let resolvedConfig: import('vite').ResolvedConfig | null = null;
Expand Down
33 changes: 30 additions & 3 deletions packages/void/src/runtime/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { BetterAuthOptions, Session, User } from 'better-auth';
import type { Auth, BetterAuthOptions, Session, User } from 'better-auth';
import type { Context, Env } from 'hono';
import { HTTPException } from 'hono/http-exception';

export type AuthUser = User;
export type AuthSession = Session;
/**
* Filled in by the generated `.void/auth.d.ts` with the user and session types
* Better Auth infers from the app's `auth.ts`. Empty when the app has no
* `auth.ts`, so `AuthUser`/`AuthSession` fall back to Better Auth's base shapes.
*
* `AuthUser`/`AuthSession` stay type aliases (not merged interfaces) so they
* remain assignable to `Record<string, unknown>`, and a union config (a function
* form that returns different shapes per branch) stays a valid union type.
*/
export interface VoidAuthTypes {}

/**
* Better Auth's inferred `{ user, session }` for an `auth.ts` default export.
* Used by the generated `.void/auth.d.ts`; it lives here so `better-auth`
* resolves from Void's own dependencies, not the app's.
*
* An `any` default export (an `auth.js` without `allowJs`, or an import that
* does not resolve) keeps Better Auth's base shapes instead of a user type that
* accepts any key. The tuple check also catches the error `any` of an untyped
* import.
*/
export type InferAuthSession<T> = [unknown] extends [T]
? { user: User; session: Session }
: Auth<
Extract<T extends (...args: Array<never>) => infer R ? R : T, BetterAuthOptions>
>['$Infer']['Session'];

export type AuthUser = VoidAuthTypes extends { user: infer U } ? U : User;
export type AuthSession = VoidAuthTypes extends { session: infer S } ? S : Session;

export type AuthState = {
session: AuthSession;
Expand Down
134 changes: 134 additions & 0 deletions packages/void/test/integration/auth-types-typecheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vite-plus/test';
import { generateAuthTypes } from '../../src/codegen/auth-types.ts';

const workspaceRoot = join(import.meta.dirname, '..', '..', '..', '..');
const roots: Array<string> = [];

afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});

/**
* Typecheck `auth.ts` plus `handler.ts` in a kitchen-sink app that depends on
* `void` only, the way pnpm lays it out: the app has no `better-auth` of its own.
* An `auth.js` config is left out of `files`, as in a TS app without `allowJs`.
*/
function typecheck(
authSource: string,
handlerSource: string,
paths: Record<string, Array<string>>,
authFileName = 'auth.ts',
) {
const root = mkdtempSync(join(workspaceRoot, 'playground/kitchen-sink/.tmp-auth-types-'));
roots.push(root);
const authFile = join(root, authFileName);
const outDir = join(root, '.void');
mkdirSync(outDir);
writeFileSync(authFile, authSource);
writeFileSync(join(outDir, 'auth.d.ts'), generateAuthTypes(authFile, outDir).dts);
writeFileSync(join(root, 'handler.ts'), handlerSource);
writeFileSync(
join(root, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
strict: true,
skipLibCheck: true,
noEmit: true,
paths,
},
files: [
...(authFileName.endsWith('.js') ? [] : [authFileName]),
'.void/auth.d.ts',
'handler.ts',
],
}),
);
return spawnSync(
process.execPath,
[join(workspaceRoot, 'node_modules/typescript/lib/tsc.js'), '--project', root],
{ encoding: 'utf8' },
);
}

describe('generated auth types in a consumer project', () => {
it('types plugin fields and additionalFields on user, session, and context', () => {
// Only auth.ts's own plugin import needs a mapping; the generated file
// reaches better-auth through void/auth.
const betterAuthPkg = join(workspaceRoot, 'packages/void/node_modules/better-auth');
const adminTypes = join(betterAuthPkg, 'dist/plugins/admin/index.d.mts');
const result = typecheck(
'import { admin } from "better-auth/plugins/admin";\n' +
'import { defineAuth } from "void/auth";\n' +
'export default defineAuth(({ defaults }) => ({\n' +
' ...defaults,\n' +
' plugins: [admin()],\n' +
' user: { additionalFields: { plan: { type: "string", required: false } } },\n' +
'}));\n',
'import { defineHandler } from "void";\n' +
'import { getUser, getSession, requireAuth } from "void/auth";\n' +
'import { logger } from "void/log";\n' +
'export const loader = defineHandler((c) => {\n' +
' const role: string | null | undefined = c.get("user")?.role;\n' +
' const plan: string | null | undefined = getUser()?.plan;\n' +
' const impersonatedBy: string | null | undefined = c.get("session")?.impersonatedBy;\n' +
' const sessionImpersonatedBy: string | null | undefined =\n' +
' getSession()?.session.impersonatedBy;\n' +
' const user = requireAuth(c);\n' +
' const requiredRole: string | null | undefined = user.role;\n' +
' const fields: Record<string, unknown> = user;\n' +
' logger.info("signed in", user);\n' +
' // @ts-expect-error not a field of the inferred user\n' +
' getUser()?.notAField;\n' +
' return { role, plan, impersonatedBy, sessionImpersonatedBy, requiredRole, fields };\n' +
'});\n',
{ 'better-auth/plugins/admin': [adminTypes] },
);
expect(result.status, result.stdout + result.stderr).toBe(0);
});

it('keeps fields every branch shares when auth.ts returns different shapes', () => {
const result = typecheck(
'import { defineAuth } from "void/auth";\n' +
'const plan = { type: "string", required: true } as const;\n' +
'const trialDays = { type: "number", required: true } as const;\n' +
'const seats = { type: "number", required: true } as const;\n' +
'export default defineAuth(({ defaults, env }) => {\n' +
' const trial = { ...defaults, user: { additionalFields: { plan, trialDays } } };\n' +
' const paid = { ...defaults, user: { additionalFields: { plan, seats } } };\n' +
' return env.TRIAL ? trial : paid;\n' +
'});\n',
'import { getUser } from "void/auth";\n' +
'export const plan: string = getUser()!.plan;\n' +
'// @ts-expect-error only one branch adds trialDays\n' +
'export const trialDays: number = getUser()!.trialDays;\n',
{},
);
expect(result.status, result.stdout + result.stderr).toBe(0);
});

it('keeps Better Auth base types when a JS auth config cannot be typed', () => {
const result = typecheck(
'import { defineAuth } from "void/auth";\n' +
'export default defineAuth({\n' +
' user: { additionalFields: { plan: { type: "string", required: true } } },\n' +
'});\n',
'import { getUser, type AuthUser } from "void/auth";\n' +
'export const email: string = getUser()!.email;\n' +
'// @ts-expect-error not a key of the base user\n' +
'export const key: keyof AuthUser = "notAField";\n' +
'// @ts-expect-error not a field of the base user\n' +
'export const plan: number = getUser()!.notAField;\n',
{},
'auth.js',
);
expect(result.status, result.stdout + result.stderr).toBe(0);
});
});
Loading
Loading