You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
`@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>
**`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:
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.
├── pnpm-workspace.yaml # Workspace for `package` + `docsite` only
@@ -134,13 +134,16 @@ Every example declares the dependency as a local file tarball:
134
134
135
135
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.
|[`backend-node`](../examples/backend-node)| Node 24 | pnpm | Plain Node server |
142
142
|[`backend-bun`](../examples/backend-bun)| Bun 1.2.4 | bun | Native Bun consumer |
143
143
|[`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 |
144
147
|[`worker-cloudflare`](../examples/worker-cloudflare)| Cloudflare Workers | pnpm | Validates `runtimeEnv: c.env` per-request; no `nodejs_compat` flag |
Copy file name to clipboardExpand all lines: docsite/content/docs/api-reference/fs.mdx
+9-3Lines changed: 9 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,21 +6,27 @@ icon: HardDrive
6
6
7
7
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.
8
8
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`.
|`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()`). |
16
16
17
17
<Callouttype="info">
18
18
Full signatures and option tables are coming soon. Until then, see
19
19
[Guides → Filesystem (`loadConfig`)](/docs/guides/fs-loadconfig) for the recipe.
20
20
</Callout>
21
21
22
22
<Callouttype="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**).
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.
`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.
-**`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).
71
71
-**`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`.
73
73
-**`defineEnv`** merges `defaults` (none here) → `config` → `process.env` and validates the result. See [Resolution order](/docs/concepts/resolution).
74
74
-**`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).
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
87
87
88
88
`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.
89
89
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:
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
+
90
105
## Tradeoffs
91
106
92
107
- 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:
- 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.
Copy file name to clipboardExpand all lines: examples/README.md
+5-2Lines changed: 5 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,12 +1,15 @@
1
1
# `@vlandoss/env` examples
2
2
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).
|[`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`|
0 commit comments