Skip to content

fix(env): apply env.ts in TanStack Start, React Router and vinext Workers - #820

Draft
morinokami wants to merge 3 commits into
voidzero-dev:mainfrom
morinokami:fix/framework-env-ts-reads
Draft

morinokami wants to merge 3 commits into
voidzero-dev:mainfrom
morinokami:fix/framework-env-ts-reads

Conversation

@morinokami

Copy link
Copy Markdown
Collaborator

In a TanStack Start, React Router or vinext app, env from void/env ignores env.ts:

// env.ts
import { boolean, defineEnv, number, string } from 'void/env';

export default defineEnv({
  STRIPE_KEY: string(),
  PORT: number().default(3000),
  DEBUG: boolean(),
});
// A React Router loader
import { env } from 'void/env';

export const loader = () =>
  Response.json({ stripeKey: env.STRIPE_KEY, port: env.PORT, debug: env.DEBUG });

With DEBUG=false and neither STRIPE_KEY nor PORT set, the route reads PORT as undefined and DEBUG as the string "false", and the missing STRIPE_KEY reads as undefined rather than failing. This happens in vp dev and in a production build. An async validator never runs.

Only the Worker entry of a Void app imports env.ts. A framework's Worker never loads it, so no schema is registered there, and env returns raw values, whatever the Vite plugin loaded in its own process.

Fix

The entry that Void generates around the framework's Worker entry (.void/<framework>-trigger-wrapper.ts) now imports env.ts ahead of the framework entry, and awaits a new internal _prepareEnv(env) on every request, in vp dev and in builds.

  • _prepareEnv validates every key, async validators included, and keeps each key's result. It never rejects: a read of a key that failed throws that key's error, also for an async validator, so a request that doesn't read the key still runs, and so does a build-time prerender without production secrets.
  • Like _validateEnvOnce since fix(env): serve async env.ts values to Void app requests #792, it reuses a validation for the same raw values and keeps the results with the env object it receives. A request whose env holds the same raw values compares them and validates nothing.
  • A D1 app's framework reads outside a request scope, through the cloudflare:workers env, which is another object. Such a read gets the latest validation's result while that env holds the raw value the result came from. The results are never copied to that object, which can come to hold other values.
  • In vp dev, the wrapper also prepares the env before it dispatches a cron or queue job.
  • void deploy bundles a framework app's cron and queue jobs apart from the app, with a copy of void/env of their own. With this change, they would see the schema that the app registers and fail to read an async key. The jobs of an app with env.ts now prepare the env through the _prepareEnv that the registering copy publishes on the env registry, so the public VITE_* values embedded in the app are the ones validated. Results by env object move from the module to the registry, where every copy reads them.

Unlike the entry of a Void app, an invalid value doesn't fail every request. That would also stop a build-time prerender, which runs without production secrets, and every request of an existing app with one invalid value. Checking the whole schema in production is left for a follow-up.

Only TanStack Start, React Router and vinext are covered here, since Void generates their Worker entry. SvelteKit, Nuxt, Analog and Astro build their Workers differently and are left for follow-ups.

Other behavior changes:

  • A read of a missing required key throws an EnvValidationError instead of returning undefined.
  • An env.ts that throws when it loads stops the Worker from starting, since the Worker now loads it.
  • _validateEnvOnce, which Void apps use, now keeps a result for each key too. A read that doesn't go through its whole-schema check, such as one in a Durable Object handler, gets the key's own result from the latest validation, even when another key failed. It used to validate the key again, which fails for an async validator.
  • A validation in which a validator threw runs again on the next call, as with _validateEnvOnce since fix(env): serve async env.ts values to Void app requests #792. Until then, a read of that key throws what the validator threw.
  • Validators of different keys run concurrently in _validateEnvOnce and _prepareEnv. validateAllEnv, which the dev server's warning and the deploy check use, still runs them in order.
  • The registry keeps the latest validation in a new field, latestValidation. 1.8.0 stores another shape under validation, and two Void versions in one process share the registry.

Docs

  • docs/integrations/frameworks/overview.md lists environment variables among the features of TanStack Start, React Router and vinext apps.
  • packages/void/AGENTS.md records why the wrapper loads env.ts, and how reads find the results.

Tests

  • packages/void/test/unit/env-schema.test.ts (10 cases):
    • _prepareEnv serves defaults, converted values and async results, resolves with invalid keys and fails only their reads (what a validator throws included), redacts the raw value from an async validator's failure, validates once for the same raw values and again for others, runs a validator that threw again, and resolves without an env.
    • Outside a request scope, it serves the results, failures included, while the env there holds the same values, and stops once it holds another.
    • With two copies of void/env, one reads what the other prepared, the registering copy's _prepareEnv is published, and registering a schema again drops the results in every copy.
  • packages/void/test/unit/plugin-class-a-wrapper.test.ts (4 cases):
    • The generated wrapper imports env.ts ahead of the framework entry and calls _prepareEnv.
    • The PostgreSQL dev and build wrappers run in Node, with a new env object for each request. A request that reads no key answers 200, a default is read as a number, a read of a missing key answers 500 with that key's error (also for an async validator), and values that the env holds read as the schema returns them. The runner gives Node a file URL for the Windows paths that the wrappers import.
    • A D1 app's build wrapper, bundled and run in workerd, answers the same.
  • packages/void/test/unit/wrapper.test.ts (2 cases): in workerd, the cron job and queue consumer that void deploy bundles for a D1 and a PostgreSQL app read a default and an async value that the app prepared. The jobs of an app without env.ts stay as they were.
  • The React Router, TanStack Start, vinext app and vinext pages playgrounds get an env.ts with a default that nothing else supplies, and a route that returns it. Their dev integration suites read it, and test/check-playground-build-artifacts.mjs checks that each server bundle contains env.ts.

All 16 unit cases and the 4 dev cases fail without this fix.

Verification

  • vp test run: 798 files pass, 1 skipped (16,879 tests pass, 30 skipped).
  • vp run check: 34/34 pass.
  • vp preview of the four playgrounds' production builds: /api/env answers {"SCHEMA_DEFAULT":48213}.
  • By hand on copies of the React Router and TanStack Start playgrounds, with an async validator for a TOKEN key that falls back to a value, and a prerendered page that reads it and SCHEMA_DEFAULT: the build prerenders the page with both values. React Router's vp build doesn't exit after prerendering, with or without env.ts.
  • By hand on the TanStack Start copy with "sideEffects": false: the server bundle keeps env.ts, and the prerendered page has both values.
  • Not run: void deploy against Cloudflare, a dev server with a PostgreSQL database (the unit tests run the PostgreSQL dev wrapper in Node), and Windows.

🤖 Generated with Claude Code

morinokami and others added 3 commits November 14, 2026 00:23
…kers

In a TanStack Start, React Router or vinext app, `env` from `void/env`
ignored env.ts: a default read as undefined, a boolean stayed a string,
and a missing required key went unnoticed. Only Void's own Worker entry
loaded env.ts, so no schema was registered in a framework Worker.

The Class A wrapper now imports env.ts ahead of the framework entry and
awaits the new internal `_prepareEnv(env)` on every request, in dev and
in builds. It validates every key, async validators included, and never
rejects: a read of a key that failed throws that key's error, so a
request that does not read it still runs, and so does a build-time
prerender without production secrets. A read of a missing required key
now throws rather than returning undefined. Unlike Void's own entry, an
invalid value does not fail every request. SvelteKit, Nuxt, Analog and
Astro Workers do not load env.ts yet.

`_prepareEnv` and `_validateEnvOnce` share one validation, which now
keeps a result for each key, and the results stay with the env object
that was validated. A D1 app's framework reads outside a request scope,
through the `cloudflare:workers` env, another object. Such a read gets
the latest validation's result, failures included, while that env holds
the raw value the result came from. A validation in which a validator
threw serves its reads and runs again on the next call.

`void deploy` bundles a framework app's cron and queue jobs apart from
the app, with a copy of `void/env` of their own. They would see the
schema that the app registers and fail to read async keys, so the jobs
of an app with env.ts now prepare the env through the `_prepareEnv` that
the registering copy publishes on the env registry. Results by env
object move to that registry, so every copy reads them. The latest
validation moves to a new registry field, `latestValidation`: its shape
changed, and two Void versions in one process share the registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PostgreSQL dev and build wrapper tests run the generated wrapper in
Node. The wrapper imports env.ts, and the build wrapper also the routing
runtime, by absolute path, which Vite resolves. Node reads a Windows
path such as `C:/…` or `C:\…` as a URL with the scheme `c:` and rejects
it with ERR_UNSUPPORTED_ESM_URL_SCHEME, so both tests would fail in the
Windows unit shard.

The runner now registers a resolve hook that gives Node a file URL for
such a path, so the tests still run the wrapper as it is generated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test that the copy of `void/env` registering a schema publishes its
`_prepareEnv` compared the registry's entry with that copy's export.
Without `_prepareEnv`, both are undefined, so the test passed without
the change it covers. It now also checks that the entry is a function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant