Skip to content

Commit ca4d221

Browse files
eliminate top-level await from the generated virtual:env/server module — boot validation used to conditionally await each Standard Schema validate() result, putting a TLA in the server env chunk whenever the schema had server keys, and any downstream bundler below esnext (Nitro's node-server preset in real deploys) rejects a TLA chunk, forcing the documented build-target-esnext workaround. Validation is now fully synchronous with identical boot semantics (same process.env read at module init, same per-key report, fail-loud before any importer's body runs, frozen env export) — synchronous init is the only shape that keeps "validated before first use", since user server modules read env.KEY at their own top level. Tradeoff made explicit: async validators are rejected for server keys at config/build time with the fix in the message (boot backstops with the same report when async-ness only surfaces on real values); client keys keep async support (baked at build time, where the plugin awaits). start-env suite: async-server-validator guard fixture + every server chunk must transform under esbuild target es2020 (rejects TLA at parse time — the exact downstream-bundler check). Patch changeset on the 3.0.0-next line.
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 40c6865 commit ca4d221

9 files changed

Lines changed: 153 additions & 10 deletions

File tree

.changeset/no-tla-server-env.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@solidjs/vite-plugin': patch
3+
---
4+
5+
`start.env`: the generated `virtual:env/server` module no longer contains
6+
top-level await, removing the esnext-target deploy requirement. Boot
7+
validation used to conditionally `await` each validator result (Standard
8+
Schema allows `validate()` to return a Promise), which put a TLA in the
9+
server env chunk whenever the schema had `server` keys — and any
10+
downstream bundler with a non-esnext target refuses a TLA chunk outright
11+
(Nitro's node-server preset is the one that bites in practice), forcing
12+
deployments to override the build target to `esnext`. Boot validation is
13+
now fully synchronous with identical semantics: same `process.env` read at
14+
module init, same per-key report, same fail-loud-at-boot before any
15+
importer's body runs, same frozen `env` export — and synchronous init is
16+
the only shape that can keep the "validated before first use" guarantee,
17+
since user server modules read `env.KEY` at their own top level (deferring
18+
the await to request entry cannot cover module-init consumers). The
19+
tradeoff is explicit: async validators (e.g. `z.string().refine(async
20+
...)`) are no longer supported for `server` keys — they are rejected at
21+
config/build time with the fix in the message (they could only ever have
22+
failed at deploy boot otherwise), and boot backstops with the same report
23+
for schemas whose async-ness only surfaces on real values. `client` keys
24+
keep async support: their values are baked at build time, where the plugin
25+
awaits. The start-env suite now asserts every built server chunk
26+
transforms under esbuild target es2020 (which rejects TLA at parse time —
27+
exactly the check a downstream bundler applies) so this cannot regress.

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,14 @@ import { env } from 'virtual:env/client'; // the VITE_-prefixed client vars
396396
is fine). Platform-injected vars that don't exist at build time work,
397397
secrets rotate without a rebuild, and no secret value exists in any
398398
dist artifact; an invalid server environment fails boot with the same
399-
per-key report.
399+
per-key report. Boot validation is synchronous: the generated server
400+
env module contains no top-level await, so the server bundle works
401+
under any downstream build target (Nitro's node-server preset,
402+
es2020 — no `esnext` override needed). The flip side: `server`
403+
validators must be synchronous — an async refinement/transform on a
404+
server key is rejected at config time with the fix in the message
405+
(`client` keys may stay async; they are awaited at build time where
406+
the values are baked).
400407
- **Leaks are errors.** Importing `virtual:env/server` from a client
401408
module graph is a hard error naming the importer (the app root and
402409
everything it imports hydrate — they are client code; keep server env

examples/start-env/env.async.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { z } from 'zod';
2+
import * as v from 'valibot';
3+
4+
// Guard fixture: a `server` key whose validator is async (zod async
5+
// refinement — Standard Schema lets validate() return a Promise). Server
6+
// env validates process.env synchronously at boot, because the generated
7+
// module must carry no top-level await (a TLA chunk forces esnext on
8+
// downstream bundle targets — Nitro's node-server preset rejects it), so
9+
// this schema must be rejected at config/build time with the
10+
// async-validator error naming the key. SESSION_SECRET is used on purpose:
11+
// .env provides a valid value, so the sync prefix of the schema passes and
12+
// zod actually goes async — exercising the Promise detection rather than a
13+
// short-circuiting sync failure.
14+
export default {
15+
server: {
16+
SESSION_SECRET: z
17+
.string()
18+
.min(32)
19+
.refine(async () => true, { message: 'async refinement' }),
20+
},
21+
client: {
22+
VITE_APP_NAME: v.pipe(v.string(), v.minLength(1)),
23+
},
24+
};

examples/start-env/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
},
1111
"devDependencies": {
1212
"vite": "^7.0.0",
13-
"@solidjs/vite-plugin": "workspace:*"
13+
"@solidjs/vite-plugin": "workspace:*",
14+
"esbuild": "^0.25.0"
1415
},
1516
"dependencies": {
1617
"solid-js": "catalog:",

examples/start-env/test/run.mjs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
// client-chunk leak scan; a missing SERVER var only warns at build
1313
// time (runtime env — deferred to boot) while a missing CLIENT var
1414
// (baked) fails the build; a non-VITE_ client key is a config-time
15-
// error,
15+
// error; an async SERVER validator is a config-time error (boot
16+
// validation is synchronous — no top-level await in the server chunk),
1617
// - prod (ssr mode): client chunks carry the client values and neither
1718
// the secret, the server key names, nor any '~standard' validator
1819
// machinery; dist/server bakes NO server values — it reads process.env
1920
// at boot through the user's schema (validator is server-only), so the
2021
// same artifact serves a rotated secret without a rebuild and fails
21-
// boot with the per-key report when the runtime env is invalid;
22+
// boot with the per-key report when the runtime env is invalid; every
23+
// server chunk bundles at target es2020 (no top-level await — the
24+
// Nitro node-server / non-esnext-target regression guard);
2225
// preview folds .env into process.env (zero-config smoke test) and serves
2326
// with the middleware headers live,
2427
// - client mode: the same env layer on a static build — index.html shell,
@@ -371,6 +374,14 @@ async function guardsMode() {
371374
// is a leak the moment it exists — reject it before anything builds.
372375
out = await build({ ENV_SCHEMA: './env.serverprefix.ts' }).catch((e) => e.message);
373376
record('guards', 'prefix', 'VITE_-prefixed server key is a config-time error', /cannot keep it secret/.test(out) && /VITE_API_SECRET/.test(out), out.slice(0, 400));
377+
378+
// Async validators are rejected for `server` keys at config time: boot
379+
// validation is synchronous (the generated server env module carries no
380+
// top-level await so server bundles work on non-esnext targets), so a
381+
// Promise-returning server validator could only ever fail at deploy
382+
// boot — fail fast at build instead, with the fix in the message.
383+
out = await build({ ENV_SCHEMA: './env.async.ts' }).catch((e) => e.message);
384+
record('guards', 'sync-only', 'async server validator is a config-time error', /async validator/.test(out) && /SESSION_SECRET/.test(out), out.slice(0, 400));
374385
}
375386

376387
async function prodMode() {
@@ -396,6 +407,27 @@ async function prodMode() {
396407
record('prod', 'server-bundle', 'server secret NOT baked into the server bundle', !serverJs.includes(SECRET));
397408
record('prod', 'server-bundle', 'server bundle validates process.env at boot (schema shipped server-side)', serverJs.includes('~standard') && serverJs.includes('validation failed at boot') && /process\.env/.test(serverJs));
398409

410+
// TLA regression guard: the env module's boot validation used to emit a
411+
// top-level await (the conditional Standard Schema promise await), which
412+
// made any downstream bundler with a non-esnext target — Nitro's
413+
// node-server preset in practice — reject the whole server chunk unless
414+
// deployments overrode the build target to esnext. esbuild at target
415+
// es2020 refuses TLA at parse time (it can lower everything else, TLA it
416+
// cannot), which is exactly the check a downstream bundler applies: every
417+
// server chunk must pass it.
418+
const esbuild = await import('esbuild');
419+
let tlaFailure = '';
420+
for (const [name, code] of Object.entries(readDistFiles(path.join(exampleDir, 'dist/server')))) {
421+
if (!/\.m?js$/.test(name)) continue;
422+
try {
423+
await esbuild.transform(code, { format: 'esm', target: 'es2020' });
424+
} catch (error) {
425+
tlaFailure = `${name}: ${error.message}`;
426+
break;
427+
}
428+
}
429+
record('prod', 'server-bundle', 'no top-level await in any server chunk (bundles at target es2020)', !tlaFailure, tlaFailure.slice(0, 300));
430+
399431
const preview = (env) => {
400432
const child = startProcess(
401433
'pnpm',

examples/start-env/vite.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import solidPlugin from '@solidjs/vite-plugin';
1616
// - ENV_SCHEMA points start.env at a fixture schema (explicit-path option):
1717
// env.fail.ts requires a variable no .env provides (validation failure),
1818
// env.badprefix.ts declares a client var without the VITE_ prefix
19-
// (config-time prefix error).
19+
// (config-time prefix error), env.async.ts puts an async validator on a
20+
// server key (config-time error — boot validation is synchronous so the
21+
// server chunk carries no top-level await).
2022
export default defineConfig({
2123
plugins: [
2224
solidPlugin({

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ssr/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,11 @@ export interface StartOptions {
180180
* only), so platform-injected vars work and secrets rotate without a
181181
* rebuild — no secret exists in any dist artifact. Build-time server
182182
* failures are a deferred-to-boot warning; dev failures stay hard.
183+
* Boot validation is synchronous — the generated module carries no
184+
* top-level await, so server bundles work on non-esnext targets
185+
* (Nitro's node-server preset needs no `esnext` override) — which is
186+
* why async validators are rejected for `server` keys at config time
187+
* (`client` keys may stay async: they are awaited at build time).
183188
*
184189
* `true` requires the conventional file (error when missing); a string
185190
* is an explicit schema path; `false` disables even the probing.

src/start-env.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
// time work, secrets rotate without a rebuild, and no secret value exists
2424
// in any dist artifact. Build-time server-value failures downgrade to a
2525
// warning (boot enforces); dev failures stay hard errors — dev IS runtime.
26+
// Boot validation is synchronous by design: the generated server module
27+
// contains no top-level await (a TLA chunk forces esnext on downstream
28+
// bundlers — Nitro's node-server preset rejects it), which is why async
29+
// validators are rejected for `server` keys (client keys may stay async;
30+
// they are awaited at build time where the values are baked).
2631
//
2732
// A failed validation fails the build; in dev it renders Vite's error
2833
// overlay with the per-key report (the virtual modules throw it on load)
@@ -436,7 +441,30 @@ export function startEnv(option: boolean | string | undefined): Plugin[] {
436441
for (const side of ['server', 'client'] as const) {
437442
for (const [key, validator] of Object.entries(schema[side] ?? {})) {
438443
let result = validator['~standard'].validate(raw[key]);
439-
if (result instanceof Promise) result = await result;
444+
if (result instanceof Promise) {
445+
// Async validation is fine for `client` keys — their values are
446+
// baked right here at build time, where awaiting costs nothing.
447+
// `server` keys validate process.env at boot through generated
448+
// code that is deliberately synchronous (a top-level await in the
449+
// server env chunk forces esnext on every downstream bundle
450+
// target — Nitro's node-server preset rejects it outright), so a
451+
// Promise-returning server validator could only ever fail at
452+
// deploy boot. Async-ness is a property of the schema, not the
453+
// value, so fail fast here with the fix in the message. Boot
454+
// still backstops (schemas whose sync prefix short-circuits at
455+
// build time can go async on real values).
456+
if (side === 'server') {
457+
throw new Error(
458+
`[@solidjs/vite-plugin] server env var "${key}" in ${envFile} uses an async ` +
459+
`validator (validate() returned a Promise). Server env is validated ` +
460+
`synchronously at boot — the generated module contains no top-level ` +
461+
`await, so server bundles work on non-esnext targets — which async ` +
462+
`validators cannot do. Make the validator synchronous (drop async ` +
463+
`refinements/transforms), or run the async check in application code.`,
464+
);
465+
}
466+
result = await result;
467+
}
440468
if (result.issues && result.issues.length) {
441469
for (const issue of result.issues) {
442470
const at = (issue.path ?? [])
@@ -532,6 +560,16 @@ export function startEnv(option: boolean | string | undefined): Plugin[] {
532560
// means. Platform-injected vars that don't exist at build time work,
533561
// secrets rotate without a rebuild, and no secret value exists in any
534562
// dist artifact.
563+
//
564+
// The generated code is deliberately free of top-level await. Module
565+
// init is the only point where "validated and frozen before any
566+
// importer's body runs" can be guaranteed — user server modules read
567+
// `env.KEY` at their own top level — and the only async thing here is
568+
// Standard Schema's option to return a Promise from validate(). A TLA
569+
// chunk breaks every downstream bundler with a non-esnext target
570+
// (Nitro's node-server preset in practice), so validation runs
571+
// synchronously and a Promise-returning server validator is itself a
572+
// boot issue (build/config time rejects it earlier when detectable).
535573
function serverEnvModuleCode(loaded: LoadedEnv) {
536574
const serverKeys = Object.keys(loaded.schema.server ?? {});
537575
const baked = `const __env = ${JSON.stringify(loaded.client)};`;
@@ -549,14 +587,18 @@ export function startEnv(option: boolean | string | undefined): Plugin[] {
549587
code: [
550588
`// Generated by @solidjs/vite-plugin (start.env) — server env.`,
551589
`// Server values are read from process.env and validated at boot;`,
552-
`// client (public) values are baked at build time.`,
590+
`// client (public) values are baked at build time. Boot validation is`,
591+
`// synchronous on purpose: a top-level await here would force esnext`,
592+
`// on every downstream bundle target (Nitro's node-server preset and`,
593+
`// anything else below esnext rejects a TLA chunk outright).`,
553594
`import __schema from ${JSON.stringify(envFileAbs)};`,
554595
baked,
555596
`const __issues = [];`,
556597
`for (const __key of ${JSON.stringify(serverKeys)}) {`,
557-
` let __result = __schema.server[__key]['~standard'].validate(process.env[__key]);`,
558-
` if (__result instanceof Promise) __result = await __result;`,
559-
` if (__result.issues && __result.issues.length) {`,
598+
` const __result = __schema.server[__key]['~standard'].validate(process.env[__key]);`,
599+
` if (__result && typeof __result.then === 'function') {`,
600+
` __issues.push(' \\u2717 ' + __key + ': validator returned a Promise \\u2014 async validators are not supported for server keys (boot validation is synchronous so the server bundle carries no top-level await); make this validator synchronous');`,
601+
` } else if (__result.issues && __result.issues.length) {`,
560602
` for (const __issue of __result.issues) __issues.push(' \\u2717 ' + __key + ': ' + __issue.message);`,
561603
` } else {`,
562604
` __env[__key] = __result.value;`,

0 commit comments

Comments
 (0)