Skip to content
Closed
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
20 changes: 20 additions & 0 deletions .changeset/selectconfig-sync-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@vlandoss/env": minor
---

Add `selectConfig(configs)` — a synchronous, runtime-agnostic counterpart to `loadConfig` for config files that can't use top-level `await`.

Some tooling loads its config file via `require()` or by bundling it to CJS, where `await loadConfig(...)` is rejected (`ERR_REQUIRE_ASYNC_MODULE` / "top-level await is not supported with the cjs output format"). `selectConfig` pairs with static `import`s and picks the entry matching `envName()` — no dynamic `import()`, no `await`:

```ts
import { defineEnv, selectConfig } from "@vlandoss/env";
import development from "./config/development.ts";
import production from "./config/production.ts";

export const env = defineEnv({
schema: Env,
config: selectConfig({ development, production }),
});
```

It throws when the current env has no entry. See the new `config-cjs` example and the `loadConfig` guide for details.
1 change: 1 addition & 0 deletions docsite/content/docs/api-reference/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The `@vlandoss/env` entrypoint is the only one you always import — it contains
| ------------ | -------- | ------------------------------------------------------------------------------------ |
| `schema` | Function | Declare the contract — branches and Standard Schema leaves. |
| `defineEnv` | Function | Merge config + environment variables, validate, and return a typed `env` object. |
| `selectConfig` | Function | Synchronously pick the current env's config from a map of statically-imported configs. The no-`await` counterpart to `loadConfig` for config files that tooling loads via `require()` or bundles to CJS. |
| `envName` | Function | Detect the current environment name across runtimes — `development`, `production`, … |
| `readEnv` | Function | Read raw values from the active source (`process.env` on the server, `window.__env` in the browser). Returns `{}` on runtimes without `process` (Workers) — pass `runtimeEnv` to `defineEnv` instead. |
| `Config` | Type | The input shape of per-environment config files for a given schema. |
Expand Down
24 changes: 23 additions & 1 deletion docsite/content/docs/guides/fs-loadconfig.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,30 @@ The pattern **must** contain `{env}`, and it throws if the resolved file doesn't

`loadConfig` always reads `envName()`. To load a non-current env, set `ENV=…` in the process env before calling — there's no `env` override on the function itself.

## Config files that can't use top-level `await` (`selectConfig`)

`loadConfig` is async — it uses dynamic `import()` under the hood, which is irreducibly asynchronous. That's fine in app code, but it breaks in **config files that tooling loads synchronously** — files pulled in via `require()` or bundled to CJS, where a top-level `await` is rejected (`ERR_REQUIRE_ASYNC_MODULE`, or a build-time _"top-level await is not supported with the cjs output format"_). Some database/ORM tooling configs fall in this bucket.

There's no runtime-agnostic way to make `loadConfig` synchronous — a sync module load means `require()`, which only exists on CJS-capable runtimes and can't load `.ts` without a transpile hook. So for these files, use **`selectConfig`** instead: the synchronous, runtime-agnostic counterpart. Pair it with **static `import`s** so the runtime/bundler resolves and transpiles each config at parse time — no dynamic import, no `await`:

```ts title="src/env/index.ts (imported by the tool's config file)"
import { defineEnv, selectConfig } from "@vlandoss/env";
import development from "./config/development.ts";
import production from "./config/production.ts";
import { Env } from "./env/schema.ts";

export const env = defineEnv({
schema: Env,
config: selectConfig({ development, production }),
});
```

`selectConfig` picks the entry matching `envName()` and **throws** if there's none (the map is explicit, so a miss is almost always a typo). Like `loadConfig`, it has no `env` override — set `ENV=…` first to select a non-current env.

See the [`config-cjs` example](https://github.com/variableland/env/tree/main/examples/config-cjs) for a runnable reproduction (it asserts the `loadConfig` + top-level await variant throws under `require()`, and the `selectConfig` variant loads).

## Tradeoffs

- Requires a runtime with a filesystem — works on Node, Bun, and Deno; not on Workers/Edge. Use the [Vite plugin](/docs/api-reference/vite) for those instead.
- Loading `.ts` / `.mts` config files depends on the host runtime's TypeScript support: native in Bun and Deno, behind `--experimental-strip-types` in Node ≥22.6 (stable in 23.6). `.js` / `.mjs` / `.json` work everywhere.
- The startup is async because `loadConfig` is async. If you can't use top-level `await`, hoist the boot into an `async function main()` and call it from your entrypoint.
- The startup is async because `loadConfig` is async. If you can't use top-level `await`, hoist the boot into an `async function main()` and call it from your entrypoint — or, for config files loaded synchronously by tooling, use [`selectConfig`](#config-files-that-cant-use-top-level-await-selectconfig) with static imports.
7 changes: 4 additions & 3 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `@vlandoss/env` examples

Real-world usage examples for [`@vlandoss/env`](../package). **Each example is runtime-isolated**: it declares its own runtime in a local `mise.toml`, brings its own package manager and lockfile, and consumes `@vlandoss/env` from a packed tarball — exactly as an external consumer would. End-to-end tests use Playwright and exercise real `env` failure modes (missing required vars, wrong types, per-mode config isolation, SSR↔client hydration drift).
Real-world usage examples for [`@vlandoss/env`](../package). **Each example is runtime-isolated**: it declares its own runtime in a local `mise.toml`, brings its own package manager and lockfile, and consumes `@vlandoss/env` from a packed tarball — exactly as an external consumer would. End-to-end tests use Playwright (and `node:test` for the `config-cjs` loader case) and exercise real `env` failure modes (missing required vars, wrong types, per-mode config isolation, SSR↔client hydration drift, config files that can't use top-level await).

| Example | Runtime | Package manager | `env` entries exercised |
| -------------------------------------------- | ---------------------------------------- | --------------- | -------------------------------------------------------------------------------- |
Expand All @@ -13,8 +13,9 @@ Real-world usage examples for [`@vlandoss/env`](../package). **Each example is r
| [`spa-vite-dynamic`](./spa-vite-dynamic) | Node.js (Vite) | pnpm | `@vlandoss/env` (dynamic `import()`) + `@vlandoss/env/vite` (for `__ENV_NAME__`) |
| [`ssr-react-router`](./ssr-react-router) | Node.js (React Router 7) | pnpm | `@vlandoss/env` + `@vlandoss/env/fs` + `@vlandoss/env/react` (`<EnvScript />`) |
| [`ssr-tanstack-start`](./ssr-tanstack-start) | Node.js (TanStack Start) | pnpm | `@vlandoss/env` + `@vlandoss/env/vite` + `@vlandoss/env/react` |
| [`config-cjs`](./config-cjs) | Node.js 22.22.3 | pnpm | `@vlandoss/env` (`selectConfig`) + `@vlandoss/env/fs` (`loadConfig`) + `@vlandoss/env/zod` |

The `backend-*`, `worker-*`, and `edge-nextjs` examples drive Playwright in HTTP-only mode (the `request` fixture); the 4 SPA / SSR examples drive a real chromium browser.
The `backend-*`, `worker-*`, and `edge-nextjs` examples drive Playwright in HTTP-only mode (the `request` fixture); the 4 SPA / SSR examples drive a real chromium browser. `config-cjs` is the odd one out — it runs `node:test` to assert config-file loading semantics under `require()` (no browser, no server).

## How `@vlandoss/env` is consumed

Expand All @@ -33,7 +34,7 @@ The tarball is generated by `mise run env:pack` (which calls `pnpm pack` inside
```sh
mise install # node + pnpm; bun/deno installed per-example as needed
mise run setup # bootstraps everything: tools, root deps, env tarball, all examples, Playwright browsers
mise run test:e2e # runs all 9 e2e suites
mise run test:e2e # runs all 10 e2e suites
```

## Run a single example
Expand Down
1 change: 1 addition & 0 deletions examples/config-cjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
54 changes: 54 additions & 0 deletions examples/config-cjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# config-cjs

Loading `@vlandoss/env` from a **config file that can't use top-level `await`** —
the kind of config file that tooling pulls in via `require()` or bundles to CJS
(e.g. some ORM / database migration tooling).

## The problem

```ts title="db.config.broken.mts"
const config = await loadConfig(Env); // ⛔ top-level await
```

`loadConfig` is async (it uses dynamic `import()` under the hood, which is
irreducibly asynchronous). The top-level `await` makes the module an **async ES
module**. When a tool loads the config synchronously, that's rejected:

- `require()` → `ERR_REQUIRE_ASYNC_MODULE` (Node ≥22.12, where `require(esm)` is on)
- esbuild/CJS bundle → _"Top-level await is currently not supported with the cjs output format"_

## The fix: `selectConfig`

`selectConfig` is the synchronous, runtime-agnostic counterpart to `loadConfig`.
Pair it with **static `import`s** so the runtime/bundler resolves and transpiles
each config file at parse time — no dynamic import, no `await`:

```ts title="src/env/index.ts"
import { defineEnv, selectConfig } from "@vlandoss/env";
import development from "../../config/development.ts";
import production from "../../config/production.ts";
import { Env } from "./schema.ts";

export const env = defineEnv({
schema: Env,
config: selectConfig({ development, production }),
});
```

`db.config.mts` imports that `env` and exports a plain config object — no
top-level await anywhere — so `require()` loads it cleanly.

## Run it

```sh
mise run //examples/config-cjs:test:e2e
```

[`test/loader.test.ts`](./test/loader.test.ts) `require()`s both configs and
asserts: the `selectConfig` one loads, the `loadConfig` + top-level await one
throws `ERR_REQUIRE_ASYNC_MODULE`.

> Pinned to Node 22.22.3 — `require(esm)` is default from 22.12 and native TS
> stripping from 22.18, so this is the lowest LTS line where the reproduction
> (and the fix) behaves as described. Node 20 would throw `ERR_REQUIRE_ESM`
> instead, which is a different failure.
4 changes: 4 additions & 0 deletions examples/config-cjs/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"extends": ["@rrlab/biome-config"]
}
6 changes: 6 additions & 0 deletions examples/config-cjs/config/development.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { EnvConfig } from "../src/env/schema.ts";

export default {
server: { PORT: 3001, HOST: "127.0.0.1" },
db: { URL: "postgres://localhost/dev", LOGGING: true },
} satisfies EnvConfig;
6 changes: 6 additions & 0 deletions examples/config-cjs/config/production.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { EnvConfig } from "../src/env/schema.ts";

export default {
server: { PORT: 3001, HOST: "0.0.0.0" },
db: { LOGGING: false },
} satisfies EnvConfig;
19 changes: 19 additions & 0 deletions examples/config-cjs/db.config.broken.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineEnv } from "@vlandoss/env";
import { loadConfig } from "@vlandoss/env/fs";
import { Env } from "./src/env/schema.ts";

// ❌ The pattern that BREAKS in a config file. `loadConfig` is async, so this
// top-level `await` turns the module into an async ES module. Tooling that
// loads the config via `require()` throws `ERR_REQUIRE_ASYNC_MODULE`, and
// bundlers targeting CJS reject the top-level await at build time.
//
// Kept here only to demonstrate the failure — see `db.config.mts` for the
// `selectConfig` fix and `test/loader.test.ts` for the assertion.
const config = await loadConfig(Env);

const env = defineEnv({ schema: Env, config });

export default {
dialect: "postgresql",
dbCredentials: { url: env.db.URL },
} as const;
14 changes: 14 additions & 0 deletions examples/config-cjs/db.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { env } from "./src/env/index.ts";

// Stand-in for an ORM / database tooling config: a file that tooling loads via
// `require()` or bundles to CJS. Because `env` is built with `selectConfig`
// (synchronous), importing it here adds no top-level `await`, so this module
// loads cleanly in a CJS / no-TLA context.
//
// Shape the default export however your tool expects — this mirrors a typical
// database migration config.
export default {
dialect: "postgresql",
dbCredentials: { url: env.db.URL },
verbose: env.db.LOGGING,
} as const;
37 changes: 37 additions & 0 deletions examples/config-cjs/mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Pinned to 22.22.3 on purpose: this example reproduces a `require()`-time
# failure that only surfaces with `require(esm)` (default since 22.12) AND native
# TypeScript stripping (default since 22.18). An older 22.x would either resolve
# the .mts as plain JS (syntax error) or report a different error code.
[tools]
node = "22.22.3"
pnpm = "10.30.3"

[env]
_.path = ["{{config_root}}/node_modules/.bin"]

[tasks.setup]
description = "Pack env + install deps (pnpm)"
depends = ["//:env:pack"]
sources = [
"package.json",
"pnpm-lock.yaml",
"{{config_root}}/../../package/.local/vlandoss-env.tgz",
]
outputs = ["node_modules/.modules.yaml"]
run = "pnpm install --ignore-workspace --no-frozen-lockfile"

[tasks.reinstall]
description = "Force-reinstall the env tarball"
depends = ["//:env:pack"]
run = "pnpm install --ignore-workspace --no-frozen-lockfile --force"

[tasks."test:e2e"]
description = "Assert the config loads under require() (selectConfig) and that the loadConfig+TLA variant is rejected"
depends = ["setup"]
env = { ENV = "development" }
run = "node --test test/loader.test.ts"

[tasks.check]
description = "JS & TS check"
depends = ["setup"]
run = "rr check"
20 changes: 20 additions & 0 deletions examples/config-cjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "example-config-cjs",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@vlandoss/env": "file:../../package/.local/vlandoss-env.tgz",
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.0.0",
"@rrlab/biome-config": "^0.0.2",
"@rrlab/biome-plugin": "^0.1.1",
"@rrlab/cli": "0.0.3",
"@rrlab/ts-config": "^0.0.2",
"@rrlab/ts-plugin": "^0.1.1",
"@types/node": "24.12.4",
"typescript": "^6.0.0"
}
}
Loading
Loading