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
12 changes: 10 additions & 2 deletions src/module/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ function getCloudflareContext(event) {

function resolveHyperdrive(event) {
const cloudflareEnv = getCloudflareContext(event)?.env
return cloudflareEnv?.POSTGRES || process.env.POSTGRES || globalThis.__env__?.POSTGRES || globalThis.POSTGRES
const runtimeEnv = event?.req?.runtime?.cloudflare?.env
return cloudflareEnv?.POSTGRES || runtimeEnv?.POSTGRES || process.env.POSTGRES || globalThis.__env__?.POSTGRES || globalThis.POSTGRES
}

function scheduleCleanup(event, cleanup) {
Expand Down Expand Up @@ -114,6 +115,11 @@ function registerClientCleanup(event, client) {
response.once('close', cleanup)
}

// Hyperdrive clients belong to one request; any other database is shared by the whole process.
export function isRequestScopedDatabase(event) {
return Boolean(resolveHyperdrive(event)?.connectionString)
Comment thread
vite-hub[bot] marked this conversation as resolved.
Resolved
}

export function createDatabase(event) {
const hyperdrive = resolveHyperdrive(event)
if (!hyperdrive?.connectionString)
Expand Down Expand Up @@ -152,11 +158,13 @@ import * as schema from './schema.${input.hubDialect}.mjs'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
const rawDialect = '${input.hubDialect}'
const dialect = rawDialect === 'postgresql' ? 'pg' : rawDialect
export function isRequestScopedDatabase() { return false }
export function createDatabase() { return drizzleAdapter(db, { provider: dialect, schema, usePlural: ${input.usePlural}, camelCase: ${input.camelCase} }) }
export { db }`
}

return `export function createDatabase() { return undefined }
return `export function isRequestScopedDatabase() { return false }
export function createDatabase() { return undefined }
export const db = undefined`
}

Expand Down
1 change: 1 addition & 0 deletions src/module/type-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type AvailableRouterMethod<_ReqT extends NitroFetchRequest> = 'get' | 'head' | '
declare module '#auth/database' {
import type { BetterAuthOptions } from 'better-auth'
export function createDatabase(event?: import('${h3TypesPath}').H3Event): BetterAuthOptions['database']
export function isRequestScopedDatabase(event?: import('${h3TypesPath}').H3Event): boolean
export const db: ${hasHubDb ? `typeof import('@nuxthub/db')['db']` : 'undefined'}
}
`,
Expand Down
24 changes: 14 additions & 10 deletions src/runtime/server/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'
import type { ServerEvent } from '../internal/nitro-compat'
import { betterAuth, env } from 'better-auth'
import { createDatabase, db } from '#auth/database'
import { createDatabase, db, isRequestScopedDatabase } from '#auth/database'
import createServerAuth from '#auth/server'
import type { DerivedAuthSecretState } from '../internal/auth-secret'
import { getConfiguredAuthSecret, getDerivedAuthSecret, hasExplicitAuthSecret, prepareAuthSecret } from '../internal/auth-secret'
Expand Down Expand Up @@ -365,6 +365,18 @@ export function serverAuth(event?: ServerEvent): AuthInstance {
if (requestContext?.[requestAuthKey])
return requestContext[requestAuthKey]

// Only a request-scoped database (a Hyperdrive client) forces a fresh instance per request. Otherwise
// a cached instance is returned before building the user config, which reruns every plugin factory.
const requestScopedDatabase = (isRequestScopedDatabase as (event?: ServerEvent) => boolean)(event)
if (!requestScopedDatabase) {
const cached = _authCache.get(cacheKey)
if (cached) {
if (requestContext)
requestContext[requestAuthKey] = cached
return cached
}
}

userConfig ??= createServerAuth({ runtimeConfig, db, requestOrigin }) as UserAuthConfig

// Explicit secrets keep their existing precedence; Nuxt's appSecret only fills the gap (4.6+).
Expand Down Expand Up @@ -404,15 +416,7 @@ export function serverAuth(event?: ServerEvent): AuthInstance {
: userConfig.rateLimit

// An instance without the derived secret must not outlive the request that needed it.
const cacheable = !database && !derivedSecretMissing
if (cacheable) {
const cached = _authCache.get(cacheKey)
if (cached) {
if (requestContext)
requestContext[requestAuthKey] = cached
return cached
}
}
const cacheable = !requestScopedDatabase && !derivedSecretMissing

const authOptions = {
...userConfig,
Expand Down
1 change: 1 addition & 0 deletions src/runtime/server/virtual-modules.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
declare module '#auth/database' {
export const db: any
export function createDatabase(...args: any[]): any
export function isRequestScopedDatabase(...args: any[]): boolean
}

declare module '#imports' {
Expand Down
1 change: 1 addition & 0 deletions test/cases/plugins-type-inference/virtual-modules.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
declare module '#auth/database' {
export const db: any
export function createDatabase(...args: any[]): any
export function isRequestScopedDatabase(...args: any[]): boolean
}

declare module '#better-auth/app-secret' {
Expand Down
43 changes: 43 additions & 0 deletions test/database-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it, vi } from 'vitest'
import { buildDatabaseCode } from '../src/module/templates'

function loadDatabase() {
const postgres = vi.fn(() => ({}))
const code = buildDatabaseCode({ provider: 'nuxthub', hubDialect: 'postgresql', usePlural: false, camelCase: false })
.replace(/^import .*$/gm, '')
.replace(/^export /gm, '')
const database = runInNewContext(`${code}\n;({ isRequestScopedDatabase, createDatabase })`, {
process: { env: {} },
db: {},
schema: {},
postgres,
drizzle: (options: unknown) => options,
drizzleAdapter: (database: unknown) => database,
})
return { ...database, postgres }
}

describe('generated database request scope', () => {
it.each(['context', 'runtime'])('recognizes Hyperdrive in the %s binding location', (location) => {
const { isRequestScopedDatabase, createDatabase, postgres } = loadDatabase()
const cloudflare = { env: { POSTGRES: { connectionString: 'postgres://hyperdrive/test' } } }
const event = location === 'context'
? { context: { cloudflare } }
: { context: {}, req: { runtime: { cloudflare } } }

expect(isRequestScopedDatabase(event)).toBe(true)
const first = createDatabase(event)
expect(createDatabase(event)).toBe(first)
expect(postgres).toHaveBeenCalledExactlyOnceWith('postgres://hyperdrive/test', expect.objectContaining({ prepare: false, max: 1 }))
})

it('keeps database access process-wide without a Hyperdrive connection string', () => {
const { isRequestScopedDatabase, createDatabase, postgres } = loadDatabase()
const event = { req: { runtime: { cloudflare: { env: {} } } } }
expect(isRequestScopedDatabase(event)).toBe(false)
expect(isRequestScopedDatabase()).toBe(false)
createDatabase(event)
expect(postgres).not.toHaveBeenCalled()
})
})
2 changes: 1 addition & 1 deletion test/get-base-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const runtimeConfig = {
}
const createServerAuthMock = vi.fn()

vi.mock('#auth/database', () => ({ createDatabase: () => undefined, db: {} }))
vi.mock('#auth/database', () => ({ createDatabase: () => undefined, db: {}, isRequestScopedDatabase: () => false }))
vi.mock('#auth/server', () => ({ default: createServerAuthMock }))
vi.mock('better-auth', () => ({
betterAuth: (options: BetterAuthOptions) => ({ options }),
Expand Down
1 change: 1 addition & 0 deletions test/server-auth-app-secret.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const appSecret = { available: true }
vi.mock('#auth/database', () => ({
createDatabase: () => undefined,
db: undefined,
isRequestScopedDatabase: () => false,
}))

vi.mock('#auth/server', () => ({
Expand Down
28 changes: 25 additions & 3 deletions test/server-auth-database-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const betterAuthMock = vi.fn()
const createDatabaseMock = vi.fn()
const isRequestScopedDatabaseMock = vi.fn()
const createServerAuthMock = vi.fn()
const useRuntimeConfigMock = vi.fn()

vi.mock('#auth/database', () => ({
createDatabase: createDatabaseMock,
db: { query: {} },
isRequestScopedDatabase: isRequestScopedDatabaseMock,
}))

vi.mock('#auth/server', () => ({
Expand Down Expand Up @@ -58,6 +60,7 @@ describe('serverAuth database cache and secret validation', () => {
createServerAuthMock.mockReturnValue({
trustedOrigins: undefined,
})
isRequestScopedDatabaseMock.mockReturnValue(false)

betterAuthMock.mockImplementation((options: Record<string, unknown>) => ({
options,
Expand Down Expand Up @@ -291,11 +294,28 @@ describe('serverAuth database cache and secret validation', () => {
expect(betterAuthMock).toHaveBeenCalledTimes(1)
})

it('creates a fresh auth instance for each request when a database adapter is active', async () => {
it('shares one auth instance across requests when the database adapter is process-wide', async () => {
createDatabaseMock.mockReturnValue({ kind: 'database-adapter' })

const { serverAuth } = await import('../src/runtime/server/utils/auth')

const first = serverAuth(createEvent())
const second = serverAuth(createEvent())
const third = serverAuth()

expect(first).toBe(second)
expect(third).toBe(first)
expect(createServerAuthMock).toHaveBeenCalledTimes(1)
expect(createDatabaseMock).toHaveBeenCalledTimes(1)
expect(betterAuthMock).toHaveBeenCalledTimes(1)
})

it('creates a fresh auth instance for each request when the database is request-scoped', async () => {
createDatabaseMock.mockReturnValue({ kind: 'database-adapter' })
isRequestScopedDatabaseMock.mockReturnValue(true)

const { serverAuth } = await import('../src/runtime/server/utils/auth')

const first = serverAuth(createEvent())
const second = serverAuth(createEvent())

Expand All @@ -304,8 +324,9 @@ describe('serverAuth database cache and secret validation', () => {
expect(betterAuthMock).toHaveBeenCalledTimes(2)
})

it('creates a fresh auth instance without request context when a database adapter is active', async () => {
it('creates a fresh auth instance without request context when the database is request-scoped', async () => {
createDatabaseMock.mockReturnValue({ kind: 'database-adapter' })
isRequestScopedDatabaseMock.mockReturnValue(true)

const { serverAuth } = await import('../src/runtime/server/utils/auth')

Expand All @@ -326,7 +347,8 @@ describe('serverAuth database cache and secret validation', () => {
const second = serverAuth()

expect(first).toBe(second)
expect(createDatabaseMock).toHaveBeenCalledTimes(2)
expect(createServerAuthMock).toHaveBeenCalledTimes(1)
expect(createDatabaseMock).toHaveBeenCalledTimes(1)
expect(betterAuthMock).toHaveBeenCalledTimes(1)
})

Expand Down
1 change: 1 addition & 0 deletions test/server-auth-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock('../src/runtime/server/internal/nitro-compat', () => ({
vi.mock('#auth/database', () => ({
createDatabase: () => undefined,
db: undefined,
isRequestScopedDatabase: () => false,
}))

vi.mock('#auth/server', () => ({
Expand Down
Loading