diff --git a/README.md b/README.md index f8041c5..d52cee5 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,13 @@ argument required in the third position: Since by default Envalid's output is wrapped in a Proxy, structuredClone [will not work](https://bugzilla.mozilla.org/show_bug.cgi?id=1269327#c1) on it. See [#177](https://github.com/af/envalid/issues/177). +### Can I use Envalid outside Node (e.g. QuickJS)? + +Yes. [WinterTC](https://min-common-api.proposal.wintertc.org/)-compatible runtimes +already provide `URL` and `console` (including `console.error`). Minimal hosts that +do not may need a small shim — see [docs/runtime-globals.md](docs/runtime-globals.md) +and [#253](https://github.com/af/envalid/issues/253). + ## Related projects - [dotenv](https://www.npmjs.com/package/dotenv) is a very handy tool for loading env vars from diff --git a/docs/runtime-globals.md b/docs/runtime-globals.md new file mode 100644 index 0000000..2ed0697 --- /dev/null +++ b/docs/runtime-globals.md @@ -0,0 +1,63 @@ +# Runtime globals (WinterTC and minimal hosts) + +Envalid expects a few web-platform globals: + +- `URL` — when you use the built-in `url()` validator +- `console` / `console.error` — when you use the default reporter + +[WinterTC’s Minimum Common Web API](https://min-common-api.proposal.wintertc.org/) +requires both `URL` and `globalThis.console`. The `console` object is defined by the +[WHATWG Console Standard](https://console.spec.whatwg.org/), which includes +`console.error`. Runtimes that aim to be web-interoperable (Node, Bun, Deno, +browsers, Cloudflare Workers, …) already provide these, so no shim is needed. + +Smaller engines that do **not** target that baseline — for example Alpine +[QuickJS](https://bellard.org/quickjs/) (`qjs`) used to validate env at container +entrypoint without shipping Node — may be missing pieces. See +[#253](https://github.com/af/envalid/issues/253). + +## When Envalid needs them + +| Global | When needed | +| --- | --- | +| `console.error` | Default reporter (at failure). Not needed if you pass your own `reporter` / `null`. | +| `URL` | Only if you use the built-in `url()` validator (`new URL(x)`). | + +Importing `envalid` itself does not require these globals (the default logger binds +`console.error` lazily). They must exist before you call `cleanEnv` with `url()`, +and before the default reporter runs. + +## Workable shim (QuickJS) + +A copy-paste starting point for Alpine QuickJS lives at +[`example/quickjs-host-shim.mjs`](../example/quickjs-host-shim.mjs). Apply it +before `cleanEnv` / `url()` if your host is missing pieces (it need not run before +`import 'envalid'`). Adapt the `console` and `URL` stubs to your engine (other +hosts can no-op or map to `print` / native logging instead of QuickJS `std`). + +The shim fills `console.*` for the default reporter; you can omit that part if you +always pass a custom `reporter`. + +```js +import { cleanEnv, str, url } from 'envalid' +import './quickjs-host-shim.mjs' // before cleanEnv / url(); order vs envalid import does not matter +import * as std from 'std' + +const env = cleanEnv( + /* your env object */, + { API_URL: url(), NAME: str() }, + { + // Custom reporter: default throws outside Node; std.exit fails closed at entrypoint + reporter: ({ errors }) => { + std.err.puts('Invalid environment:\n' + Object.keys(errors).join(', ') + '\n') + std.exit(1) + }, + }, +) +``` + +## Notes + +- Envalid does **not** ship a QuickJS stdlib integration or a full URL implementation. +- With a custom reporter, you typically only need a `URL` polyfill when using `url()`. +- Default reporter still calls `console.error` and, in Node, `process.exit(1)`. diff --git a/example/quickjs-host-shim.mjs b/example/quickjs-host-shim.mjs new file mode 100644 index 0000000..070bb71 --- /dev/null +++ b/example/quickjs-host-shim.mjs @@ -0,0 +1,36 @@ +/** + * Minimal host patches for Alpine QuickJS (`qjs`) when using envalid. + * Apply before cleanEnv / url() (not required before import envalid). + * See docs/runtime-globals.md and https://github.com/af/envalid/issues/253 + */ +import * as std from 'std' + +const root = globalThis + +const log = (...args) => { + std.err.puts(args.map(String).join(' ') + '\n') +} + +const consoleRef = typeof root.console === 'object' && root.console !== null ? root.console : {} +root.console = consoleRef + +for (const method of ['error', 'warn', 'info', 'log']) { + if (typeof consoleRef[method] !== 'function') { + consoleRef[method] = log + } +} + +if (typeof root.URL !== 'function') { + root.URL = class URL { + constructor(input) { + const value = String(input) + if (!/^[a-zA-Z][a-zA-Z\d+.-]*:\/\/\S+$/.test(value)) { + throw new TypeError('Invalid URL') + } + this.href = value + } + toString() { + return this.href + } + } +} diff --git a/src/errors.ts b/src/errors.ts index 8ff2fed..37494de 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -5,7 +5,10 @@ export class EnvError extends TypeError { constructor(message?: string) { super(message) Object.setPrototypeOf(this, new.target.prototype) - Error.captureStackTrace(this, EnvError) + // Optional; not on all engines (e.g. QuickJS) — see MDN Error.captureStackTrace + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, EnvError) + } this.name = this.constructor.name } } @@ -14,7 +17,9 @@ export class EnvMissingError extends ReferenceError { constructor(message?: string) { super(message) Object.setPrototypeOf(this, new.target.prototype) - Error.captureStackTrace(this, EnvMissingError) + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, EnvMissingError) + } this.name = this.constructor.name } } diff --git a/src/reporter.ts b/src/reporter.ts index 0b3b53e..494197b 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -11,7 +11,10 @@ type ExtraOptions = { logger: (output: string) => void } -const defaultLogger = console.error.bind(console) +// Lazy: importing envalid must not require console.error +const defaultLogger: Logger = (data, ...args) => { + console.error(data, ...args) +} // Apply ANSI colors to the reporter output only if we detect that we're running in Node const isNode = !!(typeof process === 'object' && process?.versions?.node)