diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs
index 2cc4d2310..d390b9724 100644
--- a/.github/scripts/packed-consumer.mjs
+++ b/.github/scripts/packed-consumer.mjs
@@ -7,6 +7,7 @@ import {
mkdtempSync,
readFileSync,
readdirSync,
+ rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
@@ -389,8 +390,59 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E
await task.stop();
}
}
+ if (framework === 'react') {
+ // The Node target bundles the installed void into its build, so a ValidationError from
+ // void/handler is the class that page actions check, as in dev.
+ await run(
+ manager,
+ manager === 'npm' ? ['install', '@hono/node-server'] : ['add', '@hono/node-server'],
+ fixture,
+ );
+ writeFileSync(
+ join(fixture, 'void.config.ts'),
+ "import { defineConfig } from 'void/config';\nexport default defineConfig({ target: 'node' });\n",
+ );
+ // KV and email are Cloudflare bindings.
+ rmSync(join(fixture, 'routes'), { recursive: true });
+ writeFileSync(
+ join(fixture, 'pages/form.tsx'),
+ 'export default function Form() {\n return
Form
;\n}\n',
+ );
+ writeFileSync(
+ join(fixture, 'pages/form.server.ts'),
+ "import { defineHandler, ValidationError } from 'void/handler';\nexport const action = defineHandler(() => {\n throw new ValidationError({ email: 'Email already taken' });\n});\n",
+ );
+ await run(process.execPath, [vite, 'build'], fixture);
+ const task = launch(
+ process.execPath,
+ [vite, 'preview', '--host', '127.0.0.1', '--port', '0', '--strictPort'],
+ fixture,
+ );
+ try {
+ const deadline = Date.now() + 60_000;
+ let base;
+ while (!base && Date.now() < deadline) {
+ if (task.child.exitCode !== null) {
+ throw new Error(task.failure());
+ }
+ base = packedServerUrl(task.output());
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+ assert(base, task.failure());
+ const response = await fetch(`${base}/form`, {
+ method: 'POST',
+ headers: { accept: 'text/html' },
+ body: new URLSearchParams({ email: 'taken@example.com' }),
+ });
+ const body = await response.text();
+ assert.equal(response.status, 200, `${body}\n${task.failure()}`);
+ assert(body.includes('Email already taken'), body);
+ } finally {
+ await task.stop();
+ }
+ }
console.log(
- `Passed packed ${label} (${suite}): init, frozen install, public Vite build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}`,
+ `Passed packed ${label} (${suite}): init, frozen install, public Vite build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`,
);
} catch (error) {
failures.push({ fixture: label, error: String(error.stack ?? error) });
diff --git a/docs/guide/database.md b/docs/guide/database.md
index 2e94f11f0..9384fbc05 100644
--- a/docs/guide/database.md
+++ b/docs/guide/database.md
@@ -234,10 +234,6 @@ const usersWithPosts = await db.query.users.findMany({
});
```
-::: warning ⚠️ Nuxt and Analog limitations
-In Nuxt and Analog, use the query builder (`db.select().from(table)`). The `db.query.*` relational API is not available in these frameworks.
-:::
-
## Seeding
Use `void db seed` to reset your local database, re-apply migrations, and then run a seed file.
@@ -376,7 +372,7 @@ To keep an existing migration history:
Review and commit the metadata, then run `void deploy` again. If no database has applied the SQL yet, you can instead replace it with migrations from `void db generate`.
-For D1 migrations containing triggers, add a `--> statement-breakpoint` line after each complete SQL statement, including the trigger's closing `END;`. Keep the trigger body together.
+For D1 migrations containing triggers, and MySQL migrations containing triggers, procedures, functions or events, add a `--> statement-breakpoint` line after each complete SQL statement, including the trigger's closing `END;`. Keep the trigger body together.
## Scaffolding
diff --git a/docs/guide/database/mysql.md b/docs/guide/database/mysql.md
index 22b783bef..b75dc3dcb 100644
--- a/docs/guide/database/mysql.md
+++ b/docs/guide/database/mysql.md
@@ -51,9 +51,20 @@ void deploy
For direct Cloudflare deploys, export the production `DATABASE_URL` in the shell running `void deploy --platform cloudflare`. Void uses it to provision Hyperdrive and apply pending checked-in migrations before uploading the Worker; it is not written to the Worker config.
-::: warning MySQL DDL is not transactional
-MySQL may implicitly commit schema changes. If a multi-statement migration fails partway through, earlier statements can remain applied even though Void does not record the migration as complete. Keep migrations small, review them before deploy, and make failed migrations safe to rerun after repairing the database.
-:::
+Only one migration run changes a database at a time, whether it comes from `void db migrate`, `void db reset`, the reset that `void db seed` does before it loads data, `void deploy --platform cloudflare`, or a `void deploy` of any project that uses the database. Better Auth's table changes run inside the same run. Another run waits for it, up to about 20 seconds for a command that connects with `DATABASE_URL` and about 2 minutes for a `void deploy` to the Void platform, then stops with an error that asks you to try again.
+
+### Failed migrations {#failed-migrations}
+
+Each migration file runs in a transaction, so a failed file that only changes data leaves the database unchanged. On a sharded Vitess keyspace, such as a sharded PlanetScale database, a commit that fails on one shard does not undo the shards that already committed. MySQL commits schema changes such as `CREATE TABLE` and `ALTER TABLE` immediately, so when a file with schema changes fails partway through, its earlier statements stay applied. The error names the file that failed.
+
+To recover, undo the applied statements, fix the file, then run `void db migrate` or `void deploy` again:
+
+```sh
+void db execute "DROP TABLE comments"
+void db execute --remote "DROP TABLE comments" # deployed database
+```
+
+MySQL rejects `IF NOT EXISTS` on `ADD COLUMN` and `CREATE INDEX`, so undoing a statement is usually simpler than making it safe to rerun. Small migrations leave less to undo.
## Database commands
diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md
index 4bf7608c5..a71f5592a 100644
--- a/docs/integrations/cloudflare.md
+++ b/docs/integrations/cloudflare.md
@@ -154,7 +154,7 @@ Void uses Access credentials for readiness checks without saving them. If checks
### Databases and secrets
-For PostgreSQL and MySQL, supply `DATABASE_URL` to the deploy process. Void uses it for Hyperdrive and migrations without writing it to config. MySQL schema changes can partially apply before an error.
+For PostgreSQL and MySQL, supply `DATABASE_URL` to the deploy process. Void uses it for Hyperdrive and migrations without writing it to config. MySQL schema changes can partially apply before an error; see [failed MySQL migrations](../guide/database/mysql.md#failed-migrations).
Creating Hyperdrive requires an API token with Hyperdrive edit permission. Alternatively, create it in Cloudflare and add its ID to `cloudflare.hyperdrive`; an existing binding works with browser login.
diff --git a/docs/integrations/frameworks/analog.md b/docs/integrations/frameworks/analog.md
index 742a9398e..1ee8dfa39 100644
--- a/docs/integrations/frameworks/analog.md
+++ b/docs/integrations/frameworks/analog.md
@@ -26,7 +26,6 @@ Void provides the local Cloudflare binding runtime. `nitro-cloudflare-dev` creat
### 3. Configure `vite.config.ts`
```ts
-import { resolve } from 'node:path';
import analog from '@analogjs/platform';
import { defineConfig } from 'vite';
import { voidPlugin } from 'void';
@@ -41,12 +40,7 @@ export default defineConfig({
nitro: {
preset: 'cloudflare-module',
cloudflareDev: { configPath: './.void-wrangler.jsonc' },
- modules: ['nitro-cloudflare-dev'],
- alias: {
- // Nitro has its own bundler that doesn't use Vite aliases.
- // Duplicate @schema here so Nitro can resolve it.
- '@schema': resolve(__dirname, 'db/schema.ts'),
- },
+ modules: ['nitro-cloudflare-dev', 'void/nitro'],
},
}),
voidPlugin(),
@@ -54,6 +48,8 @@ export default defineConfig({
});
```
+The `void/nitro` module gives Analog API routes and page server loads your app's `void/db` and `@schema`.
+
### 4. Create `void.config.ts`
Void generates the Cloudflare config used by Analog's development runtime:
@@ -98,10 +94,6 @@ export const load = async ({ event }: PageServerLoad) => {
};
```
-::: warning ⚠️ Analog limitation
-Use `db.select().from(table)` for database queries in Analog. The `db.query.*` relational API is not available.
-:::
-
### Other resources
Use [KV](../../guide/kv.md), [object storage](../../guide/storage.md), and [AI](../../guide/ai.md) from server-side code with the same imports as a Void app. Add [cron jobs](../../guide/jobs.md) in `crons/` and [queue consumers](../../guide/queues.md) in `queues/`.
diff --git a/docs/integrations/frameworks/nuxt.md b/docs/integrations/frameworks/nuxt.md
index ba8641af5..25065a748 100644
--- a/docs/integrations/frameworks/nuxt.md
+++ b/docs/integrations/frameworks/nuxt.md
@@ -30,23 +30,24 @@ import { resolve } from 'node:path';
import { voidPlugin } from 'void';
export default defineNuxtConfig({
+ alias: {
+ '@schema': resolve(import.meta.dirname, 'db/schema.ts'),
+ },
nitro: {
preset: 'cloudflare-module',
- cloudflareDev: { configPath: './.void-wrangler.jsonc' },
+ modules: ['void/nitro'],
+ cloudflare: { dev: { configPath: './.void-wrangler.jsonc' } },
// Required for Nuxt 4.6.0 on Windows.
externals: {
inline: [/[\\/]node_modules[\\/]nuxt[\\/]dist[\\/]/],
},
- alias: {
- // Nitro has its own bundler that doesn't use Vite aliases.
- // Duplicate @schema here so Nitro can resolve it.
- '@schema': resolve(import.meta.dirname, 'db/schema.ts'),
- },
},
vite: { plugins: [voidPlugin()] },
});
```
+The `void/nitro` module gives Nuxt server routes your app's `void/db` and `@schema`. The top-level `alias` lets `nuxi typecheck` resolve `@schema`, because Nuxt also checks server routes with the app's types. `cloudflare.dev` gives the development server the bindings from Void's generated Cloudflare config; Nuxt runs it in development when `compatibilityDate` is `2025-07-15` or later.
+
### 4. Create `void.config.ts`
Void generates the Cloudflare config used by Nuxt's development runtime:
@@ -88,10 +89,6 @@ export default defineEventHandler(async () => {
});
```
-::: warning ⚠️ Nuxt limitation
-Use `db.select().from(table)` for database queries in Nuxt. The `db.query.*` relational API is not available.
-:::
-
### Other resources
Use [KV](../../guide/kv.md), [object storage](../../guide/storage.md), and [AI](../../guide/ai.md) from server-side code with the same imports as a Void app. Add [cron jobs](../../guide/jobs.md) in `crons/` and [queue consumers](../../guide/queues.md) in `queues/`.
diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md
index 44b30e743..0d992023f 100644
--- a/docs/integrations/nodejs-bun-deno.md
+++ b/docs/integrations/nodejs-bun-deno.md
@@ -80,6 +80,8 @@ dist/
`vite preview` also uses `app.js` to serve your built app locally.
+Installed packages that depend on `void` are bundled into `dist/ssr`. If one of their dependencies uses a native addon or reads files next to its own code, add that dependency, in the version the package uses, to your app's `dependencies`. The build then leaves it to Node.
+
## What Works
These Void features work identically across all targets:
diff --git a/docs/reference/api.md b/docs/reference/api.md
index dd56075a5..a3899c162 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -68,4 +68,5 @@ Import Void's app-facing APIs from these paths:
| `void/queues` | `queues` proxy for sending messages to typed queues |
| `void/sandbox` | `getSandbox`, `sandbox`, `SandboxLimitError`, `SandboxUnavailableError`, and the SDK 1.0 file, backup, and mount helpers. `getSandbox(id)` resolves a client. Commands, files, port requests, and process completion return promises; limits reject with `SandboxLimitError`, which extends `UsageLimit`. Container state lasts until inactivity (default ten minutes), a crash, or a restart. See [Sandboxes](../guide/sandboxes.md) for the API and deployment requirements. |
| `void/env` | `defineEnv`, `env`, schema helpers (`string`, `number`, `boolean`, `url`, `email`, `oneOf`, `json`), and global Cloudflare env types |
+| `void/nitro` | Nitro module for Nuxt and Analog. Add `'void/nitro'` to the Nitro `modules` so server routes get the app's `void/db` and `@schema` |
| `void/sveltekit` | SvelteKit integration helpers: `withVoidTSConfig()` and `mergeVoidSvelteKitTsconfig()` |
diff --git a/package.json b/package.json
index 2f9d8f03c..596587b0e 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"test": "node test/affected.mjs",
"test:full": "vp test run",
"test:integration": "vp test run --project parallel-integration",
- "test:metaframeworks": "vp test run --project parallel-integration analog.test.ts astro.test.ts nuxt.test.ts react-router.test.ts sveltekit.test.ts tanstack-start.test.ts vinext-app.test.ts vinext-pages.test.ts",
+ "test:metaframeworks": "vp test run --project parallel-integration analog.test.ts astro.test.ts nitro-worker.test.ts nuxt.test.ts react-router.test.ts sveltekit.test.ts tanstack-start.test.ts vinext-app.test.ts vinext-pages.test.ts",
"test:pages:react-vue": "vp test run --project parallel-integration packages/react packages/vue",
"test:pages:svelte-solid": "vp test run --project parallel-integration packages/svelte packages/solid",
"test:platform": "vp test run --project 'platform-*'",
diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md
index d10bf736c..ac73f0c56 100644
--- a/packages/void/AGENTS.md
+++ b/packages/void/AGENTS.md
@@ -37,7 +37,7 @@ voidPlugin() → [routingPlugin, migrationPlugin, cloudflare({...inferredBinding
voidPlugin() → [routingPlugin, prerenderPlugin, nodeTargetPlugin()]
```
-Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/env`, `void/ws`) at compile time. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime.
+Non-CF targets disable CF-only bindings (`void/db`, `void/kv`, `void/auth`, `void/storage`, `void/ai`, `void/sandbox`, `void/env`; `CF_ONLY_IMPORTS`) at compile time. `void/ws` stays importable for client code. The `nodeTargetPlugin` provides a binding guard (compile-time errors for CF imports), a Vite dev server middleware (SSR-loads the Hono app), and a build entry generator for the target runtime. Its dev server, Vitest and build inline `void`, `@void/*` and installed packages that depend on either (`ssr.noExternal`), as a workspace link does. Vite externalizes a bare import that resolves into node_modules before any plugin sees it: an installed `void/kv` would skip the guard, and a build would load a second void beside the copy the generated routes bundle, so a `ValidationError` from `void/handler` fails their `instanceof` check. The build keeps a dynamic `cloudflare:` import external, as Void's runtime loads one only in workerd, and fails on a static one, which Node cannot load. `findVoidDependentPackages` reads the app's dependencies and recurses only into packages that depend on void, as vite-plugin-svelte does for Svelte libraries. A Yarn Plug'n'Play install has no node_modules, so there it asks the PnP runtime that `yarn` loads (`findPnpApi` on `node:module`) for each package directory. Vite's module runner cannot evaluate CommonJS, so it keeps a package only when the file an import loads (`exports` ".", where Vite also matches `development` or, when NODE_ENV is production, `production`; then `module`, then `main`) is an ES module (`.mjs`, or `.js` in a `type: "module"` scope) that imports no relative CommonJS file, unlike the wrapper in Node's dual-package guide. A build bundles a bare import that does not resolve from the app root, so with pnpm an inlined package's own dependencies are bundled too; the Node target docs ask users to list one that loads a native addon or reads files next to its code in the app's dependencies, which leaves it to Node (`ssr.external` alone fails at runtime, as Node cannot find the package from `dist/ssr`).
Production non-CF Pages pass a filesystem-backed `ASSETS` provider to the generated routes so manifest loading, hydration tags, public assets, and prerendering share the production path. `nodeTargetPlugin.renderChunk` resolves the client directory relative to the actual emitted chunk (including shared chunks under `ssr/assets/`), independently of `process.cwd()`. Keep `dist/ssr` and `dist/client` together.
@@ -96,13 +96,13 @@ generated entries must preserve the module-boundary rules below.
- **One-time deployment targets**: Scope saved project IDs and implicit OIDC slugs to their recorded platform origin. A different platform needs `--project` or `VOID_PROJECT`. Managed deployment may establish a first project link, but must never overwrite an existing link, another selected platform URL, or a Cloudflare/Skip deployment preference. Apply this to both prompted and explicit creation/selection, including failed deployments. Re-read the link after reauthentication so a first project created earlier in the attempt is reused.
-- **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.
+- **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.
+- **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.
- **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.
- **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers.
-- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them.
+- **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. void/db never imports its pg or mysql2 driver itself: drizzle builds the pool from `connection`, and the worker prebundle serves drizzle's bare driver import. Bundles that never run Void's Vite plugins, Nitro (through `void/nitro`) and the deploy cron/queue handler build, get the same module from `voidDbBundlerPlugin`, which resolves `@schema` through the bundler so a schema folder works. Their Worker builds import the driver that `bundleWorkerSqlDriver` builds like the migration handler: Nitro's CommonJS handling and Node stubs break pg and mysql2 in workerd. Each `pg`/`mysql2` import gets its own build of the copy the bundler resolves for it, looked up as a `require` so Rollup's CommonJS plugin neither wraps nor loads the raw driver, so Void's drizzle-orm and an app's own driver import keep their own versions. An import of `mysql2/promise` gets that copy's promise API from the same build, so it shares the driver's modules as in Node. A pg older than 8.11 has no `pg-cloudflare` socket and a mysql2 older than 3.13 has no `disableEval` option, so the lookup fails the build with an error that names the driver version and the importer. In an npm, Yarn or Bun install, Void's hoisted drizzle-orm loads the hoisted driver, which is the app's own copy when the app lists one. For pg and MySQL, `void/nitro` also runs each request in `withRuntimeEnv` (as the Class A wrapper does) and keeps Void's `node:async_hooks` native in workerd builds, because Nitro's unenv AsyncLocalStorage drops the store after the first await. Nitro turns on `cloudflare.nodeCompat` only from a `wrangler.*` file, so `void/nitro` sets it for pg/MySQL Worker builds (every deploy adds `nodejs_compat` for them) and for D1 only when the flags a deploy gives the Worker list `nodejs_compat` (the Cloudflare config's, which a managed deploy replaces with `worker.compatibility_flags` when set): a Worker built that way does not start without the flag. Nuxt and Analog also build server code with Vite (Analog's page server loads, Nuxt's app renderer), and Nitro bundles that output: Void's Vite plugin leaves a pg/MySQL `void/db` import there external, so Nitro serves one module and one driver copy, or the package's D1 client and its `void/nitro` hint when the module is missing. The deploy handler build likewise runs each cron event and queue batch in `withRuntimeEnv`.
- **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings.
- **Direct deploy package boundary**: target-neutral intents, artifacts, resources, migrations, and Durable Object plans live in public `@void/deploy-core`; root Wrangler config parsing and verified Cloudflare upload primitives live in public `@void/deploy-cloudflare`. The CLI consumes both packages directly. Hosted platform packages consume `@void/deploy-core` and must not be imported by public SDK packages.
- **Direct activation invariant**: native Cloudflare deploy uploads a Worker Version without traffic, records the validated migration filenames in its annotation, probes that version directly, activates it at 100%, and only then synchronizes triggers. A trigger-sync failure restores the prior active version. Database migrations are forward-only; rollback warns before activating code whose recorded migration set is older or unknown.
@@ -111,7 +111,7 @@ generated entries must preserve the module-boundary rules below.
- **Direct Sandbox entitlement invariant**: `void/sandbox` is opt-in and is the only public Void feature that currently requires Workers Paid on the native Cloudflare path. When source inference or `void.config.ts` enables it, deploy must verify Containers with a read-only account probe after authentication and before provisioning/building, then explain both the Workers Paid signup and Containers/Cloudchamber token permissions on failure. A non-Sandbox app must emit no Container and perform no Containers probe.
- **Local D1 identity**: `local-d1.ts` selects the configured D1 binding from root JSON/JSONC config, preferring `preview_database_id` over `database_id` for local development. Development, schema checks, auth migrations, and every local database command use this same identity and persistence directory. `voidPlugin({ persistTo })` records its canonical path in `.void/local-d1.json`; CLI commands reread binding IDs and reject stale Vite-config state until `void dev` refreshes it.
- **Migration DB path**: Miniflare stores D1 data at a deterministic path computed from the `database_id` using HMAC-SHA256. The `getMiniflareDatabasePath()` function in `migrations/runner.ts` replicates this algorithm so migrations target `/v3/d1/miniflare-D1DatabaseObject/.sqlite` (for example `.void/v3/...` in default/class-A mode and `.wrangler/state/v3/...` in class-B/C framework mode).
-- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void.
+- **Drizzle-first database**: Schema is defined in `db/schema.ts` (Drizzle table definitions). `void/db` exports a dialect-aware Drizzle instance for D1, PostgreSQL, or MySQL. `@schema` is an auto-configured alias for `db/schema.ts`. Migrations live in `db/migrations/`. CLI commands: `void db push` (prototype), `void db generate` (create migration files), `void db migrate` (apply locally). When auth is enabled, `void db generate` adds `.void/better-auth-schema.ts` to Drizzle Kit's schema inputs. Direct Cloudflare deploy applies PostgreSQL or MySQL migrations from the shell-provided production `DATABASE_URL`; PostgreSQL is transactional, while MySQL DDL is not. `drizzle-orm` and `drizzle-kit` ship with void. For PostgreSQL and MySQL, `void/db` is one drizzle instance whose client looks up the current request's pool on every call. workerd ties a socket to the request that opened it, so a query built or prepared at module scope must run on the pool of the request that executes it, and building one must not create a pool (global scope has no request to own it). mysql2 starts its idle timer only when it builds a pool with `maxIdle` below `connectionLimit`, and that timer runs until the pool ends, so the MySQL pools set neither: each connection closes from its pool `release` event after 1 second idle (60 seconds for `createDb`). Inside a request, reading `db` builds that request's pool first, so a missing connection string throws its own error rather than drizzle's "Failed query". The client answers drizzle's pool checks (`instanceof Pool`, `'getConnection' in`) through `getPrototypeOf` and `has`, cached per isolate, so a transaction still holds one connection. Astro's adapter entry runs inside `withRuntimeEnv` for these dialects, like the Class A wrapper.
- **Database provisioning adapters**: `void db connect ` saves an external database URL to `.env` and syncs a linked Void project; `--local-only` skips the sync. `void db connect --provider neon` provisions through the built-in Neon adapter using `NEON_API_KEY`. Third-party packages or local modules can export a `DatabaseProviderAdapter` built with `void/database-provider`; runtime database code remains provider-neutral.
- **Environment model**: `env.ts` is the checked-in schema; root `.env` is the only dotenv file and is local-only. Production server keys use the remote secret store, while fixed-prefix `VITE_*` keys are public build-time values. The server runtime reads the same embedded public values for boot validation and SSR. Client folding must observe the final shared `config.env`; Vite runs `configResolved` hooks concurrently. Never add `.env.*` precedence, `.secret()`/`.public()` modifiers, or plaintext Worker vars for schema-declared server keys.
- **Reserved `__VOID_*` names**: `envSchemaPlugin.config` (every mode, with or without `env.ts`) and `void env check` reject `__VOID_*` names in the root `.env` or declared in `env.ts`. Do not try to strip them from dev `vars` instead: Wrangler reads `.env` itself and writes it over `vars`, so a strip cannot be enforced. Manual trigger auth is a compile-time literal (`__VOID_SERVE` from `CompileOptions.dev`, false in builds and `vite preview`): dev accepts only `x-void-dev-trigger`, builds use the proxy token or build key. Never key it on `import.meta.env.DEV`, which is true in a `NODE_ENV=development` build.
@@ -120,6 +120,8 @@ generated entries must preserve the module-boundary rules below.
- **Journal coherence invariant**: `db/migrations/*.sql` and `db/migrations/meta/_journal.json` must stay in sync — every `.sql` file has a matching journal entry (by `tag`) and vice versa. `assertJournalCoherence` in `src/migrations/validate.ts` is the gate, called from `deploy`, `db status`, `db migrate`, and `db reset`. The two CLI commands that mutate migration state — `void gen migration` and `void db rename-migrations` — are responsible for keeping the journal coherent themselves: when the journal has entries, the first delegates to `drizzle-kit generate --custom --name `, which writes the empty `.sql`, appends the journal entry, and writes a snapshot of the current schema (it refuses if the last entry's snapshot is missing and byte-restores the journal if drizzle-kit fails); with no journal entries it writes a plain timestamped `.sql`. The second rewrites `tag` values alongside the file renames and also renames the matching `meta/_snapshot.json` (with byte-level rollback on failure). Drift will fail loudly before touching any remote DB. This prevents an orphan scaffolded `.sql` file from half-applying against D1 and blocking later deploys.
- **D1 migration recovery**: Publish the lock and attempt record atomically. Stable attempts register in `_void_migration_fences`; every migration and legacy tracking-table upgrade checks ownership inside its D1 batch. Recovery revokes ownership before reading confirmed migrations, so a resumed executor cannot write after a replacement starts. Duplicate submissions only report progress. Never steal a missing-record lock or interrupt an older/raw-exec attempt without fencing: its owner may still be running. Such interrupted legacy attempts require operator verification that the executor has stopped before manually recovering the database lock.
+- **MySQL migration lock**: The CLI runner holds `GET_LOCK` for its session. The hosted handler cannot: Hyperdrive pools connections per transaction, so a session lock would stay on, or be reset off, a pooled connection. It holds a lease row in `_void_migration_lock` instead, written one autocommit statement at a time on its own connection and renewed before each migration statement. Each kind takes its own lock, then checks for the other (`IS_USED_LOCK`, the live lease row), so runs on one database never overlap, across projects too. A run releases its lease by expiring the row, never by deleting it: InnoDB answers a delete between two waiting `INSERT IGNORE`s of the same key with a deadlock. Keep the lease longer than Hyperdrive's 60-second statement limit; a run that stops early holds the database until its lease expires. Better Auth's MySQL migrations change the schema too, so each runner keeps its lock through them: the hosted handler renews the lease on a 30-second timer while they run (their statements cannot renew it), and `vp dev` holds the lock of the database each step changes: migration files go to `.env`'s `DATABASE_URL`, and Better Auth to the shell's `DATABASE_URL` before `.env`'s, a URL it resolves once and passes to `runLocalVoidAuthMigrations`. When both name one database, Better Auth runs as `runMySqlMigrations`'s `afterApply` under the same lock. `void db reset`, and the reset that `void db seed` does first, hold `GET_LOCK` from the first `DROP` until the files are applied again, and keep `_void_migration_lock`; seed data loads after the lock, like app writes.
+- **MySQL statements**: Cloudflare documents multi-statement queries as unsupported by Hyperdrive (not tried on a live Hyperdrive), so both MySQL runners send one statement per query (`splitMySqlMigrationSql`) and the hosted connection does not set `multipleStatements`. Each breakpoint part splits again at semicolons outside strings, quoted names and comments; a statement that opens a stored program (`CREATE` or `ALTER` of a `TRIGGER`, `PROCEDURE`, `FUNCTION` or `EVENT`) keeps the rest of its part with it, since its body's semicolons look like statement ends. Without `multipleStatements` the server rejects SQL after the program as a syntax error before it runs any of the query, so the hosted runner adds a note that asks for a `--> statement-breakpoint` after the program. Words such as `event` or a `BEGIN;` transaction do not count, so hand-written files that use them still split. Vitess (PlanetScale) also rejects a comment after a statement's semicolon unless the client sets `multipleStatements`, so the splitter drops comments after a part's last statement. The CLI connections keep `multipleStatements`: seed SQL needs it, and a whole part with several statements keeps running there as before.
### Self-Host AI + ISR
@@ -185,7 +187,7 @@ Agent instructions use versioned markers (`` / ` {
const authConfig = await loadVoidAuthConfig(configPath);
const runtime = {
@@ -51,9 +53,10 @@ export async function runLocalVoidAuthMigrations(
}
const connectionString =
- typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0
+ databaseUrl ??
+ (typeof env.DATABASE_URL === 'string' && env.DATABASE_URL.length > 0
? env.DATABASE_URL
- : undefined;
+ : undefined);
if (!connectionString) {
return;
}
diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts
index 487884c71..426567948 100644
--- a/packages/void/src/cli/db.ts
+++ b/packages/void/src/cli/db.ts
@@ -596,25 +596,32 @@ async function resetDatabase(
const { hostname, host, pathname } = new URL(getLocalDatabaseUrl(root, dialect));
const target = `database "${decodeURIComponent(pathname.slice(1))}" on ${host}`;
await confirmDropAllTables(hostname, target, yes);
- await withMySqlClient(root, async (client) => {
- // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`.
- const tables = await client.query(
- "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'",
- );
- await client.query('SET FOREIGN_KEY_CHECKS = 0');
- try {
- for (const row of tables) {
- const table = String(row.table_name).replaceAll('`', '``');
- await client.query(`DROP TABLE IF EXISTS \`${table}\``);
+ const { applyPendingMySqlMigrations, withMySqlMigrationLock } =
+ await import('../migrations/runner-mysql.ts');
+ // The migration lock keeps deploys and other migration runs out from the first drop until
+ // the files are applied again.
+ await withMySqlClient(root, (client) =>
+ withMySqlMigrationLock(client, async () => {
+ // MySQL 8 returns an unaliased information_schema column as `TABLE_NAME`.
+ const tables = await client.query(
+ "SELECT table_name AS table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'",
+ );
+ await client.query('SET FOREIGN_KEY_CHECKS = 0');
+ try {
+ for (const row of tables) {
+ // Deploys of any project on this database keep their lease rows in this table.
+ if (row.table_name === '_void_migration_lock') {
+ continue;
+ }
+ const table = String(row.table_name).replaceAll('`', '``');
+ await client.query(`DROP TABLE IF EXISTS \`${table}\``);
+ }
+ } finally {
+ await client.query('SET FOREIGN_KEY_CHECKS = 1');
}
- } finally {
- await client.query('SET FOREIGN_KEY_CHECKS = 1');
- }
- });
- if (migrations.length > 0) {
- const { applyMySqlMigrations } = await import('../migrations/runner-mysql.ts');
- await withMySqlClient(root, (client) => applyMySqlMigrations(client, migrations));
- }
+ await applyPendingMySqlMigrations(client, migrations);
+ }),
+ );
return migrations.length;
}
diff --git a/packages/void/src/cli/deploy-cloudflare-migrate.ts b/packages/void/src/cli/deploy-cloudflare-migrate.ts
index e47de676e..e61ec1cc8 100644
--- a/packages/void/src/cli/deploy-cloudflare-migrate.ts
+++ b/packages/void/src/cli/deploy-cloudflare-migrate.ts
@@ -119,7 +119,9 @@ export async function migrateCloudflareWorker(input: {
let appliedMigrations = 0;
// Cloudflare never returns the Hyperdrive password, so the deploy environment provides the
- // production URL. The runners record applied names, so retries are idempotent.
+ // production URL. A rerun skips migrations already recorded as finished. A failed PostgreSQL
+ // file rolls back. A failed MySQL file rolls back its data changes, but MySQL commits DDL on
+ // its own, so earlier statements of that file can stay applied.
if (dialect !== 'sqlite' && migrations.length > 0) {
const mysql = dialect === 'mysql';
const label = mysql ? 'MySQL' : 'PostgreSQL';
diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts
index 0ebcf10ac..d6bd48da1 100644
--- a/packages/void/src/cli/deploy-cloudflare-static.ts
+++ b/packages/void/src/cli/deploy-cloudflare-static.ts
@@ -133,7 +133,11 @@ export async function prepareCloudflareFrameworkArtifact(options: {
voidConfig.worker?.compatibility_date ??
readWranglerTopLevelString(rootConfig, 'compatibility_date') ??
DEFAULT_COMPATIBILITY_DATE,
- compatibilityFlags: preset.cloudflareCompatibilityFlags,
+ // The PostgreSQL and MySQL drivers need workerd's Node modules, as in a managed deploy.
+ compatibilityFlags:
+ analysis.dialect === 'sqlite'
+ ? preset.cloudflareCompatibilityFlags
+ : [...(preset.cloudflareCompatibilityFlags ?? []), 'nodejs_compat'],
crons: [...new Set(crons.flatMap((job) => job.crons))],
requiredSecrets: requiredSecretNames,
versionMetadataBinding,
diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts
index 56c638545..51b767690 100644
--- a/packages/void/src/cli/init.ts
+++ b/packages/void/src/cli/init.ts
@@ -2373,9 +2373,18 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void {
if (framework.name === 'nuxt') {
note(
`Detected Nuxt. Void init does not patch nuxt.config.* automatically.\n\n` +
- `Add \`voidPlugin()\` to the Nuxt Vite plugins option:\n\n` +
+ `Add \`voidPlugin()\` to the Nuxt Vite plugins option, the \`@schema\` alias that \`nuxi typecheck\` reads, and the Cloudflare preset, \`void/nitro\` and the generated Cloudflare config to the Nitro options:\n\n` +
+ `import { resolve } from "node:path";\n` +
`import { voidPlugin } from "void";\n\n` +
`export default defineNuxtConfig({\n` +
+ ` alias: {\n` +
+ ` "@schema": resolve(import.meta.dirname, "db/schema.ts"),\n` +
+ ` },\n` +
+ ` nitro: {\n` +
+ ` preset: "cloudflare-module",\n` +
+ ` modules: ["void/nitro"],\n` +
+ ` cloudflare: { dev: { configPath: "./.void-wrangler.jsonc" } },\n` +
+ ` },\n` +
` vite: { plugins: [voidPlugin()] },\n` +
`});`,
'Manual Nuxt config step',
@@ -2383,6 +2392,30 @@ function noteManualFrameworkConfigSetup(framework: DetectedFramework): void {
return;
}
+ if (framework.name === 'analog') {
+ note(
+ `Detected Analog. Void init does not patch vite.config.* automatically.\n\n` +
+ `Install \`nitro-cloudflare-dev\` as a dev dependency. Add \`voidPlugin()\` to the Vite plugins, and the Cloudflare preset, \`void/nitro\` and the generated Cloudflare config to Analog's Nitro options:\n\n` +
+ `import analog from "@analogjs/platform";\n` +
+ `import { voidPlugin } from "void";\n\n` +
+ `export default defineConfig({\n` +
+ ` plugins: [\n` +
+ ` analog({\n` +
+ ` ssr: true,\n` +
+ ` nitro: {\n` +
+ ` preset: "cloudflare-module",\n` +
+ ` modules: ["nitro-cloudflare-dev", "void/nitro"],\n` +
+ ` cloudflareDev: { configPath: "./.void-wrangler.jsonc" },\n` +
+ ` },\n` +
+ ` }),\n` +
+ ` voidPlugin(),\n` +
+ ` ],\n` +
+ `});`,
+ 'Manual Analog config step',
+ );
+ return;
+ }
+
noteManualViteConfigSetup();
}
diff --git a/packages/void/src/cli/pg-bundle-transform.ts b/packages/void/src/cli/worker-require-transform.ts
similarity index 82%
rename from packages/void/src/cli/pg-bundle-transform.ts
rename to packages/void/src/cli/worker-require-transform.ts
index 1237ef117..ae13e8e16 100644
--- a/packages/void/src/cli/pg-bundle-transform.ts
+++ b/packages/void/src/cli/worker-require-transform.ts
@@ -29,11 +29,17 @@ function nodes(root: AstNode): Array {
return found;
}
-/** Rewrite the two CommonJS constructs emitted by the PG migration bundle. */
-export function transformPgWorkerBundle(
+/**
+ * Replace the `createRequire(import.meta.url)` binding that the bundler emits for CommonJS
+ * database drivers with `requireShim`, which serves Node built-ins from static imports.
+ * workerd gives modules no `import.meta.url`, so the original binding fails at startup.
+ * PostgreSQL bundles also replace `pgpass`, which reads the filesystem.
+ */
+export function transformWorkerRequireBundle(
code: string,
filename: string,
requireShim: string,
+ database: 'PostgreSQL' | 'MySQL',
): string {
if (!code.includes('createRequire') && !code.includes('pgpass')) {
return code;
@@ -53,7 +59,9 @@ export function transformPgWorkerBundle(
!isIdentifier(specifiers[0]?.imported as AstNode | undefined) ||
(specifiers[0]!.imported as { name: string }).name !== 'createRequire'
) {
- throw new Error(`deploy: Unsupported node:module import in PostgreSQL bundle ${filename}.`);
+ throw new Error(
+ `deploy: Unsupported node:module import in ${database} bundle ${filename}.`,
+ );
}
edits.push({ start: statement.start!, end: statement.end!, replacement: '' });
}
@@ -67,7 +75,7 @@ export function transformPgWorkerBundle(
)
) {
if (declarations.length !== 1 || hasRequireBinding) {
- throw new Error(`deploy: Unsupported require binding in PostgreSQL bundle ${filename}.`);
+ throw new Error(`deploy: Unsupported require binding in ${database} bundle ${filename}.`);
}
hasRequireBinding = true;
edits.push({ start: statement.start!, end: statement.end!, replacement: requireShim });
@@ -91,12 +99,12 @@ export function transformPgWorkerBundle(
}
if (code.includes('createRequire') && !hasRequireBinding) {
throw new Error(
- `deploy: Could not locate the generated require binding in PostgreSQL bundle ${filename}.`,
+ `deploy: Could not locate the generated require binding in ${database} bundle ${filename}.`,
);
}
if (code.includes('pgpass') && pgpassCalls === 0 && code.includes('__require')) {
throw new Error(
- `deploy: Could not locate the pgpass require in PostgreSQL bundle ${filename}.`,
+ `deploy: Could not locate the pgpass require in ${database} bundle ${filename}.`,
);
}
const ms = new MagicString(code);
@@ -105,7 +113,9 @@ export function transformPgWorkerBundle(
}
const transformed = ms.toString();
if (transformed.includes('createRequire') || transformed.includes('__require("pgpass")')) {
- throw new Error(`deploy: PostgreSQL bundle ${filename} still contains an unsupported require.`);
+ throw new Error(
+ `deploy: ${database} bundle ${filename} still contains an unsupported require.`,
+ );
}
return pgpassCalls > 0
? `const __voidPgpassNoop = Object.assign(function pgpass(_connectionInfo, callback) { callback(undefined); }, { warnTo() {} });\n${transformed}`
diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts
index 87c9cce71..cb22a7d31 100644
--- a/packages/void/src/cli/wrapper.ts
+++ b/packages/void/src/cli/wrapper.ts
@@ -1,3 +1,4 @@
+import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
@@ -10,12 +11,14 @@ import {
} from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
-import { dirname, join, extname } from 'pathslash';
+import { dirname, join, extname, toSlash } from 'pathslash';
import { build } from 'vite';
+import { getDatabaseDialect, readConfig, type DatabaseDialect } from '../config.ts';
import type { MigrationPlanBundle } from '../migrations/plan.ts';
+import { voidDbBundlerPlugin } from '../plugin-drizzle.ts';
import { readProjectPaths } from '../project-paths.ts';
import { generateQueueResolverSource } from '../queues/dispatch.ts';
-import { transformPgWorkerBundle } from './pg-bundle-transform.ts';
+import { transformWorkerRequireBundle } from './worker-require-transform.ts';
export type WrapperFeatures = {
/** Cron job definitions (from scanJobsSync). */
@@ -45,6 +48,8 @@ export type WrapperResult = {
};
const BUNDLED_ENTRY_RE = /^index\.(?:m?js|cjs)$/;
+// The env module that the app's `void/db` imports, so the jobs' request scope is the one it reads.
+const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env'));
const WRAPPER_MARKER = '// Generated by Void: deployment wrapper.';
function isGeneratedWrapper(source: string, originalName: string): boolean {
@@ -113,7 +118,8 @@ export async function generateWrapper(
if (features.crons.length > 0 || features.queues.length > 0) {
const tmpDir = join(root, '.void', 'handlers');
mkdirSync(tmpDir, { recursive: true });
- const handlersSource = generateHandlersSource(root, features);
+ const config = readConfig(root);
+ const handlersSource = generateHandlersSource(root, features, getDatabaseDialect(config));
const handlersEntry = join(tmpDir, 'handlers.ts');
writeFileSync(handlersEntry, handlersSource);
@@ -121,6 +127,15 @@ export async function generateWrapper(
await build({
root: tmpDir,
logLevel: 'silent',
+ // This focused build does not run voidPlugin(), so give it the app's `void/db` and
+ // `@schema`. A PostgreSQL or MySQL driver comes prebuilt for workerd.
+ plugins: [
+ voidDbBundlerPlugin(root, {
+ config,
+ sqlDriver: (dialect, entry) =>
+ bundleWorkerSqlDriver(dialect, entry, join(tmpDir, 'sql-driver')),
+ }),
+ ],
resolve: {
// Cron/queue scaffolds intentionally import their lightweight runtime helpers from the
// public `void` entry. This focused build does not run voidPlugin(), so reproduce its
@@ -263,24 +278,15 @@ async function bundleMigrationHandler(
root: tmpDir,
logLevel: 'silent',
resolve: isPostgresql
- ? {
- alias: {
- 'pg-cloudflare': resolvePgCloudflareWorkerdEntry(handlerPath),
- 'pg-pool': resolvePgCjsDependency(handlerPath, 'pg-pool'),
- },
- conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'],
- }
+ ? { alias: pgWorkerAliases(handlerPath), conditions: WORKER_CONDITIONS }
: undefined,
- ssr: isPostgresql
- ? {
- noExternal: ['pg', 'pg-cloudflare'],
- resolve: {
- conditions: ['workerd', 'worker', 'browser', 'module', 'import', 'default'],
- },
- }
- : isMysql
- ? { noExternal: ['mysql2'] }
- : undefined,
+ // A build decides what is external by resolving from `root`, which sits in the app. npm,
+ // Yarn and Bun hoist dependencies there, and workerd cannot load a bare import, so bundle
+ // every package. Node built-ins stay external for the require shims below.
+ ssr: {
+ noExternal: true,
+ ...(isPostgresql && { resolve: { conditions: WORKER_CONDITIONS } }),
+ },
build: {
outDir,
emptyOutDir: true,
@@ -289,7 +295,7 @@ async function bundleMigrationHandler(
target: 'esnext',
rollupOptions: {
input: handlerPath,
- external: [/^cloudflare:/, /^node:/, /^pgpass$/],
+ external: SQL_WORKER_EXTERNAL,
output: {
entryFileNames: '__void_migration_handler.mjs',
chunkFileNames: '__void_migration_handler-[hash].mjs',
@@ -298,22 +304,120 @@ async function bundleMigrationHandler(
},
});
- if (isPostgresql) {
- patchPgWorkerBundle(outDir);
+ if (isPostgresql || isMysql) {
+ patchWorkerRequireBundle(outDir, isPostgresql ? 'PostgreSQL' : 'MySQL', 'migration bundle');
}
for (const file of readdirSync(outDir)) {
copyFileSync(join(outDir, file), join(workerDir, file));
}
}
-function patchPgWorkerBundle(outDir: string): void {
+// workerd's conditions, then a browser build's, so packages pick their Worker builds.
+const WORKER_CONDITIONS = ['workerd', 'worker', 'browser', 'module', 'import', 'default'];
+const SQL_WORKER_EXTERNAL = [/^cloudflare:/, /^node:/, /^pgpass$/];
+
+/** pg's workerd socket and its CommonJS pool, resolved from the pg that `fromPath` loads. */
+function pgWorkerAliases(fromPath: string): Record {
+ return {
+ 'pg-cloudflare': resolvePgCloudflareWorkerdEntry(fromPath),
+ 'pg-pool': resolvePgCjsDependency(fromPath, 'pg-pool'),
+ };
+}
+
+/**
+ * Build the PostgreSQL or MySQL driver whose entry file is `main` as an ES module for workerd,
+ * the way the migration handler bundles Void's own, and return its path. Bundlers that do not run
+ * voidPlugin() import this module for `pg` or `mysql2`: their CommonJS handling breaks the
+ * drivers in workerd. Each driver copy gets its own folder in `baseDir`. The same build gives
+ * mysql2's `mysql2/promise` API as `promise.mjs` next to it, so both share one copy of the driver.
+ */
+export async function bundleWorkerSqlDriver(
+ dialect: 'postgresql' | 'mysql',
+ main: string,
+ baseDir: string,
+): Promise {
+ const packageName = dialect === 'postgresql' ? 'pg' : 'mysql2';
+ const outDir = join(baseDir, createHash('sha256').update(main).digest('hex').slice(0, 12));
+ mkdirSync(outDir, { recursive: true });
+ const input: Record = {
+ [packageName]: writeDriverEntry(join(outDir, 'entry.mjs'), main),
+ };
+ const promise = dialect === 'mysql' ? resolveMysqlPromise(main) : undefined;
+ if (promise !== undefined) {
+ input.promise = writeDriverEntry(join(outDir, 'promise-entry.mjs'), promise);
+ }
+ const bundleDir = join(outDir, 'out');
+ await build({
+ root: outDir,
+ logLevel: 'silent',
+ resolve: {
+ alias: dialect === 'postgresql' ? pgWorkerAliases(main) : {},
+ conditions: WORKER_CONDITIONS,
+ },
+ ssr: { noExternal: true, resolve: { conditions: WORKER_CONDITIONS } },
+ build: {
+ outDir: bundleDir,
+ emptyOutDir: true,
+ minify: false,
+ ssr: true,
+ target: 'esnext',
+ rollupOptions: {
+ input,
+ external: SQL_WORKER_EXTERNAL,
+ output: {
+ entryFileNames: '[name].mjs',
+ chunkFileNames: `${packageName}-[hash].mjs`,
+ },
+ },
+ },
+ });
+ patchWorkerRequireBundle(
+ bundleDir,
+ dialect === 'postgresql' ? 'PostgreSQL' : 'MySQL',
+ 'driver bundle',
+ );
+ return join(bundleDir, `${packageName}.mjs`);
+}
+
+/** Write an ES module at `entry` that exports the CommonJS module `file`, and return `entry`. */
+function writeDriverEntry(entry: string, file: string): string {
+ const names = Object.keys(createRequire(file)(file) as object).filter(
+ (name) => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name),
+ );
+ writeFileSync(
+ entry,
+ `import driver from ${JSON.stringify(toSlash(file))};\nexport default driver;\nexport const { ${names.join(', ')} } = driver;\n`,
+ );
+ return entry;
+}
+
+/** The `mysql2/promise` file of the mysql2 copy whose entry file is `main`, if it has one. */
+function resolveMysqlPromise(main: string): string | undefined {
+ try {
+ return createRequire(main).resolve('mysql2/promise');
+ } catch {
+ return undefined;
+ }
+}
+
+/** `bundle` names the bundle in the error a failed require throws. */
+function patchWorkerRequireBundle(
+ outDir: string,
+ database: 'PostgreSQL' | 'MySQL',
+ bundle: string,
+): void {
for (const file of readdirSync(outDir)) {
if (!file.endsWith('.mjs') && !file.endsWith('.js')) {
continue;
}
const path = join(outDir, file);
const code = readFileSync(path, 'utf-8');
- const transformed = transformPgWorkerBundle(code, file, pgWorkerRequireShim());
+ const transformed = transformWorkerRequireBundle(
+ code,
+ file,
+ database === 'PostgreSQL' ? pgWorkerRequireShim(bundle) : mysqlWorkerRequireShim(bundle),
+ database,
+ );
if (transformed !== code) {
writeFileSync(path, transformed);
}
@@ -334,12 +438,14 @@ function resolvePgCloudflareWorkerdEntry(fromPath: string): string {
return join(dirname(packageJsonPath), 'dist', 'index.js');
}
-function pgWorkerRequireShim(): string {
+// pg requires `fs` only to read sslcert/sslkey/sslrootcert files, which a Worker cannot read.
+// workerd has node:fs only from compatibility date 2025-09-15, and this module loads at Worker
+// start, so that require stays unsupported and throws only if it runs.
+function pgWorkerRequireShim(bundle: string): string {
return [
'import * as __voidNodeCrypto from "node:crypto";',
'import * as __voidNodeDns from "node:dns";',
'import * as __voidNodeEvents from "node:events";',
- 'import * as __voidNodeFs from "node:fs";',
'import * as __voidNodeNet from "node:net";',
'import * as __voidNodeTls from "node:tls";',
'import * as __voidNodeUtil from "node:util";',
@@ -348,7 +454,6 @@ function pgWorkerRequireShim(): string {
' crypto: __voidNodeCrypto,',
' dns: __voidNodeDns,',
' events: __voidNodeEvents,',
- ' fs: __voidNodeFs,',
' net: __voidNodeNet,',
' tls: __voidNodeTls,',
' util: __voidNodeUtil,',
@@ -358,7 +463,50 @@ function pgWorkerRequireShim(): string {
' if (id.startsWith("node:")) id = id.slice(5);',
' const mod = __voidNodeRequireModules[id];',
' if (mod) return mod;',
- ' throw new Error(`Unsupported require("${id}") in Void PG migration bundle`);',
+ ` throw new Error(\`Unsupported require("\${id}") in Void PG ${bundle}\`);`,
+ '};',
+ ].join('\n');
+}
+
+// The Node built-ins that mysql2 and its bundled dependencies require. Each one is the
+// module's default export, which matches CommonJS `module.exports`: safer-buffer calls
+// `hasOwnProperty` on `require("buffer")`, and an ESM namespace object has no prototype.
+// mysql2 wraps its optional `cardinal` require in try/catch, so it stays unsupported.
+function mysqlWorkerRequireShim(bundle: string): string {
+ return [
+ 'import __voidNodeBuffer from "node:buffer";',
+ 'import __voidNodeCrypto from "node:crypto";',
+ 'import __voidNodeDiagnosticsChannel from "node:diagnostics_channel";',
+ 'import __voidNodeEvents from "node:events";',
+ 'import __voidNodeNet from "node:net";',
+ 'import __voidNodeProcess from "node:process";',
+ 'import __voidNodeStream from "node:stream";',
+ 'import __voidNodeStringDecoder from "node:string_decoder";',
+ 'import __voidNodeTimers from "node:timers";',
+ 'import __voidNodeTls from "node:tls";',
+ 'import __voidNodeUrl from "node:url";',
+ 'import __voidNodeUtil from "node:util";',
+ 'import __voidNodeZlib from "node:zlib";',
+ 'var __voidNodeRequireModules = {',
+ ' buffer: __voidNodeBuffer,',
+ ' crypto: __voidNodeCrypto,',
+ ' diagnostics_channel: __voidNodeDiagnosticsChannel,',
+ ' events: __voidNodeEvents,',
+ ' net: __voidNodeNet,',
+ ' process: __voidNodeProcess,',
+ ' stream: __voidNodeStream,',
+ ' string_decoder: __voidNodeStringDecoder,',
+ ' timers: __voidNodeTimers,',
+ ' tls: __voidNodeTls,',
+ ' url: __voidNodeUrl,',
+ ' util: __voidNodeUtil,',
+ ' zlib: __voidNodeZlib,',
+ '};',
+ 'var __require = (id) => {',
+ ' if (id.startsWith("node:")) id = id.slice(5);',
+ ' const mod = __voidNodeRequireModules[id];',
+ ' if (mod) return mod;',
+ ` throw new Error(\`Unsupported require("\${id}") in Void MySQL ${bundle}\`);`,
'};',
].join('\n');
}
@@ -619,9 +767,21 @@ function generateWrapperSource(
return lines.join('\n');
}
-function generateHandlersSource(root: string, features: WrapperFeatures): string {
+function generateHandlersSource(
+ root: string,
+ features: WrapperFeatures,
+ dialect: DatabaseDialect,
+): string {
const lines: Array = [];
const paths = readProjectPaths(root);
+ // PostgreSQL and MySQL jobs run each event in Void's request scope, as a Void app's do, so
+ // void/db keeps one pool for the event instead of opening one for each query. D1 jobs keep
+ // their reads on the primary: in a request scope, void/db reads D1 through a replica-capable
+ // session.
+ const scoped = dialect !== 'sqlite';
+ if (scoped) {
+ lines.push(`import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};`);
+ }
// Scheduled handlers
if (features.crons.length > 0) {
@@ -631,7 +791,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string
}
lines.push('');
- lines.push('export async function handleScheduled(controller, env, ctx) {');
+ lines.push(
+ scoped
+ ? 'async function __runScheduled(controller, env, ctx) {'
+ : 'export async function handleScheduled(controller, env, ctx) {',
+ );
lines.push(' const tasks = [];');
for (let i = 0; i < features.crons.length; i++) {
lines.push(` if (${JSON.stringify(features.crons[i].crons)}.includes(controller.cron)) {`);
@@ -652,6 +816,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string
);
lines.push(' if (errors.length) throw new AggregateError(errors, "Scheduled jobs failed");');
lines.push('}');
+ if (scoped) {
+ lines.push('export function handleScheduled(controller, env, ctx) {');
+ lines.push(' return withRuntimeEnv(env, () => __runScheduled(controller, env, ctx));');
+ lines.push('}');
+ }
}
// Queue handlers
@@ -665,7 +834,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string
lines.push('');
lines.push(generateQueueResolverSource(features.queues.map((queue) => queue.name)));
lines.push('');
- lines.push('export async function handleQueue(batch, env, ctx) {');
+ lines.push(
+ scoped
+ ? 'async function __runQueue(batch, env, ctx) {'
+ : 'export async function handleQueue(batch, env, ctx) {',
+ );
lines.push(' const queueSuffix = batch.queue;');
lines.push(' const __queueIndex = __resolveQueueIndex(queueSuffix);');
lines.push(' if (__queueIndex === -1) {');
@@ -688,6 +861,11 @@ function generateHandlersSource(root: string, features: WrapperFeatures): string
}
lines.push(' }');
lines.push('}');
+ if (scoped) {
+ lines.push('export function handleQueue(batch, env, ctx) {');
+ lines.push(' return withRuntimeEnv(env, () => __runQueue(batch, env, ctx));');
+ lines.push('}');
+ }
lines.push(`
export async function handleQueueRelay(payload, env, ctx) {
if (!payload || typeof payload.queue !== "string" || !Array.isArray(payload.messages) ||
diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts
index 26384479c..f2d1b7d01 100644
--- a/packages/void/src/index.ts
+++ b/packages/void/src/index.ts
@@ -63,6 +63,7 @@ import { restartPlugin } from './plugin-restart.ts';
import { routingPlugin } from './plugin-routing.ts';
import { mergeWorkerBindings } from './plugin-worker-bindings.ts';
import {
+ createBareVoidRedirectPlugin,
createServerRuntimeOptimizerPlugin,
DEV_ASSET_ROUTING_EXCLUSIONS,
createCloudflareBuiltinsExternalPlugin,
@@ -108,18 +109,26 @@ export function voidPlugin(options?: {
? projectCloudflareConfigPath
: undefined;
let previewing = false;
+ // A preview reads the build's Wrangler deploy config, which the build moved aside. Put it back
+ // until the preview exits.
+ const restorePreviewPointer = () => {
+ if (!existsSync(join(root, 'void.config.ts'))) {
+ return;
+ }
+ if (restoreCloudflarePreviewPointer(root) && !scheduledPreviewPointerCleanups.has(root)) {
+ scheduledPreviewPointerCleanups.add(root);
+ process.once('exit', () => {
+ rmSync(join(root, '.wrangler', 'deploy', 'config.json'), { force: true });
+ });
+ }
+ };
const implicitDeployGuard: Plugin = {
name: 'void:remove-implicit-cloudflare-deploy-pointer',
enforce: 'pre',
config(_config, environment) {
previewing = environment.isPreview === true;
- if (previewing && existsSync(join(root, 'void.config.ts'))) {
- if (restoreCloudflarePreviewPointer(root) && !scheduledPreviewPointerCleanups.has(root)) {
- scheduledPreviewPointerCleanups.add(root);
- process.once('exit', () => {
- rmSync(join(root, '.wrangler', 'deploy', 'config.json'), { force: true });
- });
- }
+ if (previewing) {
+ restorePreviewPointer();
}
},
closeBundle() {
@@ -157,6 +166,11 @@ export function voidPlugin(options?: {
assertNoRootSourceConventionConflicts(paths);
const detected = application.framework;
const framework = detected?.class === 'a' ? (detected.name as Framework) : undefined;
+ if (detected?.name === 'astro' && process.argv[2] === 'preview') {
+ // `astro preview` loads this config but starts the adapter's preview server without its
+ // Vite plugins, after the adapter checks for the deploy config.
+ restorePreviewPointer();
+ }
const scanDirs = config.inference?.scanDirs ?? (detected ? FRAMEWORK_SCAN_DIRS : undefined);
const frameworkOwnsCloudflareRuntime =
@@ -208,8 +222,8 @@ export function voidPlugin(options?: {
// graph and therefore another envContext, so request bindings disappear for
// the prebundled copy. Every return path includes this plugin, including
// framework and Node targets that do not all use bindingRuntimePlugin.
- const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin(
- !isNodeTarget(config.target) && !frameworkOwnsCloudflareRuntime,
+ const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin(dialect, () =>
+ readWranglerCompat(root, config),
);
// Scan queues/websocket routes early so both framework-mode and default-mode
// branches can safely close over these bindings.
@@ -619,6 +633,7 @@ export function voidPlugin(options?: {
framework,
root,
devTriggerToken,
+ dialect,
config.routing?.notFound,
);
// Class A builds write no `assets` config, and `not_found_handling` alone would keep
@@ -649,6 +664,7 @@ export function voidPlugin(options?: {
framework,
getOriginalFrameworkEntry(framework, root),
true,
+ dialect,
resolved.build.assetsDir,
config.routing?.notFound,
);
@@ -726,6 +742,7 @@ export function voidPlugin(options?: {
return [
createCloudflareBuiltinsExternalPlugin(),
+ createBareVoidRedirectPlugin(),
createWorkerEnvironmentSanitizerPlugin([
viteEnvironment.name,
...(viteEnvironment.childEnvironments ?? []),
diff --git a/packages/void/src/installed-packages.ts b/packages/void/src/installed-packages.ts
new file mode 100644
index 000000000..bf9f41f96
--- /dev/null
+++ b/packages/void/src/installed-packages.ts
@@ -0,0 +1,273 @@
+import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
+import module from 'node:module';
+import { dirname, join, normalize } from 'pathslash';
+import { parseSync } from 'vite';
+import { staticModuleReferences } from './shared/module-references.ts';
+
+type PackageManifest = {
+ readonly type?: string;
+ readonly module?: unknown;
+ readonly main?: unknown;
+ readonly exports?: unknown;
+ readonly dependencies?: Record;
+ readonly devDependencies?: Record;
+ readonly optionalDependencies?: Record;
+ readonly peerDependencies?: Record;
+};
+
+function readManifest(dir: string): PackageManifest | undefined {
+ try {
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as PackageManifest;
+ } catch {
+ return undefined;
+ }
+}
+
+type PnpApi = { resolveToUnqualified(request: string, issuer: string): string | null };
+
+/** The directory Node loads `name` from for code in `fromDir`. Ignores `exports`. */
+function findInstalledPackage(name: string, fromDir: string): string | undefined {
+ // A Yarn Plug'n'Play install has no node_modules. Under `yarn`, its runtime adds
+ // `findPnpApi` to `node:module` and serves the packages' files from its archives.
+ const pnp = (module as { findPnpApi?: (path: string) => PnpApi | null }).findPnpApi?.(fromDir);
+ if (pnp) {
+ try {
+ const manifest = pnp.resolveToUnqualified(`${name}/package.json`, `${fromDir}/`);
+ return manifest ? dirname(manifest) : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+ for (let dir = fromDir; ; dir = dirname(dir)) {
+ const candidate = join(dir, 'node_modules', name);
+ if (existsSync(join(candidate, 'package.json'))) {
+ return realpathSync(candidate);
+ }
+ if (dirname(dir) === dir) {
+ return undefined;
+ }
+ }
+}
+
+function isVoidPackage(name: string): boolean {
+ return name === 'void' || name.startsWith('@void/');
+}
+
+/** The `exports` conditions that an import matches in Vite's SSR graph. */
+const IMPORT_CONDITIONS = new Set(['node', 'import', 'module', 'default']);
+
+/**
+ * Vite's SSR graph also matches `production` or `development`, as NODE_ENV says. Vite sets
+ * NODE_ENV before it runs the plugin hooks that call this.
+ */
+function matchesImport(condition: string): boolean {
+ const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
+ return IMPORT_CONDITIONS.has(condition) || condition === mode;
+}
+
+/** The target an import picks from an `exports` value: its first matching key, as in Node. */
+function importTarget(value: unknown): string | undefined {
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const target = importTarget(item);
+ if (target) {
+ return target;
+ }
+ }
+ return undefined;
+ }
+ if (!value || typeof value !== 'object') {
+ return undefined;
+ }
+ for (const [key, item] of Object.entries(value)) {
+ const target = matchesImport(key) ? importTarget(item) : undefined;
+ if (target) {
+ return target;
+ }
+ }
+ return undefined;
+}
+
+function isFile(path: string): boolean {
+ try {
+ return statSync(path).isFile();
+ } catch {
+ return false;
+ }
+}
+
+/** The file an import of the package loads: `exports` ".", then `module`, then `main`. */
+function findImportEntry(dir: string, manifest: PackageManifest): string | undefined {
+ const exports = manifest.exports;
+ const root =
+ exports &&
+ typeof exports === 'object' &&
+ Object.keys(exports).some((key) => key.startsWith('.'))
+ ? (exports as Record)['.']
+ : exports;
+ const entry =
+ importTarget(root) ??
+ (typeof manifest.module === 'string' ? manifest.module : undefined) ??
+ (typeof manifest.main === 'string' ? manifest.main : 'index.js');
+ // A `main` may leave out the extension or name a directory.
+ for (const candidate of [entry, `${entry}.js`, `${entry}/index.js`]) {
+ const file = join(dir, candidate);
+ if (isFile(file)) {
+ return file;
+ }
+ }
+ return undefined;
+}
+
+/** Whether Node loads a `.mjs` or `.js` file in the package as an ES module. */
+function isEsmFile(file: string, dir: string, manifest: PackageManifest): boolean {
+ if (file.endsWith('.mjs')) {
+ return true;
+ }
+ if (!file.endsWith('.js')) {
+ return false;
+ }
+ // The nearest package.json sets the type, and a package can nest one in a subdirectory.
+ for (let scope = dirname(file); scope.startsWith(`${dir}/`); scope = dirname(scope)) {
+ const nested = readManifest(scope);
+ if (nested) {
+ return nested.type === 'module';
+ }
+ }
+ return manifest.type === 'module';
+}
+
+/** A dynamic `import()` argument that is a single string literal naming a relative file. */
+const RELATIVE_LITERAL = /^(["'])(\.{1,2}\/[^"']*)\1$/;
+
+/** The relative files that a module's imports, re-exports and `import()` calls name. */
+function relativeImports(code: string, parsed: ReturnType): Array {
+ const specifiers = staticModuleReferences(parsed, code).map((reference) => reference.source);
+ for (const { moduleRequest } of parsed.module.dynamicImports) {
+ const literal = RELATIVE_LITERAL.exec(code.slice(moduleRequest.start, moduleRequest.end));
+ if (literal) {
+ specifiers.push(literal[2]!);
+ }
+ }
+ return specifiers.filter((specifier) => /^\.{1,2}\//.test(specifier));
+}
+
+/** The most files of one package that the check reads. A package with more stays with Node. */
+const MAX_PACKAGE_MODULES = 2000;
+
+/**
+ * Vite's SSR module runner evaluates an inlined package as ES modules. The package's import
+ * entry and every file it reaches through relative imports must be one. So the ES module
+ * wrapper in Node's dual-package guide, or any ES module of the package that imports a
+ * CommonJS file, keeps the package with Node.
+ */
+function shipsEsm(packageDir: string, manifest: PackageManifest): boolean {
+ const dir = normalize(packageDir);
+ const entry = findImportEntry(dir, manifest);
+ if (!entry) {
+ return false;
+ }
+ const files = [entry];
+ const seen = new Set(files);
+ for (const file of files) {
+ if (!isEsmFile(file, dir, manifest)) {
+ return false;
+ }
+ let code: string;
+ try {
+ code = readFileSync(file, 'utf8');
+ } catch {
+ return false;
+ }
+ // Vite's SSR transform parses with the same parser and fails on any error, so the module
+ // runner could not load a file that does not parse.
+ const parsed = parseSync(file, code, { lang: 'js', sourceType: 'module' });
+ if (parsed.errors.length > 0) {
+ return false;
+ }
+ for (const specifier of relativeImports(code, parsed)) {
+ const next = join(dirname(file), specifier);
+ if (/\.[cm]?js$/.test(next) && !seen.has(next)) {
+ if (seen.size >= MAX_PACKAGE_MODULES) {
+ return false;
+ }
+ seen.add(next);
+ files.push(next);
+ }
+ }
+ }
+ return true;
+}
+
+/**
+ * Installed ES module packages that list `void` or an `@void/` package as a dependency, among
+ * the app's own dependencies and, through them, the packages they depend on (the rule
+ * vite-plugin-svelte applies to Svelte libraries). Vite leaves a package in node_modules to Node,
+ * whose `void/*` imports then skip Void's plugins: the generated `void/db` and the Node target's
+ * guard. Listing these packages in `ssr.noExternal` keeps them in Vite's graph.
+ */
+export function findVoidDependentPackages(root: string): Array {
+ const app = readManifest(root);
+ if (!app) {
+ return [];
+ }
+ const found = new Set();
+ const seen = new Set();
+ const visit = (names: Iterable, fromDir: string) => {
+ for (const name of names) {
+ if (isVoidPackage(name)) {
+ continue;
+ }
+ const dir = findInstalledPackage(name, fromDir);
+ if (!dir || seen.has(dir)) {
+ continue;
+ }
+ seen.add(dir);
+ const manifest = readManifest(dir);
+ const dependencies = {
+ ...manifest?.dependencies,
+ ...manifest?.optionalDependencies,
+ };
+ if (
+ !manifest ||
+ !Object.keys({ ...dependencies, ...manifest.peerDependencies }).some(isVoidPackage) ||
+ !shipsEsm(dir, manifest)
+ ) {
+ continue;
+ }
+ found.add(name);
+ visit(Object.keys(dependencies), dir);
+ }
+ };
+ visit(
+ Object.keys({ ...app.dependencies, ...app.devDependencies, ...app.optionalDependencies }),
+ root,
+ );
+ return [...found].sort();
+}
+
+/** `name` and every package it depends on, as installed for code in `fromDir`. */
+export function findDependencyClosure(name: string, fromDir: string): Array {
+ const found = new Set();
+ const seen = new Set();
+ const visit = (dependency: string, from: string) => {
+ const dir = findInstalledPackage(dependency, from);
+ if (!dir || seen.has(dir)) {
+ return;
+ }
+ seen.add(dir);
+ found.add(dependency);
+ const manifest = readManifest(dir);
+ for (const next of Object.keys({
+ ...manifest?.dependencies,
+ ...manifest?.optionalDependencies,
+ })) {
+ visit(next, dir);
+ }
+ };
+ visit(name, fromDir);
+ return [...found].sort();
+}
diff --git a/packages/void/src/migrations/runner-mysql.ts b/packages/void/src/migrations/runner-mysql.ts
index ca130b02d..de6a82356 100644
--- a/packages/void/src/migrations/runner-mysql.ts
+++ b/packages/void/src/migrations/runner-mysql.ts
@@ -1,5 +1,10 @@
import type { Connection } from 'mysql2/promise';
-import { splitMigrationSql } from '../runtime/migration-handler.ts';
+import {
+ mySqlMigrationLockError,
+ mySqlMigrationLockName,
+ readMySqlMigrationLease,
+} from '../runtime/migration-lock-mysql.ts';
+import { splitMySqlMigrationSql } from '../runtime/mysql-migration-sql.ts';
import { collectMigrations } from './collect.ts';
export type MySqlClient = {
@@ -47,6 +52,95 @@ export async function getMySqlAppliedMigrations(url: string): Promise {
+ const [row] = await client.query>('SELECT DATABASE() AS db');
+ const database = row?.db ?? '';
+ const name = mySqlMigrationLockName(database);
+ const [lock] = await client.query>>(
+ 'SELECT GET_LOCK(?, ?) AS acquired',
+ [name, MIGRATION_LOCK_TIMEOUT_SECONDS],
+ );
+ // Vitess names the column after the call and ignores the alias.
+ if (Number(Object.values(lock ?? {})[0]) !== 1) {
+ throw mySqlMigrationLockError(database, 'run the command again');
+ }
+
+ const readLease = () => readMySqlMigrationLease((sql, params) => client.query(sql, params));
+ const deadline = Date.now() + MIGRATION_LOCK_TIMEOUT_SECONDS * 1000;
+ let remaining = await readLease();
+ while (remaining !== undefined && Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ remaining = await readLease();
+ }
+ if (remaining !== undefined) {
+ await client.query('SELECT RELEASE_LOCK(?)', [name]).catch(() => {});
+ throw new Error(
+ `A deploy is applying migrations to MySQL database "${database}". Wait for it to finish, then run the command again. If that deploy stopped, its lock expires in ${remaining} seconds.`,
+ );
+ }
+ return name;
+}
+
+/**
+ * A DDL statement makes MySQL commit and close the open transaction. A savepoint can only be
+ * rolled back to inside a transaction, so this tells whether the file's changes can still be undone.
+ */
+async function isTransactionOpen(client: MySqlClient): Promise {
+ try {
+ await client.query('SAVEPOINT `void_migration`');
+ await client.query('ROLLBACK TO SAVEPOINT `void_migration`');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ER_LOCK_DEADLOCK and ER_TRANSACTION_ROLLBACK_DURING_COMMIT: MySQL has rolled back the whole
+// transaction itself, which also closes it, so the savepoint probe cannot tell it from a DDL commit.
+const ROLLED_BACK_BY_SERVER_ERRNOS = new Set([1213, 3101]);
+
+function isRolledBackByServer(error: unknown): boolean {
+ const errno =
+ typeof error === 'object' && error !== null ? (error as { errno?: unknown }).errno : undefined;
+ return typeof errno === 'number' && ROLLED_BACK_BY_SERVER_ERRNOS.has(errno);
+}
+
+function mySqlMigrationError(name: string, error: unknown, committed: boolean): Error {
+ const lines = [
+ `migration ${name} failed: ${error instanceof Error ? error.message : String(error)}`,
+ ];
+ if (committed) {
+ lines.push(
+ `MySQL commits schema changes immediately, so earlier statements in ${name} may already be applied. Undo them, fix the file, then run the command again.`,
+ 'See https://void.cloud/guide/database/mysql#failed-migrations',
+ );
+ }
+ return new Error(lines.join('\n'), { cause: error });
+}
+
+/**
+ * Runs `work` while this session holds the database's migration lock, so no other migration run,
+ * deploy or reset changes the schema meanwhile.
+ */
+export async function withMySqlMigrationLock(
+ client: MySqlClient,
+ work: () => Promise,
+): Promise {
+ const lock = await acquireMySqlMigrationLock(client);
+ try {
+ return await work();
+ } finally {
+ await client.query('SELECT RELEASE_LOCK(?)', [lock]).catch(() => {});
+ }
+}
+
export async function applyMySqlMigrations(
client: MySqlClient,
migrations: Array<{ name: string; sql: string }>,
@@ -54,6 +148,17 @@ export async function applyMySqlMigrations(
if (migrations.length === 0) {
return [];
}
+ return withMySqlMigrationLock(client, () => applyPendingMySqlMigrations(client, migrations));
+}
+
+/** Applies the files not yet recorded. The caller holds the migration lock. */
+export async function applyPendingMySqlMigrations(
+ client: MySqlClient,
+ migrations: Array<{ name: string; sql: string }>,
+): Promise> {
+ if (migrations.length === 0) {
+ return [];
+ }
await client.query(
'CREATE TABLE IF NOT EXISTS `_void_migrations` (`name` VARCHAR(255) PRIMARY KEY, `applied_at` VARCHAR(64) NOT NULL)',
@@ -65,33 +170,65 @@ export async function applyMySqlMigrations(
const pending = migrations.filter((migration) => !appliedSet.has(migration.name));
for (const migration of pending) {
- // MySQL does not read drizzle-kit `--> statement-breakpoint` lines as comments.
- for (const statement of splitMigrationSql(migration.sql)) {
- await client.query(statement);
+ let ran = 0;
+ // Data changes commit with the tracking row. MySQL commits DDL, and anything before it, on its own.
+ await client.query('BEGIN');
+ try {
+ // One statement per query, so `ran` tells whether earlier statements of the file ran.
+ for (const statement of splitMySqlMigrationSql(migration.sql)) {
+ await client.query(statement);
+ ran++;
+ }
+ await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [
+ migration.name,
+ new Date().toISOString(),
+ ]);
+ await client.query('COMMIT');
+ } catch (error) {
+ const committed =
+ ran > 0 && !isRolledBackByServer(error) && !(await isTransactionOpen(client));
+ // A failed ROLLBACK means the session is gone, and MySQL rolls back with it.
+ await client.query('ROLLBACK').catch(() => {});
+ throw mySqlMigrationError(migration.name, error, committed);
}
- await client.query('INSERT INTO `_void_migrations` (`name`, `applied_at`) VALUES (?, ?)', [
- migration.name,
- new Date().toISOString(),
- ]);
}
return pending.map((migration) => migration.name);
}
+/** Connects to `url` and runs `work` while holding that database's migration lock. */
+export async function withMySqlMigrationLockAt(
+ url: string,
+ work: (client: MySqlClient) => Promise,
+): Promise {
+ const { createConnection } = await import('mysql2/promise');
+ const connection = await createConnection({ uri: url, multipleStatements: true });
+ try {
+ const client = toMySqlClient(connection);
+ return await withMySqlMigrationLock(client, () => work(client));
+ } finally {
+ await connection.end();
+ }
+}
+
+/**
+ * Applies the pending files in `migrationsDir`. `afterApply` gets the applied names and runs before
+ * the lock is released, so the schema changes it makes (Better Auth's tables) do not overlap
+ * another run either.
+ */
export async function runMySqlMigrations(
migrationsDir: string,
url: string,
+ afterApply?: (applied: Array) => Promise,
): Promise> {
const migrations = collectMigrations(migrationsDir);
- if (migrations.length === 0) {
+ if (migrations.length === 0 && !afterApply) {
return [];
}
- const { createConnection } = await import('mysql2/promise');
- const connection = await createConnection({ uri: url, multipleStatements: true });
- try {
- return await applyMySqlMigrations(toMySqlClient(connection), migrations);
- } finally {
- await connection.end();
- }
+ return withMySqlMigrationLockAt(url, async (client) => {
+ const applied = await applyPendingMySqlMigrations(client, migrations);
+ await afterApply?.(applied);
+ return applied;
+ });
}
diff --git a/packages/void/src/migrations/runner-pg.ts b/packages/void/src/migrations/runner-pg.ts
index 62e8f15f3..75e116695 100644
--- a/packages/void/src/migrations/runner-pg.ts
+++ b/packages/void/src/migrations/runner-pg.ts
@@ -71,8 +71,12 @@ export async function applyPgMigrations(
]);
await client.query('COMMIT');
} catch (err) {
- await client.query('ROLLBACK');
- throw err;
+ // A failed ROLLBACK means the session is gone, and PostgreSQL rolls back with it.
+ await client.query('ROLLBACK').catch(() => {});
+ throw new Error(
+ `migration ${m.name} failed: ${err instanceof Error ? err.message : String(err)}`,
+ { cause: err },
+ );
}
}
diff --git a/packages/void/src/migrations/runner.ts b/packages/void/src/migrations/runner.ts
index 18a296a36..d7ee3c861 100644
--- a/packages/void/src/migrations/runner.ts
+++ b/packages/void/src/migrations/runner.ts
@@ -270,7 +270,17 @@ export async function runMigrations(migrationsDir: string, dbPath: string): Prom
return true;
});
- if (tx.immediate()) {
+ // A throw inside the transaction rolls the whole file back.
+ let applied: boolean;
+ try {
+ applied = tx.immediate();
+ } catch (err) {
+ throw new Error(
+ `migration ${migration.name} failed: ${err instanceof Error ? err.message : String(err)}`,
+ { cause: err },
+ );
+ }
+ if (applied) {
appliedNames.push(migration.name);
}
}
diff --git a/packages/void/src/nitro.ts b/packages/void/src/nitro.ts
new file mode 100644
index 000000000..ee09a28aa
--- /dev/null
+++ b/packages/void/src/nitro.ts
@@ -0,0 +1,138 @@
+import { resolve as resolveWindowsPath } from 'node:path/win32';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'pathslash';
+import { normalizePath, type Plugin } from 'vite';
+import { bundleWorkerSqlDriver } from './cli/wrapper.ts';
+import { getDatabaseDialect, readConfig } from './config.ts';
+import { voidDbBundlerPlugin } from './plugin-drizzle.ts';
+
+const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env'));
+// The package's built modules, where the env runtime and its AsyncLocalStorage live.
+const packageDistDir = `${normalizePath(dirname(dirname(envModuleFile)))}/`;
+const REQUEST_SCOPE_PLUGIN = '#void/nitro-request-scope';
+
+/** The parts of a Nitro instance that the module uses. */
+type NitroInstance = {
+ options: {
+ dev: boolean;
+ rootDir: string;
+ buildDir: string;
+ exportConditions?: Array;
+ plugins: Array;
+ virtual: Record string | Promise)>;
+ rollupConfig?: { plugins?: unknown };
+ cloudflare?: { nodeCompat?: boolean };
+ };
+};
+
+const NODEJS_COMPAT_FLAGS = new Set(['nodejs_compat', 'nodejs_compat_v2']);
+
+/**
+ * `path` as Nitro's Rollup names its modules: with backslashes on Windows, as its node-resolve
+ * plugin returns them. That plugin looks up an absolute import, such as the `global` polyfill
+ * Nitro adds to pg, by walking up from the importer to a package.json. From a module named with
+ * forward slashes there, the walk does not stop at the drive root, so it never ends when no
+ * package.json is above the module.
+ */
+function nitroModuleId(path: string): string {
+ return process.platform === 'win32' ? resolveWindowsPath(path) : path;
+}
+
+/**
+ * A Nitro runtime plugin that runs each request in Void's request scope, as the Class A wrapper
+ * does, so void/db keeps one PostgreSQL or MySQL client per request instead of one per query.
+ */
+function requestScopePluginSource(): string {
+ return `import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};
+
+export default function voidRequestScope(nitroApp) {
+ const handler = nitroApp.h3App.handler;
+ // Nitro's Cloudflare entry and dev plugins keep the env here; a dev plugin may first set a
+ // promise of it.
+ nitroApp.h3App.handler = async (event) =>
+ withRuntimeEnv(await globalThis.__env__, () => handler(event));
+}
+`;
+}
+
+/**
+ * Keep Void's `node:async_hooks` imports for workerd's own module. With its `cloudflare.nodeCompat`
+ * option off, Nitro bundles a stub whose AsyncLocalStorage loses the request scope at the first
+ * await.
+ */
+const nativeAsyncHooksPlugin: Plugin = {
+ name: 'void:nitro-async-hooks',
+ resolveId(id, importer) {
+ if (
+ id === 'node:async_hooks' &&
+ importer !== undefined &&
+ normalizePath(importer).startsWith(packageDistDir)
+ ) {
+ return { id, external: true };
+ }
+ return null;
+ },
+};
+
+/**
+ * Nitro module for apps whose server routes Nitro bundles (Nuxt and Analog). Nitro's bundler
+ * does not run Vite plugins, so without this module `void/db` in a server route, page server
+ * load or cron job is the package's D1 client without the app's schema. Register it in the
+ * Nitro config: `modules: ['void/nitro']`.
+ */
+const voidNitroModule = {
+ name: 'void',
+ setup(nitro: NitroInstance): void {
+ const { options } = nitro;
+ const config = readConfig(options.rootDir);
+ const dialect = getDatabaseDialect(config);
+ // Development runs Nitro in Node, which loads the drivers itself.
+ const workerd = !options.dev && options.exportConditions?.includes('workerd') === true;
+ const plugins: Array = [
+ voidDbBundlerPlugin(options.rootDir, {
+ config,
+ ...(workerd && {
+ sqlDriver: async (sqlDialect, entry) =>
+ nitroModuleId(
+ await bundleWorkerSqlDriver(sqlDialect, entry, join(options.buildDir, 'void')),
+ ),
+ }),
+ }),
+ ];
+ // Nitro uses workerd's own Node modules instead of its stubs only when it finds nodejs_compat
+ // in a Wrangler config, and Void's Cloudflare config is not one it reads. Deploys add the flag
+ // for PostgreSQL and MySQL, whose drivers need it. Otherwise the Worker gets the Cloudflare
+ // config's flags, or in a managed deploy `worker.compatibility_flags` when those are set: a
+ // Worker built for native modules does not start without the flag.
+ const nodeCompat = (flags: Array | undefined) =>
+ flags?.some((flag) => NODEJS_COMPAT_FLAGS.has(flag)) === true;
+ const cloudflareFlags = config.cloudflare?.compatibility_flags;
+ if (
+ workerd &&
+ (dialect !== 'sqlite' ||
+ (nodeCompat(cloudflareFlags) &&
+ nodeCompat(config.worker?.compatibility_flags ?? cloudflareFlags)))
+ ) {
+ options.cloudflare ??= {};
+ options.cloudflare.nodeCompat ??= true;
+ }
+ // D1 apps keep Nitro's requests as they are: in a request scope, void/db reads D1 through
+ // a replica-capable session instead of the primary.
+ if (dialect !== 'sqlite') {
+ options.virtual[REQUEST_SCOPE_PLUGIN] = requestScopePluginSource();
+ options.plugins.push(REQUEST_SCOPE_PLUGIN);
+ if (workerd) {
+ plugins.push(nativeAsyncHooksPlugin);
+ }
+ }
+ const rollupConfig = (options.rollupConfig ??= {});
+ const existing = rollupConfig.plugins;
+ // Nitro adds its own resolvers after these, so `void/db` never reaches them.
+ rollupConfig.plugins = [
+ ...plugins,
+ ...(Array.isArray(existing) ? existing : existing ? [existing] : []),
+ ];
+ },
+};
+
+export default voidNitroModule;
diff --git a/packages/void/src/plugin-drizzle.ts b/packages/void/src/plugin-drizzle.ts
index 7d5c6ea51..996e4f98e 100644
--- a/packages/void/src/plugin-drizzle.ts
+++ b/packages/void/src/plugin-drizzle.ts
@@ -1,12 +1,14 @@
-import { existsSync, readdirSync } from 'node:fs';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
-import { join } from 'pathslash';
+import { dirname, isAbsolute, join, relative } from 'pathslash';
import { normalizePath, type Plugin } from 'vite';
import { DRIZZLE_OPERATORS, generateDbDts } from './codegen/db-types.ts';
import { ensureProjectTsconfig } from './codegen/project-tsconfig.ts';
+import { getDatabaseDialect, readConfig, resolveBindingNames, type VoidConfig } from './config.ts';
import { discoverSchema } from './drizzle/discover.ts';
import { voidWarn } from './log.ts';
-import { readProjectPaths } from './project-paths.ts';
+import { readProjectPaths, resolveProjectPaths, type ProjectPaths } from './project-paths.ts';
// Resolve drizzle-orm paths from void's own deps so they work in any project
// (including metaframeworks where drizzle-orm isn't a direct dependency).
@@ -15,124 +17,105 @@ const drizzleOrmResolved = fileURLToPath(import.meta.resolve('drizzle-orm'));
const envModuleResolved = fileURLToPath(import.meta.resolve('#self/_env'));
const drizzleNodePgResolved = fileURLToPath(import.meta.resolve('drizzle-orm/node-postgres'));
const drizzleMysql2Resolved = fileURLToPath(import.meta.resolve('drizzle-orm/mysql2'));
-const pgResolved = fileURLToPath(import.meta.resolve('pg'));
-const mysql2Resolved = fileURLToPath(import.meta.resolve('mysql2'));
const VIRTUAL_DB_ID = '\0virtual:void-db';
const VIRTUAL_SCHEMA_DB_ID = '\0virtual:void-db-schema';
-type DrizzlePluginOptions = {
+
+// Workers tie a connection to the request that opened it. void/db's PostgreSQL and MySQL
+// templates share one drizzle instance whose client looks up the current request's pool on
+// every call, so a query built or prepared in one request runs on the pool of the request that
+// executes it. `has` and `getPrototypeOf` answer drizzle's pool checks before a transaction.
+// Every pool has the same shape, so those answers are kept: outside a request scope, where each
+// lookup builds a pool, a check would otherwise build one that never runs a query.
+const REQUEST_POOL_PROXY = `
+function requestPool(resolve, own = {}) {
+ const has = new Map();
+ let prototype;
+ return new Proxy({}, {
+ get(_, prop) {
+ if (Object.hasOwn(own, prop)) return own[prop];
+ const pool = resolve();
+ const value = Reflect.get(pool, prop);
+ return typeof value === "function" ? value.bind(pool) : value;
+ },
+ has(_, prop) {
+ if (Object.hasOwn(own, prop)) return true;
+ if (!has.has(prop)) has.set(prop, Reflect.has(resolve(), prop));
+ return has.get(prop);
+ },
+ getPrototypeOf: () => (prototype ??= Object.getPrototypeOf(resolve())),
+ });
+}
+`;
+type VoidDbModuleOptions = {
d1BindingName?: string;
forceVirtualDb?: boolean;
+};
+type DrizzlePluginOptions = VoidDbModuleOptions & {
localD1DatabaseId?: string;
localD1PersistRoot?: string;
redirectDbTypePaths?: boolean;
+ /** Nitro bundles the server build's output (Nuxt and Analog). */
+ nitroBundlesServer?: boolean;
};
/**
- * Vite plugin for Drizzle integration.
- *
- * - Intercepts `void/db` imports in SSR/worker environments and serves
- * a virtual module that creates a Drizzle DB instance, using the user's
- * schema when present.
- * - Generates `.void/db.d.ts` with proper Drizzle types.
- * - Sets up `@schema` Vite alias for convenient imports when a schema exists.
- * - Updates `.void/tsconfig.json` with TypeScript paths.
+ * The app's `void/db` module for its dialect, schema and D1 binding. Resolving and loading
+ * have no side effects, so the Vite plugin and `voidDbBundlerPlugin` share them.
*/
-export function drizzlePlugin(
+function createVoidDbModule(
root: string,
- hasRouteTypes: boolean,
- dialect: 'sqlite' | 'postgresql' | 'mysql' = 'sqlite',
- options: DrizzlePluginOptions = {},
-): Plugin {
- const outDir = join(root, '.void');
- const paths = readProjectPaths(root);
+ dialect: 'sqlite' | 'postgresql' | 'mysql',
+ options: VoidDbModuleOptions,
+ paths: ProjectPaths = readProjectPaths(root),
+) {
const schema = discoverSchema(root, paths);
- const hasDbRuntime = schema != null || options.forceVirtualDb === true;
+ // The package's own `void/db` is a D1 client, so PostgreSQL and MySQL always need this one.
+ const hasDbRuntime = schema != null || options.forceVirtualDb === true || dialect !== 'sqlite';
const d1BindingName = options.d1BindingName ?? 'DB';
- // Always write tsconfig (for routes.d.ts, queues.d.ts, etc.)
- ensureProjectTsconfig(root, hasRouteTypes, hasDbRuntime, schema != null, {
- redirectDbModule: options.redirectDbTypePaths,
- paths,
- });
-
- // Generate db.d.ts when void/db is available, even without a user schema.
- if (hasDbRuntime) {
- generateDbDts(outDir, schema ?? null, dialect);
- }
-
- let isBuild = false;
-
return {
- name: 'void:drizzle',
- enforce: 'pre',
-
- config(_, env) {
- isBuild = env.command === 'build';
- if (!schema) {
- return;
- }
-
- return {
- resolve: {
- alias: {
- '@schema': schema.path,
- },
- },
- };
- },
-
- resolveId: {
- filter: { id: /^(void\/db|@schema|pg$)/ },
- handler(id, importer) {
- // Resolve pg from void's own deps during build so PG projects
- // don't need pg as a direct dependency.
- if (id === 'pg' && isBuild && dialect === 'postgresql' && importer === VIRTUAL_DB_ID) {
- return pgResolved;
- }
-
- // Only intercept void/db in server/worker environments (not client)
- if (id === 'void/db' && this.environment?.name !== 'client') {
- if (!hasDbRuntime) {
- return;
- } // Fall through to the real module (stub)
- // Don't redirect schema files to avoid circular dependency
- // (virtual:void-db imports the schema, which imports void/db).
- // Schema files only need SQL helpers, not the runtime DB instance.
- if (schema && importer) {
- // Normalize separators for Windows — Vite uses forward slashes,
- // but schema.path from Node's path.join uses backslashes on Windows.
- const normalizedImporter = normalizePath(importer);
- const normalizedSchemaPath = normalizePath(schema.path);
- if (schema.type === 'file' && normalizedImporter === normalizedSchemaPath) {
- return VIRTUAL_SCHEMA_DB_ID;
- }
- if (
- schema.type === 'directory' &&
- normalizedImporter.startsWith(normalizedSchemaPath)
- ) {
- return VIRTUAL_SCHEMA_DB_ID;
- }
+ schema,
+ hasDbRuntime,
+
+ resolveId(id: string, importer: string | undefined): string | undefined {
+ if (id === 'void/db') {
+ if (!hasDbRuntime) {
+ return;
+ } // Fall through to the real module (stub)
+ // Don't redirect schema files to avoid circular dependency
+ // (virtual:void-db imports the schema, which imports void/db).
+ // Schema files only need SQL helpers, not the runtime DB instance.
+ if (schema && importer) {
+ // Normalize separators for Windows — Vite uses forward slashes,
+ // but schema.path from Node's path.join uses backslashes on Windows.
+ const normalizedImporter = normalizePath(importer);
+ const normalizedSchemaPath = normalizePath(schema.path);
+ if (schema.type === 'file' && normalizedImporter === normalizedSchemaPath) {
+ return VIRTUAL_SCHEMA_DB_ID;
+ }
+ if (schema.type === 'directory' && normalizedImporter.startsWith(normalizedSchemaPath)) {
+ return VIRTUAL_SCHEMA_DB_ID;
}
- return VIRTUAL_DB_ID;
}
+ return VIRTUAL_DB_ID;
+ }
- // Resolve @schema alias explicitly via resolveId so it works in
- // meta-framework SSR environments (Astro's workerd runner, etc.)
- // where Vite's config-level resolve.alias may not be applied.
- if (schema && (id === '@schema' || id.startsWith('@schema/'))) {
- if (id === '@schema') {
- return schema.path;
- }
- return join(schema.path, id.slice('@schema/'.length));
+ // Resolve @schema alias explicitly via resolveId so it works in
+ // meta-framework SSR environments (Astro's workerd runner, etc.)
+ // where Vite's config-level resolve.alias may not be applied.
+ if (schema && (id === '@schema' || id.startsWith('@schema/'))) {
+ if (id === '@schema') {
+ return schema.path;
}
- },
+ return join(schema.path, id.slice('@schema/'.length));
+ }
},
load: {
// oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention
filter: { id: /^\0virtual:void-db(?:-schema)?$/ },
- handler(id) {
+ handler(id: string): string {
if (id === VIRTUAL_SCHEMA_DB_ID) {
return `
export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)};
@@ -147,16 +130,14 @@ export function createDb() {
const schemaImport = schema
? `import * as schema from ${JSON.stringify(schema.path)};`
: '';
- const drizzleConfig = schema
- ? dialect === 'mysql'
- ? ', { schema, mode: "default" }'
- : ', { schema }'
- : '';
+ const drizzleConfig = schema ? ', { schema }' : '';
if (dialect === 'mysql') {
+ // drizzle builds the pool from `connection`, so its bare mysql2 import is the only one
+ // and the worker prebundle serves it. Its config check rejects `mode` without `schema`.
+ const mysqlDrizzleConfig = schema ? ', schema, mode: "default"' : '';
return `
import { drizzle } from ${JSON.stringify(drizzleMysql2Resolved)};
-import { createPool } from ${JSON.stringify(mysql2Resolved)};
import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)};
${schemaImport}
export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)};
@@ -171,34 +152,74 @@ function getConnectionString() {
throw new Error("db: MySQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production.");
}
-function getInstance() {
- return getRuntimeRequestResource(_requestResourceKey, () => {
- const pool = createPool({
- uri: getConnectionString(),
- connectionLimit: 1,
- maxIdle: 1,
- idleTimeout: 1000,
- });
- return drizzle(pool${drizzleConfig});
+// mysql2 closes idle connections only from a timer that it starts when it builds a pool with
+// maxIdle below connectionLimit, and that timer runs until the pool ends: workerd rejects it in
+// global scope, and nothing ends Void's pools. Time each connection from its release instead,
+// which runs in the request that ran the query.
+function closeIdleConnections(pool, idleTimeout) {
+ const timers = new WeakMap();
+ pool.on("release", (connection) => {
+ const timer = setTimeout(() => connection.destroy(), idleTimeout);
+ // A Node script that ends its pool exits without waiting for this timer.
+ timer.unref?.();
+ timers.set(connection, timer);
});
+ pool.on("acquire", (connection) => clearTimeout(timers.get(connection)));
+ return pool;
}
+function createPools() {
+ const pool = closeIdleConnections(
+ drizzle({
+ connection: {
+ uri: getConnectionString(),
+ connectionLimit: 1,
+ // Workers forbid eval, which mysql2 uses to build its row parsers.
+ disableEval: true,
+ },
+ }).$client,
+ 1000,
+ );
+ return { pool, promisePool: pool.promise() };
+}
+
+function getPools() {
+ // Framework entries that Void does not own run outside its request scope:
+ // they get a client for each use, never one shared across requests.
+ return getRuntimeRequestResource(_requestResourceKey, createPools, createPools);
+}
+${REQUEST_POOL_PROXY}
+const promiseClient = requestPool(() => getPools().promisePool);
+// drizzle asks for the promise API while it builds the database, which can run in global
+// scope. No request exists there to own a pool, so answer without one.
+const client = requestPool(() => getPools().pool, { promise: () => promiseClient });
+
+let instance;
+
export const db = new Proxy({}, {
get(_, prop) {
- return Reflect.get(getInstance(), prop);
+ // drizzle reports a query's errors as "Failed query", so build the request's pool here:
+ // a missing connection string then throws its own message. Outside a request, build none.
+ getRuntimeRequestResource(_requestResourceKey, createPools, () => undefined);
+ instance ??= drizzle({ client${mysqlDrizzleConfig} });
+ const value = Reflect.get(instance, prop);
+ return typeof value === "function" ? value.bind(instance) : value;
},
});
export function createDb(connectionString) {
- return drizzle(connectionString${drizzleConfig});
+ const db = drizzle({ connection: { uri: connectionString, disableEval: true }${mysqlDrizzleConfig} });
+ // 60 seconds is mysql2's own idleTimeout default.
+ closeIdleConnections(db.$client, 60000);
+ return db;
}
`;
}
if (dialect === 'postgresql') {
+ // Like MySQL: drizzle builds the pool, so its bare pg import is the only one.
return `
import { drizzle } from ${JSON.stringify(drizzleNodePgResolved)};
-import pg from "pg";
import { getRuntimeBinding, getRuntimeRequestResource } from ${JSON.stringify(envModuleResolved)};
${schemaImport}
export { ${DRIZZLE_OPERATORS} } from ${JSON.stringify(drizzleOrmResolved)};
@@ -213,20 +234,32 @@ function getConnectionString() {
throw new Error("db: PostgreSQL is not configured. Add DATABASE_URL to .env for local dev, or configure Hyperdrive for production.");
}
-function getInstance() {
- return getRuntimeRequestResource(_requestResourceKey, () => {
- const pool = new pg.Pool({
+function createPool() {
+ return drizzle({
+ connection: {
connectionString: getConnectionString(),
max: 1,
idleTimeoutMillis: 1000,
- });
- return drizzle(pool${drizzleConfig});
- });
+ },
+ }).$client;
+}
+
+function getPool() {
+ // Framework entries that Void does not own run outside its request scope:
+ // they get a client for each use, never one shared across requests.
+ return getRuntimeRequestResource(_requestResourceKey, createPool, createPool);
}
+${REQUEST_POOL_PROXY}
+let instance;
export const db = new Proxy({}, {
get(_, prop) {
- return Reflect.get(getInstance(), prop);
+ // drizzle reports a query's errors as "Failed query", so build the request's pool here:
+ // a missing connection string then throws its own message. Outside a request, build none.
+ getRuntimeRequestResource(_requestResourceKey, createPool, () => undefined);
+ instance ??= drizzle({ client: requestPool(getPool)${schema ? ', schema' : ''} });
+ const value = Reflect.get(instance, prop);
+ return typeof value === "function" ? value.bind(instance) : value;
},
});
@@ -266,6 +299,83 @@ function createPrimaryDb(d1) {
`;
},
},
+ };
+}
+
+/**
+ * Vite plugin for Drizzle integration.
+ *
+ * - Intercepts `void/db` imports in SSR/worker environments and serves
+ * a virtual module that creates a Drizzle DB instance, using the user's
+ * schema when present.
+ * - Generates `.void/db.d.ts` with proper Drizzle types.
+ * - Sets up `@schema` Vite alias for convenient imports when a schema exists.
+ * - Updates `.void/tsconfig.json` with TypeScript paths.
+ */
+export function drizzlePlugin(
+ root: string,
+ hasRouteTypes: boolean,
+ dialect: 'sqlite' | 'postgresql' | 'mysql' = 'sqlite',
+ options: DrizzlePluginOptions = {},
+): Plugin {
+ const outDir = join(root, '.void');
+ const paths = readProjectPaths(root);
+ const dbModule = createVoidDbModule(root, dialect, options, paths);
+ const { schema, hasDbRuntime } = dbModule;
+
+ // Always write tsconfig (for routes.d.ts, queues.d.ts, etc.)
+ ensureProjectTsconfig(root, hasRouteTypes, hasDbRuntime, schema != null, {
+ redirectDbModule: options.redirectDbTypePaths,
+ paths,
+ });
+
+ // Generate db.d.ts when void/db is available, even without a user schema.
+ if (hasDbRuntime) {
+ generateDbDts(outDir, schema ?? null, dialect);
+ }
+
+ return {
+ name: 'void:drizzle',
+ enforce: 'pre',
+
+ config() {
+ if (!schema) {
+ return;
+ }
+
+ return {
+ resolve: {
+ alias: {
+ '@schema': schema.path,
+ },
+ },
+ };
+ },
+
+ resolveId: {
+ filter: { id: /^(void\/db|@schema)/ },
+ handler(id, importer) {
+ // Only intercept void/db in server/worker environments (not client)
+ if (id === 'void/db' && this.environment?.name === 'client') {
+ return;
+ }
+ const resolved = dbModule.resolveId(id, importer);
+ // Nitro bundles this output with the server routes. Leave a PostgreSQL or MySQL app's
+ // `void/db` to it, so the app has one client and one driver copy: the one `void/nitro`
+ // serves, or without the module the package's own, which says to add it.
+ if (
+ resolved === VIRTUAL_DB_ID &&
+ dialect !== 'sqlite' &&
+ options.nitroBundlesServer &&
+ this.environment?.mode === 'build'
+ ) {
+ return { id, external: true };
+ }
+ return resolved;
+ },
+ },
+
+ load: dbModule.load,
async configureServer() {
if (!schema) {
@@ -300,3 +410,154 @@ function createPrimaryDb(d1) {
},
};
}
+
+/**
+ * `void/db` and `@schema` for bundles that do not run Void's Vite plugins: Nitro's (Nuxt and
+ * Analog server routes, through `void/nitro`) and the cron and queue handler bundle that deploys
+ * build. Without it they load the package's static `void/db`, a D1 client without the app's
+ * schema. It reads the dialect, schema and D1 binding from the project, as the Vite plugin does.
+ * Its hooks are plain Rollup hooks; Vite also needs `enforce` to run them before its resolver.
+ *
+ * For a bundle that runs in workerd, `sqlDriver` builds a copy of the PostgreSQL or MySQL driver
+ * from its entry file, and the plugin serves that module for `pg` or `mysql2` (for
+ * `mysql2/promise`, the promise API from the same build). Each import gets the copy that the
+ * bundler resolves for it: the generated client's drizzle-orm gets the one it depends on, and an
+ * app's own import gets the app's version.
+ */
+export function voidDbBundlerPlugin(
+ root: string,
+ options: {
+ config?: VoidConfig;
+ sqlDriver?: (dialect: 'postgresql' | 'mysql', entry: string) => Promise;
+ } = {},
+): Plugin {
+ const config = options.config ?? readConfig(root);
+ const dialect = getDatabaseDialect(config);
+ const d1BindingName = resolveBindingNames(config.inference?.bindings)?.d1;
+ const dbModule = createVoidDbModule(
+ root,
+ dialect,
+ // The package's own `void/db` reads D1 from the `DB` binding.
+ { d1BindingName, forceVirtualDb: d1BindingName !== undefined },
+ resolveProjectPaths(root, config),
+ );
+ const driverPackage =
+ dialect === 'postgresql' ? 'pg' : dialect === 'mysql' ? 'mysql2' : undefined;
+ const drivers = new Map>();
+ const driverDirs: Array = [];
+ return {
+ name: 'void:db',
+ enforce: 'pre',
+ async resolveId(id, importer, resolveOptions) {
+ const promiseApi = driverPackage === 'mysql2' && MYSQL2_PROMISE.test(id);
+ if ((id === driverPackage || promiseApi) && options.sqlDriver && dialect !== 'sqlite') {
+ // Resolve it as a `require` would. For an import, Rollup's CommonJS plugin loads a CommonJS
+ // driver to wrap it: that hides the file, and its build of the driver can fail on optional
+ // native modules.
+ const custom = resolveOptions?.custom;
+ const resolved = await this.resolve(id, importer, {
+ ...resolveOptions,
+ skipSelf: true,
+ custom: { ...custom, 'node-resolve': { ...custom?.['node-resolve'], isRequire: true } },
+ });
+ const entry =
+ resolved && !resolved.external
+ ? resolvePackageEntry(driverPackage, resolved.id)
+ : undefined;
+ if (entry === undefined) {
+ return resolved;
+ }
+ let driver = drivers.get(entry);
+ if (!driver) {
+ assertWorkerDriver(driverPackage, entry, root, importer);
+ driver = options.sqlDriver(dialect, entry).then((path) => {
+ driverDirs.push(`${normalizePath(dirname(path))}/`);
+ return path;
+ });
+ drivers.set(entry, driver);
+ }
+ // The promise API sits next to the driver, named the way `sqlDriver` names the driver.
+ return promiseApi ? (await driver).replace(/mysql2\.mjs$/, 'promise.mjs') : driver;
+ }
+ // The driver needs workerd's own Node modules: Nitro replaces them with stubs when its
+ // `cloudflare.nodeCompat` option is off.
+ if (
+ id.startsWith('node:') &&
+ importer &&
+ driverDirs.some((dir) => normalizePath(importer).startsWith(dir))
+ ) {
+ return { id, external: true };
+ }
+ const resolved = dbModule.resolveId(id, importer);
+ if (resolved !== undefined && id !== 'void/db') {
+ // `@schema` can name a schema folder or a file without its extension. Only Vite's
+ // config alias hands those to a resolver, so apply the bundler's own rules here.
+ return this.resolve(resolved, importer, { skipSelf: true });
+ }
+ return resolved ?? null;
+ },
+ load(id) {
+ if (id !== VIRTUAL_DB_ID && id !== VIRTUAL_SCHEMA_DB_ID) {
+ return null;
+ }
+ return dbModule.load.handler(id);
+ },
+ };
+}
+
+/** mysql2's promise API, which `bundleWorkerSqlDriver` builds next to the driver. */
+const MYSQL2_PROMISE = /^mysql2\/promise(?:\.js)?$/;
+
+/**
+ * The first driver versions that run in Workers: pg 8.11 brings pg-cloudflare, its socket there,
+ * and mysql2 3.13 the `disableEval` option, without which it compiles its parsers with
+ * `new Function`, which workerd forbids.
+ */
+const WORKER_DRIVER_MINIMUM: Record<'pg' | 'mysql2', [major: number, minor: number]> = {
+ pg: [8, 11],
+ mysql2: [3, 13],
+};
+
+/** Throw when the driver whose entry file is `entry` is older than Workers need. */
+function assertWorkerDriver(
+ name: 'pg' | 'mysql2',
+ entry: string,
+ root: string,
+ importer: string | undefined,
+): void {
+ let version: string | undefined;
+ try {
+ const packageJson = createRequire(entry).resolve(`${name}/package.json`);
+ ({ version } = JSON.parse(readFileSync(packageJson, 'utf8')) as { version?: string });
+ } catch {
+ return;
+ }
+ const match = /^(\d+)\.(\d+)\./.exec(version ?? '');
+ const [major, minor] = WORKER_DRIVER_MINIMUM[name];
+ if (
+ !match ||
+ Number(match[1]) > major ||
+ (Number(match[1]) === major && Number(match[2]) >= minor)
+ ) {
+ return;
+ }
+ const source = importer ? ` Imported by ${relative(root, importer)}.` : '';
+ throw new Error(
+ `${name} ${version} cannot run in Cloudflare Workers, which need ${name} ${major}.${minor} or later. Upgrade ${name}.${source}`,
+ );
+}
+
+/**
+ * The CommonJS entry of package `name`, from `file` inside one of its copies (which a bundler may
+ * have resolved to an ES module entry), or undefined if `file` is not on disk.
+ */
+function resolvePackageEntry(name: string, file: string): string | undefined {
+ if (!isAbsolute(file)) {
+ return undefined;
+ }
+ try {
+ return createRequire(file).resolve(name);
+ } catch {
+ return undefined;
+ }
+}
diff --git a/packages/void/src/plugin-framework-entry.ts b/packages/void/src/plugin-framework-entry.ts
index 728f484e3..6a098e944 100644
--- a/packages/void/src/plugin-framework-entry.ts
+++ b/packages/void/src/plugin-framework-entry.ts
@@ -1,7 +1,7 @@
import { mkdirSync } from 'node:fs';
import { join } from 'pathslash';
import { writeFileIfChanged } from './codegen/write-if-changed.ts';
-import { type NotFoundHandling } from './config.ts';
+import { type DatabaseDialect, type NotFoundHandling } from './config.ts';
import { isViteBuildOrPreview } from './plugin-dev-config.ts';
import {
frameworkTriggersDispatchPath,
@@ -22,6 +22,7 @@ export function getFrameworkEntry(
framework: Framework,
root: string,
devTriggerToken: string,
+ dialect: DatabaseDialect,
notFound?: NotFoundHandling,
): string {
const isBuild = isViteBuildOrPreview();
@@ -38,6 +39,7 @@ export function getFrameworkEntry(
framework,
getOriginalFrameworkEntry(framework, root),
isBuild,
+ dialect,
undefined,
notFound,
);
@@ -63,6 +65,11 @@ export function getOriginalFrameworkEntry(framework: Framework, root: string): s
* first, dispatches `/__void/{scheduled,queue}` if it matches, and otherwise
* delegates to the framework's original entry.
*
+ * For PostgreSQL and MySQL, both shapes run the framework inside Void's request scope
+ * (`withRuntimeEnv`), as Void's own handlers do, so void/db keeps one client per request. D1 apps
+ * keep the framework's own fetch: in a request scope, void/db reads D1 through a replica-capable
+ * session instead of the primary.
+ *
* We emit two shapes of the wrapper depending on the Vite command:
*
* - Serve: dispatch is dynamically imported and gets a chance to handle
@@ -80,6 +87,7 @@ export function generateClassATriggerWrapper(
framework: Framework,
original: string,
isBuild: boolean,
+ dialect: DatabaseDialect,
assetsDir = 'assets',
/**
* Resolved `assetConfig.not_found_handling`. A Class A app deploys through
@@ -92,25 +100,9 @@ export function generateClassATriggerWrapper(
const outDir = join(root, '.void');
mkdirSync(outDir, { recursive: true });
const wrapperPath = join(outDir, `${framework}-trigger-wrapper.ts`);
- const body = isBuild
- ? `// @ts-nocheck — Auto-generated by Void for vite build. Do NOT edit.
-import original from ${JSON.stringify(original)};
-
-export default {
- ...original,
- async fetch(request, env, ctx) {
- return serveWithAssets(request, env, await original.fetch(request, env, ctx));
- },
-};
-${compileAssetFallbackHelper(assetsDir, false, notFound)}
-`
- : `// @ts-nocheck — Auto-generated by Void in dev. Do NOT edit.
-import original from ${JSON.stringify(original)};
-
-export default {
- ...original,
- async fetch(request, env, ctx) {
- if (request.method === "POST") {
+ const fetchBody = isBuild
+ ? ` return serveWithAssets(request, env, await original.fetch(request, env, ctx));`
+ : ` if (request.method === "POST") {
const pathname = new URL(request.url).pathname;
if (pathname === "/__void/scheduled" || pathname === "/__void/queue") {
const { handleDevTrigger } = await import(${JSON.stringify(frameworkTriggersDispatchPath(root))});
@@ -118,10 +110,25 @@ export default {
if (response) return response;
}
}
- return original.fetch(request, env, ctx);
- },
+ return original.fetch(request, env, ctx);`;
+ const scoped = dialect !== 'sqlite';
+ const fetchMethod = scoped
+ ? ` fetch(request, env, ctx) {
+ return withRuntimeEnv(env, async () => {
+${fetchBody.replace(/^/gm, ' ')}
+ });
+ },`
+ : ` async fetch(request, env, ctx) {
+${fetchBody}
+ },`;
+ const body = `// @ts-nocheck — Auto-generated by Void ${isBuild ? 'for vite build' : 'in dev'}. Do NOT edit.
+import original from ${JSON.stringify(original)};
+${scoped ? 'import { withRuntimeEnv } from "void/_env";\n' : ''}
+export default {
+ ...original,
+${fetchMethod}
};
-`;
+${isBuild ? `${compileAssetFallbackHelper(assetsDir, false, notFound)}\n` : ''}`;
writeFileIfChanged(wrapperPath, body);
return wrapperPath;
}
diff --git a/packages/void/src/plugin-framework-triggers.ts b/packages/void/src/plugin-framework-triggers.ts
index 9f66ab397..65d7205ec 100644
--- a/packages/void/src/plugin-framework-triggers.ts
+++ b/packages/void/src/plugin-framework-triggers.ts
@@ -554,6 +554,23 @@ export const onRequest = defineMiddleware(async (context, next) => {
`;
}
+type AstroMiddlewareClaim = { servers: number; gitignoreLines: ReadonlyArray };
+
+const ASTRO_MIDDLEWARE_CLAIMS: unique symbol = Symbol.for('void.astroDevMiddleware.v1');
+
+/**
+ * The dev servers in this process that use the middleware Void wrote, by file path. Astro restarts
+ * the dev server in the same process with a fresh config, so a new plugin instance (from a new
+ * copy of this module, too) is set up before the old server closes. Kept on `globalThis` so both
+ * see the same claim.
+ */
+function astroMiddlewareClaims(): Map {
+ const store = globalThis as typeof globalThis & {
+ [ASTRO_MIDDLEWARE_CLAIMS]?: Map;
+ };
+ return (store[ASTRO_MIDDLEWARE_CLAIMS] ??= new Map());
+}
+
/**
* Writes a tiny Astro middleware to the user's project so
* `/__void/scheduled` and `/__void/queue` are intercepted inside Astro's
@@ -563,47 +580,72 @@ export const onRequest = defineMiddleware(async (context, next) => {
* already has `src/middleware.ts` we don't overwrite it — instead we log a
* one-shot snippet they can paste into their existing middleware.
*
- * If we wrote the file we remove it on dev-server close so the user's
- * source tree stays clean. `src/middleware.ts` belongs to the user, so the
- * `.gitignore` line we add for it is removed too; otherwise a middleware they
- * write later would be silently left out of git.
+ * If we wrote the file we remove it when the last dev server using it closes,
+ * so the user's source tree stays clean. `src/middleware.ts` belongs to the
+ * user, so the `.gitignore` line we add for it is removed too; otherwise a
+ * middleware they write later would be silently left out of git.
*/
export function astroDevTriggerInjectPlugin(root: string): Plugin {
const middlewarePath = join(root, ASTRO_MIDDLEWARE_RELATIVE);
const middlewareDirIndexPath = join(root, 'src', 'middleware', 'index.ts');
- let wroteFile = false;
- let addedGitignoreLines: ReadonlyArray = [];
-
- const ensureFile = () => {
+ const claims = astroMiddlewareClaims();
+
+ /** Whether the dev server being set up now holds the middleware Void wrote. */
+ const ensureFile = (): boolean => {
+ const claim = claims.get(middlewarePath);
+ if (claim && existsSync(middlewarePath)) {
+ // A restart: the server being replaced wrote this file and still holds it.
+ claim.servers++;
+ return true;
+ }
if (existsSync(middlewarePath) || existsSync(middlewareDirIndexPath)) {
voidWarn(
'Astro middleware already exists. To enable /__void/scheduled and /__void/queue,\n' +
' add the Void dev-trigger middleware to your existing onRequest sequence.\n' +
' See https://void.cloud/guide/jobs#local-development.',
);
- return;
+ return false;
}
mkdirSync(dirname(middlewarePath), { recursive: true });
writeFileSync(middlewarePath, astroMiddlewareSource(frameworkTriggersDispatchPath(root)));
- wroteFile = true;
+ if (claim) {
+ // Removed while the server being replaced still holds it.
+ claim.servers++;
+ } else {
+ claims.set(middlewarePath, {
+ servers: 1,
+ gitignoreLines: ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]),
+ });
+ }
+ return true;
};
- const cleanup = () => {
- if (wroteFile) {
- rmSync(middlewarePath, { force: true });
+ const release = () => {
+ const claim = claims.get(middlewarePath);
+ if (!claim || --claim.servers > 0) {
+ return;
}
- removeGitignoreLines(root, addedGitignoreLines);
- addedGitignoreLines = [];
+ claims.delete(middlewarePath);
+ rmSync(middlewarePath, { force: true });
+ removeGitignoreLines(root, claim.gitignoreLines);
};
return {
name: 'void:astro-dev-trigger-inject',
apply: 'serve',
configureServer(server) {
- ensureFile();
- if (wroteFile) {
- addedGitignoreLines = ensureGitignoreLines(root, [ASTRO_MIDDLEWARE_RELATIVE]);
+ if (!ensureFile()) {
+ return;
}
+ // One release per dev server. Vite's own restart (after an .env edit) sets up this same
+ // plugin for the new server before it closes the old one.
+ let held = true;
+ const cleanup = () => {
+ if (held) {
+ held = false;
+ release();
+ }
+ };
server.httpServer?.once('close', cleanup);
process.once('exit', cleanup);
},
diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts
index fa9fba0c4..774fc58b3 100644
--- a/packages/void/src/plugin-framework.ts
+++ b/packages/void/src/plugin-framework.ts
@@ -1,4 +1,6 @@
-import { type Plugin } from 'vite';
+import { fileURLToPath } from 'node:url';
+import { dirname } from 'pathslash';
+import { defaultServerConditions, type Plugin } from 'vite';
import { type ApplicationAnalysis } from './application-analysis.ts';
import {
ensureWranglerCompat,
@@ -9,6 +11,7 @@ import {
syncWranglerBindings,
} from './cli/wrangler.ts';
import { readConfig } from './config.ts';
+import { findDependencyClosure, findVoidDependentPackages } from './installed-packages.ts';
import { resolveLocalD1Target } from './local-d1.ts';
import { voidLog } from './log.ts';
import { bindingRuntimePlugin } from './plugin-binding-runtime.ts';
@@ -26,8 +29,19 @@ import {
nitroDevTriggerInjectPlugin,
} from './plugin-framework-triggers.ts';
import { migrationPlugin } from './plugin-migration.ts';
-import { createCloudflareBuiltinsExternalPlugin } from './plugin-worker-environment.ts';
-import { injectSvelteKitHooks } from './sveltekit-hooks-transform.ts';
+import {
+ createBareVoidRedirectPlugin,
+ createCloudflareBuiltinsExternalPlugin,
+} from './plugin-worker-environment.ts';
+import { injectSvelteKitBuildHooks, injectSvelteKitHooks } from './sveltekit-hooks-transform.ts';
+
+const handlerFile = fileURLToPath(import.meta.resolve('#self/handler'));
+const envModuleFile = fileURLToPath(import.meta.resolve('#self/_env'));
+// Packages Void depends on are installed for code in this directory.
+const voidDir = dirname(fileURLToPath(import.meta.url));
+
+const ASTRO_SERVER_ENTRY = '@astrojs/cloudflare/entrypoints/server';
+const ASTRO_SCOPED_SERVER_ENTRY = '\0void:astro-server-entry';
export function createFrameworkPlugins(input: {
readonly localD1: ReturnType;
@@ -180,6 +194,36 @@ export function createFrameworkPlugins(input: {
const svelteKitHooksInjectPlugin: Plugin = {
name: 'void:sveltekit-hooks-inject',
apply: 'serve',
+ config() {
+ if (detected.class !== 'b') {
+ return;
+ }
+ // Kit's dev server runs server code in Vite's Node SSR graph. That graph
+ // externalizes a bare import resolving into node_modules before any plugin
+ // sees it, so an installed void/db would load the package's D1 runtime
+ // instead of the virtual module. Inlining keeps every void import, including
+ // the injected env import below and those of PostgreSQL and MySQL apps'
+ // installed packages that import void, in the graph that holds void/db's env
+ // module. In a D1 app, a package's own void/db reads the same D1 binding, so
+ // those packages stay with Node.
+ return {
+ ssr: {
+ noExternal: ['void', ...(dialect === 'sqlite' ? [] : findVoidDependentPackages(root))],
+ },
+ };
+ },
+ resolveId: {
+ order: 'pre',
+ filter: { id: /^void$/ },
+ handler() {
+ if (detected.class !== 'b' || this.environment?.name === 'client') {
+ return;
+ }
+ // Inlined, the package entry would load the whole Vite plugin into Kit's module
+ // runner. Server code gets the lightweight handler subpath, as in Void apps.
+ return handlerFile;
+ },
+ },
transform(code, id) {
if (detected.class !== 'b') {
return;
@@ -194,14 +238,111 @@ export function createFrameworkPlugins(input: {
},
};
+ // Astro's Cloudflare adapter owns the Worker entry. For PostgreSQL and MySQL, its default
+ // entry runs each request inside Void's request scope (`withRuntimeEnv`), as the Class A
+ // wrapper does, so void/db keeps one client per request instead of one per query. D1 apps
+ // keep Astro's own entry: in a request scope, void/db reads D1 through a replica-capable
+ // session instead of the primary.
+ const astroRequestScopePlugin: Plugin | undefined =
+ detected.name === 'astro' && dialect !== 'sqlite'
+ ? {
+ name: 'void:astro-request-scope',
+ enforce: 'pre',
+ resolveId: {
+ filter: { id: /^@astrojs\/cloudflare\/entrypoints\/server(?:\.js)?$/ },
+ handler(_id, importer) {
+ if (importer === ASTRO_SCOPED_SERVER_ENTRY || this.environment?.name === 'client') {
+ return;
+ }
+ return ASTRO_SCOPED_SERVER_ENTRY;
+ },
+ },
+ load: {
+ // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention
+ filter: { id: /^\0void:astro-server-entry$/ },
+ handler() {
+ return `import original from ${JSON.stringify(ASTRO_SERVER_ENTRY)};
+import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};
+export * from ${JSON.stringify(ASTRO_SERVER_ENTRY)};
+
+export default {
+ ...original,
+ fetch(request, env, ctx) {
+ return withRuntimeEnv(env, () => original.fetch(request, env, ctx));
+ },
+};
+`;
+ },
+ },
+ }
+ : undefined;
+
+ // SvelteKit builds its server in Vite's Node `ssr` environment, and the adapter's Worker runs
+ // that output as it is. For PostgreSQL and MySQL, void/db's driver then fails in workerd: pg
+ // loads pg-cloudflare's Node stub, and CommonJS requires of Node built-ins become
+ // `createRequire(import.meta.url)`. A driver that Vite can resolve from the app root (npm, Yarn
+ // and Bun installs, or an app that lists it) even stays external, and Void's browser-target
+ // packaging bundles it with browser stubs. Build void/db as the Cloudflare plugin builds
+ // Workers: void, drizzle-orm, the driver with every package it depends on, and installed
+ // packages that import void. Other dependencies stay external, as in Kit's own build: Kit's
+ // analyse step loads this output in Node, and a package's `workerd` build may import
+ // `cloudflare:` modules (capnweb does). Run each request in Void's request scope, as in dev, so
+ // void/db keeps one client per request. D1 apps keep Kit's output: in a request scope, void/db
+ // reads D1 through a replica-capable session instead of the primary.
+ const svelteKitWorkerBuildPlugin: Plugin | undefined =
+ detected.class === 'b' && dialect !== 'sqlite'
+ ? {
+ name: 'void:sveltekit-worker-build',
+ apply: 'build',
+ enforce: 'pre',
+ configEnvironment(name, environment) {
+ if (name !== 'ssr') {
+ return;
+ }
+ return {
+ resolve: {
+ conditions: environment.resolve?.conditions
+ ? ['workerd', 'worker']
+ : ['workerd', 'worker', ...defaultServerConditions],
+ noExternal: [
+ 'void',
+ // By name as well: a Yarn PnP install has no node_modules to read. The
+ // driver's own dependencies then resolve only through it, so they are bundled.
+ 'drizzle-orm',
+ dialect === 'mysql' ? 'mysql2' : 'pg',
+ ...findDependencyClosure('drizzle-orm', voidDir),
+ ...findDependencyClosure(dialect === 'mysql' ? 'mysql2' : 'pg', voidDir),
+ ...findVoidDependentPackages(root),
+ ],
+ },
+ // Node output binds `require` at load with createRequire(import.meta.url). Neutral
+ // output throws only when a require runs, as for mysql2's optional `cardinal`.
+ build: { rolldownOptions: { platform: 'neutral' } },
+ };
+ },
+ transform(code, id) {
+ if (
+ !id.includes('.svelte-kit/generated/server/internal') &&
+ !id.includes('.svelte-kit/generated/build/server.js')
+ ) {
+ return;
+ }
+ return injectSvelteKitBuildHooks(code, id, envModuleFile);
+ },
+ }
+ : undefined;
+
return [
devTriggerSecretGuardPlugin(),
createCloudflareBuiltinsExternalPlugin(),
wranglerCompatPlugin,
wranglerSyncPlugin,
...(nuxtVite8WebSocketCompatPlugin ? [nuxtVite8WebSocketCompatPlugin] : []),
+ ...(astroRequestScopePlugin ? [astroRequestScopePlugin] : []),
svelteKitHooksInjectPlugin,
+ ...(svelteKitWorkerBuildPlugin ? [svelteKitWorkerBuildPlugin] : []),
clientStubsPlugin,
+ // Also serves the Node built-ins that the driver requires in SvelteKit's workerd build.
serverRuntimeOptimizerPlugin,
bindingRuntimePlugin(bindingNames),
// Framework-agnostic env DX: typegen, client leak guard, and build-time
@@ -217,6 +358,7 @@ export function createFrameworkPlugins(input: {
forceVirtualDb: bindingNames?.d1 !== undefined,
localD1DatabaseId: localD1.databaseId,
localD1PersistRoot: frameworkPersistRoot,
+ nitroBundlesServer: detected.name === 'nuxt' || detected.name === 'analog',
}),
migrationPlugin(root, dialect, localD1.databaseId, frameworkPersistRoot, {
enabled: false,
@@ -227,7 +369,10 @@ export function createFrameworkPlugins(input: {
? [nitroDevTriggerInjectPlugin(root, { serverDir: 'server' })]
: []),
...(detected.name === 'analog' ? [analogDevTriggerInjectPlugin(root)] : []),
- ...(detected.name === 'astro' ? [astroDevTriggerInjectPlugin(root)] : []),
+ // Astro runs its server code, the dev cron and queue dispatch included, in workerd.
+ ...(detected.name === 'astro'
+ ? [createBareVoidRedirectPlugin(), astroDevTriggerInjectPlugin(root)]
+ : []),
implicitDeployGuard,
];
}
diff --git a/packages/void/src/plugin-migration.ts b/packages/void/src/plugin-migration.ts
index 1940b1a07..5c34d33d1 100644
--- a/packages/void/src/plugin-migration.ts
+++ b/packages/void/src/plugin-migration.ts
@@ -11,6 +11,11 @@ function readDatabaseUrl(root: string): string | undefined {
return loadLocalEnv(root).DATABASE_URL;
}
+/** DATABASE_URL as Better Auth's local migrations read it: the shell before `.env`. */
+function readAuthDatabaseUrl(root: string): string | undefined {
+ return (process.env.DATABASE_URL ?? readDatabaseUrl(root)) || undefined;
+}
+
/**
* Vite plugin that runs local migrations on dev server startup.
* For SQLite/D1: applies migrations to the local miniflare database.
@@ -35,10 +40,37 @@ export function migrationPlugin(
return;
}
+ const migrateAuth = async (databaseUrl?: string) => {
+ const { runLocalVoidAuthMigrations } = await import('./auth/node.ts');
+ await runLocalVoidAuthMigrations(
+ root,
+ dialect,
+ persistRoot,
+ databaseId,
+ auth?.configPath,
+ databaseUrl,
+ );
+ };
+ // MySQL runs Better Auth's migrations under the migration lock of the database they change.
+ const mysqlAuth = dialect === 'mysql' && auth?.enabled === true;
+ const logApplied = (applied: Array) => {
+ if (applied.length > 0) {
+ voidLog(`Applied ${applied.length} migration(s): ${applied.join(', ')}`);
+ }
+ };
+
try {
- if (hasSqlMigrations && dialect !== 'sqlite') {
- const url = readDatabaseUrl(root);
- if (!url) {
+ if (dialect !== 'sqlite') {
+ const { collectMigrations } = await import('./migrations/collect.ts');
+ const allMigrations = collectMigrations(migrationsDir);
+ // For MySQL an empty migrations folder has nothing to apply.
+ const runFiles = dialect === 'mysql' ? allMigrations.length > 0 : hasSqlMigrations;
+ // Migration files apply to the database in .env, as `void db migrate` does. Better
+ // Auth's migrations read DATABASE_URL from the shell before .env, and each step holds
+ // the lock of the database it changes.
+ const url = runFiles ? readDatabaseUrl(root) : undefined;
+ const authUrl = mysqlAuth ? readAuthDatabaseUrl(root) : undefined;
+ if ((runFiles && !url) || (mysqlAuth && !authUrl)) {
afterServerReady(server, () => {
console.log();
voidError(
@@ -47,38 +79,55 @@ export function migrationPlugin(
});
return;
}
- const { collectMigrations } = await import('./migrations/collect.ts');
- const { validateMigrations } = await import('./migrations/validate.ts');
+ // One lock covers both steps when they change the same database.
+ const authWithFiles = authUrl !== undefined && authUrl === url;
- const allMigrations = collectMigrations(migrationsDir);
- const appliedNames =
- dialect === 'mysql'
- ? await import('./migrations/runner-mysql.ts').then((runner) =>
- runner.getMySqlAppliedMigrations(url),
- )
- : await import('./migrations/runner-pg.ts').then((runner) =>
- runner.getPgAppliedMigrations(url),
- );
- const appliedSet = new Set(appliedNames);
- const pending = allMigrations.filter((migration) => !appliedSet.has(migration.name));
- if (pending.length > 0) {
- try {
- validateMigrations(pending);
- } catch (err) {
- voidWarn(err instanceof Error ? err.message : String(err));
+ if (url) {
+ const { validateMigrations } = await import('./migrations/validate.ts');
+ const appliedNames =
+ dialect === 'mysql'
+ ? await import('./migrations/runner-mysql.ts').then((runner) =>
+ runner.getMySqlAppliedMigrations(url),
+ )
+ : await import('./migrations/runner-pg.ts').then((runner) =>
+ runner.getPgAppliedMigrations(url),
+ );
+ const appliedSet = new Set(appliedNames);
+ const pending = allMigrations.filter((migration) => !appliedSet.has(migration.name));
+ if (pending.length > 0) {
+ try {
+ validateMigrations(pending);
+ } catch (err) {
+ voidWarn(err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ const applied =
+ dialect === 'mysql'
+ ? await import('./migrations/runner-mysql.ts').then((runner) =>
+ runner.runMySqlMigrations(
+ migrationsDir,
+ url,
+ // Report the applied files before Better Auth's step, which can still fail.
+ authWithFiles
+ ? async (names) => {
+ logApplied(names);
+ await migrateAuth(url);
+ }
+ : undefined,
+ ),
+ )
+ : await import('./migrations/runner-pg.ts').then((runner) =>
+ runner.runPgMigrations(migrationsDir, url),
+ );
+ if (!authWithFiles) {
+ logApplied(applied);
}
}
- const applied =
- dialect === 'mysql'
- ? await import('./migrations/runner-mysql.ts').then((runner) =>
- runner.runMySqlMigrations(migrationsDir, url),
- )
- : await import('./migrations/runner-pg.ts').then((runner) =>
- runner.runPgMigrations(migrationsDir, url),
- );
- if (applied.length > 0) {
- voidLog(`Applied ${applied.length} migration(s): ${applied.join(', ')}`);
+ if (authUrl && !authWithFiles) {
+ const { withMySqlMigrationLockAt } = await import('./migrations/runner-mysql.ts');
+ await withMySqlMigrationLockAt(authUrl, () => migrateAuth(authUrl));
}
} else if (hasSqlMigrations) {
const { runMigrations, getMiniflareDatabasePath, getAppliedMigrations } =
@@ -106,8 +155,9 @@ export function migrationPlugin(
}
if (auth?.enabled) {
- const { runLocalVoidAuthMigrations } = await import('./auth/node.ts');
- await runLocalVoidAuthMigrations(root, dialect, persistRoot, databaseId, auth.configPath);
+ if (!mysqlAuth) {
+ await migrateAuth();
+ }
voidLog('Applied Better Auth migrations');
}
} catch (err) {
diff --git a/packages/void/src/plugin-node-target.ts b/packages/void/src/plugin-node-target.ts
index b9cadbed6..eefdcf6ac 100644
--- a/packages/void/src/plugin-node-target.ts
+++ b/packages/void/src/plugin-node-target.ts
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import { pathToFileURL } from 'node:url';
import { dirname, join, relative } from 'pathslash';
import type { Plugin } from 'vite';
+import { findVoidDependentPackages } from './installed-packages.ts';
import { readProjectPaths } from './project-paths.ts';
/** Imports that require CF bindings and are not available in Node.js mode. */
@@ -111,17 +112,23 @@ function honoMiddleware(
* single SSR build for API-only apps
*/
export function nodeTargetPlugin(target: NodeTarget): Array {
+ const unavailable = (id: string, root: string, importer: string | undefined) => {
+ // The import can come from an installed package, so name the file.
+ const source = importer ? ` Imported by ${relative(root, importer)}.` : '';
+ return new Error(
+ `target: '${id}' is not available with target '${target}'. ` +
+ `CF bindings require target: "cloudflare".${source}`,
+ );
+ };
+
const guardPlugin: Plugin = {
name: 'void:node-target',
enforce: 'pre',
resolveId: {
filter: { id: CF_ONLY_REGEX },
- handler(id) {
- throw new Error(
- `target: '${id}' is not available with target '${target}'. ` +
- `CF bindings require target: "cloudflare".`,
- );
+ handler(id, importer) {
+ throw unavailable(id, this.environment.config.root, importer);
},
},
@@ -142,11 +149,20 @@ export function nodeTargetPlugin(target: NodeTarget): Array {
},
config(_, { command }) {
+ const root = process.cwd();
+ // Vite's SSR graph leaves a bare import that resolves into node_modules to Node before
+ // any plugin sees it. An installed void would then skip the guard above, and in a build
+ // the app's `void/handler` would load beside the copy that the generated routes bundle,
+ // so its ValidationError is not the class the routes check. Inline void, its adapters
+ // and installed packages that import void in the dev server, Vitest and the build alike,
+ // as with a workspace link.
+ const ssr = {
+ noExternal: ['void', /^@void\//, ...findVoidDependentPackages(root)],
+ };
if (command !== 'build') {
- return;
+ return { ssr };
}
- const root = process.cwd();
const voidDir = join(root, '.void');
const ssrBuildOptions = {
outDir: 'dist/ssr',
@@ -163,6 +179,7 @@ export function nodeTargetPlugin(target: NodeTarget): Array {
// API-only apps need a single SSR build.
if (existsSync(readProjectPaths(root).pagesDir)) {
return {
+ ssr,
build: { outDir: 'dist/client' },
environments: {
ssr: { build: ssrBuildOptions },
@@ -174,6 +191,7 @@ export function nodeTargetPlugin(target: NodeTarget): Array {
}
return {
+ ssr,
build: {
ssr: true,
...ssrBuildOptions,
@@ -181,14 +199,15 @@ export function nodeTargetPlugin(target: NodeTarget): Array {
};
},
- // Orchestrate dual-environment build for pages apps.
- // For API-only apps (no `environments.ssr`), this hook is not triggered
- // because Vite's builder is only created when extra environments exist.
+ // Orchestrate the builds. Vite runs this hook with either builder. API-only apps use
+ // the legacy single-environment builder, which creates only the `ssr` environment.
async buildApp(builder) {
// SSR must build first so that dist/ssr/app.js exists when the
// prerender plugin runs in the client environment's closeBundle hook.
await builder.build(builder.environments.ssr);
- await builder.build(builder.environments.client);
+ if (builder.environments.client) {
+ await builder.build(builder.environments.client);
+ }
},
configureServer(server) {
@@ -224,5 +243,22 @@ export function nodeTargetPlugin(target: NodeTarget): Array {
},
};
- return [guardPlugin];
+ // The build bundles void, whose runtime loads `cloudflare:` modules with import() only when it
+ // runs in workerd. Keep those external. Node cannot load a static one, so fail the build.
+ const cloudflareModulesPlugin: Plugin = {
+ name: 'void:node-target-cloudflare-modules',
+ apply: 'build',
+ enforce: 'pre',
+ resolveId: {
+ filter: { id: /^cloudflare:/ },
+ handler(id, importer, options) {
+ if (options.kind === 'dynamic-import') {
+ return { id, external: true };
+ }
+ throw unavailable(id, this.environment.config.root, importer);
+ },
+ },
+ };
+
+ return [guardPlugin, cloudflareModulesPlugin];
}
diff --git a/packages/void/src/plugin-worker-environment.ts b/packages/void/src/plugin-worker-environment.ts
index 020b09a24..5bb0396ae 100644
--- a/packages/void/src/plugin-worker-environment.ts
+++ b/packages/void/src/plugin-worker-environment.ts
@@ -1,7 +1,10 @@
-import { fileURLToPath } from 'node:url';
+import { readFileSync } from 'node:fs';
+import { builtinModules, createRequire } from 'node:module';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { dirname, join } from 'pathslash';
import { type Plugin } from 'vite';
-import { ensureWranglerCompat } from './cli/wrangler.ts';
-import { readConfig, isNodeTarget } from './config.ts';
+import { ensureWranglerCompat, type WranglerCompat } from './cli/wrangler.ts';
+import { readConfig, isNodeTarget, type DatabaseDialect } from './config.ts';
const CLOUDFLARE_BUILTIN_IMPORT = /^cloudflare:/;
@@ -46,6 +49,29 @@ export function createCloudflareBuiltinsExternalPlugin(): Plugin {
};
}
+/**
+ * Send a bare `void` import in server code to the handler helpers (`defineScheduled`,
+ * `defineQueue`, ...), as Void apps and the cron/queue deploy bundles do. The package entry is the
+ * Vite plugin: loaded in a worker environment, it pulls Wrangler into the dependency optimizer.
+ */
+export function createBareVoidRedirectPlugin(): Plugin {
+ let handlerFile: string | undefined;
+ return {
+ name: 'void:bare-void-redirect',
+ resolveId: {
+ order: 'pre',
+ filter: { id: /^void$/ },
+ handler() {
+ if (this.environment?.config.consumer === 'client') {
+ return;
+ }
+ handlerFile ??= fileURLToPath(import.meta.resolve('#self/handler'));
+ return handlerFile;
+ },
+ },
+ };
+}
+
export function createDefaultWorkerEnvironmentPlugin(): Plugin {
return {
name: 'void:default-worker-environment',
@@ -130,27 +156,106 @@ export function createWranglerCompatPersistPlugin(
};
}
-export function createServerRuntimeOptimizerPlugin(voidOwnsWorkerRuntime: boolean): Plugin {
+// void/db leaves its driver import to drizzle, so these nested prebundles serve it.
+const DATABASE_DRIVER_DEPS: Record> = {
+ sqlite: [],
+ postgresql: ['void > pg'],
+ mysql: ['void > mysql2'],
+};
+
+const NODE_BUILTIN = new RegExp(`^(?:node:)?(?:${builtinModules.join('|')})$`);
+const NODE_BUILTIN_REQUIRE_PREFIX = '\0void:node-require:';
+
+/**
+ * The Node built-ins that workerd provides for the given compatibility settings, from the preset
+ * the Cloudflare plugin builds Workers with (a dependency of `@cloudflare/vite-plugin`). Before
+ * 2025-09-15 the preset fills in parts of `node:process` instead of listing it, but workerd
+ * provides that module at every date.
+ */
+async function readWorkerdNodeBuiltins(compat: WranglerCompat): Promise> {
+ const vitePlugin = fileURLToPath(import.meta.resolve('@cloudflare/vite-plugin'));
+ const manifestPath = createRequire(vitePlugin).resolve('@cloudflare/unenv-preset/package.json');
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
+ exports: { '.': { import: string } };
+ };
+ const { getCloudflarePreset } = (await import(
+ pathToFileURL(join(dirname(manifestPath), manifest.exports['.'].import)).href
+ )) as {
+ getCloudflarePreset(compat: { compatibilityDate: string; compatibilityFlags: Array }): {
+ external: Array;
+ };
+ };
+ const { external } = getCloudflarePreset({
+ compatibilityDate: compat.compatibilityDate,
+ compatibilityFlags: compat.compatibilityFlags ?? [],
+ });
+ return new Set([...external.map((name) => name.replace(/^node:/, '')), 'process']);
+}
+
+export function createServerRuntimeOptimizerPlugin(
+ dialect: DatabaseDialect,
+ readCompat: () => WranglerCompat,
+): Plugin {
+ let workerdNodeBuiltins: Promise> | undefined;
const serverRuntimeOptimizerPlugin: Plugin = {
name: 'void:server-runtime-optimizer',
- configEnvironment(name) {
+ configEnvironment(name, environment) {
if (name === 'client') {
return;
}
- if (!voidOwnsWorkerRuntime) {
+ // The Cloudflare plugin gives every environment it runs in workerd these conditions from
+ // its config hook, which runs before any configEnvironment hook. That covers Void's own
+ // worker, class A frameworks and adapters such as @astrojs/cloudflare. Node runtimes load
+ // CommonJS themselves, and an include there turns on SSR prebundling nobody asked for.
+ if (!environment.resolve?.conditions?.includes('workerd')) {
return { optimizeDeps: { exclude: ['void'] } };
}
return {
optimizeDeps: {
exclude: ['void'],
// Excluding void also skips converting its CommonJS dependencies, which workerd cannot
- // evaluate: an installed void/email fails with "exports is not defined". Runtimes a
- // framework owns stay untouched; Node loads CommonJS itself, and forcing an include
- // there turns on SSR prebundling the framework did not ask for.
- include: ['void > email-validator'],
+ // evaluate: an installed void/email fails with "exports is not defined".
+ include: ['void > email-validator', ...DATABASE_DRIVER_DEPS[dialect]],
},
};
},
+ // A build bundles void/db's driver, which is CommonJS. The Cloudflare plugin turns its requires
+ // of Node built-ins that workerd provides into imports with rolldown's esmExternalRequirePlugin,
+ // but rolldown skips that plugin unless the Vite copy running the build made it. pnpm installs
+ // Vite once per set of peers, so the app's Vite can differ from the one the Cloudflare plugin
+ // imports, and each require then throws in workerd. Serve those built-ins from an import here.
+ // A require of any other built-in stays as it is and throws only if it runs.
+ resolveId: {
+ // Before Vite's resolver, which makes a required built-in external.
+ order: 'pre',
+ filter: { id: NODE_BUILTIN },
+ async handler(id, _importer, options) {
+ if (
+ dialect === 'sqlite' ||
+ options.kind !== 'require-call' ||
+ this.environment?.mode !== 'build' ||
+ !this.environment.config.resolve.conditions.includes('workerd')
+ ) {
+ return;
+ }
+ const name = id.replace(/^node:/, '');
+ workerdNodeBuiltins ??= readWorkerdNodeBuiltins(readCompat());
+ if (!(await workerdNodeBuiltins).has(name)) {
+ return;
+ }
+ return `${NODE_BUILTIN_REQUIRE_PREFIX}${name}`;
+ },
+ },
+ load: {
+ // oxlint-disable-next-line no-control-regex -- \0 prefix is Vite's virtual module convention
+ filter: { id: /^\0void:node-require:/ },
+ handler(id) {
+ // A built-in's default export is its CommonJS exports, and `require` returns an ES
+ // module's "module.exports" export.
+ const name = id.slice(NODE_BUILTIN_REQUIRE_PREFIX.length);
+ return `import builtin from "node:${name}";\nexport { builtin as "module.exports" };\n`;
+ },
+ },
};
return serverRuntimeOptimizerPlugin;
diff --git a/packages/void/src/runtime/better-auth-mysql.ts b/packages/void/src/runtime/better-auth-mysql.ts
index 051a54f5c..adb9a9476 100644
--- a/packages/void/src/runtime/better-auth-mysql.ts
+++ b/packages/void/src/runtime/better-auth-mysql.ts
@@ -12,7 +12,10 @@ async function applyMigrations(
migrationSchema: VoidAuthMigrationSchema,
): Promise {
const { createConnection } = await import('mysql2/promise');
- const connection = await createConnection(resolveConnectionString(env, 'MySQL'));
+ const connection = await createConnection({
+ uri: resolveConnectionString(env, 'MySQL'),
+ disableEval: true,
+ });
try {
await applyMySqlAuthMigrations(mysqlMigrationRunner(connection), migrationSchema);
} finally {
diff --git a/packages/void/src/runtime/db.ts b/packages/void/src/runtime/db.ts
index 12cc91cc5..8d6a74939 100644
--- a/packages/void/src/runtime/db.ts
+++ b/packages/void/src/runtime/db.ts
@@ -6,7 +6,7 @@
* This stub exists for the published npm package and type declarations.
*/
import { drizzle as _drizzle, type DrizzleD1Database } from 'drizzle-orm/d1';
-import { getRuntimeRequestResource, requireRuntimeBinding } from './env.ts';
+import { getRuntimeBinding, getRuntimeRequestResource, requireRuntimeBinding } from './env.ts';
// Re-export commonly used Drizzle utilities so consumers never need
// to depend on drizzle-orm directly.
@@ -56,6 +56,22 @@ function createPrimaryDb(d1: D1Database): DrizzleD1Database {
const requestDbKey = {};
+function requireD1(): D1Database {
+ // Bundlers that skip Void's plugins load this D1 client for every app. Nitro needs the
+ // `void/nitro` module to serve a PostgreSQL or MySQL app's own `void/db`. Vite serves it to
+ // an installed package only when the package is an ES module that depends on void and the
+ // app lists it or reaches it through such packages.
+ if (
+ getRuntimeBinding('DB') == null &&
+ (getRuntimeBinding('HYPERDRIVE') != null || getRuntimeBinding('DATABASE_URL') != null)
+ ) {
+ throw new Error(
+ "db: Missing Cloudflare binding 'DB'. This app connects through HYPERDRIVE or DATABASE_URL, but its server bundle loaded the D1 client. In Nuxt or Analog, add 'void/nitro' to the Nitro modules. An installed package that imports void/db must be an ES module that lists void in its dependencies or peerDependencies, and the app must list it or reach it through such packages.",
+ );
+ }
+ return requireRuntimeBinding('DB');
+}
+
/**
* Default Drizzle D1 instance — resolves the `DB` binding from the runtime
* environment. When the Void plugin is active, this is replaced with a
@@ -65,10 +81,10 @@ export const db: DrizzleD1Database = new Proxy({} as DrizzleD1Database, {
get(_, prop) {
const instance = getRuntimeRequestResource(
requestDbKey,
- () => createDb(requireRuntimeBinding('DB')),
+ () => createDb(requireD1()),
// Framework adapters do not always enter Void's request scope. Without a
// shared session, each operation starts on primary to preserve fresh reads.
- () => createPrimaryDb(requireRuntimeBinding('DB')),
+ () => createPrimaryDb(requireD1()),
);
return Reflect.get(instance, prop);
},
diff --git a/packages/void/src/runtime/env-raw.ts b/packages/void/src/runtime/env-raw.ts
index 908763d1d..33a0fe312 100644
--- a/packages/void/src/runtime/env-raw.ts
+++ b/packages/void/src/runtime/env-raw.ts
@@ -22,7 +22,18 @@ export function isIsrActive(rawEnv: RuntimeEnv): boolean {
return rawEnv.__VOID_ISR_DEPLOYMENT_ID != null;
}
-export const envContext: AsyncLocalStorage = new AsyncLocalStorage();
+/**
+ * One process or isolate can evaluate this module more than once: Vite's SSR module
+ * runner keeps its own copy beside a package it leaves external, and a dependency
+ * optimizer reload re-evaluates it while a request is in flight. Every copy must read
+ * the env of the current request, so the store lives on `globalThis`, like
+ * `authContext`. Two Void versions in one process share it: change the key when the
+ * store shape changes.
+ */
+const ENV_CONTEXT_SYMBOL: unique symbol = Symbol.for('void.envContext.v1');
+const shared = globalThis as { [ENV_CONTEXT_SYMBOL]?: AsyncLocalStorage };
+shared[ENV_CONTEXT_SYMBOL] ??= new AsyncLocalStorage();
+export const envContext: AsyncLocalStorage = shared[ENV_CONTEXT_SYMBOL];
let cloudflareEnv: RuntimeEnv | undefined;
diff --git a/packages/void/src/runtime/env.ts b/packages/void/src/runtime/env.ts
index 2e39cd786..5e553bbca 100644
--- a/packages/void/src/runtime/env.ts
+++ b/packages/void/src/runtime/env.ts
@@ -22,7 +22,11 @@ type RuntimeRequestScope = {
resources: Map