@ayepi/env 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/ayepi-env.md +303 -0
- package/dist/dotenv.cjs +45 -0
- package/dist/dotenv.d.cts +36 -0
- package/dist/dotenv.d.ts +36 -0
- package/dist/dotenv.js +40 -0
- package/dist/index.cjs +739 -0
- package/dist/index.d.cts +166 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +722 -0
- package/dist/load.cjs +40 -0
- package/dist/load.d.cts +21 -0
- package/dist/load.d.ts +21 -0
- package/dist/load.js +37 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# @ayepi/env
|
|
2
|
+
|
|
3
|
+
Typed, **lazy**, **reactive** environment/config on zod. Declare a config once with `env({ … })`,
|
|
4
|
+
read it from `process.env` (or anything you `set(...)`), and `get(...)` / `parse()` it on demand.
|
|
5
|
+
Scalars are coerced from strings and **complex types are JSON-decoded**, with full zod validation
|
|
6
|
+
and a readable aggregated error. Fields can be plain **zod schemas**, **factories** computed from
|
|
7
|
+
earlier fields, or — with `asyncEnv` — **dynamic** values backed by a live, subscribable provider.
|
|
8
|
+
|
|
9
|
+
The main entry is pure (no `node:fs`); file reading lives in `@ayepi/env/load`.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add @ayepi/env zod
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { env } from '@ayepi/env'
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
|
|
19
|
+
const ENV = env({
|
|
20
|
+
PORT: z.number().default(3000), // plain z.number() — no z.coerce needed
|
|
21
|
+
DATABASE_URL: z.string().url(),
|
|
22
|
+
DEBUG: z.boolean().default(false), // DEBUG=1 / true / yes / on
|
|
23
|
+
FLAGS: z.array(z.string()).default([]), // FLAGS='["a","b"]' (complex → JSON)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
ENV.get('PORT') // number, typed; throws a readable EnvError if invalid
|
|
27
|
+
ENV.parse() // resolve everything; throws an aggregated EnvError listing every bad/missing var
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
By default fields read from `process.env`. Anything you `set(...)` layers on top (and wins).
|
|
31
|
+
Schemas are **auto-coerced** from their string form, so plain `z.number()` / `z.boolean()` /
|
|
32
|
+
`z.date()` / `z.object()` work straight from env vars — `z.coerce.*` is never required.
|
|
33
|
+
|
|
34
|
+
## Fields
|
|
35
|
+
|
|
36
|
+
A field is one of:
|
|
37
|
+
|
|
38
|
+
- a **zod schema** — read from the source (its name, or its `vars` for aliasing), coerced and validated;
|
|
39
|
+
- a **factory** `(inherited) => …` — called with the fields resolved by *earlier* `add(...)` groups.
|
|
40
|
+
Return another **zod schema** (a schema that depends on other values), or a **plain value** (a
|
|
41
|
+
computed field):
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const ENV = env({
|
|
45
|
+
NODE_ENV: z.enum(['development', 'production']).default('development'),
|
|
46
|
+
})
|
|
47
|
+
.add({ IS_PROD: (e) => e.NODE_ENV === 'production' }) // computed value
|
|
48
|
+
.add({ LOG_LEVEL: (e) => (e.IS_PROD ? z.enum(['warn', 'error']) : z.string().default('debug')) }) // computed schema
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`add(...)` widens the type; each group's factories see all fields from the groups before it.
|
|
52
|
+
|
|
53
|
+
## Reading & writing
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
ENV.set(process.env) // feed a whole source (process.env, a loaded .env, …)
|
|
57
|
+
ENV.set('PORT', 8080) // one field — a typed value …
|
|
58
|
+
ENV.set({ PORT: '8080' }) // … or a raw string (coerced)
|
|
59
|
+
|
|
60
|
+
ENV.get('PORT') // resolve one field (lazy); throws if invalid/missing
|
|
61
|
+
ENV.parse() // resolve all; throws an aggregated EnvError
|
|
62
|
+
ENV.map((e) => e.get('PORT')) // run a fn with the env, returning its result
|
|
63
|
+
ENV.with((e) => e.set({ PORT: '1' }))// run a fn with the env (no return)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Resolution is **lazy** — nothing is parsed until `get`/`parse`, and results are cached until a
|
|
67
|
+
`set(...)` invalidates them.
|
|
68
|
+
|
|
69
|
+
## Coercion
|
|
70
|
+
|
|
71
|
+
Environment values are strings; coercion turns each into what its field expects, by reading the
|
|
72
|
+
field's zod type:
|
|
73
|
+
|
|
74
|
+
- **number / bigint / boolean / date** — parsed (`'42'`→`42`, `'yes'`→`true`, an ISO string→`Date`).
|
|
75
|
+
- **object / array / record / tuple / union** — **JSON-decoded** (`FLAGS='["a","b"]'`).
|
|
76
|
+
- **string / enum / literal** — left as-is.
|
|
77
|
+
- a value that is **already non-string** (e.g. from a JSON file) passes through untouched.
|
|
78
|
+
|
|
79
|
+
Coercion never throws — when a conversion is ambiguous it leaves the raw string, so **zod** owns
|
|
80
|
+
the final, authoritative error. Wrappers (`.optional()`, `.default()`, `.nullable()`, …) are seen
|
|
81
|
+
through to the underlying type.
|
|
82
|
+
|
|
83
|
+
The strings that count as booleans are configurable (case-insensitive); each side you pass
|
|
84
|
+
**replaces** its default set, an omitted side keeps the default:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
env({ FEATURE: z.boolean() }, { booleans: { true: ['enabled', 'on'], false: ['disabled', 'off'] } })
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Aliasing
|
|
91
|
+
|
|
92
|
+
Read a field from one of several source keys (first present wins) via metadata on its schema:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { alias } from '@ayepi/env'
|
|
96
|
+
|
|
97
|
+
env({
|
|
98
|
+
DATABASE_URL: alias(z.string().url(), 'DATABASE_URL', 'DB_URL', 'POSTGRES_URL'),
|
|
99
|
+
PORT: z.number().meta({ vars: ['PORT', 'APP_PORT'] }), // same thing, by hand
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Reactivity — `on(...)`
|
|
104
|
+
|
|
105
|
+
Subscribe to the changes a `set(...)` (or a provider, in `asyncEnv`) causes. Computed fields update
|
|
106
|
+
too — the keys a factory reads are auto-tracked, so they recompute and notify when a dependency changes.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
ENV.on('PORT', (value) => …) // one field
|
|
110
|
+
ENV.on(['PORT', 'DEBUG'], (value) => …) // several fields
|
|
111
|
+
ENV.on((key, value) => …) // any field
|
|
112
|
+
const off = ENV.on('PORT', cb, { once: true, immediate: true, deep: true })
|
|
113
|
+
off() // unsubscribe
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **`immediate`** — fire right away with the current value.
|
|
117
|
+
- **`once`** — fire at most once, then auto-unsubscribe.
|
|
118
|
+
- **`deep`** — compare structurally, so a structurally-identical update (e.g. a re-parsed object)
|
|
119
|
+
doesn't notify. (Default compares by identity.)
|
|
120
|
+
|
|
121
|
+
## Files — `@ayepi/env/load`
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { env } from '@ayepi/env'
|
|
125
|
+
import { loadEnv } from '@ayepi/env/load'
|
|
126
|
+
|
|
127
|
+
const ENV = env(schema)
|
|
128
|
+
ENV.set(loadEnv({ files: ['.env', 'config.json'] })) // files win over process.env
|
|
129
|
+
ENV.set({ ...loadEnv({ files: ['.env'] }), ...process.env }) // …or let process.env win
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`loadEnv({ files, required? })` reads `.env`/`.json` files into a plain source record (later files
|
|
133
|
+
win) that you feed to `set(...)`. A `.json` file may carry already-typed values (numbers, nested
|
|
134
|
+
objects). `readEnvFile(path)` and `parseDotenv(text)` are exported for direct use.
|
|
135
|
+
|
|
136
|
+
Missing files are **ignored** by default. Control that with `required`:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
loadEnv({ files: ['.env', '.env.local'] }) // ignore any missing file
|
|
140
|
+
loadEnv({ files: ['.env', '.env.local'], required: true }) // throw if ANY is missing
|
|
141
|
+
loadEnv({ files: ['.env', '.env.local'], required: ['.env'] }) // throw only if .env is missing
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Async + dynamic — `asyncEnv`
|
|
145
|
+
|
|
146
|
+
When fields resolve asynchronously or come from a **live, subscribable** source (a service, a DB,
|
|
147
|
+
a remote config store), use `asyncEnv`. It mirrors `env`, but `get`/`parse` are async and fields
|
|
148
|
+
can be **async factories** or `dynamic(provider, schema)` bindings.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { asyncEnv, dynamic, pollProvider } from '@ayepi/env'
|
|
152
|
+
|
|
153
|
+
const ENV = asyncEnv({
|
|
154
|
+
NODE_ENV: z.enum(['development', 'production']),
|
|
155
|
+
MAINTENANCE: dynamic(pollProvider(() => db.getFlag('maintenance'), 15_000), z.boolean()),
|
|
156
|
+
}).add({
|
|
157
|
+
CACHE_KEY: async (e) => `app:${e.NODE_ENV}:${await currentReleaseSha()}`, // async computed value
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await ENV.get('MAINTENANCE') // boolean, typed
|
|
161
|
+
ENV.on('MAINTENANCE', (on) => toggleBanner(on)) // notified on live updates (next microtask)
|
|
162
|
+
await ENV.refresh('MAINTENANCE') // force a re-pull now
|
|
163
|
+
ENV.close() // stop all provider watchers
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A `dynamic` value is coerced + validated against its schema on every update; a **bad update is
|
|
167
|
+
ignored** (the last good value is kept). An `EnvProvider` is just `{ load(); watch?(emit) }`;
|
|
168
|
+
`pollProvider(fn, ms)` and `staticProvider(value)` are built in.
|
|
169
|
+
|
|
170
|
+
## For AI coding agents
|
|
171
|
+
|
|
172
|
+
This package ships a dense, machine-oriented reference doc — point your agent at it:
|
|
173
|
+
|
|
174
|
+
- [`ayepi-env.md`](./ayepi-env.md)
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT © Philip Diffenderfer
|
package/ayepi-env.md
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ayepi-env.md — reference for `@ayepi/env`, written for coding agents.
|
|
3
|
+
|
|
4
|
+
Copy this file into any project that depends on `@ayepi/env` (e.g. into your repo's
|
|
5
|
+
`docs/` or `.claude/` directory) and reference it from your agents and slash commands.
|
|
6
|
+
It documents the public API, the patterns the package expects, and how it works under the
|
|
7
|
+
hood, with copy-pasteable examples. Keep it in sync with the installed package version.
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# `@ayepi/env` — overview
|
|
11
|
+
|
|
12
|
+
`@ayepi/env` is **typed, lazy, reactive config on zod**. You declare a config with `env({ … })`:
|
|
13
|
+
each entry is a field. The package reads each field from a **source** (`process.env` by default,
|
|
14
|
+
plus anything you `set(...)`), **coerces** the raw string to the field's type, and validates it with
|
|
15
|
+
zod — **lazily**, only when you `get(...)` or `parse()`. Fields can be plain zod **schemas**,
|
|
16
|
+
**factories** computed from earlier fields, or — with `asyncEnv` — **dynamic** values backed by a
|
|
17
|
+
live `EnvProvider`. Subscribe to changes with `on(...)`; **aliasing** (one field from several keys)
|
|
18
|
+
is metadata on the schema.
|
|
19
|
+
|
|
20
|
+
zod is a **peer dependency**. The main entry (`@ayepi/env`) is pure — no `node:fs` — so it is safe
|
|
21
|
+
anywhere; filesystem reading is isolated in `@ayepi/env/load`.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pnpm add @ayepi/env zod
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Two entry points
|
|
28
|
+
|
|
29
|
+
- **`@ayepi/env`** — `env` (sync) and `asyncEnv` (async + providers); the `dynamic` / `pollProvider`
|
|
30
|
+
/ `staticProvider` providers; `alias` (+ `varsOf`); the `coerce` primitive; `parseDotenv` (pure
|
|
31
|
+
string parser); `EnvError` and source helpers.
|
|
32
|
+
- **`@ayepi/env/load`** — `loadEnv` / `readEnvFile` (read `.env` + `.json` files into a source;
|
|
33
|
+
the only `node:fs` user).
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## `env(input)` → `Env<T>`
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
function env(input: EnvInput, options?: EnvOptions): Env<T>
|
|
41
|
+
|
|
42
|
+
type EnvInput<Inherited> = Record<string, z.ZodType | ((inherited: Inherited) => z.ZodType | any)>
|
|
43
|
+
|
|
44
|
+
interface Env<T> {
|
|
45
|
+
add<A>(input: A): Env<T & EnvOutput<…>> // widen with more fields (factories see earlier fields)
|
|
46
|
+
get<K extends keyof T>(key: K): T[K] // resolve one field (lazy); throws EnvError if invalid
|
|
47
|
+
set<K extends keyof T>(key: K, value: T[K] | string): void
|
|
48
|
+
set(updates: { [K in keyof T]?: T[K] | string } & Record<string, unknown>): void
|
|
49
|
+
parse(): T // resolve all; throws an aggregated EnvError
|
|
50
|
+
on(listener: (key, value) => void, opts?): () => void // any field
|
|
51
|
+
on<K>(key: K, listener: (value) => void, opts?): () => void // one field
|
|
52
|
+
on<K>(keys: K[], listener: (value) => void, opts?): () => void // several fields
|
|
53
|
+
with(fn: (env: Env<T>) => void): void // run a side effect with the env
|
|
54
|
+
map<R>(fn: (env: Env<T>) => R): R // run a fn with the env, return its result
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A **field** is either:
|
|
59
|
+
|
|
60
|
+
- a **zod schema** — read from the source key matching its name (or its `vars` meta, see Aliasing),
|
|
61
|
+
coerced from its string form and validated; or
|
|
62
|
+
- a **factory** `(inherited) => …` — receives the already-resolved fields from **earlier `add(...)`
|
|
63
|
+
groups** (not its own group). Return a **zod schema** (a schema that depends on other values) or a
|
|
64
|
+
**plain value** (a computed field, no source read, no extra validation).
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { env } from '@ayepi/env'
|
|
68
|
+
import { z } from 'zod'
|
|
69
|
+
|
|
70
|
+
const ENV = env({
|
|
71
|
+
NODE_ENV: z.enum(['development', 'production']).default('development'),
|
|
72
|
+
PORT: z.number().default(3000), // plain z.number() — auto-coerced, no z.coerce
|
|
73
|
+
})
|
|
74
|
+
.add({ IS_PROD: (e) => e.NODE_ENV === 'production' }) // computed value (boolean)
|
|
75
|
+
.add({ LOG: (e) => (e.IS_PROD ? z.enum(['warn', 'error']) : z.string().default('debug')) })
|
|
76
|
+
|
|
77
|
+
ENV.set(process.env) // values default to process.env anyway; set() layers on top and wins
|
|
78
|
+
ENV.get('PORT') // 3000 (number)
|
|
79
|
+
ENV.get('IS_PROD') // false
|
|
80
|
+
ENV.parse() // { NODE_ENV, PORT, IS_PROD, LOG }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Grouping & inheritance (important)
|
|
84
|
+
|
|
85
|
+
Each `env(...)` / `add(...)` call is a **group**. A factory's `inherited` argument exposes **only
|
|
86
|
+
fields from strictly-earlier groups** — never its own siblings. To let `B` read `A`, put `A` in an
|
|
87
|
+
earlier `add(...)` than `B`. This matches the types exactly (`inherited` is the type resolved *so
|
|
88
|
+
far*). Because dependencies only ever point at earlier groups, computed chains can't form cycles.
|
|
89
|
+
|
|
90
|
+
### Lazy resolution & caching
|
|
91
|
+
|
|
92
|
+
Nothing resolves until `get`/`parse`. Results are cached; a `set(...)` clears the cache (and a
|
|
93
|
+
factory recomputes on next read). `get(key)` throws an `EnvError` scoped to that one key; `parse()`
|
|
94
|
+
resolves every field and throws a single `EnvError` aggregating **all** failures.
|
|
95
|
+
|
|
96
|
+
## `set(...)` and the source
|
|
97
|
+
|
|
98
|
+
The live source is `process.env` (or `{}` off-Node) with your `set(...)` overrides merged **on top**
|
|
99
|
+
(overrides win). `set(key, value)` and `set(updates)` both write into the override layer.
|
|
100
|
+
|
|
101
|
+
- Pass a **typed value** or a **raw string** — strings are coerced, typed values pass through.
|
|
102
|
+
- `set(updates)` accepts known fields *and* arbitrary source keys (e.g. pass the whole `process.env`
|
|
103
|
+
or a loaded `.env`), so it composes with aliasing.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
ENV.set('PORT', 8080) // typed
|
|
107
|
+
ENV.set({ PORT: '8080', APP_PORT: '1' }) // raw strings + extra source keys
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Aliasing — read a field from several keys
|
|
111
|
+
|
|
112
|
+
Attach metadata with zod's `.meta(...)`, or the `alias(...)` helper. `vars` (several; first present
|
|
113
|
+
wins) and `var` (single) are honored; default is the field's own name.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { alias } from '@ayepi/env'
|
|
117
|
+
|
|
118
|
+
env({
|
|
119
|
+
DATABASE_URL: alias(z.string().url(), 'DATABASE_URL', 'DB_URL', 'POSTGRES_URL'),
|
|
120
|
+
PORT: z.number().meta({ vars: ['PORT', 'APP_PORT'] }),
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`varsOf(schema, key)` (exported) returns the resolved key list.
|
|
125
|
+
|
|
126
|
+
## Reactivity — `on(...)`
|
|
127
|
+
|
|
128
|
+
`on(...)` notifies when a `set(...)` (or, in `asyncEnv`, a provider/`refresh`) changes a watched
|
|
129
|
+
value. Computed fields participate: a factory's read keys are auto-tracked, so dependents recompute
|
|
130
|
+
and notify on a dependency change. Returns an **unsubscribe** function.
|
|
131
|
+
|
|
132
|
+
Options: `{ once?, immediate?, deep? }`
|
|
133
|
+
|
|
134
|
+
- **`immediate`** — fire on subscribe with the current value(s).
|
|
135
|
+
- **`once`** — fire at most once, then auto-unsubscribe.
|
|
136
|
+
- **`deep`** — change detection by **structural** equality instead of identity (`Object.is`), so a
|
|
137
|
+
re-parsed-but-equal object/array does **not** notify.
|
|
138
|
+
|
|
139
|
+
A field that is currently missing/invalid is skipped (no throw) during notification; a throwing
|
|
140
|
+
subscriber is swallowed (it can't break the engine). In **sync `env`**, notifications fire
|
|
141
|
+
**synchronously** inside `set(...)`. In **`asyncEnv`**, they fire on the next microtask.
|
|
142
|
+
|
|
143
|
+
## Coercion (how strings become values)
|
|
144
|
+
|
|
145
|
+
`coerce(schema, value)` (exported) reads the field's **effective zod type** (looking through
|
|
146
|
+
`.optional()`/`.default()`/`.nullable()`/`.catch()`/`.readonly()`/`.prefault()`/`.nonoptional()`)
|
|
147
|
+
and converts a **string**:
|
|
148
|
+
|
|
149
|
+
| effective type | from `'…'` | notes |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `number` | `Number(v)` | empty / `NaN` → left as string (zod rejects) |
|
|
152
|
+
| `bigint` | `BigInt(v)` | invalid → left as string |
|
|
153
|
+
| `boolean` | `true`/`false` | `true,1,yes,y,on` / `false,0,no,n,off` (case-insensitive); else left as string |
|
|
154
|
+
| `date` | `new Date(v)` | invalid date → left as string |
|
|
155
|
+
| `object`/`array`/`record`/`tuple`/`map`/`set`/`union`/`intersection`/`json` | `JSON.parse(v)` | malformed JSON → left as string |
|
|
156
|
+
| `string`/`enum`/`literal` | unchanged | |
|
|
157
|
+
|
|
158
|
+
A **non-string** input (e.g. a number from a JSON file) is returned untouched. Coercion **never
|
|
159
|
+
throws** — when in doubt it returns the raw string and lets **zod** produce the authoritative error.
|
|
160
|
+
`effectiveType(schema)` is exported too.
|
|
161
|
+
|
|
162
|
+
Because coercion runs **before** zod, you write plain schemas — **`z.coerce.*` is not required** (and
|
|
163
|
+
harmless if used). The boolean spellings are configurable per env via `EnvOptions.booleans`; each
|
|
164
|
+
side you provide **replaces** its default set (case-insensitive), an omitted side keeps the default:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
env({ FEATURE: z.boolean() }, { booleans: { true: ['enabled', 'on'], false: ['disabled', 'off'] } })
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`DEFAULT_TRUE` / `DEFAULT_FALSE` (the default sets) and `coerce(schema, value, words?)` (which takes
|
|
171
|
+
a `BooleanWords` override directly) are exported.
|
|
172
|
+
|
|
173
|
+
## `EnvError`
|
|
174
|
+
|
|
175
|
+
Thrown by `get`/`parse` (and rejected by their async counterparts). `error.issues` is the underlying
|
|
176
|
+
`z.core.$ZodIssue[]`; `error.message` lists `KEY: message` per line (a root-level issue shows as
|
|
177
|
+
`(root)`).
|
|
178
|
+
|
|
179
|
+
## `.env` parsing — `parseDotenv`
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
function parseDotenv(text: string): Record<string, string>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Pure (no fs). Supports `KEY=value`, an optional `export ` prefix, `#` comments (whole-line and
|
|
186
|
+
trailing on **unquoted** values), blank lines, single-quoted (literal) and double-quoted values
|
|
187
|
+
(with `\n`/`\t`/`\r`/`\\`/`\"` escapes). Malformed lines are ignored; a later assignment wins.
|
|
188
|
+
|
|
189
|
+
## File loading — `@ayepi/env/load`
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { loadEnv, readEnvFile } from '@ayepi/env/load'
|
|
193
|
+
|
|
194
|
+
function readEnvFile(path: string): EnvSource // .env → record, .json → parsed object
|
|
195
|
+
function loadEnv(opts?: { files?: string[]; required?: boolean | readonly string[] }): EnvSource
|
|
196
|
+
|
|
197
|
+
type EnvSource = Record<string, unknown>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`loadEnv` reads the listed `.env`/`.json` files into a single merged source (later files win) — a
|
|
201
|
+
plain record you feed to `set(...)`. A `.json` file may carry already-typed values (they pass through
|
|
202
|
+
coercion). Missing files are **ignored** by default; `required` controls failure: `true` throws if
|
|
203
|
+
**any** listed file is missing; a `string[]` throws only if one of **those** files is missing (other
|
|
204
|
+
missing files are still skipped). **Precedence is up to you** at the `set(...)` call:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
const ENV = env(schema)
|
|
208
|
+
ENV.set(loadEnv({ files: ['.env', 'config.json'] })) // files win over process.env
|
|
209
|
+
ENV.set({ ...loadEnv({ files: ['.env'] }), ...process.env }) // process.env wins
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## `asyncEnv(input)` → `AsyncEnv<T>`
|
|
215
|
+
|
|
216
|
+
The async sibling of `env`. Everything `env` does, plus async resolution and **dynamic** fields.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
function asyncEnv(input: AsyncEnvInput, options?: EnvOptions): AsyncEnv<T> // same EnvOptions as env()
|
|
220
|
+
|
|
221
|
+
type AsyncEnvInput<Inherited> = Record<string,
|
|
222
|
+
| z.ZodType
|
|
223
|
+
| DynamicBinding<any> // dynamic(provider, schema)
|
|
224
|
+
| ((inherited: Inherited) => MaybePromise<z.ZodType | any>) // (possibly async) factory
|
|
225
|
+
>
|
|
226
|
+
|
|
227
|
+
interface AsyncEnv<T> {
|
|
228
|
+
add<A>(input: A): AsyncEnv<…>
|
|
229
|
+
get<K>(key: K): Promise<T[K]> // async
|
|
230
|
+
set<K>(key: K, value: T[K] | string): void
|
|
231
|
+
set(updates): void
|
|
232
|
+
parse(): Promise<T> // async
|
|
233
|
+
on(…): () => void // same overloads/options as Env (fires on a microtask)
|
|
234
|
+
refresh(key?): Promise<void> // re-pull dynamic providers (all, or one)
|
|
235
|
+
close(): void // stop all provider watchers
|
|
236
|
+
with(fn): void
|
|
237
|
+
map<R>(fn): R
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Dynamic fields & providers
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { dynamic, pollProvider, staticProvider } from '@ayepi/env'
|
|
245
|
+
|
|
246
|
+
dynamic<V>(provider: EnvProvider, schema: z.ZodType<V>): DynamicBinding<V>
|
|
247
|
+
|
|
248
|
+
interface EnvProvider {
|
|
249
|
+
load(): MaybePromise<string | undefined> // initial value
|
|
250
|
+
watch?(emit: (raw: string | undefined) => void): () => void // live updates; returns unsubscribe
|
|
251
|
+
}
|
|
252
|
+
pollProvider(fetch: () => MaybePromise<string | undefined>, intervalMs): EnvProvider
|
|
253
|
+
staticProvider(value: string | undefined): EnvProvider
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
A real DB/service provider implements `load` (query) and optionally `watch` (poll, `LISTEN/NOTIFY`,
|
|
257
|
+
a change feed). The provider loads once and starts watching the first time the field is resolved;
|
|
258
|
+
each raw value is **coerced + validated** against the field's schema.
|
|
259
|
+
|
|
260
|
+
### Runtime updates, cascade & last-good
|
|
261
|
+
|
|
262
|
+
When a dynamic value changes (via `watch` or `refresh`):
|
|
263
|
+
|
|
264
|
+
1. it is coerced + validated; **if invalid it is ignored** — the previous good value is kept (the
|
|
265
|
+
config never goes invalid at runtime),
|
|
266
|
+
2. if valid, the cache is dropped and any **computed** fields depending on it recompute (cascading),
|
|
267
|
+
3. subscribers for the changed keys are notified (on the next microtask).
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
const ENV = asyncEnv({
|
|
271
|
+
NODE_ENV: z.enum(['development', 'production']),
|
|
272
|
+
MAINTENANCE: dynamic(pollProvider(() => db.getFlag('maintenance'), 15_000), z.boolean()),
|
|
273
|
+
}).add({
|
|
274
|
+
BANNER: (e) => (e.MAINTENANCE ? '🚧 down for maintenance' : ''), // recomputes when MAINTENANCE flips
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
await ENV.get('MAINTENANCE')
|
|
278
|
+
const off = ENV.on('BANNER', (text) => render(text))
|
|
279
|
+
await ENV.refresh('MAINTENANCE') // force a re-pull now
|
|
280
|
+
// … later
|
|
281
|
+
off(); ENV.close()
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Gotchas / constraints
|
|
287
|
+
|
|
288
|
+
- **`inherited` only sees earlier groups.** A factory can read fields added in *prior* `env`/`add`
|
|
289
|
+
calls, never its own group. Split into `add(...)` steps to express dependencies.
|
|
290
|
+
- **Lazy.** Values resolve on `get`/`parse`, not at declaration. `get` throws for its one field;
|
|
291
|
+
`parse` aggregates all failures.
|
|
292
|
+
- **`set(...)` wins over `process.env`.** The source is `{ ...process.env, ...overrides }`. To make
|
|
293
|
+
files lower precedence than `process.env`, spread `process.env` last into `set(...)`.
|
|
294
|
+
- **Computed value fields aren't re-validated.** A factory returning a plain value is trusted as-is;
|
|
295
|
+
only factories returning a **schema** (and plain zod fields) are coerced + validated.
|
|
296
|
+
- **`deep` flips change detection.** Default is identity (`Object.is`) — a re-parsed equal object
|
|
297
|
+
notifies; `deep: true` compares structurally and suppresses equal updates.
|
|
298
|
+
- **`asyncEnv` notifications are async.** They fire on a microtask after `set`/push/`refresh`; await
|
|
299
|
+
a tick before asserting in tests.
|
|
300
|
+
- **A bad dynamic update is silent.** It's ignored to keep the last good value — there is no error
|
|
301
|
+
event; surface provider/validation errors in the provider itself if you need them.
|
|
302
|
+
- **`process.env` only on Node.** The default source is `process.env` when present, else `{}` — in a
|
|
303
|
+
browser, `set(...)` your own source.
|
package/dist/dotenv.cjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region src/dotenv.ts
|
|
2
|
+
/**
|
|
3
|
+
* # `.env` parsing (pure, no filesystem)
|
|
4
|
+
*
|
|
5
|
+
* A small, dependency-free `.env` **string** parser. The filesystem reader lives in
|
|
6
|
+
* `@ayepi/env/load`; this stays pure so it works anywhere and is easy to test.
|
|
7
|
+
*
|
|
8
|
+
* Supported: `KEY=value`, optional `export ` prefix, `#` comments (whole-line and trailing
|
|
9
|
+
* on unquoted values), blank lines, single-quoted (literal) and double-quoted values
|
|
10
|
+
* (with `\n`/`\t`/`\r`/`\\`/`\"` escapes). Malformed lines are ignored.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
|
|
15
|
+
/** Unescape the common backslash sequences inside a double-quoted value. */
|
|
16
|
+
function unescape(s) {
|
|
17
|
+
return s.replace(/\\([ntr"\\])/g, (_m, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c);
|
|
18
|
+
}
|
|
19
|
+
/** Parse `.env` text into a flat record. Order matters: a later assignment wins. */
|
|
20
|
+
function parseDotenv(text) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
23
|
+
const line = rawLine.trimStart();
|
|
24
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
25
|
+
const m = LINE.exec(line);
|
|
26
|
+
if (!m) continue;
|
|
27
|
+
const key = m[1];
|
|
28
|
+
let value = m[2];
|
|
29
|
+
if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) value = unescape(value.slice(1, -1));
|
|
30
|
+
else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) value = value.slice(1, -1);
|
|
31
|
+
else {
|
|
32
|
+
const hash = value.indexOf(" #");
|
|
33
|
+
if (hash !== -1) value = value.slice(0, hash).trimEnd();
|
|
34
|
+
}
|
|
35
|
+
out[key] = value;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
Object.defineProperty(exports, "parseDotenv", {
|
|
41
|
+
enumerable: true,
|
|
42
|
+
get: function() {
|
|
43
|
+
return parseDotenv;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/source.d.ts
|
|
4
|
+
|
|
5
|
+
/** A flat config source (e.g. `process.env`, a parsed `.env`, or a JSON object). */
|
|
6
|
+
type EnvSource = Record<string, unknown>;
|
|
7
|
+
/** `process.env` when available (Node), else an empty record (so the core stays browser-safe). */
|
|
8
|
+
declare function defaultSource(): EnvSource;
|
|
9
|
+
/** Flatten one or more sources into a single record; later sources win. */
|
|
10
|
+
declare function mergeSources(source: EnvSource | readonly EnvSource[] | undefined): EnvSource;
|
|
11
|
+
/** Resolve a value from the first present (defined) of several source `keys` (**aliasing**). */
|
|
12
|
+
declare function resolveRaw(source: EnvSource, keys: readonly string[]): unknown;
|
|
13
|
+
/** A readable, aggregated environment validation error built from zod issues. */
|
|
14
|
+
declare class EnvError extends Error {
|
|
15
|
+
/** The individual zod issues that caused the failure. */
|
|
16
|
+
readonly issues: readonly z.core.$ZodIssue[];
|
|
17
|
+
constructor(issues: readonly z.core.$ZodIssue[]);
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/dotenv.d.ts
|
|
21
|
+
/**
|
|
22
|
+
* # `.env` parsing (pure, no filesystem)
|
|
23
|
+
*
|
|
24
|
+
* A small, dependency-free `.env` **string** parser. The filesystem reader lives in
|
|
25
|
+
* `@ayepi/env/load`; this stays pure so it works anywhere and is easy to test.
|
|
26
|
+
*
|
|
27
|
+
* Supported: `KEY=value`, optional `export ` prefix, `#` comments (whole-line and trailing
|
|
28
|
+
* on unquoted values), blank lines, single-quoted (literal) and double-quoted values
|
|
29
|
+
* (with `\n`/`\t`/`\r`/`\\`/`\"` escapes). Malformed lines are ignored.
|
|
30
|
+
*
|
|
31
|
+
* @module
|
|
32
|
+
*/
|
|
33
|
+
/** Parse `.env` text into a flat record. Order matters: a later assignment wins. */
|
|
34
|
+
declare function parseDotenv(text: string): Record<string, string>;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { mergeSources as a, defaultSource as i, EnvError as n, resolveRaw as o, EnvSource as r, parseDotenv as t };
|
package/dist/dotenv.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/source.d.ts
|
|
4
|
+
|
|
5
|
+
/** A flat config source (e.g. `process.env`, a parsed `.env`, or a JSON object). */
|
|
6
|
+
type EnvSource = Record<string, unknown>;
|
|
7
|
+
/** `process.env` when available (Node), else an empty record (so the core stays browser-safe). */
|
|
8
|
+
declare function defaultSource(): EnvSource;
|
|
9
|
+
/** Flatten one or more sources into a single record; later sources win. */
|
|
10
|
+
declare function mergeSources(source: EnvSource | readonly EnvSource[] | undefined): EnvSource;
|
|
11
|
+
/** Resolve a value from the first present (defined) of several source `keys` (**aliasing**). */
|
|
12
|
+
declare function resolveRaw(source: EnvSource, keys: readonly string[]): unknown;
|
|
13
|
+
/** A readable, aggregated environment validation error built from zod issues. */
|
|
14
|
+
declare class EnvError extends Error {
|
|
15
|
+
/** The individual zod issues that caused the failure. */
|
|
16
|
+
readonly issues: readonly z.core.$ZodIssue[];
|
|
17
|
+
constructor(issues: readonly z.core.$ZodIssue[]);
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/dotenv.d.ts
|
|
21
|
+
/**
|
|
22
|
+
* # `.env` parsing (pure, no filesystem)
|
|
23
|
+
*
|
|
24
|
+
* A small, dependency-free `.env` **string** parser. The filesystem reader lives in
|
|
25
|
+
* `@ayepi/env/load`; this stays pure so it works anywhere and is easy to test.
|
|
26
|
+
*
|
|
27
|
+
* Supported: `KEY=value`, optional `export ` prefix, `#` comments (whole-line and trailing
|
|
28
|
+
* on unquoted values), blank lines, single-quoted (literal) and double-quoted values
|
|
29
|
+
* (with `\n`/`\t`/`\r`/`\\`/`\"` escapes). Malformed lines are ignored.
|
|
30
|
+
*
|
|
31
|
+
* @module
|
|
32
|
+
*/
|
|
33
|
+
/** Parse `.env` text into a flat record. Order matters: a later assignment wins. */
|
|
34
|
+
declare function parseDotenv(text: string): Record<string, string>;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { mergeSources as a, defaultSource as i, EnvError as n, resolveRaw as o, EnvSource as r, parseDotenv as t };
|
package/dist/dotenv.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/dotenv.ts
|
|
2
|
+
/**
|
|
3
|
+
* # `.env` parsing (pure, no filesystem)
|
|
4
|
+
*
|
|
5
|
+
* A small, dependency-free `.env` **string** parser. The filesystem reader lives in
|
|
6
|
+
* `@ayepi/env/load`; this stays pure so it works anywhere and is easy to test.
|
|
7
|
+
*
|
|
8
|
+
* Supported: `KEY=value`, optional `export ` prefix, `#` comments (whole-line and trailing
|
|
9
|
+
* on unquoted values), blank lines, single-quoted (literal) and double-quoted values
|
|
10
|
+
* (with `\n`/`\t`/`\r`/`\\`/`\"` escapes). Malformed lines are ignored.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
|
|
15
|
+
/** Unescape the common backslash sequences inside a double-quoted value. */
|
|
16
|
+
function unescape(s) {
|
|
17
|
+
return s.replace(/\\([ntr"\\])/g, (_m, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c);
|
|
18
|
+
}
|
|
19
|
+
/** Parse `.env` text into a flat record. Order matters: a later assignment wins. */
|
|
20
|
+
function parseDotenv(text) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
23
|
+
const line = rawLine.trimStart();
|
|
24
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
25
|
+
const m = LINE.exec(line);
|
|
26
|
+
if (!m) continue;
|
|
27
|
+
const key = m[1];
|
|
28
|
+
let value = m[2];
|
|
29
|
+
if (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) value = unescape(value.slice(1, -1));
|
|
30
|
+
else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) value = value.slice(1, -1);
|
|
31
|
+
else {
|
|
32
|
+
const hash = value.indexOf(" #");
|
|
33
|
+
if (hash !== -1) value = value.slice(0, hash).trimEnd();
|
|
34
|
+
}
|
|
35
|
+
out[key] = value;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { parseDotenv as t };
|