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 packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion packages/void/src/plugin-worker-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
Expand Down
83 changes: 83 additions & 0 deletions packages/void/test/unit/plugin-better-auth-dev.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = [];

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);
});
4 changes: 2 additions & 2 deletions packages/void/test/unit/plugin-dev-asset-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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'] },
});
}
Expand Down
4 changes: 4 additions & 0 deletions packages/void/test/unit/plugin-vite-environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])(
Expand Down
Loading