@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 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.
@@ -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 };
@@ -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 };