Skip to content

feat: make loadConfig synchronous#22

Merged
rqbazan merged 1 commit into
mainfrom
feat/load-config-sync
May 28, 2026
Merged

feat: make loadConfig synchronous#22
rqbazan merged 1 commit into
mainfrom
feat/load-config-sync

Conversation

@rqbazan
Copy link
Copy Markdown
Member

@rqbazan rqbazan commented May 27, 2026

Why

loadConfig was async only because dynamic import() is — the async-ness was incidental, not a feature. Pre-v1, collapsing it to a single synchronous function is simpler and strictly more capable: it works in app code and in config files that tooling loads via require() or bundles to CJS (e.g. database/ORM tool configs), where a top-level await is rejected (ERR_REQUIRE_ASYNC_MODULE).

Verified across the matrix: require(esm) + native TS stripping load .ts/.mjs/.json configs synchronously on Node ≥22.18, Bun, and Deno (with createRequire based on the config path, so it stays clean even when a config is bundled to CJS).

What

@vlandoss/env/fs exports a single, synchronous loadConfig — same auto-discovery and {env} template shapes, but it loads with require() and returns Config<S> directly:

import { defineEnv } from "@vlandoss/env";
import { loadConfig } from "@vlandoss/env/fs";

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

BREAKING

loadConfig is now synchronous — drop the await:

- 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 — a config file that keeps it still can't be require()'d. Remove it. (Minor bump: pre-v1.)

Changes

  • fs: loadConfig is sync, over a shared pure core (discovery, {env} template, default-export unwrap, normalization); loads via require().
  • tests: fs.test.ts covers the sync loader — auto-discovery, .ts via require, template, throws, composition.
  • examples: backend-{node,bun,deno} drop the await; new backend-{node,bun,deno}-cjs boot the server from a CommonJS require() entry (server.cjs) and are e2e-tested with Playwright — they only boot because loadConfig is sync.
  • engines: bump to >=22.18 (require(esm) + native TS stripping).
  • docs: loadConfig guide, fs reference, quickstart, SSR guide, landing snippet, examples tables — all reflect the sync API.

Verification

  • Package suite: 82 tests pass (node + browser).
  • e2e: backend-{node,bun,deno} + backend-{node,bun,deno}-cjs (6 each) and ssr-react-router (4) pass.
  • rr check (package + docsite): pass.

🤖 Generated with Claude Code

@vland-bot
Copy link
Copy Markdown
Contributor

vland-bot Bot commented May 27, 2026

Preview release

Latest commit: e0021bb

Some packages have been released:

Package Version Install
@vlandoss/env 0.2.2-git-e0021bb.0 @vlandoss/env@0.2.2-git-e0021bb.0

Note

Use the PR number as tag to install any package. For instance:

pnpm add @vlandoss/env@pr-22

@rqbazan rqbazan force-pushed the feat/load-config-sync branch from d1e41de to 8620276 Compare May 28, 2026 00:29
@rqbazan rqbazan changed the title feat: add loadConfigSync for config files that can't use top-level await feat: make loadConfig synchronous May 28, 2026
@rqbazan rqbazan force-pushed the feat/load-config-sync branch 2 times, most recently from bbc6590 to 00d51b7 Compare May 28, 2026 03:49
`@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>
@rqbazan rqbazan force-pushed the feat/load-config-sync branch from 00d51b7 to e0021bb Compare May 28, 2026 03:54
@rqbazan rqbazan merged commit fd1f5f8 into main May 28, 2026
12 checks passed
@rqbazan rqbazan deleted the feat/load-config-sync branch May 28, 2026 03:56
@vland-bot vland-bot Bot mentioned this pull request May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant