diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index fadcc7af3..aa46c3d05 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -102,7 +102,7 @@ generated entries must preserve the module-boundary rules below. - **Import redirection**: The routing plugin's `resolveId` hook redirects bare `"void"` imports to `void/handler` (the lightweight subpath). Without this, route files pull the entire plugin (vite, @cloudflare/vite-plugin, lightningcss) into the Worker bundle. Uses `import.meta.resolve()` to compute absolute paths at plugin init. Class A frameworks and Astro get the same redirect for their server environments from `createBareVoidRedirectPlugin`, in dev and build: their dev trigger dispatch imports every cron and queue file into workerd, so one `import { defineScheduled } from 'void'` would otherwise break every trigger (and, in Astro, every request). -- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev for PostgreSQL and MySQL apps, the plugin also inlines installed ES module packages that depend on void, so their `void/db` is the app's. In a D1 app, and for a CommonJS package or one that does not list void, the package stays external and loads its own copy of the env modules. `envContext` and the request scope therefore live on `globalThis` under versioned `Symbol.for` keys, like `authContext`, so every copy reads the current request; this also covers modules that a dependency-optimizer reload evaluates again during a request. Change a key when its store shape changes, because two Void versions in one process share it. +- **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. Vite never discovers bare imports from inside node_modules, so the same plugin prebundles Void's CommonJS dependencies (`void > email-validator`, the void/db driver) in every environment whose resolve conditions contain `workerd`: Void's worker, class A frameworks and Astro. Node environments get only the exclusion. Workerd environments also exclude `better-auth`. Its OAuth request state is a module-local ref, and the dev worker must load the same module Void's runtime does: prebundling an app import of `better-auth/api` while the excluded `void` package loads `better-auth` from source makes `getOAuthState()` miss the state the runtime set. A production build already bundles one copy. SvelteKit's dev server runs server code in Vite's Node `ssr` graph, which externalizes a bare import that resolves into node_modules before any plugin resolves it, so an installed `void/db` would load the package's D1 runtime. The SvelteKit plugin sets `ssr.noExternal: ['void']` during serve; Vite matches it by package name, so the whole package moves into that graph together and the injected `void/_env` shares void/db's env module. Like the routing plugin, it sends bare `void` to `void/handler` in server environments. In SvelteKit dev for PostgreSQL and MySQL apps, the plugin also inlines installed ES module packages that depend on void, so their `void/db` is the app's. In a D1 app, and for a CommonJS package or one that does not list void, the package stays external and loads its own copy of the env modules. `envContext` and the request scope therefore live on `globalThis` under versioned `Symbol.for` keys, like `authContext`, so every copy reads the current request; this also covers modules that a dependency-optimizer reload evaluates again during a request. Change a key when its store shape changes, because two Void versions in one process share it. - **Plugin entry imports**: Every `vp dev` and `vp build` imports the plugin entry before it starts. Import dependencies that only some apps or commands use (feature codegen, CLI-only helpers) where they are used, so other apps don't load them at startup. `test/unit/plugin-entry-imports.test.ts` guards this; add a mock there when you move a heavy import out. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. - **Scaffold dependency installation**: Vite+ projects install through local/global `vp`, bootstrapping with `npx` when neither is available. Preserve existing package-manager pins and lockfiles; fresh Vite+ projects select pnpm through the build-policy YAML without adding a version pin. Plain Vite projects use their selected package manager. Do not run a global package manager directly for Vite+ installs. diff --git a/packages/void/src/plugin-worker-environment.ts b/packages/void/src/plugin-worker-environment.ts index 5bb0396ae..725bfc99f 100644 --- a/packages/void/src/plugin-worker-environment.ts +++ b/packages/void/src/plugin-worker-environment.ts @@ -212,7 +212,11 @@ export function createServerRuntimeOptimizerPlugin( } return { optimizeDeps: { - exclude: ['void'], + // `better-auth` keeps OAuth request state on a module-local ref. Prebundling an app + // import of `better-auth/api` while this excluded package loads `better-auth` from + // source gives the dev worker two copies, so `getOAuthState()` misses the state the + // runtime set. A production build already bundles one copy. + exclude: ['void', 'better-auth'], // Excluding void also skips converting its CommonJS dependencies, which workerd cannot // evaluate: an installed void/email fails with "exports is not defined". include: ['void > email-validator', ...DATABASE_DRIVER_DEPS[dialect]], diff --git a/packages/void/test/unit/plugin-better-auth-dev.test.ts b/packages/void/test/unit/plugin-better-auth-dev.test.ts new file mode 100644 index 000000000..cb282ea26 --- /dev/null +++ b/packages/void/test/unit/plugin-better-auth-dev.test.ts @@ -0,0 +1,83 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { voidPlugin } from '../../src/index.ts'; + +const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)); +const roots: Array = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('void dev better-auth module', () => { + it('loads one OAuth state module for the worker runtime and app code', async () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'void-better-auth-'))); + roots.push(root); + // The package export is `dist/index.mjs`. + const betterAuthEntry = fileURLToPath(import.meta.resolve('better-auth')); + const betterAuthRoot = realpathSync(dirname(dirname(betterAuthEntry))); + mkdirSync(join(root, 'node_modules', 'void'), { recursive: true }); + symlinkSync(betterAuthRoot, join(root, 'node_modules', 'better-auth'), 'dir'); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { 'better-auth': '*', void: '*' } }), + ); + // Auth apps enable nodejs_compat because Better Auth hashes passwords with node:crypto. + writeFileSync( + join(root, 'void.json'), + JSON.stringify({ + worker: { compatibility_date: '2026-02-24', compatibility_flags: ['nodejs_compat'] }, + }), + ); + // An installed `void` is excluded from dependency optimization, so its import of + // better-auth stays on the package source. This probe is that file: the relative path + // is the module the runtime reaches, and the route imports the public subpath a plugin uses. + writeFileSync( + join(root, 'node_modules', 'void', 'package.json'), + JSON.stringify({ + name: 'void', + type: 'module', + exports: { './oauth-probe': './oauth-probe.mjs' }, + }), + ); + writeFileSync( + join(root, 'node_modules', 'void', 'oauth-probe.mjs'), + "export { getOAuthState } from '../better-auth/dist/api/state/oauth.mjs';\n", + ); + mkdirSync(join(root, 'routes', 'api'), { recursive: true }); + writeFileSync( + join(root, 'routes', 'api', 'oauth-state.ts'), + [ + "import { getOAuthState as fromApp } from 'better-auth/api';", + "import { getOAuthState as fromRuntime } from 'void/oauth-probe';", + 'export const GET = () => Response.json({ same: fromApp === fromRuntime });', + '', + ].join('\n'), + ); + vi.spyOn(process, 'cwd').mockReturnValue(root); + + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + server: { host: '127.0.0.1', port: 0, fs: { allow: [root, repoRoot] } }, + plugins: [voidPlugin()], + }); + try { + await server.listen(); + const response = await fetch(new URL('/api/oauth-state', server.resolvedUrls!.local[0]!)); + const body = await response.text(); + expect(response.status, body).toBe(200); + expect(JSON.parse(body)).toEqual({ same: true }); + } finally { + await server.close(); + } + }, 60_000); +}); diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index fc6a2ddfb..83490d489 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -252,7 +252,7 @@ describe('voidPlugin dev asset routing', () => { it('converts void CommonJS dependencies only in environments that run in workerd', async () => { const workerdOptimizer = { - optimizeDeps: { exclude: ['void'], include: ['void > email-validator'] }, + optimizeDeps: { exclude: ['void', 'better-auth'], include: ['void > email-validator'] }, }; const nodeOptimizer = { optimizeDeps: { exclude: ['void'] } }; const cases = [ @@ -290,7 +290,7 @@ describe('voidPlugin dev asset routing', () => { `${createRoot.name} ${database}`, ).resolves.toEqual({ optimizeDeps: drivers - ? { exclude: ['void'], include: ['void > email-validator', ...drivers] } + ? { exclude: ['void', 'better-auth'], include: ['void > email-validator', ...drivers] } : { exclude: ['void'] }, }); } diff --git a/packages/void/test/unit/plugin-vite-environment.test.ts b/packages/void/test/unit/plugin-vite-environment.test.ts index 054cae67e..7c2f1e6b8 100644 --- a/packages/void/test/unit/plugin-vite-environment.test.ts +++ b/packages/void/test/unit/plugin-vite-environment.test.ts @@ -174,7 +174,11 @@ describe('voidPlugin Vite environments', () => { expect(config.environments.void_worker.optimizeDeps.include).toEqual( expect.arrayContaining(['void > email-validator', 'void > pg']), ); + expect(config.environments.void_worker.optimizeDeps.exclude).toEqual( + expect.arrayContaining(['void', 'better-auth']), + ); expect(config.environments.ssr.optimizeDeps.include).toEqual([]); + expect(config.environments.ssr.optimizeDeps.exclude ?? []).not.toContain('better-auth'); }); it.each(['before', 'after'])(