Skip to content

fix(env): serve async env.ts values to Void app requests - #792

Open
morinokami wants to merge 1 commit into
voidzero-dev:mainfrom
morinokami:fix/void-env-async-reads
Open

morinokami wants to merge 1 commit into
voidzero-dev:mainfrom
morinokami:fix/void-env-async-reads

Conversation

@morinokami

Copy link
Copy Markdown
Collaborator

When env.ts declares a key with an async validator, such as a Zod schema with an async refinement, a Void app can't read it:

// env.ts
import { defineEnv } from 'void/env';
import * as z from 'zod';

export default defineEnv({
  API_TOKEN: z.string().refine(async (token) => token.startsWith('sk_')),
});
// routes/api/token.ts
import { defineHandler } from 'void';
import { env } from 'void/env';

export const GET = defineHandler(() => ({ token: env.API_TOKEN }));
Error: env: Async validator for 'API_TOKEN' cannot be resolved synchronously.

The route answers 500 on every request, in vp dev and in a production build. A cron job that reads the key fails the same way, and so does a Valibot schema built with pipeAsync.

The generated entry awaits _validateEnvOnce(env), which resolves async validators ahead of the synchronous env.X reads, and then opens the request scope. _validateEnvOnce cached the resolved values under the env that reads would use at that moment. Outside a scope, that is the env from cloudflare:workers. Reads in the scope look values up under the env the handler received, which is another object in workerd. The cron, queue and email handlers validate in the same order.

Fix

_validateEnvOnce now seeds the cache of the env object it receives. The request scope opens with that object, or in remote dev mode with a copy of it.

  • A completed validation is reused for the same raw values, so a schema is still validated once per isolate. This includes vp dev, which passes a new env object for each request.
  • An env with other values is validated again and keeps its own values. Cloudflare may keep an isolate running when only its bindings change, so requests that hold the old and the new values can overlap.
  • A read through an env that was never validated itself takes a key's value from the latest validation while that env holds the raw value the value came from. That serves the copy that remote dev mode reads, and the cloudflare:workers env outside a request scope.
  • Only a completed validation is reused. In workerd, a promise that waits on the I/O of a canceled request never settles, so each call runs its own validation until one completes.
  • defineEnv replaces the per-env caches. It used to clear them through a list that kept every cache alive, one per env object.

Two behaviors change besides the fix:

  • The whole-schema validation used to run once per isolate, whatever the values. It now runs once per set of raw values: a failed validation runs again once the values change, and an env with new values is validated instead of skipped.
  • The cloudflare:workers env used to receive the values of the first validation, whatever it held. It is now served a value only for the raw value it holds.

A handler that runs in a Durable Object (a WebSocket route, a Durable State definition) doesn't validate the env. It can now read an async key in an isolate where a request or a cron, queue or email event has validated, but not in one that runs only the Durable Object. Validating from those handlers is left for a follow-up.

Tests

packages/void/test/unit/env-schema.test.ts (12 cases) calls _validateEnvOnce and reads env:

  • An async value is read in a request scope opened after validation, and through another env object that holds the same values.
  • A key is served only for the raw value it was validated from, inside and outside a request scope.
  • An env with other values is validated again, and the first env keeps its values afterwards.
  • A failed validation runs again once the value changes.
  • A validation that never settles doesn't block the next call, and a schema registered during the wait is the one validated.

packages/void/test/unit/routing-runtime.test.ts (3 cases) sends requests through createWorkerFetch to a Hono route that reads an async key: with a new env object for each request, with the remote dev copy, and with two overlapping requests that hold different values.

12 of the 15 cases fail without this fix. The other three check behavior that main already has: a read outside a request scope with the same values, a validator that throws running again, and an unfinished validation not blocking the next call.

Verification

  • vp test run: 792 files pass, 1 skipped (16,644 tests pass, 30 skipped).
  • vp run check: 33/33 pass.
  • By hand on a copy of playground/ssr with the env.ts and route above, API_TOKEN=sk_live in .env, and a cron job that logs the value:
    • vp dev and vp preview: the route answers {"token":"sk_live"} on every request, and in vp dev the cron job logs the value. Without this fix, the route answers 500 in both, and the cron job fails.
    • The built Worker in workerd through Miniflare: the route and the scheduled handler read the value, also when the cron event is the isolate's first event. Without API_TOKEN, or with a value the refinement rejects, requests answer 500 with the boot validation error, and the cron event fails.
    • With Valibot's pipeAsync and checkAsync in place of the Zod schema: the route reads the value in vp dev and vp preview, and answers 500 in both without this fix.
    • With database: 'pg' and no database connection, since the route doesn't query it: the route reads the value in vp dev and vp preview.
    • With a WebSocket route whose onRequest reads the key: it reads the value in vp dev and vp preview. Without this fix, it fails in both.
  • Not run: void deploy against Cloudflare, remote dev mode, and a binding change that reuses an isolate. The last two are covered by the unit tests above.

🤖 Generated with Claude Code

A Void app could not read an env.ts key whose validator is async: every
request that read it failed with "Async validator ... cannot be resolved
synchronously", in dev and in production builds.

The generated entry validates the env before it opens the request scope.
_validateEnvOnce cached the resolved values under the env that reads use
outside a scope, while reads in the scope look them up under the env the
handler received.

_validateEnvOnce now seeds the cache of the env it receives. A completed
validation is reused for the same raw values, so a schema is still
validated once per isolate, also when a dev server passes a new env
object for each request. An env with other values is validated again
rather than given values validated for another, and keeps its own values:
Cloudflare may keep an isolate running when only its bindings change, so
requests that hold the old and the new values can overlap.

A read through an env that was never validated itself, such as the remote
dev copy or the cloudflare:workers env outside a request scope, takes the
value from the latest validation while that env holds the raw value the
value came from.

Only a completed validation is reused. In workerd, a promise that waits
on the I/O of a canceled request never settles, so each call runs its own
validation until one completes. A call that finds another schema
registered after it waited validates again with that schema.

The per-env caches no longer stay referenced after their env is gone.

Co-Authored-By: Claude Fable 5.2 <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