diff --git a/docs/guide/auth.md b/docs/guide/auth.md index fac1aa526..f7a0ebae1 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -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 diff --git a/docs/reference/api/auth.md b/docs/reference/api/auth.md index a759edf75..107f373c7 100644 --- a/docs/reference/api/auth.md +++ b/docs/reference/api/auth.md @@ -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 { @@ -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 { @@ -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: diff --git a/packages/void/src/codegen/auth-types.ts b/packages/void/src/codegen/auth-types.ts new file mode 100644 index 000000000..0fe447a69 --- /dev/null +++ b/packages/void/src/codegen/auth-types.ts @@ -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 = []; + + 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;`, + ); + 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); +} diff --git a/packages/void/src/codegen/prepare.ts b/packages/void/src/codegen/prepare.ts index e6e780c37..9d1151209 100644 --- a/packages/void/src/codegen/prepare.ts +++ b/packages/void/src/codegen/prepare.ts @@ -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'; @@ -55,6 +56,13 @@ export async function prepareProject(root: string): Promise 0 || (scan.websockets?.length ?? 0) > 0 || scan.pages.pages.length > 0; if (hasRouteTypes) { diff --git a/packages/void/src/codegen/project-tsconfig.ts b/packages/void/src/codegen/project-tsconfig.ts index 7d602a9ab..0a0b677ba 100644 --- a/packages/void/src/codegen/project-tsconfig.ts +++ b/packages/void/src/codegen/project-tsconfig.ts @@ -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'; @@ -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 }; @@ -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'); +} diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index f19b029d3..f91cd9a24 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -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'; @@ -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 = []; for (const file of files) { @@ -358,6 +366,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug async function writeAuthDatabaseFiles(): Promise { if (!compileOptions.auth) { + if (removeAuthTypesIfPresent(outDir)) { + syncAuthTypesInProjectTsconfig(root); + } return; } @@ -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; diff --git a/packages/void/src/runtime/auth.ts b/packages/void/src/runtime/auth.ts index 88ed87eed..e34cbbb69 100644 --- a/packages/void/src/runtime/auth.ts +++ b/packages/void/src/runtime/auth.ts @@ -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`, 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 = [unknown] extends [T] + ? { user: User; session: Session } + : Auth< + Extract) => 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; diff --git a/packages/void/test/integration/auth-types-typecheck.test.ts b/packages/void/test/integration/auth-types-typecheck.test.ts new file mode 100644 index 000000000..f753529e9 --- /dev/null +++ b/packages/void/test/integration/auth-types-typecheck.test.ts @@ -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 = []; + +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>, + 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 = 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); + }); +}); diff --git a/packages/void/test/unit/auth-types-codegen.test.ts b/packages/void/test/unit/auth-types-codegen.test.ts new file mode 100644 index 000000000..da8a2749d --- /dev/null +++ b/packages/void/test/unit/auth-types-codegen.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vite-plus/test'; +import { + generateAuthTypes, + removeAuthTypesIfPresent, + syncAuthTypesFile, +} from '../../src/codegen/auth-types.ts'; + +function withTempProject(setup: (root: string) => void): { + root: string; + outDir: string; + cleanup: () => void; +} { + const root = mkdtempSync(join(tmpdir(), 'void-auth-codegen-')); + setup(root); + const outDir = join(root, '.void'); + mkdirSync(outDir, { recursive: true }); + return { + root, + outDir, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +describe('syncAuthTypesFile', () => { + it('writes auth.d.ts when a config path is provided and removes it when null', () => { + const { root, outDir, cleanup } = withTempProject((r) => { + writeFileSync(join(r, 'auth.ts'), 'export default {};\n'); + }); + try { + expect(syncAuthTypesFile(join(root, 'auth.ts'), outDir)).toBe(true); + expect(syncAuthTypesFile(join(root, 'auth.ts'), outDir)).toBe(false); + expect(syncAuthTypesFile(null, outDir)).toBe(true); + expect(removeAuthTypesIfPresent(outDir)).toBe(false); + } finally { + cleanup(); + } + }); +}); + +describe('generateAuthTypes', () => { + it('emits a module that fills the void/auth VoidAuthTypes registry', () => { + const { root, outDir, cleanup } = withTempProject((r) => { + writeFileSync(join(r, 'auth.ts'), 'export default {};'); + }); + try { + const { dts } = generateAuthTypes(join(root, 'auth.ts'), outDir); + expect(dts).toContain('export {}'); + // better-auth is reached through void/auth: apps usually cannot resolve it themselves. + expect(dts).toContain('import type { InferAuthSession } from "void/auth";'); + expect(dts).not.toContain('from "better-auth"'); + expect(dts).toContain( + 'type _VoidAuthSession = InferAuthSession;', + ); + expect(dts).toContain('declare module "void/auth"'); + expect(dts).toContain('interface VoidAuthTypes {'); + expect(dts).toContain('user: _VoidAuthSession["user"];'); + expect(dts).toContain('session: _VoidAuthSession["session"];'); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/void/test/unit/auth-types-prepare.test.ts b/packages/void/test/unit/auth-types-prepare.test.ts new file mode 100644 index 000000000..c80cfb238 --- /dev/null +++ b/packages/void/test/unit/auth-types-prepare.test.ts @@ -0,0 +1,56 @@ +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 { afterEach, describe, expect, it } from 'vite-plus/test'; + +const roots: Array = []; + +function runPrepare(root: string): { written: Array; removed: Array } { + const stdout = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + 'const { prepareProject } = await import(process.argv[1]); console.log(JSON.stringify(await prepareProject(process.argv[2])));', + fileURLToPath(new URL('../../src/codegen/prepare.ts', import.meta.url)), + root, + ], + { encoding: 'utf8', stdio: 'pipe' }, + ); + return JSON.parse(stdout.trim().split('\n').at(-1)!); +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('auth types prepare lifecycle', () => { + it('writes auth.d.ts and updates tsconfig after prepare; removes both when auth.ts is gone', () => { + const root = mkdtempSync(join(tmpdir(), 'void-auth-prepare-')); + roots.push(root); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync( + join(root, 'auth.ts'), + 'import type { VoidAuthConfig } from "void/auth";\nexport default {} satisfies VoidAuthConfig;\n', + ); + + expect(runPrepare(root).written).toContain('.void/auth.d.ts'); + expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(true); + const withAuth = JSON.parse(readFileSync(join(root, '.void', 'tsconfig.json'), 'utf8')) as { + files: Array; + }; + expect(withAuth.files).toContain('auth.d.ts'); + + rmSync(join(root, 'auth.ts')); + expect(runPrepare(root).removed).toContain('.void/auth.d.ts'); + expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(false); + const withoutAuth = JSON.parse(readFileSync(join(root, '.void', 'tsconfig.json'), 'utf8')) as { + files: Array; + }; + expect(withoutAuth.files).not.toContain('auth.d.ts'); + }, 30_000); +}); diff --git a/packages/void/test/unit/plugin-routing.test.ts b/packages/void/test/unit/plugin-routing.test.ts index 76359de1d..5bb76b13f 100644 --- a/packages/void/test/unit/plugin-routing.test.ts +++ b/packages/void/test/unit/plugin-routing.test.ts @@ -22,6 +22,7 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { analyzeApplicationSync } from '../../src/application-analysis.ts'; import { GENERATED_ASSET_HEADERS_MARKER } from '../../src/asset-worker-bypass.ts'; import * as authConfig from '../../src/auth/config.ts'; +import { ensureProjectTsconfig } from '../../src/codegen/project-tsconfig.ts'; import { readConfig } from '../../src/config.ts'; import { routingPlugin } from '../../src/plugin-routing.ts'; import { readProjectPaths } from '../../src/project-paths.ts'; @@ -212,6 +213,42 @@ describe('routingPlugin', () => { expect(schema).toContain('pronouns'); }); + it('writes and removes auth.d.ts and lists it in the existing tsconfig only', async () => { + const root = createTempRoot(); + writeFileSync(join(root, 'routes', 'health.ts'), 'export const GET = () => new Response();'); + writeFileSync( + join(root, 'auth.ts'), + "import type { VoidAuthConfig } from 'void/auth';\nexport default {} satisfies VoidAuthConfig;\n", + ); + const tsconfigPath = join(root, '.void', 'tsconfig.json'); + const readTsconfig = () => + JSON.parse(readFileSync(tsconfigPath, 'utf8')) as { + files: Array; + compilerOptions?: { paths?: Record> }; + }; + + // The Drizzle plugin wrote this tsconfig at startup, before auth.ts existed: no db.d.ts. + ensureProjectTsconfig(root, true, false); + const withAuth = createRoutingPlugin(root, { isAuthEnabled: () => true }); + resolveConfig(withAuth, 'serve'); + await runBuildStart(withAuth); + + expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(true); + expect(readTsconfig().files).toEqual(['routes.d.ts', 'auth.d.ts']); + + // A pg app keeps db.d.ts and its void/db path after auth is turned off. + ensureProjectTsconfig(root, true, true); + expect(readTsconfig().files).toEqual(['routes.d.ts', 'db.d.ts', 'auth.d.ts']); + rmSync(join(root, 'auth.ts')); + const withoutAuth = createRoutingPlugin(root, { isAuthEnabled: () => false }); + resolveConfig(withoutAuth, 'build'); + await runBuildStart(withoutAuth); + + expect(existsSync(join(root, '.void', 'auth.d.ts'))).toBe(false); + expect(readTsconfig().files).toEqual(['routes.d.ts', 'db.d.ts']); + expect(readTsconfig().compilerOptions?.paths?.['void/db']).toEqual(['./db.d.ts']); + }); + it('bakes explicit page opt-outs into the native ISR policy', async () => { const root = createTempRoot(); mkdirSync(join(root, 'pages'), { recursive: true }); diff --git a/packages/void/test/unit/project-tsconfig.test.ts b/packages/void/test/unit/project-tsconfig.test.ts index 01eb329cb..b198c5a8d 100644 --- a/packages/void/test/unit/project-tsconfig.test.ts +++ b/packages/void/test/unit/project-tsconfig.test.ts @@ -75,6 +75,23 @@ describe('ensureProjectTsconfig', () => { expect(statSync(tsconfigPath).mtimeMs).not.toBe(oldTime.getTime()); }); + it('lists auth.d.ts only when the generated file already exists', () => { + writeFileSync( + join(root, 'auth.ts'), + 'import { defineAuth } from "void/auth";\nexport default defineAuth({});\n', + ); + ensureProjectTsconfig(root); + expect(readTsconfig().files).not.toContain('auth.d.ts'); + + writeFileSync(join(root, '.void', 'auth.d.ts'), 'export {};\n'); + ensureProjectTsconfig(root); + expect(readTsconfig().files).toContain('auth.d.ts'); + + rmSync(join(root, '.void', 'auth.d.ts')); + ensureProjectTsconfig(root); + expect(readTsconfig().files).not.toContain('auth.d.ts'); + }); + it('combines routes, queues, and md when all present', () => { mkdirSync(join(root, 'routes'), { recursive: true }); mkdirSync(join(root, 'queues'), { recursive: true });