Skip to content

Commit bbc6590

Browse files
rqbazanclaude
andcommitted
feat: make loadConfig synchronous
`@vlandoss/env/fs`'s `loadConfig` now loads the config with `require()` and returns `Config<S>` directly instead of a `Promise`. One function, no `await` — 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. BREAKING: `loadConfig` is synchronous. Drop the `await`: - const config = await loadConfig(Env) + const config = loadConfig(Env) (Awaiting a non-promise still returns the value at runtime, but the `await` keeps the module async so a config file can't be require()'d — remove it.) - fs: sync `loadConfig` over a shared pure core; loads via `require()` - unwrapDefault: discriminates ESM namespace by `Symbol.toStringTag === "Module"` (verified consistent across Node 22.22.3, Bun 1.2.4, Deno 2) — a CJS exports object that owns a `default` property is no longer silently stripped; sibling keys survive - auto-discovery: now also covers `.cjs` / `.cts` (was just .ts/.mts/.js/.mjs/.json) - options: `loadConfig({ schema, pattern?, cwd? })` — `cwd` overrides `process.cwd()` for orchestrators / SSR workers / monorepo runners - tests: fs.test.ts now covers CJS auto-discovery, the `default`-key preservation invariant (regression guard), and the `cwd` option - examples: backend-{node,bun,deno} drop the await; backend-{node,bun,deno}-cjs boot the server from a CommonJS require() entry, verified with Playwright e2e (validation.spec spawns now pin `cwd` for cwd-independence) - engines: bump to >=22.12 (the `require(esm)` baseline). Per-extension requirements documented: .ts/.mts/.cts need Node ≥22.18 (native TS stripping); .mjs/.js/.cjs need ≥22.12; .json works on any supported Node - docs: loadConfig guide, fs reference, quickstart, ssr, landing, examples tables Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent abe1926 commit bbc6590

73 files changed

Lines changed: 2705 additions & 116 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/loadconfig-sync.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@vlandoss/env": minor
3+
---
4+
5+
**`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).
6+
7+
**Migration — drop the `await`:**
8+
9+
```diff
10+
- const config = await loadConfig(Env);
11+
+ const config = loadConfig(Env);
12+
```
13+
14+
`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.
15+
16+
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.
17+
18+
Files are loaded with `require()`. Runtime requirements by extension:
19+
- `.ts` / `.mts` / `.cts` — needs native TS stripping (native in Bun/Deno, **Node ≥22.18**).
20+
- `.mjs` / `.js` / `.cjs` — needs `require(esm)` (native in Bun/Deno, **Node ≥22.12**).
21+
- `.json` — works on any supported Node.
22+
23+
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.
24+
25+
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.

docs/DEVELOPMENT.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ How to set up, work in, and ship from this monorepo. For contribution convention
88
env/
99
├── package/ # @vlandoss/env library — published to npm
1010
├── docsite/ # Fumadocs site → env.oss.variable.land (Cloudflare Workers)
11-
├── examples/ # 9 runtime-isolated demos (Node, Bun, Deno, Workers, Edge, Vite, SSR)
11+
├── examples/ # 12 runtime-isolated demos (Node, Bun, Deno, Workers, Edge, Vite, SSR)
1212
├── docs/ # Repository docs (CONTRIBUTING.md, DEVELOPMENT.md)
1313
├── mise.toml # Tool versions + task orchestration
1414
├── pnpm-workspace.yaml # Workspace for `package` + `docsite` only
@@ -134,13 +134,16 @@ Every example declares the dependency as a local file tarball:
134134

135135
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.
136136

137-
### The 9 examples
137+
### The 12 examples
138138

139139
| Example | Runtime | PM | Why |
140140
| -------------------------------------------------------- | ---------------------------------------- | ---- | ------------------------------------------------------------------ |
141141
| [`backend-node`](../examples/backend-node) | Node 24 | pnpm | Plain Node server |
142142
| [`backend-bun`](../examples/backend-bun) | Bun 1.2.4 | bun | Native Bun consumer |
143143
| [`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"` |
144+
| [`backend-node-cjs`](../examples/backend-node-cjs) | Node 24 | pnpm | `loadConfig` (sync) — server booted from a CommonJS `require()` entry |
145+
| [`backend-bun-cjs`](../examples/backend-bun-cjs) | Bun 1.2.4 | bun | `loadConfig` (sync) under a Bun CommonJS `require()` entry |
146+
| [`backend-deno-cjs`](../examples/backend-deno-cjs) | Deno 2 (server) + Node 24 (test runner) | bun | `loadConfig` (sync) under a Deno CommonJS `require()` entry |
144147
| [`worker-cloudflare`](../examples/worker-cloudflare) | Cloudflare Workers | pnpm | Validates `runtimeEnv: c.env` per-request; no `nodejs_compat` flag |
145148
| [`edge-nextjs`](../examples/edge-nextjs) | Next.js Edge | pnpm | Validates the Edge runtime path |
146149
| [`spa-vite-plugin`](../examples/spa-vite-plugin) | Vite + React | pnpm | `envConfig()` plugin + `#config` alias |

docsite/content/docs/api-reference/fs.mdx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,27 @@ icon: HardDrive
66

77
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.
88

9-
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`.
9+
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`.
1010

1111
## Exports
1212

1313
| Export | Kind | Summary |
1414
| ------------ | -------- | ------------------------------------------------------------------------------------ |
15-
| `loadConfig` | Function | Discover and load `[src/]config/<envName>.{ts,mts,js,mjs,json}` for the current env. |
15+
| `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()`). |
1616

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

2222
<Callout type="warn">
23-
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.
23+
`loadConfig` loads files with `require()`. Runtime requirements by extension:
24+
25+
- `.ts` / `.mts` / `.cts` — needs native TypeScript stripping (native in **Bun** and **Deno**, **Node ≥22.18**).
26+
- `.mjs` / `.js` / `.cjs` — needs `require(esm)` (native in Bun/Deno, **Node ≥22.12**).
27+
- `.json` — works on any supported Node.
28+
29+
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.
2430
</Callout>
2531

2632
## See also

docsite/content/docs/getting-started/quickstart.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ import { defineEnv } from "@vlandoss/env";
6161
import { loadConfig } from "@vlandoss/env/fs";
6262
import { Env } from "./schema.ts";
6363

64-
const config = await loadConfig(Env);
64+
const config = loadConfig(Env);
6565
export const env = defineEnv({ schema: Env, config });
6666
```
6767

68-
`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.
68+
`loadConfig(Env)` **synchronously** auto-discovers `[src/]config/<envName>.{ts,mts,js,mjs,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.
6969

7070
## 4. Read typed values
7171

docsite/content/docs/guides/fs-loadconfig.mdx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import { defineEnv } from "@vlandoss/env";
4848
import { loadConfig } from "@vlandoss/env/fs";
4949
import { Env } from "./schema.ts";
5050

51-
const config = await loadConfig(Env);
51+
const config = loadConfig(Env);
5252

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

7070
- **`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).
7171
- **`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`.
72-
- **`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).
72+
- **`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`.
7373
- **`defineEnv`** merges `defaults` (none here) → `config``process.env` and validates the result. See [Resolution order](/docs/concepts/resolution).
7474
- **`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).
7575

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

8080
```ts
81-
const config = await loadConfig({ schema: Env, pattern: "config/{env}.ts" });
81+
const config = loadConfig({ schema: Env, pattern: "config/{env}.ts" });
8282
```
8383

8484
The pattern **must** contain `{env}`, and it throws if the resolved file doesn't exist (the short form silently falls back to `{}`).
@@ -87,8 +87,26 @@ The pattern **must** contain `{env}`, and it throws if the resolved file doesn't
8787

8888
`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.
8989

90+
## Resolving from a custom `cwd`
91+
92+
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:
93+
94+
```ts
95+
const config = loadConfig({ schema: Env, cwd: appRoot }); // auto-discovery
96+
const config = loadConfig({ schema: Env, pattern: "config/{env}.ts", cwd: appRoot }); // template
97+
```
98+
99+
## Config files loaded synchronously (`require()` / CJS)
100+
101+
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.
102+
103+
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.
104+
90105
## Tradeoffs
91106

92107
- 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.
93-
- 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.
94-
- 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.
108+
- `loadConfig` loads files with `require()`. Runtime requirements by extension:
109+
- `.ts` / `.mts` / `.cts` — native TypeScript stripping (native in Bun/Deno, **Node ≥22.18**).
110+
- `.mjs` / `.js` / `.cjs``require(esm)` (native in Bun/Deno, **Node ≥22.12**).
111+
- `.json` — works on any supported Node.
112+
- 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.

docsite/content/docs/guides/ssr.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import { defineEnv } from "@vlandoss/env";
6262
import { loadConfig } from "@vlandoss/env/fs";
6363
import { ServerEnv } from "./schema.server.ts";
6464

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

6767
export const env = defineEnv({
6868
schema: ServerEnv,

docsite/content/docs/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { Env } from "./schema.ts";
3838

3939
export const env = defineEnv({
4040
schema: Env,
41-
config: await loadConfig(Env)
41+
config: loadConfig(Env)
4242
});
4343
```
4444

docsite/src/components/landing/data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const WIRE_CODE = `import { defineEnv } from "@vlandoss/env";
1717
import { loadConfig } from "@vlandoss/env/fs";
1818
import { Env } from "./schema.ts";
1919
20-
const config = await loadConfig(Env);
20+
const config = loadConfig(Env);
2121
2222
export const env = defineEnv({
2323
schema: Env,

examples/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
# `@vlandoss/env` examples
22

3-
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).
3+
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).
44

55
| Example | Runtime | Package manager | `env` entries exercised |
66
| -------------------------------------------- | ---------------------------------------- | --------------- | -------------------------------------------------------------------------------- |
77
| [`backend-node`](./backend-node) | Node.js 24 | pnpm | `@vlandoss/env` + `@vlandoss/env/fs` + `@vlandoss/env/zod` |
88
| [`backend-bun`](./backend-bun) | Bun 1.2.4 | bun | `@vlandoss/env` + `@vlandoss/env/fs` + `@vlandoss/env/zod` |
99
| [`backend-deno`](./backend-deno) | Deno 2 (server) + Node 24 (test runner) | bun | `@vlandoss/env` + `@vlandoss/env/fs` + `@vlandoss/env/zod` |
10+
| [`backend-node-cjs`](./backend-node-cjs) | Node.js 24 | pnpm | `@vlandoss/env` + `@vlandoss/env/fs` (sync `loadConfig` via `require()`) + `@vlandoss/env/zod` |
11+
| [`backend-bun-cjs`](./backend-bun-cjs) | Bun 1.2.4 | bun | `@vlandoss/env` + `@vlandoss/env/fs` (sync `loadConfig` via `require()`) + `@vlandoss/env/zod` |
12+
| [`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` |
1013
| [`worker-cloudflare`](./worker-cloudflare) | Cloudflare Workers (wrangler) | pnpm | `@vlandoss/env` (`runtimeEnv: c.env`, per-request) + `@vlandoss/env/zod` |
1114
| [`edge-nextjs`](./edge-nextjs) | Next.js Edge runtime | pnpm | `@vlandoss/env` + `@vlandoss/env/zod` (no FS on Edge) |
1215
| [`spa-vite-plugin`](./spa-vite-plugin) | Node.js (Vite) | pnpm | `@vlandoss/env` + `@vlandoss/env/vite` (`envConfig()` + `#config`) |
@@ -33,7 +36,7 @@ The tarball is generated by `mise run env:pack` (which calls `pnpm pack` inside
3336
```sh
3437
mise install # node + pnpm; bun/deno installed per-example as needed
3538
mise run setup # bootstraps everything: tools, root deps, env tarball, all examples, Playwright browsers
36-
mise run test:e2e # runs all 9 e2e suites
39+
mise run test:e2e # runs all 12 e2e suites
3740
```
3841

3942
## Run a single example
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
playwright-report
3+
test-results

0 commit comments

Comments
 (0)