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

**`loadConfig` (`@vlandoss/env/fs`) is now synchronous.** It loads the config with `require()` and returns `Config<S>` directly instead of a `Promise`, so it works in app code **and** in config files that tooling loads via `require()` or bundles to CJS (where a top-level `await` is rejected).

**Migration — drop the `await`:**

```diff
- const config = await loadConfig(Env);
+ const config = loadConfig(Env);
```

`await loadConfig(...)` still returns the right value at runtime (awaiting a non-promise yields the value), but the `await` keeps the module asynchronous — so a config file that keeps it still can't be `require()`d. Remove it.

Auto-discovery now also covers `.cjs` / `.cts` (was: `.ts` `.mts` `.js` `.mjs` `.json`). The options form gains an optional `cwd` so callers can override `process.cwd()` when the working directory isn't the project root.

Files are loaded with `require()`. Runtime requirements by extension:
- `.ts` / `.mts` / `.cts` — needs native TS stripping (native in Bun/Deno, **Node ≥22.18**).
- `.mjs` / `.js` / `.cjs` — needs `require(esm)` (native in Bun/Deno, **Node ≥22.12**).
- `.json` — works on any supported Node.

The package's `engines` is **Node ≥22.12** (the `require(esm)` baseline). The `.ts` strip requirement is documented per-extension; consumers using only `.json`/`.mjs`/`.cjs` configs aren't blocked by an over-broad floor.

CJS configs (`module.exports = {...}`): a CJS exports object that owns a `default` property name is **no longer** silently stripped — `loadConfig` discriminates ESM namespaces by `Symbol.toStringTag === "Module"` and returns CJS exports as-is, so sibling keys survive.
7 changes: 5 additions & 2 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ How to set up, work in, and ship from this monorepo. For contribution convention
env/
├── package/ # @vlandoss/env library — published to npm
├── docsite/ # Fumadocs site → env.oss.variable.land (Cloudflare Workers)
├── examples/ # 9 runtime-isolated demos (Node, Bun, Deno, Workers, Edge, Vite, SSR)
├── examples/ # 12 runtime-isolated demos (Node, Bun, Deno, Workers, Edge, Vite, SSR)
├── docs/ # Repository docs (CONTRIBUTING.md, DEVELOPMENT.md)
├── mise.toml # Tool versions + task orchestration
├── pnpm-workspace.yaml # Workspace for `package` + `docsite` only
Expand Down Expand Up @@ -134,13 +134,16 @@ Every example declares the dependency as a local file tarball:

The tarball is generated by `mise run env:pack` (which calls `pnpm pack` inside `package/`). This guarantees each example consumes the package **as published** — same `files`, same `publishConfig.exports`, same peerDeps.

### The 9 examples
### The 12 examples

| Example | Runtime | PM | Why |
| -------------------------------------------------------- | ---------------------------------------- | ---- | ------------------------------------------------------------------ |
| [`backend-node`](../examples/backend-node) | Node 24 | pnpm | Plain Node server |
| [`backend-bun`](../examples/backend-bun) | Bun 1.2.4 | bun | Native Bun consumer |
| [`backend-deno`](../examples/backend-deno) | Deno 2 (server) + Node 24 (test runner) | bun | Deno can't extract `file:` tarballs natively — we use `bun install` to hydrate `node_modules/` (flat layout) and let Deno read it via `nodeModulesDir: "manual"` |
| [`backend-node-cjs`](../examples/backend-node-cjs) | Node 24 | pnpm | `loadConfig` (sync) — server booted from a CommonJS `require()` entry |
| [`backend-bun-cjs`](../examples/backend-bun-cjs) | Bun 1.2.4 | bun | `loadConfig` (sync) under a Bun CommonJS `require()` entry |
| [`backend-deno-cjs`](../examples/backend-deno-cjs) | Deno 2 (server) + Node 24 (test runner) | bun | `loadConfig` (sync) under a Deno CommonJS `require()` entry |
| [`worker-cloudflare`](../examples/worker-cloudflare) | Cloudflare Workers | pnpm | Validates `runtimeEnv: c.env` per-request; no `nodejs_compat` flag |
| [`edge-nextjs`](../examples/edge-nextjs) | Next.js Edge | pnpm | Validates the Edge runtime path |
| [`spa-vite-plugin`](../examples/spa-vite-plugin) | Vite + React | pnpm | `envConfig()` plugin + `#config` alias |
Expand Down
12 changes: 9 additions & 3 deletions docsite/content/docs/api-reference/fs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,27 @@ icon: HardDrive

The `@vlandoss/env/fs` entrypoint is the file-system adapter. It auto-discovers per-environment config files on disk so you don't have to wire imports by hand.

Works on any runtime that exposes a Node-compatible filesystem: **Node ≥20**, **Bun**, and **Deno** (via its Node-compat layer). It does **not** work on Workers, Edge, or any environment without a filesystem — those should resolve config at build time (see [`@vlandoss/env/vite`](/docs/api-reference/vite)) or pass it explicitly to `defineEnv`.
Works on any runtime that exposes a Node-compatible filesystem: **Node ≥22.12** (≥22.18 for `.ts`/`.mts`/`.cts` configs), **Bun**, and **Deno** (via its Node-compat layer). It does **not** work on Workers, Edge, or any environment without a filesystem — those should resolve config at build time (see [`@vlandoss/env/vite`](/docs/api-reference/vite)) or pass it explicitly to `defineEnv`.

## Exports

| Export | Kind | Summary |
| ------------ | -------- | ------------------------------------------------------------------------------------ |
| `loadConfig` | Function | Discover and load `[src/]config/<envName>.{ts,mts,js,mjs,json}` for the current env. |
| `loadConfig` | Function | **Synchronously** discover and load `[src/]config/<envName>.{ts,mts,cts,js,mjs,cjs,json}` for the current env. Returns `Config<S>` directly (loads via `require()`), so it works in app code and in config files that tooling loads synchronously. Accepts an optional `pattern` (template) and `cwd` (override `process.cwd()`). |

<Callout type="info">
Full signatures and option tables are coming soon. Until then, see
[Guides → Filesystem (`loadConfig`)](/docs/guides/fs-loadconfig) for the recipe.
</Callout>

<Callout type="warn">
Loading `.ts` / `.mts` files at runtime depends on the host runtime's TypeScript support: native in **Bun** and **Deno**, and in **Node ≥22.6** behind `--experimental-strip-types` (stable in 23.6). `.js` / `.mjs` / `.json` work everywhere.
`loadConfig` loads files with `require()`. Runtime requirements by extension:

- `.ts` / `.mts` / `.cts` — needs native TypeScript stripping (native in **Bun** and **Deno**, **Node ≥22.18**).
- `.mjs` / `.js` / `.cjs` — needs `require(esm)` (native in Bun/Deno, **Node ≥22.12**).
- `.json` — works on any supported Node.

Module loads are cached by the host's module system: editing a `.ts`/`.mjs`/`.cjs` config in a long-running process won't be picked up until restart. `.json` files are re-read on every call.
</Callout>

## See also
Expand Down
4 changes: 2 additions & 2 deletions docsite/content/docs/getting-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ import { defineEnv } from "@vlandoss/env";
import { loadConfig } from "@vlandoss/env/fs";
import { Env } from "./schema.ts";

const config = await loadConfig(Env);
const config = loadConfig(Env);
export const env = defineEnv({ schema: Env, config });
```

`loadConfig(Env)` auto-discovers `[src/]config/<envName>.{ts,mts,js,mjs,json}` under `process.cwd()`. `defineEnv` then merges `config` with `process.env` and validates against the schema. If anything is missing or wrong, it throws naming the dot-path of the offending leaf.
`loadConfig(Env)` **synchronously** auto-discovers `[src/]config/<envName>.{ts,mts,cts,js,mjs,cjs,json}` under `process.cwd()` (no `await`). `defineEnv` then merges `config` with `process.env` and validates against the schema. If anything is missing or wrong, it throws naming the dot-path of the offending leaf.

## 4. Read typed values

Expand Down
2 changes: 1 addition & 1 deletion docsite/content/docs/guides/custom-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ src/
production.ts
```

The plugin's discovery scans `[src/]config/<mode>.{ts,mts,js,mjs,json}` and pulls in the right one — same algorithm as `loadConfig` in `@vlandoss/env/fs`.
The plugin's discovery scans `[src/]config/<mode>.{ts,mts,cts,js,mjs,cjs,json}` and pulls in the right one — same algorithm as `loadConfig` in `@vlandoss/env/fs`.

## Verifying it works

Expand Down
28 changes: 23 additions & 5 deletions docsite/content/docs/guides/fs-loadconfig.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { defineEnv } from "@vlandoss/env";
import { loadConfig } from "@vlandoss/env/fs";
import { Env } from "./schema.ts";

const config = await loadConfig(Env);
const config = loadConfig(Env);

export const env = defineEnv({
schema: Env,
Expand All @@ -69,7 +69,7 @@ server.listen(env.server.PORT, env.server.HOST);

- **`schema()`** is the contract. Leaves use Standard Schema validators (Zod here, but any Standard Schema lib works). The `@vlandoss/env/zod` primitives (`e.port`, `e.host`, `e.logLevel`, `e.bool`) are opinionated single-purpose schemas — see the [Zod primitives reference](/docs/api-reference/zod).
- **`config/<envName>.ts`** is the typed, versioned default per environment. `satisfies EnvConfig` is what gives you compile-time errors on typos. **Secrets do not go here** — leave them out of `config/*` and put them in `process.env`.
- **`loadConfig(Env)`** is the short form: it auto-discovers `[src/]config/<envName>.{ts,mts,js,mjs,json}` under `process.cwd()` and returns the first match (or `{}` if none).
- **`loadConfig(Env)`** is the short form: it **synchronously** auto-discovers `[src/]config/<envName>.{ts,mts,cts,js,mjs,cjs,json}` under `process.cwd()` and returns the first match (or `{}` if none) — no `await`.
- **`defineEnv`** merges `defaults` (none here) → `config` → `process.env` and validates the result. See [Resolution order](/docs/concepts/resolution).
- **`vars: { db: { URL: "DATABASE_URL" } }`** overrides the convention for one leaf. `db.URL` would default to `DB_URL`; here we map it to the conventional `DATABASE_URL`. See [Env-var naming](/docs/concepts/env-var-naming).

Expand All @@ -78,7 +78,7 @@ server.listen(env.server.PORT, env.server.HOST);
If your config directory doesn't follow the convention, use the long form:

```ts
const config = await loadConfig({ schema: Env, pattern: "config/{env}.ts" });
const config = loadConfig({ schema: Env, pattern: "config/{env}.ts" });
```

The pattern **must** contain `{env}`, and it throws if the resolved file doesn't exist (the short form silently falls back to `{}`).
Expand All @@ -87,8 +87,26 @@ 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.

## Resolving from a custom `cwd`

Both auto-discovery and the `{env}` template resolve paths against `process.cwd()` by default. If the process working directory isn't the project root (monorepo task runners, orchestrators, SSR workers launched from elsewhere), pass `cwd` explicitly:

```ts
const config = loadConfig({ schema: Env, cwd: appRoot }); // auto-discovery
const config = loadConfig({ schema: Env, pattern: "config/{env}.ts", cwd: appRoot }); // template
```

## Config files loaded synchronously (`require()` / CJS)

Because `loadConfig` is synchronous, it also works in **config files that tooling loads synchronously** — files pulled in via `require()` or bundled to CJS, where a top-level `await` would be rejected (`ERR_REQUIRE_ASYNC_MODULE`, or a build-time _"top-level await is not supported with the cjs output format"_). The wiring is exactly the same as above; there's no `await` to trip over.

See the runnable [`backend-node-cjs`](https://github.com/variableland/env/tree/main/examples/backend-node-cjs), [`backend-bun-cjs`](https://github.com/variableland/env/tree/main/examples/backend-bun-cjs), and [`backend-deno-cjs`](https://github.com/variableland/env/tree/main/examples/backend-deno-cjs) examples — each boots its server from a CommonJS `require()` entry.

## 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.
- `loadConfig` loads files with `require()`. Runtime requirements by extension:
- `.ts` / `.mts` / `.cts` — native TypeScript stripping (native in Bun/Deno, **Node ≥22.18**).
- `.mjs` / `.js` / `.cjs` — `require(esm)` (native in Bun/Deno, **Node ≥22.12**).
- `.json` — works on any supported Node.
- Module loads are cached by Node/Bun/Deno's module system. Editing a `.ts`/`.mjs`/`.cjs` config in a long-running process isn't picked up until the process restarts; `.json` files are re-read on every call.
2 changes: 1 addition & 1 deletion docsite/content/docs/guides/spa-vite-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Each command produces a separate `dist/` whose bundle contains **only** the matc

## What each piece does

- **`envConfig()` plugin** registers `resolve.alias["#config"]` pointing at `[src/]config/<mode>.{ts,mts,js,mjs,json}` (same discovery algorithm as `loadConfig`). It also injects `__ENV_NAME__ = JSON.stringify(mode)` so `envName()` returns the right mode in the browser — see [Custom modes](/docs/guides/custom-modes).
- **`envConfig()` plugin** registers `resolve.alias["#config"]` pointing at `[src/]config/<mode>.{ts,mts,cts,js,mjs,cjs,json}` (same discovery algorithm as `loadConfig`). It also injects `__ENV_NAME__ = JSON.stringify(mode)` so `envName()` returns the right mode in the browser — see [Custom modes](/docs/guides/custom-modes).
- **`#config.d.ts`** declares the type of the alias for TypeScript. Vite resolves the runtime import; this just stops `tsc` from complaining.
- **`defineEnv({ schema, config })`** runs synchronously here — `config` is a plain object, not a Promise.

Expand Down
2 changes: 1 addition & 1 deletion docsite/content/docs/guides/ssr.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import { defineEnv } from "@vlandoss/env";
import { loadConfig } from "@vlandoss/env/fs";
import { ServerEnv } from "./schema.server.ts";

const config = await loadConfig({ schema: ServerEnv, pattern: "app/config/{env}.ts" });
const config = loadConfig({ schema: ServerEnv, pattern: "app/config/{env}.ts" });

export const env = defineEnv({
schema: ServerEnv,
Expand Down
2 changes: 1 addition & 1 deletion docsite/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { Env } from "./schema.ts";

export const env = defineEnv({
schema: Env,
config: await loadConfig(Env)
config: loadConfig(Env)
});
```

Expand Down
4 changes: 2 additions & 2 deletions docsite/src/components/landing/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const WIRE_CODE = `import { defineEnv } from "@vlandoss/env";
import { loadConfig } from "@vlandoss/env/fs";
import { Env } from "./schema.ts";

const config = await loadConfig(Env);
const config = loadConfig(Env);

export const env = defineEnv({
schema: Env,
Expand Down Expand Up @@ -116,7 +116,7 @@ export const WHY_POINTS = [
] as const;

export const LANDING_META = {
version: "v0.2.1",
version: "v0.3.0",
vlandUrl: "https://variable.land",
githubUrl: "https://github.com/variableland/env",
npmUrl: "https://www.npmjs.com/package/@vlandoss/env",
Expand Down
7 changes: 5 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# `@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 exercise real `env` failure modes (missing required vars, wrong types, per-mode config isolation, SSR↔client hydration drift, config loaded synchronously from a CommonJS/`require()` entry).

| Example | Runtime | Package manager | `env` entries exercised |
| -------------------------------------------- | ---------------------------------------- | --------------- | -------------------------------------------------------------------------------- |
| [`backend-node`](./backend-node) | Node.js 24 | pnpm | `@vlandoss/env` + `@vlandoss/env/fs` + `@vlandoss/env/zod` |
| [`backend-bun`](./backend-bun) | Bun 1.2.4 | bun | `@vlandoss/env` + `@vlandoss/env/fs` + `@vlandoss/env/zod` |
| [`backend-deno`](./backend-deno) | Deno 2 (server) + Node 24 (test runner) | bun | `@vlandoss/env` + `@vlandoss/env/fs` + `@vlandoss/env/zod` |
| [`backend-node-cjs`](./backend-node-cjs) | Node.js 24 | pnpm | `@vlandoss/env` + `@vlandoss/env/fs` (sync `loadConfig` via `require()`) + `@vlandoss/env/zod` |
| [`backend-bun-cjs`](./backend-bun-cjs) | Bun 1.2.4 | bun | `@vlandoss/env` + `@vlandoss/env/fs` (sync `loadConfig` via `require()`) + `@vlandoss/env/zod` |
| [`backend-deno-cjs`](./backend-deno-cjs) | Deno 2 (server) + Node 24 (test runner) | bun | `@vlandoss/env` + `@vlandoss/env/fs` (sync `loadConfig` via `require()`) + `@vlandoss/env/zod` |
| [`worker-cloudflare`](./worker-cloudflare) | Cloudflare Workers (wrangler) | pnpm | `@vlandoss/env` (`runtimeEnv: c.env`, per-request) + `@vlandoss/env/zod` |
| [`edge-nextjs`](./edge-nextjs) | Next.js Edge runtime | pnpm | `@vlandoss/env` + `@vlandoss/env/zod` (no FS on Edge) |
| [`spa-vite-plugin`](./spa-vite-plugin) | Node.js (Vite) | pnpm | `@vlandoss/env` + `@vlandoss/env/vite` (`envConfig()` + `#config`) |
Expand All @@ -33,7 +36,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 12 e2e suites
```

## Run a single example
Expand Down
3 changes: 3 additions & 0 deletions examples/backend-bun-cjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
playwright-report
test-results
18 changes: 18 additions & 0 deletions examples/backend-bun-cjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# backend-bun-cjs

Same Bun + Hono server as [`backend-bun`](../backend-bun), but the server is
booted from a **CommonJS entry** ([`server.cjs`](./server.cjs) →
`require("./src/server.ts")`).

That `require()` works because `loadConfig` is **synchronous** — `src/env/index.ts`
has no top-level `await`. Write `await loadConfig(...)` instead and the `await`
makes the module async, so Bun rejects the same `require()` as an async module.
This mirrors how tools that load a config via `require()` (or bundle it to CJS)
reject top-level await.

The Playwright e2e (`test/e2e`) boots the server through `server.cjs` and hits
`/env` — proving the config loaded synchronously through the require path.

```sh
mise run //examples/backend-bun-cjs:test:e2e
```
Loading
Loading