Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/integrations/frameworks/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ With `voidPlugin()` added to the framework's Vite config, frameworks get most of
- **Typed database:** query types come from your Drizzle schema.
- **Migrations:** local D1 is updated on dev startup, and migrations are validated and deployed with `void deploy`.
- **Runtime helpers:** `void/db`, `void/kv`, `void/storage`, and `void/ai` give you typed access to Cloudflare bindings in dev and production.
- **Environment variables:** in TanStack Start, React Router, and vinext apps, `env` from `void/env` returns the values of your [`env.ts`](../../guide/env-vars.md) schema in dev and production, with defaults, type conversions, and async validators applied. A missing or invalid value fails only the requests that read it.
- **Auth:** Void-managed auth is not supported in framework mode yet. For now, use Better Auth's official integration for your framework.
- **Cron jobs:** scheduled handlers via the `crons/` directory.
- **Queue consumers:** typed producers and consumers via the `queues/` directory.
Expand Down
1 change: 1 addition & 0 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ generated entries must preserve the module-boundary rules below.
- **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 <url>` 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.
- **Framework env schema**: `env` has no schema in a Worker whose own modules have not loaded `env.ts`, whatever the Vite plugin loaded in its process, so it returns raw values. The Class A wrapper (TanStack Start, React Router, vinext) imports `env.ts` ahead of the framework entry and awaits `_prepareEnv(env)` on every request, in dev and build. Unlike `_validateEnvOnce`, it never rejects: a read of a key that failed throws that key's error, async validators included, so requests that do not read it and a build-time prerender without production secrets still run. Both keep one result per key, reuse a validation for the same raw values, and keep the results with the env object they received. Reads through another object (the `cloudflare:workers` env that a D1 app's framework reads outside a request scope, the remote dev copy) get the latest validation's result while they hold the raw value it came from; never copy results to that object, which can come to hold other values. A validation in which a validator threw serves its reads but runs again on the next call. Results live on the process-global registry because `void deploy` bundles cron and queue jobs apart from the app, with a copy of `void/env` of their own: the jobs call `registry.prepare`, the `_prepareEnv` of the copy that registered the schema (key in `shared/env-registry.ts`). SvelteKit, Nuxt, Analog and Astro Workers do not load `env.ts` yet.
- **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.
- **Typed Durable State convention**: each default-exported `defineDurableState()` definition in `durable-objects/*.ts` becomes a filename-derived binding and worker class (`shopping-cart.ts` → `SHOPPING_CART` / `ShoppingCartDurableObject`). The generated entry calls the internal `__bind()` hook so named imports can use `State.get(name)` without touching `c.env`, exports `State.class`, and emits a stable `new_sqlite_classes` migration. `syncWranglerDurableObjects()` persists bindings and append-only class migration history in `void.lock.json` through the generated Cloudflare config; never regenerate migration order solely from the current sorted files. The generated class extends `DurableObject` from `cloudflare:workers` so namespace stubs expose real RPC methods; runtime state and helpers use JavaScript private fields and methods. Runtime operations are serialized and persisted after successful typed RPC, `fetch`, or `alarm` handlers; versioned value migrations run on load. This is native-Void + Cloudflare only and is supported by direct Cloudflare deploys. Typed `*.ws.ts` routes likewise use SQLite-backed classes; their root-config sync uses the comment-preserving writer, retains legacy `new_classes` histories, and appends a new migration tag only for new classes.
- **Persistent resource names**: optional static `name` on Durable State and WebSocket definitions uses the same identity derivation as unnamed filenames/routes. Preserve unnamed defaults exactly. `void info` reports names and verified unmatched lock identities without executing source or writing files. Scanners use the shared AST reader; runtime validation imports only the Worker-safe `shared/resource-name.ts` leaf. Carry explicit names through generated metadata and `__bind()`; runtime mismatches fail instead of selecting an inferred replacement. WebSocket `key({ params })` is shared by namespace selection and hook `ctx.id`; absent keys keep the existing ordered `param=value` format.
Expand Down
14 changes: 14 additions & 0 deletions packages/void/src/cli/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join, extname, toSlash } from 'pathslash';
import { build } from 'vite';
import { findEnvFile } from '../codegen/env-types.ts';
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 { cloudflareQueueName, type CloudflareQueueNames } from '../queues/cloudflare-names.ts';
import { generateQueueResolverSource } from '../queues/dispatch.ts';
import { ENV_REGISTRY_KEY } from '../shared/env-registry.ts';
import { WORKER_CONDITIONS } from './worker-conditions.ts';
import { transformWorkerRequireBundle } from './worker-require-transform.ts';

Expand Down Expand Up @@ -794,6 +796,12 @@ function generateHandlersSource(
if (scoped) {
lines.push(`import { withRuntimeEnv } from ${JSON.stringify(envModuleFile)};`);
}
// The app's entry loads env.ts when the Worker starts, and the copy of `void/env` that
// registers it publishes its `_prepareEnv`. Jobs bundle their own copy apart from the app, so
// they prepare the env through that one, and their reads serve the values it resolved.
const prepareEnv = findEnvFile(root)
? ` await globalThis[Symbol.for(${JSON.stringify(ENV_REGISTRY_KEY)})]?.prepare?.(env);`
: undefined;

// Scheduled handlers
if (features.crons.length > 0) {
Expand All @@ -808,6 +816,9 @@ function generateHandlersSource(
? 'async function __runScheduled(controller, env, ctx) {'
: 'export async function handleScheduled(controller, env, ctx) {',
);
if (prepareEnv) {
lines.push(prepareEnv);
}
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)) {`);
Expand Down Expand Up @@ -858,6 +869,9 @@ function generateHandlersSource(
? 'async function __runQueue(batch, env, ctx) {'
: 'export async function handleQueue(batch, env, ctx) {',
);
if (prepareEnv) {
lines.push(prepareEnv);
}
lines.push(' const queueSuffix = batch.queue;');
lines.push(' const __queueIndex = __resolveQueueIndex(queueSuffix);');
lines.push(' if (__queueIndex === -1) {');
Expand Down
31 changes: 24 additions & 7 deletions packages/void/src/plugin-framework-entry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mkdirSync, readdirSync, statSync } from 'node:fs';
import { isAbsolute, join, resolve } from 'pathslash';
import { findEnvFile } from './codegen/env-types.ts';
import { writeFileIfChanged } from './codegen/write-if-changed.ts';
import { type DatabaseDialect, type NotFoundHandling } from './config.ts';
import { isViteBuildOrPreview } from './plugin-dev-config.ts';
Expand Down Expand Up @@ -130,6 +131,10 @@ export function getOriginalFrameworkEntry(
* splitting, *before* constant folding removes the dead branch, so the
* dispatch file (and its transitive user-cron/queue imports + dev token)
* would still ship in the production bundle.
*
* With an `env.ts`, both shapes import it ahead of the framework entry and await `_prepareEnv`
* on every request, so `env` from `void/env` serves the schema's values. A key that does not
* validate fails only the requests that read it.
*/
export function generateClassATriggerWrapper(
root: string,
Expand All @@ -152,19 +157,31 @@ export function generateClassATriggerWrapper(
// vinext resolves public assets after middleware itself. Retrying its 404s
// through ASSETS would expose files deliberately hidden by middleware.
const assetFallback = framework !== 'vinext-app' && framework !== 'vinext-pages';
const fetchBody = isBuild
? assetFallback
? ` return serveWithAssets(request, env, await original.fetch(request, env, ctx));`
: ` return original.fetch(request, env, ctx);`
: ` if (request.method === "POST") {
const envFile = findEnvFile(root);
// Ahead of the dev trigger dispatch too: the cron and queue jobs it runs read the same values.
const prepareEnv = envFile ? ` await _prepareEnv(env);\n` : '';
const fetchBody =
prepareEnv +
(isBuild
? assetFallback
? ` return serveWithAssets(request, env, await original.fetch(request, env, ctx));`
: ` return 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))});
const response = await handleDevTrigger(request, env, ctx);
if (response) return response;
}
}
return original.fetch(request, env, ctx);`;
return original.fetch(request, env, ctx);`);
// env.ts first, so its schema is registered before the framework's modules evaluate. A
// Worker whose own modules have not loaded env.ts has no schema, whatever the Vite plugin
// loaded in its process. `void/env` by package name, so the wrapper shares the copy that the
// app's own imports load.
const envImports = envFile
? `import ${JSON.stringify(envFile)};\nimport { _prepareEnv } from "void/env";\n`
: '';
const scoped = dialect !== 'sqlite';
const fetchMethod = scoped
? ` fetch(request, env, ctx) {
Expand All @@ -176,7 +193,7 @@ ${fetchBody.replace(/^/gm, ' ')}
${fetchBody}
},`;
const body = `// @ts-nocheck — Auto-generated by Void ${isBuild ? 'for vite build' : 'in dev'}. Do NOT edit.
import original from ${JSON.stringify(original)};
${envImports}import original from ${JSON.stringify(original)};
export * from ${JSON.stringify(original)};
${scoped ? 'import { withRuntimeEnv } from "void/_env";\n' : ''}
export default {
Expand Down
Loading
Loading