@ayepi/env 0.4.1 → 0.5.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/README.md +12 -0
- package/ayepi-env.md +80 -9
- package/dist/index.cjs +217 -34
- package/dist/index.d.cts +102 -13
- package/dist/index.d.ts +102 -13
- package/dist/index.js +215 -35
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -100,6 +100,18 @@ env({
|
|
|
100
100
|
})
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
## Slices, prefixes and `.env.example`
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { env, envExample, mergeEnvs } from '@ayepi/env'
|
|
107
|
+
|
|
108
|
+
const http = env({ PORT: z.number().default(3000).describe('Where to listen.') }, { prefix: 'APP_' }) // reads APP_PORT
|
|
109
|
+
const db = env({ DB_FILE: z.string().default('app.sqlite') }, { prefix: 'APP_' })
|
|
110
|
+
const settings = mergeEnvs(http, db) // one typed view; a field in two slices fails to compile
|
|
111
|
+
settings.parse() // one EnvError listing every bad field
|
|
112
|
+
envExample(settings.describe()) // '# Where to listen.\nAPP_PORT=3000\n\nAPP_DB_FILE=app.sqlite\n'
|
|
113
|
+
```
|
|
114
|
+
|
|
103
115
|
## Reactivity — `on(...)`
|
|
104
116
|
|
|
105
117
|
Subscribe to the changes a `set(...)` (or a provider, in `asyncEnv`) causes. Computed fields update
|
package/ayepi-env.md
CHANGED
|
@@ -27,8 +27,9 @@ pnpm add @ayepi/env zod
|
|
|
27
27
|
## Two entry points
|
|
28
28
|
|
|
29
29
|
- **`@ayepi/env`** — `env` (sync) and `asyncEnv` (async + providers); the `dynamic` / `pollProvider`
|
|
30
|
-
/ `staticProvider` providers; `alias` (+ `varsOf`);
|
|
31
|
-
|
|
30
|
+
/ `staticProvider` providers; `alias` (+ `varsOf`, `sourceKeyOf`); `mergeEnvs` (one view over
|
|
31
|
+
several slices); `envExample`; the `coerce` primitive; `parseDotenv` (pure string parser);
|
|
32
|
+
`EnvError` and source helpers.
|
|
32
33
|
- **`@ayepi/env/load`** — `loadEnv` / `readEnvFile` (read `.env` + `.json` files into a source;
|
|
33
34
|
the only `node:fs` user).
|
|
34
35
|
|
|
@@ -41,8 +42,18 @@ function env(input: EnvInput, options?: EnvOptions): Env<T>
|
|
|
41
42
|
|
|
42
43
|
type EnvInput<Inherited> = Record<string, z.ZodType | ((inherited: Inherited) => z.ZodType | any)>
|
|
43
44
|
|
|
44
|
-
interface
|
|
45
|
+
interface EnvOptions {
|
|
46
|
+
prefix?: string // field PORT reads `${prefix}PORT` (aliases are read as written)
|
|
47
|
+
booleans?: { true?: string[]; false?: string[] }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Env<T> extends EnvView<T> {
|
|
45
51
|
add<A>(input: A): Env<T & EnvOutput<…>> // widen with more fields (factories see earlier fields)
|
|
52
|
+
with(fn: (env: Env<T>) => void): void // run a side effect with the env
|
|
53
|
+
map<R>(fn: (env: Env<T>) => R): R // run a fn with the env, return its result
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface EnvView<T> { // what Env and a mergeEnvs view share
|
|
46
57
|
get<K extends keyof T>(key: K): T[K] // resolve one field (lazy); throws EnvError if invalid
|
|
47
58
|
set<K extends keyof T>(key: K, value: T[K] | string): void
|
|
48
59
|
set(updates: { [K in keyof T]?: T[K] | string } & Record<string, unknown>): void
|
|
@@ -50,8 +61,7 @@ interface Env<T> {
|
|
|
50
61
|
on(listener: (key, value) => void, opts?): () => void // any field
|
|
51
62
|
on<K>(key: K, listener: (value) => void, opts?): () => void // one field
|
|
52
63
|
on<K>(keys: K[], listener: (value) => void, opts?): () => void // several fields
|
|
53
|
-
|
|
54
|
-
map<R>(fn: (env: Env<T>) => R): R // run a fn with the env, return its result
|
|
64
|
+
describe(): readonly EnvFieldInfo[] // every field: keys read, required, description, default, JSON Schema
|
|
55
65
|
}
|
|
56
66
|
```
|
|
57
67
|
|
|
@@ -96,11 +106,15 @@ resolves every field and throws a single `EnvError` aggregating **all** failures
|
|
|
96
106
|
## `set(...)` and the source
|
|
97
107
|
|
|
98
108
|
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
|
|
109
|
+
(overrides win). `set(key, value)` and `set(updates)` both write into the override layer, which is
|
|
110
|
+
keyed by **source key**.
|
|
100
111
|
|
|
101
112
|
- Pass a **typed value** or a **raw string** — strings are coerced, typed values pass through.
|
|
102
|
-
- `set(
|
|
103
|
-
or
|
|
113
|
+
- `set(key, value)` with a field's name writes under the first key that field reads (its prefixed
|
|
114
|
+
name, or its first alias — `sourceKeyOf(schema, key, prefix)`), so it always reaches the field.
|
|
115
|
+
A key that names no field is written as given.
|
|
116
|
+
- `set(updates)` takes source keys as given (e.g. pass the whole `process.env` or a loaded
|
|
117
|
+
`.env`), so it composes with prefixes and aliasing.
|
|
104
118
|
|
|
105
119
|
```ts
|
|
106
120
|
ENV.set('PORT', 8080) // typed
|
|
@@ -121,7 +135,62 @@ env({
|
|
|
121
135
|
})
|
|
122
136
|
```
|
|
123
137
|
|
|
124
|
-
`varsOf(schema, key)` (exported) returns the resolved key list.
|
|
138
|
+
`varsOf(schema, key, prefix?)` (exported) returns the resolved key list.
|
|
139
|
+
|
|
140
|
+
## Prefix — one namespace for every key
|
|
141
|
+
|
|
142
|
+
`env(input, { prefix: 'APP_' })` makes field `PORT` read `APP_PORT`: the TypeScript name stays short
|
|
143
|
+
and the namespace is written once. Keys named by `vars`/`var` metadata are read exactly as written
|
|
144
|
+
(so an alias can reach an unprefixed key such as `PORT` from a platform). `asyncEnv` takes the same
|
|
145
|
+
option.
|
|
146
|
+
|
|
147
|
+
## `describe()` and `envExample(fields)`
|
|
148
|
+
|
|
149
|
+
`describe()` lists every field in declaration order as an `EnvFieldInfo`:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
interface EnvFieldInfo {
|
|
153
|
+
key: string // the field name you get()
|
|
154
|
+
vars: readonly string[] // the source keys it reads (prefix and aliases applied); [] when computed
|
|
155
|
+
computed: boolean // a factory that returned a plain value — reads no key
|
|
156
|
+
required: boolean // the schema rejects a missing value (no default, not optional)
|
|
157
|
+
description?: string // .describe(...) / .meta({ description })
|
|
158
|
+
default?: unknown // the schema's default
|
|
159
|
+
schema?: Record<string, unknown> // JSON Schema of the accepted input (zod's toJSONSchema, io: 'input')
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A factory field is run to find its schema; if it throws (an earlier field it reads is invalid) it is
|
|
164
|
+
reported as required with no `schema`. `envExample(fields)` renders the list as a commented
|
|
165
|
+
`.env.example` — the description as `#` lines, `KEY=default`, `KEY=` marked `(required)`, or
|
|
166
|
+
`# KEY=` when optional; computed fields are left out. A settings page can render the same list.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
writeFileSync('.env.example', envExample(ENV.describe()))
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## `mergeEnvs(...slices)` — one view over several configs
|
|
173
|
+
|
|
174
|
+
A large app configures each module with its own slice. `mergeEnvs(a, b, …)` joins them into one
|
|
175
|
+
`EnvView` of every field; each slice stays the source of truth for its fields:
|
|
176
|
+
|
|
177
|
+
- `get` and single-field `set` go to the slice that owns the field; a bulk `set(updates)` (and a
|
|
178
|
+
single `set` of a key no field has) reaches every slice.
|
|
179
|
+
- `parse()` resolves every slice and throws **one** `EnvError` with every slice's issues.
|
|
180
|
+
- `on(key | keys, …)` subscribes in the owning slices; `on(listener)` in every slice (a `once`
|
|
181
|
+
listener fires once per slice). The returned function unsubscribes from all of them.
|
|
182
|
+
- `describe()` lists every field, slice by slice.
|
|
183
|
+
- Two slices declaring the same field name is a **compile-time error** naming it (for argument
|
|
184
|
+
tuples) and throws at runtime in every case.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { env, mergeEnvs } from '@ayepi/env'
|
|
188
|
+
|
|
189
|
+
const http = env({ PORT: z.number().default(3000) }, { prefix: 'APP_' })
|
|
190
|
+
const db = env({ DB_FILE: z.string().default('app.sqlite') }, { prefix: 'APP_' })
|
|
191
|
+
export const settings = mergeEnvs(http, db) // EnvView<{ PORT: number; DB_FILE: string }>
|
|
192
|
+
settings.parse()
|
|
193
|
+
```
|
|
125
194
|
|
|
126
195
|
## Reactivity — `on(...)`
|
|
127
196
|
|
|
@@ -289,6 +358,8 @@ off(); ENV.close()
|
|
|
289
358
|
calls, never its own group. Split into `add(...)` steps to express dependencies.
|
|
290
359
|
- **Lazy.** Values resolve on `get`/`parse`, not at declaration. `get` throws for its one field;
|
|
291
360
|
`parse` aggregates all failures.
|
|
361
|
+
- **`set(updates)` is source-keyed.** With a `prefix`, `set({ PORT: '1' })` sets the source key
|
|
362
|
+
`PORT`, not the field; write `set('PORT', 1)` or `set({ APP_PORT: '1' })`.
|
|
292
363
|
- **`set(...)` wins over `process.env`.** The source is `{ ...process.env, ...overrides }`. To make
|
|
293
364
|
files lower precedence than `process.env`, spread `process.env` last into `set(...)`.
|
|
294
365
|
- **Computed value fields aren't re-validated.** A factory returning a plain value is trusted as-is;
|
package/dist/index.cjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_dotenv = require("./dotenv.cjs");
|
|
3
|
+
let zod = require("zod");
|
|
3
4
|
//#region src/coerce.ts
|
|
4
5
|
/** Read a schema's internal `_zod.def` (zod v4 stores its definition there). */
|
|
5
6
|
function def(schema) {
|
|
@@ -99,12 +100,23 @@ function coerce(schema, value, words) {
|
|
|
99
100
|
function readMeta(schema) {
|
|
100
101
|
return schema.meta?.();
|
|
101
102
|
}
|
|
102
|
-
/**
|
|
103
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The source keys a field reads, in precedence order — its `vars`/`var` meta (taken as written), else
|
|
105
|
+
* `[prefix + key]`.
|
|
106
|
+
*/
|
|
107
|
+
function varsOf(schema, key, prefix = "") {
|
|
104
108
|
const meta = readMeta(schema);
|
|
105
109
|
if (meta?.vars && meta.vars.length > 0) return meta.vars;
|
|
106
110
|
if (meta?.var) return [meta.var];
|
|
107
|
-
return [key];
|
|
111
|
+
return [prefix + key];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The source key a field is written under by a single-field `set`: the first key it reads. `schema` is undefined for
|
|
115
|
+
* a factory field, whose schema is only known once it runs; it is written under its prefixed name.
|
|
116
|
+
*/
|
|
117
|
+
function sourceKeyOf(schema, key, prefix = "") {
|
|
118
|
+
const meta = schema === void 0 ? void 0 : readMeta(schema);
|
|
119
|
+
return meta?.vars?.[0] ?? meta?.var ?? prefix + key;
|
|
108
120
|
}
|
|
109
121
|
/** Attach aliasing metadata: read `schema` from the first present of `keys`. */
|
|
110
122
|
function alias(schema, ...keys) {
|
|
@@ -186,6 +198,32 @@ function changed(prev, next, deep) {
|
|
|
186
198
|
}
|
|
187
199
|
//#endregion
|
|
188
200
|
//#region src/env.ts
|
|
201
|
+
/**
|
|
202
|
+
* # `env(...)` — the typed, lazy, reactive config builder
|
|
203
|
+
*
|
|
204
|
+
* Declare a config as a record of fields and get back a typed {@link Env}. A field is either:
|
|
205
|
+
*
|
|
206
|
+
* - a **zod schema** — read from a source key (its name, or its `vars` meta for aliasing),
|
|
207
|
+
* coerced from its string form and validated; or
|
|
208
|
+
* - a **factory** `(inherited) => …` — called with the already-resolved fields from *earlier*
|
|
209
|
+
* `add(...)` groups. Return another **zod schema** (a schema that depends on other values),
|
|
210
|
+
* or return a **plain value** (a computed field derived purely from other fields).
|
|
211
|
+
*
|
|
212
|
+
* Resolution is **lazy**: nothing is parsed until you `get(...)` or `parse()`. Values come from
|
|
213
|
+
* `process.env` by default; `set(...)` layers overrides on top. `on(...)` subscribes to changes
|
|
214
|
+
* that `set(...)` causes; the keys a factory reads are auto-tracked so computed fields update too.
|
|
215
|
+
*
|
|
216
|
+
* ```ts
|
|
217
|
+
* const ENV = env({ PORT: z.coerce.number().default(3000) })
|
|
218
|
+
* .add({ IS_PROD: (e) => e.NODE_ENV === 'production' }) // factory: computed value
|
|
219
|
+
*
|
|
220
|
+
* ENV.set(process.env)
|
|
221
|
+
* ENV.get('PORT') // typed, throws if invalid
|
|
222
|
+
* ENV.parse() // resolve everything; throws an aggregated EnvError
|
|
223
|
+
* ```
|
|
224
|
+
*
|
|
225
|
+
* @module
|
|
226
|
+
*/
|
|
189
227
|
/** Normalize {@link EnvOptions} into the lowercased {@link BooleanWords} the coercer consumes. */
|
|
190
228
|
function toWords(options) {
|
|
191
229
|
const b = options?.booleans;
|
|
@@ -206,9 +244,11 @@ var EnvImpl = class {
|
|
|
206
244
|
globalLs = /* @__PURE__ */ new Set();
|
|
207
245
|
prev = /* @__PURE__ */ new Map();
|
|
208
246
|
words;
|
|
247
|
+
prefix;
|
|
209
248
|
groupCount = 0;
|
|
210
249
|
constructor(input, options) {
|
|
211
250
|
this.words = toWords(options);
|
|
251
|
+
this.prefix = options?.prefix ?? "";
|
|
212
252
|
this.define(input);
|
|
213
253
|
}
|
|
214
254
|
/** Register a group of fields under the next group index. */
|
|
@@ -234,13 +274,12 @@ var EnvImpl = class {
|
|
|
234
274
|
/** A proxy over fields from groups *earlier* than `group`, recording which keys a factory reads. */
|
|
235
275
|
inheritedFor(group) {
|
|
236
276
|
const reads = /* @__PURE__ */ new Set();
|
|
237
|
-
const
|
|
238
|
-
const visible = (prop) => typeof prop === "string" && (self.defs.get(prop)?.group ?? Infinity) < group;
|
|
277
|
+
const visible = (prop) => typeof prop === "string" && (this.defs.get(prop)?.group ?? Infinity) < group;
|
|
239
278
|
return {
|
|
240
279
|
proxy: new Proxy(Object.create(null), {
|
|
241
|
-
get: (_t, prop) => visible(prop) ? (reads.add(prop),
|
|
280
|
+
get: (_t, prop) => visible(prop) ? (reads.add(prop), this.resolve(prop)) : void 0,
|
|
242
281
|
has: (_t, prop) => visible(prop),
|
|
243
|
-
ownKeys: () => [...
|
|
282
|
+
ownKeys: () => [...this.defs.values()].filter((f) => f.group < group).map((f) => f.key),
|
|
244
283
|
getOwnPropertyDescriptor: () => ({
|
|
245
284
|
enumerable: true,
|
|
246
285
|
configurable: true
|
|
@@ -249,34 +288,32 @@ var EnvImpl = class {
|
|
|
249
288
|
reads
|
|
250
289
|
};
|
|
251
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* What a field is made of: its schema, or the value its factory computed. A factory is run against the fields of
|
|
293
|
+
* earlier groups (recording what it reads); a throwing factory becomes an {@link EnvError} scoped to the field.
|
|
294
|
+
*/
|
|
295
|
+
produce(def) {
|
|
296
|
+
if (def.kind === "schema") return { schema: def.schema };
|
|
297
|
+
const { proxy, reads } = this.inheritedFor(def.group);
|
|
298
|
+
let produced;
|
|
299
|
+
try {
|
|
300
|
+
produced = def.factory(proxy);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
throw new EnvError([customIssue(def.key, errMessage(err))]);
|
|
303
|
+
} finally {
|
|
304
|
+
this.deps.set(def.key, reads);
|
|
305
|
+
}
|
|
306
|
+
return isZodType(produced) ? { schema: produced } : { value: produced };
|
|
307
|
+
}
|
|
252
308
|
/** Resolve `key` to its value (cached). Throws an {@link EnvError} (scoped to `key`) on failure. */
|
|
253
309
|
resolve(key) {
|
|
254
310
|
if (this.cache.has(key)) return this.cache.get(key);
|
|
255
|
-
const
|
|
256
|
-
let schema;
|
|
257
|
-
let computed;
|
|
258
|
-
let isComputed = false;
|
|
259
|
-
if (def.kind === "schema") schema = def.schema;
|
|
260
|
-
else {
|
|
261
|
-
const { proxy, reads } = this.inheritedFor(def.group);
|
|
262
|
-
let produced;
|
|
263
|
-
try {
|
|
264
|
-
produced = def.factory(proxy);
|
|
265
|
-
} catch (err) {
|
|
266
|
-
this.deps.set(key, reads);
|
|
267
|
-
throw new EnvError([customIssue(key, errMessage(err))]);
|
|
268
|
-
}
|
|
269
|
-
this.deps.set(key, reads);
|
|
270
|
-
if (isZodType(produced)) schema = produced;
|
|
271
|
-
else {
|
|
272
|
-
computed = produced;
|
|
273
|
-
isComputed = true;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
311
|
+
const made = this.produce(this.defs.get(key));
|
|
276
312
|
let result;
|
|
277
|
-
if (
|
|
313
|
+
if ("value" in made) result = made.value;
|
|
278
314
|
else {
|
|
279
|
-
const
|
|
315
|
+
const { schema } = made;
|
|
316
|
+
const raw = resolveRaw(this.source(), varsOf(schema, key, this.prefix));
|
|
280
317
|
const res = schema.safeParse(raw !== void 0 ? coerce(schema, raw, this.words) : void 0);
|
|
281
318
|
if (!res.success) throw new EnvError(keyed(key, res.error.issues));
|
|
282
319
|
result = res.data;
|
|
@@ -284,6 +321,28 @@ var EnvImpl = class {
|
|
|
284
321
|
this.cache.set(key, result);
|
|
285
322
|
return result;
|
|
286
323
|
}
|
|
324
|
+
describe() {
|
|
325
|
+
return [...this.defs.values()].map((def) => {
|
|
326
|
+
let made;
|
|
327
|
+
try {
|
|
328
|
+
made = this.produce(def);
|
|
329
|
+
} catch {
|
|
330
|
+
return {
|
|
331
|
+
key: def.key,
|
|
332
|
+
vars: [this.prefix + def.key],
|
|
333
|
+
computed: false,
|
|
334
|
+
required: true
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if ("value" in made) return {
|
|
338
|
+
key: def.key,
|
|
339
|
+
vars: [],
|
|
340
|
+
computed: true,
|
|
341
|
+
required: false
|
|
342
|
+
};
|
|
343
|
+
return describeSchema(def.key, made.schema, this.prefix);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
287
346
|
get(key) {
|
|
288
347
|
return this.resolve(key);
|
|
289
348
|
}
|
|
@@ -299,10 +358,16 @@ var EnvImpl = class {
|
|
|
299
358
|
return out;
|
|
300
359
|
}
|
|
301
360
|
set(keyOrUpdates, value) {
|
|
302
|
-
const updates = typeof keyOrUpdates === "string" ? { [keyOrUpdates]: value } : keyOrUpdates;
|
|
361
|
+
const updates = typeof keyOrUpdates === "string" ? { [this.sourceKey(keyOrUpdates)]: value } : keyOrUpdates;
|
|
303
362
|
Object.assign(this.overrides, updates);
|
|
304
363
|
this.invalidate();
|
|
305
364
|
}
|
|
365
|
+
/** The source key `set(key, value)` writes: a field's first source key, or `key` itself when no field has that name. */
|
|
366
|
+
sourceKey(key) {
|
|
367
|
+
const def = this.defs.get(key);
|
|
368
|
+
if (def === void 0) return key;
|
|
369
|
+
return sourceKeyOf(def.kind === "factory" ? void 0 : def.schema, key, this.prefix);
|
|
370
|
+
}
|
|
306
371
|
add(input) {
|
|
307
372
|
this.define(input);
|
|
308
373
|
this.cache.clear();
|
|
@@ -413,11 +478,118 @@ var EnvImpl = class {
|
|
|
413
478
|
}
|
|
414
479
|
}
|
|
415
480
|
};
|
|
481
|
+
/** Describe a field read through `schema`: the keys it reads, whether it is required, and its documented shape. */
|
|
482
|
+
function describeSchema(key, schema, prefix) {
|
|
483
|
+
const { $schema: _dialect, ...shape } = zod.z.toJSONSchema(schema, {
|
|
484
|
+
io: "input",
|
|
485
|
+
unrepresentable: "any"
|
|
486
|
+
});
|
|
487
|
+
return {
|
|
488
|
+
key,
|
|
489
|
+
vars: varsOf(schema, key, prefix),
|
|
490
|
+
computed: false,
|
|
491
|
+
required: !schema.safeParse(void 0).success,
|
|
492
|
+
...schema.description === void 0 ? {} : { description: schema.description },
|
|
493
|
+
..."default" in shape ? { default: shape.default } : {},
|
|
494
|
+
schema: shape
|
|
495
|
+
};
|
|
496
|
+
}
|
|
416
497
|
/** Build a typed, lazy, reactive config from a record of fields. Reads `process.env` by default. */
|
|
417
498
|
function env(input, options) {
|
|
418
499
|
return new EnvImpl(input, options);
|
|
419
500
|
}
|
|
420
501
|
//#endregion
|
|
502
|
+
//#region src/merge.ts
|
|
503
|
+
/** The engine behind {@link mergeEnvs}: each call goes to the slice that owns the field, or to every slice. */
|
|
504
|
+
var MergedEnv = class {
|
|
505
|
+
slices;
|
|
506
|
+
owner = /* @__PURE__ */ new Map();
|
|
507
|
+
constructor(slices) {
|
|
508
|
+
this.slices = slices;
|
|
509
|
+
for (const slice of slices) for (const { key } of slice.describe()) {
|
|
510
|
+
if (this.owner.has(key)) throw new Error(`mergeEnvs: field "${key}" is declared by more than one env`);
|
|
511
|
+
this.owner.set(key, slice);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
sliceOf(key) {
|
|
515
|
+
const slice = this.owner.get(key);
|
|
516
|
+
if (slice === void 0) throw new Error(`mergeEnvs: no env declares field "${key}"`);
|
|
517
|
+
return slice;
|
|
518
|
+
}
|
|
519
|
+
get(key) {
|
|
520
|
+
return this.sliceOf(key).get(key);
|
|
521
|
+
}
|
|
522
|
+
set(keyOrUpdates, value) {
|
|
523
|
+
if (typeof keyOrUpdates === "string") {
|
|
524
|
+
const slice = this.owner.get(keyOrUpdates);
|
|
525
|
+
if (slice !== void 0) return slice.set(keyOrUpdates, value);
|
|
526
|
+
for (const one of this.slices) one.set({ [keyOrUpdates]: value });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
for (const one of this.slices) one.set(keyOrUpdates);
|
|
530
|
+
}
|
|
531
|
+
parse() {
|
|
532
|
+
const out = {};
|
|
533
|
+
const issues = [];
|
|
534
|
+
for (const slice of this.slices) try {
|
|
535
|
+
Object.assign(out, slice.parse());
|
|
536
|
+
} catch (err) {
|
|
537
|
+
if (!(err instanceof EnvError)) throw err;
|
|
538
|
+
issues.push(...err.issues);
|
|
539
|
+
}
|
|
540
|
+
if (issues.length > 0) throw new EnvError(issues);
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
on(arg1, arg2, arg3) {
|
|
544
|
+
let offs;
|
|
545
|
+
if (typeof arg1 === "function") {
|
|
546
|
+
const listener = arg1;
|
|
547
|
+
offs = this.slices.map((slice) => slice.on(listener, arg2));
|
|
548
|
+
} else {
|
|
549
|
+
const keys = Array.isArray(arg1) ? arg1 : [String(arg1)];
|
|
550
|
+
const listener = arg2;
|
|
551
|
+
offs = keys.map((key) => this.sliceOf(key).on(key, listener, arg3));
|
|
552
|
+
}
|
|
553
|
+
return () => {
|
|
554
|
+
for (const off of offs) off();
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
describe() {
|
|
558
|
+
return this.slices.flatMap((slice) => slice.describe());
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
/**
|
|
562
|
+
* Join config slices into one {@link EnvView} of every field. Each slice stays the source of truth for its own
|
|
563
|
+
* fields: `get` and single-field `set` go to the owning slice, a bulk `set` reaches every slice, `parse` throws one
|
|
564
|
+
* {@link EnvError} with every slice's issues, `on` subscribes in the owning slices, and `describe` lists all fields
|
|
565
|
+
* in slice order. Two slices declaring the same field is a compile-time error (for literal tuples) and throws.
|
|
566
|
+
* A `once` listener subscribed to all fields fires once per slice.
|
|
567
|
+
*/
|
|
568
|
+
function mergeEnvs(...envs) {
|
|
569
|
+
return new MergedEnv(envs);
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/example.ts
|
|
573
|
+
/** The text a value has in a `.env` file: strings as written, anything else as JSON. */
|
|
574
|
+
function dotenvText(value) {
|
|
575
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* A `.env.example` for `fields`: each field read from a source key becomes a `KEY=default` line under its
|
|
579
|
+
* description as a comment. A field with no default is written as an empty `KEY=` marked `(required)`, or
|
|
580
|
+
* commented out when it is optional. Computed fields read no key and are left out.
|
|
581
|
+
*/
|
|
582
|
+
function envExample(fields) {
|
|
583
|
+
const blocks = fields.filter((field) => !field.computed).map((field) => {
|
|
584
|
+
const key = field.vars[0] ?? field.key;
|
|
585
|
+
const notes = [field.description, field.required ? "(required)" : void 0].filter((note) => note !== void 0);
|
|
586
|
+
const comment = notes.length === 0 ? [] : notes.join(" ").split("\n").map((text) => `# ${text}`);
|
|
587
|
+
const line = "default" in field ? `${key}=${dotenvText(field.default)}` : field.required ? `${key}=` : `# ${key}=`;
|
|
588
|
+
return [...comment, line].join("\n");
|
|
589
|
+
});
|
|
590
|
+
return blocks.length === 0 ? "" : `${blocks.join("\n\n")}\n`;
|
|
591
|
+
}
|
|
592
|
+
//#endregion
|
|
421
593
|
//#region src/provider.ts
|
|
422
594
|
/** Bind a field to a live {@link EnvProvider}; values are validated against `schema`. */
|
|
423
595
|
function dynamic(provider, schema) {
|
|
@@ -467,9 +639,11 @@ var AsyncEnvImpl = class {
|
|
|
467
639
|
started = /* @__PURE__ */ new Set();
|
|
468
640
|
watchers = /* @__PURE__ */ new Map();
|
|
469
641
|
words;
|
|
642
|
+
prefix;
|
|
470
643
|
groupCount = 0;
|
|
471
644
|
constructor(input, options) {
|
|
472
645
|
this.words = toWords(options);
|
|
646
|
+
this.prefix = options?.prefix ?? "";
|
|
473
647
|
this.define(input);
|
|
474
648
|
}
|
|
475
649
|
define(input) {
|
|
@@ -525,7 +699,7 @@ var AsyncEnvImpl = class {
|
|
|
525
699
|
/** Do the work of resolving one field (no memoization — see {@link resolve}). */
|
|
526
700
|
async compute(key) {
|
|
527
701
|
const def = this.defs.get(key);
|
|
528
|
-
if (def.kind === "schema") return this.validate(def.schema, key, resolveRaw(this.source(), varsOf(def.schema, key)));
|
|
702
|
+
if (def.kind === "schema") return this.validate(def.schema, key, resolveRaw(this.source(), varsOf(def.schema, key, this.prefix)));
|
|
529
703
|
if (def.kind === "dynamic") {
|
|
530
704
|
await this.ensureStarted(def);
|
|
531
705
|
return this.validate(def.schema, key, this.dynamicRaw.get(def.key));
|
|
@@ -544,7 +718,7 @@ var AsyncEnvImpl = class {
|
|
|
544
718
|
throw new EnvError([customIssue(key, errMessage(err))]);
|
|
545
719
|
}
|
|
546
720
|
this.deps.set(key, reads);
|
|
547
|
-
if (isZodType(produced)) return this.validate(produced, key, resolveRaw(this.source(), varsOf(produced, key)));
|
|
721
|
+
if (isZodType(produced)) return this.validate(produced, key, resolveRaw(this.source(), varsOf(produced, key, this.prefix)));
|
|
548
722
|
return produced;
|
|
549
723
|
}
|
|
550
724
|
/** Resolve `key` (memoized per snapshot). Cleared by `set`/provider updates. */
|
|
@@ -581,10 +755,16 @@ var AsyncEnvImpl = class {
|
|
|
581
755
|
return out;
|
|
582
756
|
}
|
|
583
757
|
set(keyOrUpdates, value) {
|
|
584
|
-
const updates = typeof keyOrUpdates === "string" ? { [keyOrUpdates]: value } : keyOrUpdates;
|
|
758
|
+
const updates = typeof keyOrUpdates === "string" ? { [this.sourceKey(keyOrUpdates)]: value } : keyOrUpdates;
|
|
585
759
|
Object.assign(this.overrides, updates);
|
|
586
760
|
this.emitChange();
|
|
587
761
|
}
|
|
762
|
+
/** The source key `set(key, value)` writes: a field's first source key, or `key` itself when no field has that name. */
|
|
763
|
+
sourceKey(key) {
|
|
764
|
+
const def = this.defs.get(key);
|
|
765
|
+
if (def === void 0) return key;
|
|
766
|
+
return sourceKeyOf(def.kind === "factory" ? void 0 : def.schema, key, this.prefix);
|
|
767
|
+
}
|
|
588
768
|
add(input) {
|
|
589
769
|
this.define(input);
|
|
590
770
|
this.cache.clear();
|
|
@@ -730,10 +910,13 @@ exports.defaultSource = defaultSource;
|
|
|
730
910
|
exports.dynamic = dynamic;
|
|
731
911
|
exports.effectiveType = effectiveType;
|
|
732
912
|
exports.env = env;
|
|
913
|
+
exports.envExample = envExample;
|
|
733
914
|
exports.isDynamic = isDynamic;
|
|
915
|
+
exports.mergeEnvs = mergeEnvs;
|
|
734
916
|
exports.mergeSources = mergeSources;
|
|
735
917
|
exports.parseDotenv = require_dotenv.parseDotenv;
|
|
736
918
|
exports.pollProvider = pollProvider;
|
|
737
919
|
exports.resolveRaw = resolveRaw;
|
|
920
|
+
exports.sourceKeyOf = sourceKeyOf;
|
|
738
921
|
exports.staticProvider = staticProvider;
|
|
739
922
|
exports.varsOf = varsOf;
|
package/dist/index.d.cts
CHANGED
|
@@ -27,7 +27,7 @@ declare function coerce(schema: z.ZodType, value: unknown, words?: BooleanWords)
|
|
|
27
27
|
//#region src/env.d.ts
|
|
28
28
|
|
|
29
29
|
/** A field definition: a zod schema, or a factory taking the inherited (earlier-group) values. */
|
|
30
|
-
type EnvFieldDef<Inherited> = z.ZodType | ((inherited: Inherited) =>
|
|
30
|
+
type EnvFieldDef<Inherited> = z.ZodType | ((inherited: Inherited) => unknown);
|
|
31
31
|
/** The fields passed to {@link env} / {@link Env.add}. `Inherited` is the type resolved so far. */
|
|
32
32
|
type EnvInput<Inherited> = Record<string, EnvFieldDef<Inherited>>;
|
|
33
33
|
/** The resolved output type of an {@link EnvInput} (zod outputs, computed schema outputs, or plain values). */
|
|
@@ -38,6 +38,11 @@ type EnvSet<T extends object> = { [K in keyof T]?: T[K] | string } & {
|
|
|
38
38
|
};
|
|
39
39
|
/** Build-time options for {@link env} / {@link asyncEnv}. */
|
|
40
40
|
interface EnvOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Prepended to each field's name to make the source key it reads: with `prefix: 'APP_'`, field `PORT` reads
|
|
43
|
+
* `APP_PORT`. Keys named by `vars`/`var` metadata are read as written.
|
|
44
|
+
*/
|
|
45
|
+
readonly prefix?: string;
|
|
41
46
|
/**
|
|
42
47
|
* Customize which strings coerce to booleans (case-insensitive). Each side you provide
|
|
43
48
|
* **replaces** its default set; an omitted side keeps the default. E.g.
|
|
@@ -59,15 +64,33 @@ interface EnvOnOptions {
|
|
|
59
64
|
/** Fire immediately on subscribe with the current value(s). */
|
|
60
65
|
immediate?: boolean;
|
|
61
66
|
}
|
|
62
|
-
/**
|
|
63
|
-
interface
|
|
64
|
-
/**
|
|
65
|
-
|
|
67
|
+
/** What {@link EnvView.describe} reports about one field — enough for a settings page or a `.env.example`. */
|
|
68
|
+
interface EnvFieldInfo {
|
|
69
|
+
/** The field's name (the key you `get`). */
|
|
70
|
+
readonly key: string;
|
|
71
|
+
/** The source keys it reads, in precedence order (prefix and aliases applied); none for a computed field. */
|
|
72
|
+
readonly vars: readonly string[];
|
|
73
|
+
/** A value computed from other fields; it reads no source key and has no schema. */
|
|
74
|
+
readonly computed: boolean;
|
|
75
|
+
/** Whether a value must be given: the schema rejects a missing one (no default, not optional). */
|
|
76
|
+
readonly required: boolean;
|
|
77
|
+
/** The schema's description (`.describe(...)` or `.meta({ description })`). */
|
|
78
|
+
readonly description?: string;
|
|
79
|
+
/** The schema's default, when it has one. */
|
|
80
|
+
readonly default?: unknown;
|
|
81
|
+
/** The JSON Schema of the accepted input (unrepresentable parts become `{}`). */
|
|
82
|
+
readonly schema?: Readonly<Record<string, unknown>>;
|
|
83
|
+
}
|
|
84
|
+
/** The reading surface of a config: {@link Env} has it, and so does a {@link mergeEnvs} view over several. */
|
|
85
|
+
interface EnvView<T extends object> {
|
|
66
86
|
/** Resolve a single field (lazily). Throws an {@link EnvError} if that field is missing/invalid. */
|
|
67
87
|
get<K extends keyof T>(key: K): T[K];
|
|
68
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Override a single field (typed value or raw string), written under the field's source key (its prefixed name,
|
|
90
|
+
* or its first alias). A key that names no field is written as a source key, as given.
|
|
91
|
+
*/
|
|
69
92
|
set<K extends keyof T>(key: K, value: T[K] | string): void;
|
|
70
|
-
/** Override several
|
|
93
|
+
/** Override several source keys at once (e.g. pass `process.env` or a loaded `.env`); keys are used as given. */
|
|
71
94
|
set(updates: EnvSet<T>): void;
|
|
72
95
|
/** Resolve every field. Throws an aggregated {@link EnvError} listing all missing/invalid fields. */
|
|
73
96
|
parse(): T;
|
|
@@ -77,6 +100,16 @@ interface Env<T extends object> {
|
|
|
77
100
|
on<K extends keyof T>(key: K, listener: (value: T[K]) => void, options?: EnvOnOptions): () => void;
|
|
78
101
|
/** Subscribe to several fields' changes with one listener. */
|
|
79
102
|
on<K extends keyof T>(keys: readonly K[], listener: (value: T[K]) => void, options?: EnvOnOptions): () => void;
|
|
103
|
+
/**
|
|
104
|
+
* Describe every field, in declaration order. A factory field is run to find its schema; one that throws (an
|
|
105
|
+
* earlier field it reads is invalid) is reported as required, without `schema`, `default` or `description`.
|
|
106
|
+
*/
|
|
107
|
+
describe(): readonly EnvFieldInfo[];
|
|
108
|
+
}
|
|
109
|
+
/** A typed, lazy, reactive config object built by {@link env}. */
|
|
110
|
+
interface Env<T extends object> extends EnvView<T> {
|
|
111
|
+
/** Add more fields; their factories receive the fields resolved so far. Returns a wider {@link Env}. */
|
|
112
|
+
add<A extends EnvInput<T>>(input: A): Env<T & EnvOutput<T, A>>;
|
|
80
113
|
/** Run `fn` with this env (for grouped setup); returns nothing. */
|
|
81
114
|
with(fn: (env: Env<T>) => void): void;
|
|
82
115
|
/** Run `fn` with this env and return its result. */
|
|
@@ -85,6 +118,54 @@ interface Env<T extends object> {
|
|
|
85
118
|
/** Build a typed, lazy, reactive config from a record of fields. Reads `process.env` by default. */
|
|
86
119
|
declare function env<const T extends EnvInput<{}>>(input: T, options?: EnvOptions): Env<EnvOutput<{}, T>>;
|
|
87
120
|
//#endregion
|
|
121
|
+
//#region src/merge.d.ts
|
|
122
|
+
/**
|
|
123
|
+
* Any config view: every {@link EnvView} member, with field names erased so a view of any fields fits (methods take
|
|
124
|
+
* `never` parameters, so this is the supertype of every `EnvView<T>`). Its fields are read from what `parse` returns.
|
|
125
|
+
*/
|
|
126
|
+
interface AnyEnvView {
|
|
127
|
+
get(key: never): unknown;
|
|
128
|
+
set(key: never, value: never): void;
|
|
129
|
+
parse(): object;
|
|
130
|
+
on(...args: never[]): () => void;
|
|
131
|
+
describe(): readonly EnvFieldInfo[];
|
|
132
|
+
}
|
|
133
|
+
/** The fields of a config view. */
|
|
134
|
+
type FieldsOf<E> = E extends {
|
|
135
|
+
parse(): infer T;
|
|
136
|
+
} ? T : never;
|
|
137
|
+
/** The field names of a config view. */
|
|
138
|
+
type NamesOf<E> = keyof FieldsOf<E> & string;
|
|
139
|
+
/** The positions of a tuple (`'0' | '1' | …`); `never` for a plain array. */
|
|
140
|
+
type Positions<T extends readonly unknown[]> = Exclude<keyof T, keyof unknown[]>;
|
|
141
|
+
/** Field names that two different views of `T` both declare. */
|
|
142
|
+
type SharedNames<T extends readonly unknown[]> = { [I in Positions<T>]: { [J in Positions<T>]: J extends I ? never : NamesOf<T[I]> & NamesOf<T[J]> }[Positions<T>] }[Positions<T>];
|
|
143
|
+
/** Turn a union into the intersection of its members. */
|
|
144
|
+
type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never;
|
|
145
|
+
/**
|
|
146
|
+
* `T` when no field name is declared by more than one of its views; otherwise a tuple whose only element names the
|
|
147
|
+
* clash, so the call fails to compile with that message.
|
|
148
|
+
*/
|
|
149
|
+
type DisjointEnvs<T extends readonly unknown[]> = [SharedNames<T>] extends [never] ? T : readonly [`fields declared by more than one env: ${SharedNames<T>}`];
|
|
150
|
+
/** The fields of every view in `T`, as one object type. */
|
|
151
|
+
type MergedFields<T extends readonly unknown[]> = { [K in keyof UnionToIntersection<FieldsOf<T[number]>>]: UnionToIntersection<FieldsOf<T[number]>>[K] };
|
|
152
|
+
/**
|
|
153
|
+
* Join config slices into one {@link EnvView} of every field. Each slice stays the source of truth for its own
|
|
154
|
+
* fields: `get` and single-field `set` go to the owning slice, a bulk `set` reaches every slice, `parse` throws one
|
|
155
|
+
* {@link EnvError} with every slice's issues, `on` subscribes in the owning slices, and `describe` lists all fields
|
|
156
|
+
* in slice order. Two slices declaring the same field is a compile-time error (for literal tuples) and throws.
|
|
157
|
+
* A `once` listener subscribed to all fields fires once per slice.
|
|
158
|
+
*/
|
|
159
|
+
declare function mergeEnvs<const T extends readonly [AnyEnvView, ...AnyEnvView[]]>(...envs: T & DisjointEnvs<T>): EnvView<MergedFields<T>>;
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/example.d.ts
|
|
162
|
+
/**
|
|
163
|
+
* A `.env.example` for `fields`: each field read from a source key becomes a `KEY=default` line under its
|
|
164
|
+
* description as a comment. A field with no default is written as an empty `KEY=` marked `(required)`, or
|
|
165
|
+
* commented out when it is optional. Computed fields read no key and are left out.
|
|
166
|
+
*/
|
|
167
|
+
declare function envExample(fields: readonly EnvFieldInfo[]): string;
|
|
168
|
+
//#endregion
|
|
88
169
|
//#region src/provider.d.ts
|
|
89
170
|
/** A value, or a promise of one. */
|
|
90
171
|
type MaybePromise<T> = T | Promise<T>;
|
|
@@ -115,7 +196,7 @@ declare function staticProvider(value: string | undefined): EnvProvider;
|
|
|
115
196
|
//#endregion
|
|
116
197
|
//#region src/async.d.ts
|
|
117
198
|
/** A field for {@link asyncEnv}: zod schema, dynamic binding, or a (possibly async) factory. */
|
|
118
|
-
type AsyncEnvFieldDef<Inherited> = z.ZodType | DynamicBinding<
|
|
199
|
+
type AsyncEnvFieldDef<Inherited> = z.ZodType | DynamicBinding<unknown> | ((inherited: Inherited) => unknown);
|
|
119
200
|
/** The fields passed to {@link asyncEnv} / {@link AsyncEnv.add}. */
|
|
120
201
|
type AsyncEnvInput<Inherited> = Record<string, AsyncEnvFieldDef<Inherited>>;
|
|
121
202
|
/** The resolved output type of an {@link AsyncEnvInput}. */
|
|
@@ -126,9 +207,9 @@ interface AsyncEnv<T extends object> {
|
|
|
126
207
|
add<A extends AsyncEnvInput<T>>(input: A): AsyncEnv<T & AsyncEnvOutput<T, A>>;
|
|
127
208
|
/** Resolve a single field (lazily, async). Rejects with an {@link EnvError} if missing/invalid. */
|
|
128
209
|
get<K extends keyof T>(key: K): Promise<T[K]>;
|
|
129
|
-
/** Override a single field
|
|
210
|
+
/** Override a single field (typed value or raw string), under its source key (prefixed name or first alias); another key is used as given. */
|
|
130
211
|
set<K extends keyof T>(key: K, value: T[K] | string): void;
|
|
131
|
-
/** Override several
|
|
212
|
+
/** Override several source keys at once; keys are used as given. */
|
|
132
213
|
set(updates: EnvSet<T>): void;
|
|
133
214
|
/** Resolve every field. Rejects with an aggregated {@link EnvError} listing all failures. */
|
|
134
215
|
parse(): Promise<T>;
|
|
@@ -158,9 +239,17 @@ interface EnvMeta {
|
|
|
158
239
|
/** Several source keys to read this field from — first present wins. */
|
|
159
240
|
readonly vars?: readonly string[];
|
|
160
241
|
}
|
|
161
|
-
/**
|
|
162
|
-
|
|
242
|
+
/**
|
|
243
|
+
* The source keys a field reads, in precedence order — its `vars`/`var` meta (taken as written), else
|
|
244
|
+
* `[prefix + key]`.
|
|
245
|
+
*/
|
|
246
|
+
declare function varsOf(schema: z.ZodType, key: string, prefix?: string): readonly string[];
|
|
247
|
+
/**
|
|
248
|
+
* The source key a field is written under by a single-field `set`: the first key it reads. `schema` is undefined for
|
|
249
|
+
* a factory field, whose schema is only known once it runs; it is written under its prefixed name.
|
|
250
|
+
*/
|
|
251
|
+
declare function sourceKeyOf(schema: z.ZodType | undefined, key: string, prefix?: string): string;
|
|
163
252
|
/** Attach aliasing metadata: read `schema` from the first present of `keys`. */
|
|
164
253
|
declare function alias<S extends z.ZodType>(schema: S, ...keys: string[]): S;
|
|
165
254
|
//#endregion
|
|
166
|
-
export { type AsyncEnv, type AsyncEnvFieldDef, type AsyncEnvInput, type AsyncEnvOutput, type BooleanWords, DEFAULT_FALSE, DEFAULT_TRUE, type DynamicBinding, type Env, EnvError, type EnvFieldDef, type EnvInput, type EnvMeta, type EnvOnOptions, type EnvOptions, type EnvOutput, type EnvProvider, type EnvSet, type EnvSource, type MaybePromise, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, isDynamic, mergeSources, parseDotenv, pollProvider, resolveRaw, staticProvider, varsOf };
|
|
255
|
+
export { type AnyEnvView, type AsyncEnv, type AsyncEnvFieldDef, type AsyncEnvInput, type AsyncEnvOutput, type BooleanWords, DEFAULT_FALSE, DEFAULT_TRUE, type DisjointEnvs, type DynamicBinding, type Env, EnvError, type EnvFieldDef, type EnvFieldInfo, type EnvInput, type EnvMeta, type EnvOnOptions, type EnvOptions, type EnvOutput, type EnvProvider, type EnvSet, type EnvSource, type EnvView, type MaybePromise, type MergedFields, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, envExample, isDynamic, mergeEnvs, mergeSources, parseDotenv, pollProvider, resolveRaw, sourceKeyOf, staticProvider, varsOf };
|
package/dist/index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ declare function coerce(schema: z.ZodType, value: unknown, words?: BooleanWords)
|
|
|
27
27
|
//#region src/env.d.ts
|
|
28
28
|
|
|
29
29
|
/** A field definition: a zod schema, or a factory taking the inherited (earlier-group) values. */
|
|
30
|
-
type EnvFieldDef<Inherited> = z.ZodType | ((inherited: Inherited) =>
|
|
30
|
+
type EnvFieldDef<Inherited> = z.ZodType | ((inherited: Inherited) => unknown);
|
|
31
31
|
/** The fields passed to {@link env} / {@link Env.add}. `Inherited` is the type resolved so far. */
|
|
32
32
|
type EnvInput<Inherited> = Record<string, EnvFieldDef<Inherited>>;
|
|
33
33
|
/** The resolved output type of an {@link EnvInput} (zod outputs, computed schema outputs, or plain values). */
|
|
@@ -38,6 +38,11 @@ type EnvSet<T extends object> = { [K in keyof T]?: T[K] | string } & {
|
|
|
38
38
|
};
|
|
39
39
|
/** Build-time options for {@link env} / {@link asyncEnv}. */
|
|
40
40
|
interface EnvOptions {
|
|
41
|
+
/**
|
|
42
|
+
* Prepended to each field's name to make the source key it reads: with `prefix: 'APP_'`, field `PORT` reads
|
|
43
|
+
* `APP_PORT`. Keys named by `vars`/`var` metadata are read as written.
|
|
44
|
+
*/
|
|
45
|
+
readonly prefix?: string;
|
|
41
46
|
/**
|
|
42
47
|
* Customize which strings coerce to booleans (case-insensitive). Each side you provide
|
|
43
48
|
* **replaces** its default set; an omitted side keeps the default. E.g.
|
|
@@ -59,15 +64,33 @@ interface EnvOnOptions {
|
|
|
59
64
|
/** Fire immediately on subscribe with the current value(s). */
|
|
60
65
|
immediate?: boolean;
|
|
61
66
|
}
|
|
62
|
-
/**
|
|
63
|
-
interface
|
|
64
|
-
/**
|
|
65
|
-
|
|
67
|
+
/** What {@link EnvView.describe} reports about one field — enough for a settings page or a `.env.example`. */
|
|
68
|
+
interface EnvFieldInfo {
|
|
69
|
+
/** The field's name (the key you `get`). */
|
|
70
|
+
readonly key: string;
|
|
71
|
+
/** The source keys it reads, in precedence order (prefix and aliases applied); none for a computed field. */
|
|
72
|
+
readonly vars: readonly string[];
|
|
73
|
+
/** A value computed from other fields; it reads no source key and has no schema. */
|
|
74
|
+
readonly computed: boolean;
|
|
75
|
+
/** Whether a value must be given: the schema rejects a missing one (no default, not optional). */
|
|
76
|
+
readonly required: boolean;
|
|
77
|
+
/** The schema's description (`.describe(...)` or `.meta({ description })`). */
|
|
78
|
+
readonly description?: string;
|
|
79
|
+
/** The schema's default, when it has one. */
|
|
80
|
+
readonly default?: unknown;
|
|
81
|
+
/** The JSON Schema of the accepted input (unrepresentable parts become `{}`). */
|
|
82
|
+
readonly schema?: Readonly<Record<string, unknown>>;
|
|
83
|
+
}
|
|
84
|
+
/** The reading surface of a config: {@link Env} has it, and so does a {@link mergeEnvs} view over several. */
|
|
85
|
+
interface EnvView<T extends object> {
|
|
66
86
|
/** Resolve a single field (lazily). Throws an {@link EnvError} if that field is missing/invalid. */
|
|
67
87
|
get<K extends keyof T>(key: K): T[K];
|
|
68
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Override a single field (typed value or raw string), written under the field's source key (its prefixed name,
|
|
90
|
+
* or its first alias). A key that names no field is written as a source key, as given.
|
|
91
|
+
*/
|
|
69
92
|
set<K extends keyof T>(key: K, value: T[K] | string): void;
|
|
70
|
-
/** Override several
|
|
93
|
+
/** Override several source keys at once (e.g. pass `process.env` or a loaded `.env`); keys are used as given. */
|
|
71
94
|
set(updates: EnvSet<T>): void;
|
|
72
95
|
/** Resolve every field. Throws an aggregated {@link EnvError} listing all missing/invalid fields. */
|
|
73
96
|
parse(): T;
|
|
@@ -77,6 +100,16 @@ interface Env<T extends object> {
|
|
|
77
100
|
on<K extends keyof T>(key: K, listener: (value: T[K]) => void, options?: EnvOnOptions): () => void;
|
|
78
101
|
/** Subscribe to several fields' changes with one listener. */
|
|
79
102
|
on<K extends keyof T>(keys: readonly K[], listener: (value: T[K]) => void, options?: EnvOnOptions): () => void;
|
|
103
|
+
/**
|
|
104
|
+
* Describe every field, in declaration order. A factory field is run to find its schema; one that throws (an
|
|
105
|
+
* earlier field it reads is invalid) is reported as required, without `schema`, `default` or `description`.
|
|
106
|
+
*/
|
|
107
|
+
describe(): readonly EnvFieldInfo[];
|
|
108
|
+
}
|
|
109
|
+
/** A typed, lazy, reactive config object built by {@link env}. */
|
|
110
|
+
interface Env<T extends object> extends EnvView<T> {
|
|
111
|
+
/** Add more fields; their factories receive the fields resolved so far. Returns a wider {@link Env}. */
|
|
112
|
+
add<A extends EnvInput<T>>(input: A): Env<T & EnvOutput<T, A>>;
|
|
80
113
|
/** Run `fn` with this env (for grouped setup); returns nothing. */
|
|
81
114
|
with(fn: (env: Env<T>) => void): void;
|
|
82
115
|
/** Run `fn` with this env and return its result. */
|
|
@@ -85,6 +118,54 @@ interface Env<T extends object> {
|
|
|
85
118
|
/** Build a typed, lazy, reactive config from a record of fields. Reads `process.env` by default. */
|
|
86
119
|
declare function env<const T extends EnvInput<{}>>(input: T, options?: EnvOptions): Env<EnvOutput<{}, T>>;
|
|
87
120
|
//#endregion
|
|
121
|
+
//#region src/merge.d.ts
|
|
122
|
+
/**
|
|
123
|
+
* Any config view: every {@link EnvView} member, with field names erased so a view of any fields fits (methods take
|
|
124
|
+
* `never` parameters, so this is the supertype of every `EnvView<T>`). Its fields are read from what `parse` returns.
|
|
125
|
+
*/
|
|
126
|
+
interface AnyEnvView {
|
|
127
|
+
get(key: never): unknown;
|
|
128
|
+
set(key: never, value: never): void;
|
|
129
|
+
parse(): object;
|
|
130
|
+
on(...args: never[]): () => void;
|
|
131
|
+
describe(): readonly EnvFieldInfo[];
|
|
132
|
+
}
|
|
133
|
+
/** The fields of a config view. */
|
|
134
|
+
type FieldsOf<E> = E extends {
|
|
135
|
+
parse(): infer T;
|
|
136
|
+
} ? T : never;
|
|
137
|
+
/** The field names of a config view. */
|
|
138
|
+
type NamesOf<E> = keyof FieldsOf<E> & string;
|
|
139
|
+
/** The positions of a tuple (`'0' | '1' | …`); `never` for a plain array. */
|
|
140
|
+
type Positions<T extends readonly unknown[]> = Exclude<keyof T, keyof unknown[]>;
|
|
141
|
+
/** Field names that two different views of `T` both declare. */
|
|
142
|
+
type SharedNames<T extends readonly unknown[]> = { [I in Positions<T>]: { [J in Positions<T>]: J extends I ? never : NamesOf<T[I]> & NamesOf<T[J]> }[Positions<T>] }[Positions<T>];
|
|
143
|
+
/** Turn a union into the intersection of its members. */
|
|
144
|
+
type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never;
|
|
145
|
+
/**
|
|
146
|
+
* `T` when no field name is declared by more than one of its views; otherwise a tuple whose only element names the
|
|
147
|
+
* clash, so the call fails to compile with that message.
|
|
148
|
+
*/
|
|
149
|
+
type DisjointEnvs<T extends readonly unknown[]> = [SharedNames<T>] extends [never] ? T : readonly [`fields declared by more than one env: ${SharedNames<T>}`];
|
|
150
|
+
/** The fields of every view in `T`, as one object type. */
|
|
151
|
+
type MergedFields<T extends readonly unknown[]> = { [K in keyof UnionToIntersection<FieldsOf<T[number]>>]: UnionToIntersection<FieldsOf<T[number]>>[K] };
|
|
152
|
+
/**
|
|
153
|
+
* Join config slices into one {@link EnvView} of every field. Each slice stays the source of truth for its own
|
|
154
|
+
* fields: `get` and single-field `set` go to the owning slice, a bulk `set` reaches every slice, `parse` throws one
|
|
155
|
+
* {@link EnvError} with every slice's issues, `on` subscribes in the owning slices, and `describe` lists all fields
|
|
156
|
+
* in slice order. Two slices declaring the same field is a compile-time error (for literal tuples) and throws.
|
|
157
|
+
* A `once` listener subscribed to all fields fires once per slice.
|
|
158
|
+
*/
|
|
159
|
+
declare function mergeEnvs<const T extends readonly [AnyEnvView, ...AnyEnvView[]]>(...envs: T & DisjointEnvs<T>): EnvView<MergedFields<T>>;
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/example.d.ts
|
|
162
|
+
/**
|
|
163
|
+
* A `.env.example` for `fields`: each field read from a source key becomes a `KEY=default` line under its
|
|
164
|
+
* description as a comment. A field with no default is written as an empty `KEY=` marked `(required)`, or
|
|
165
|
+
* commented out when it is optional. Computed fields read no key and are left out.
|
|
166
|
+
*/
|
|
167
|
+
declare function envExample(fields: readonly EnvFieldInfo[]): string;
|
|
168
|
+
//#endregion
|
|
88
169
|
//#region src/provider.d.ts
|
|
89
170
|
/** A value, or a promise of one. */
|
|
90
171
|
type MaybePromise<T> = T | Promise<T>;
|
|
@@ -115,7 +196,7 @@ declare function staticProvider(value: string | undefined): EnvProvider;
|
|
|
115
196
|
//#endregion
|
|
116
197
|
//#region src/async.d.ts
|
|
117
198
|
/** A field for {@link asyncEnv}: zod schema, dynamic binding, or a (possibly async) factory. */
|
|
118
|
-
type AsyncEnvFieldDef<Inherited> = z.ZodType | DynamicBinding<
|
|
199
|
+
type AsyncEnvFieldDef<Inherited> = z.ZodType | DynamicBinding<unknown> | ((inherited: Inherited) => unknown);
|
|
119
200
|
/** The fields passed to {@link asyncEnv} / {@link AsyncEnv.add}. */
|
|
120
201
|
type AsyncEnvInput<Inherited> = Record<string, AsyncEnvFieldDef<Inherited>>;
|
|
121
202
|
/** The resolved output type of an {@link AsyncEnvInput}. */
|
|
@@ -126,9 +207,9 @@ interface AsyncEnv<T extends object> {
|
|
|
126
207
|
add<A extends AsyncEnvInput<T>>(input: A): AsyncEnv<T & AsyncEnvOutput<T, A>>;
|
|
127
208
|
/** Resolve a single field (lazily, async). Rejects with an {@link EnvError} if missing/invalid. */
|
|
128
209
|
get<K extends keyof T>(key: K): Promise<T[K]>;
|
|
129
|
-
/** Override a single field
|
|
210
|
+
/** Override a single field (typed value or raw string), under its source key (prefixed name or first alias); another key is used as given. */
|
|
130
211
|
set<K extends keyof T>(key: K, value: T[K] | string): void;
|
|
131
|
-
/** Override several
|
|
212
|
+
/** Override several source keys at once; keys are used as given. */
|
|
132
213
|
set(updates: EnvSet<T>): void;
|
|
133
214
|
/** Resolve every field. Rejects with an aggregated {@link EnvError} listing all failures. */
|
|
134
215
|
parse(): Promise<T>;
|
|
@@ -158,9 +239,17 @@ interface EnvMeta {
|
|
|
158
239
|
/** Several source keys to read this field from — first present wins. */
|
|
159
240
|
readonly vars?: readonly string[];
|
|
160
241
|
}
|
|
161
|
-
/**
|
|
162
|
-
|
|
242
|
+
/**
|
|
243
|
+
* The source keys a field reads, in precedence order — its `vars`/`var` meta (taken as written), else
|
|
244
|
+
* `[prefix + key]`.
|
|
245
|
+
*/
|
|
246
|
+
declare function varsOf(schema: z.ZodType, key: string, prefix?: string): readonly string[];
|
|
247
|
+
/**
|
|
248
|
+
* The source key a field is written under by a single-field `set`: the first key it reads. `schema` is undefined for
|
|
249
|
+
* a factory field, whose schema is only known once it runs; it is written under its prefixed name.
|
|
250
|
+
*/
|
|
251
|
+
declare function sourceKeyOf(schema: z.ZodType | undefined, key: string, prefix?: string): string;
|
|
163
252
|
/** Attach aliasing metadata: read `schema` from the first present of `keys`. */
|
|
164
253
|
declare function alias<S extends z.ZodType>(schema: S, ...keys: string[]): S;
|
|
165
254
|
//#endregion
|
|
166
|
-
export { type AsyncEnv, type AsyncEnvFieldDef, type AsyncEnvInput, type AsyncEnvOutput, type BooleanWords, DEFAULT_FALSE, DEFAULT_TRUE, type DynamicBinding, type Env, EnvError, type EnvFieldDef, type EnvInput, type EnvMeta, type EnvOnOptions, type EnvOptions, type EnvOutput, type EnvProvider, type EnvSet, type EnvSource, type MaybePromise, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, isDynamic, mergeSources, parseDotenv, pollProvider, resolveRaw, staticProvider, varsOf };
|
|
255
|
+
export { type AnyEnvView, type AsyncEnv, type AsyncEnvFieldDef, type AsyncEnvInput, type AsyncEnvOutput, type BooleanWords, DEFAULT_FALSE, DEFAULT_TRUE, type DisjointEnvs, type DynamicBinding, type Env, EnvError, type EnvFieldDef, type EnvFieldInfo, type EnvInput, type EnvMeta, type EnvOnOptions, type EnvOptions, type EnvOutput, type EnvProvider, type EnvSet, type EnvSource, type EnvView, type MaybePromise, type MergedFields, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, envExample, isDynamic, mergeEnvs, mergeSources, parseDotenv, pollProvider, resolveRaw, sourceKeyOf, staticProvider, varsOf };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { t as parseDotenv } from "./dotenv.js";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
//#region src/coerce.ts
|
|
3
4
|
/** Read a schema's internal `_zod.def` (zod v4 stores its definition there). */
|
|
4
5
|
function def(schema) {
|
|
@@ -98,12 +99,23 @@ function coerce(schema, value, words) {
|
|
|
98
99
|
function readMeta(schema) {
|
|
99
100
|
return schema.meta?.();
|
|
100
101
|
}
|
|
101
|
-
/**
|
|
102
|
-
|
|
102
|
+
/**
|
|
103
|
+
* The source keys a field reads, in precedence order — its `vars`/`var` meta (taken as written), else
|
|
104
|
+
* `[prefix + key]`.
|
|
105
|
+
*/
|
|
106
|
+
function varsOf(schema, key, prefix = "") {
|
|
103
107
|
const meta = readMeta(schema);
|
|
104
108
|
if (meta?.vars && meta.vars.length > 0) return meta.vars;
|
|
105
109
|
if (meta?.var) return [meta.var];
|
|
106
|
-
return [key];
|
|
110
|
+
return [prefix + key];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The source key a field is written under by a single-field `set`: the first key it reads. `schema` is undefined for
|
|
114
|
+
* a factory field, whose schema is only known once it runs; it is written under its prefixed name.
|
|
115
|
+
*/
|
|
116
|
+
function sourceKeyOf(schema, key, prefix = "") {
|
|
117
|
+
const meta = schema === void 0 ? void 0 : readMeta(schema);
|
|
118
|
+
return meta?.vars?.[0] ?? meta?.var ?? prefix + key;
|
|
107
119
|
}
|
|
108
120
|
/** Attach aliasing metadata: read `schema` from the first present of `keys`. */
|
|
109
121
|
function alias(schema, ...keys) {
|
|
@@ -185,6 +197,32 @@ function changed(prev, next, deep) {
|
|
|
185
197
|
}
|
|
186
198
|
//#endregion
|
|
187
199
|
//#region src/env.ts
|
|
200
|
+
/**
|
|
201
|
+
* # `env(...)` — the typed, lazy, reactive config builder
|
|
202
|
+
*
|
|
203
|
+
* Declare a config as a record of fields and get back a typed {@link Env}. A field is either:
|
|
204
|
+
*
|
|
205
|
+
* - a **zod schema** — read from a source key (its name, or its `vars` meta for aliasing),
|
|
206
|
+
* coerced from its string form and validated; or
|
|
207
|
+
* - a **factory** `(inherited) => …` — called with the already-resolved fields from *earlier*
|
|
208
|
+
* `add(...)` groups. Return another **zod schema** (a schema that depends on other values),
|
|
209
|
+
* or return a **plain value** (a computed field derived purely from other fields).
|
|
210
|
+
*
|
|
211
|
+
* Resolution is **lazy**: nothing is parsed until you `get(...)` or `parse()`. Values come from
|
|
212
|
+
* `process.env` by default; `set(...)` layers overrides on top. `on(...)` subscribes to changes
|
|
213
|
+
* that `set(...)` causes; the keys a factory reads are auto-tracked so computed fields update too.
|
|
214
|
+
*
|
|
215
|
+
* ```ts
|
|
216
|
+
* const ENV = env({ PORT: z.coerce.number().default(3000) })
|
|
217
|
+
* .add({ IS_PROD: (e) => e.NODE_ENV === 'production' }) // factory: computed value
|
|
218
|
+
*
|
|
219
|
+
* ENV.set(process.env)
|
|
220
|
+
* ENV.get('PORT') // typed, throws if invalid
|
|
221
|
+
* ENV.parse() // resolve everything; throws an aggregated EnvError
|
|
222
|
+
* ```
|
|
223
|
+
*
|
|
224
|
+
* @module
|
|
225
|
+
*/
|
|
188
226
|
/** Normalize {@link EnvOptions} into the lowercased {@link BooleanWords} the coercer consumes. */
|
|
189
227
|
function toWords(options) {
|
|
190
228
|
const b = options?.booleans;
|
|
@@ -205,9 +243,11 @@ var EnvImpl = class {
|
|
|
205
243
|
globalLs = /* @__PURE__ */ new Set();
|
|
206
244
|
prev = /* @__PURE__ */ new Map();
|
|
207
245
|
words;
|
|
246
|
+
prefix;
|
|
208
247
|
groupCount = 0;
|
|
209
248
|
constructor(input, options) {
|
|
210
249
|
this.words = toWords(options);
|
|
250
|
+
this.prefix = options?.prefix ?? "";
|
|
211
251
|
this.define(input);
|
|
212
252
|
}
|
|
213
253
|
/** Register a group of fields under the next group index. */
|
|
@@ -233,13 +273,12 @@ var EnvImpl = class {
|
|
|
233
273
|
/** A proxy over fields from groups *earlier* than `group`, recording which keys a factory reads. */
|
|
234
274
|
inheritedFor(group) {
|
|
235
275
|
const reads = /* @__PURE__ */ new Set();
|
|
236
|
-
const
|
|
237
|
-
const visible = (prop) => typeof prop === "string" && (self.defs.get(prop)?.group ?? Infinity) < group;
|
|
276
|
+
const visible = (prop) => typeof prop === "string" && (this.defs.get(prop)?.group ?? Infinity) < group;
|
|
238
277
|
return {
|
|
239
278
|
proxy: new Proxy(Object.create(null), {
|
|
240
|
-
get: (_t, prop) => visible(prop) ? (reads.add(prop),
|
|
279
|
+
get: (_t, prop) => visible(prop) ? (reads.add(prop), this.resolve(prop)) : void 0,
|
|
241
280
|
has: (_t, prop) => visible(prop),
|
|
242
|
-
ownKeys: () => [...
|
|
281
|
+
ownKeys: () => [...this.defs.values()].filter((f) => f.group < group).map((f) => f.key),
|
|
243
282
|
getOwnPropertyDescriptor: () => ({
|
|
244
283
|
enumerable: true,
|
|
245
284
|
configurable: true
|
|
@@ -248,34 +287,32 @@ var EnvImpl = class {
|
|
|
248
287
|
reads
|
|
249
288
|
};
|
|
250
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* What a field is made of: its schema, or the value its factory computed. A factory is run against the fields of
|
|
292
|
+
* earlier groups (recording what it reads); a throwing factory becomes an {@link EnvError} scoped to the field.
|
|
293
|
+
*/
|
|
294
|
+
produce(def) {
|
|
295
|
+
if (def.kind === "schema") return { schema: def.schema };
|
|
296
|
+
const { proxy, reads } = this.inheritedFor(def.group);
|
|
297
|
+
let produced;
|
|
298
|
+
try {
|
|
299
|
+
produced = def.factory(proxy);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
throw new EnvError([customIssue(def.key, errMessage(err))]);
|
|
302
|
+
} finally {
|
|
303
|
+
this.deps.set(def.key, reads);
|
|
304
|
+
}
|
|
305
|
+
return isZodType(produced) ? { schema: produced } : { value: produced };
|
|
306
|
+
}
|
|
251
307
|
/** Resolve `key` to its value (cached). Throws an {@link EnvError} (scoped to `key`) on failure. */
|
|
252
308
|
resolve(key) {
|
|
253
309
|
if (this.cache.has(key)) return this.cache.get(key);
|
|
254
|
-
const
|
|
255
|
-
let schema;
|
|
256
|
-
let computed;
|
|
257
|
-
let isComputed = false;
|
|
258
|
-
if (def.kind === "schema") schema = def.schema;
|
|
259
|
-
else {
|
|
260
|
-
const { proxy, reads } = this.inheritedFor(def.group);
|
|
261
|
-
let produced;
|
|
262
|
-
try {
|
|
263
|
-
produced = def.factory(proxy);
|
|
264
|
-
} catch (err) {
|
|
265
|
-
this.deps.set(key, reads);
|
|
266
|
-
throw new EnvError([customIssue(key, errMessage(err))]);
|
|
267
|
-
}
|
|
268
|
-
this.deps.set(key, reads);
|
|
269
|
-
if (isZodType(produced)) schema = produced;
|
|
270
|
-
else {
|
|
271
|
-
computed = produced;
|
|
272
|
-
isComputed = true;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
310
|
+
const made = this.produce(this.defs.get(key));
|
|
275
311
|
let result;
|
|
276
|
-
if (
|
|
312
|
+
if ("value" in made) result = made.value;
|
|
277
313
|
else {
|
|
278
|
-
const
|
|
314
|
+
const { schema } = made;
|
|
315
|
+
const raw = resolveRaw(this.source(), varsOf(schema, key, this.prefix));
|
|
279
316
|
const res = schema.safeParse(raw !== void 0 ? coerce(schema, raw, this.words) : void 0);
|
|
280
317
|
if (!res.success) throw new EnvError(keyed(key, res.error.issues));
|
|
281
318
|
result = res.data;
|
|
@@ -283,6 +320,28 @@ var EnvImpl = class {
|
|
|
283
320
|
this.cache.set(key, result);
|
|
284
321
|
return result;
|
|
285
322
|
}
|
|
323
|
+
describe() {
|
|
324
|
+
return [...this.defs.values()].map((def) => {
|
|
325
|
+
let made;
|
|
326
|
+
try {
|
|
327
|
+
made = this.produce(def);
|
|
328
|
+
} catch {
|
|
329
|
+
return {
|
|
330
|
+
key: def.key,
|
|
331
|
+
vars: [this.prefix + def.key],
|
|
332
|
+
computed: false,
|
|
333
|
+
required: true
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if ("value" in made) return {
|
|
337
|
+
key: def.key,
|
|
338
|
+
vars: [],
|
|
339
|
+
computed: true,
|
|
340
|
+
required: false
|
|
341
|
+
};
|
|
342
|
+
return describeSchema(def.key, made.schema, this.prefix);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
286
345
|
get(key) {
|
|
287
346
|
return this.resolve(key);
|
|
288
347
|
}
|
|
@@ -298,10 +357,16 @@ var EnvImpl = class {
|
|
|
298
357
|
return out;
|
|
299
358
|
}
|
|
300
359
|
set(keyOrUpdates, value) {
|
|
301
|
-
const updates = typeof keyOrUpdates === "string" ? { [keyOrUpdates]: value } : keyOrUpdates;
|
|
360
|
+
const updates = typeof keyOrUpdates === "string" ? { [this.sourceKey(keyOrUpdates)]: value } : keyOrUpdates;
|
|
302
361
|
Object.assign(this.overrides, updates);
|
|
303
362
|
this.invalidate();
|
|
304
363
|
}
|
|
364
|
+
/** The source key `set(key, value)` writes: a field's first source key, or `key` itself when no field has that name. */
|
|
365
|
+
sourceKey(key) {
|
|
366
|
+
const def = this.defs.get(key);
|
|
367
|
+
if (def === void 0) return key;
|
|
368
|
+
return sourceKeyOf(def.kind === "factory" ? void 0 : def.schema, key, this.prefix);
|
|
369
|
+
}
|
|
305
370
|
add(input) {
|
|
306
371
|
this.define(input);
|
|
307
372
|
this.cache.clear();
|
|
@@ -412,11 +477,118 @@ var EnvImpl = class {
|
|
|
412
477
|
}
|
|
413
478
|
}
|
|
414
479
|
};
|
|
480
|
+
/** Describe a field read through `schema`: the keys it reads, whether it is required, and its documented shape. */
|
|
481
|
+
function describeSchema(key, schema, prefix) {
|
|
482
|
+
const { $schema: _dialect, ...shape } = z.toJSONSchema(schema, {
|
|
483
|
+
io: "input",
|
|
484
|
+
unrepresentable: "any"
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
key,
|
|
488
|
+
vars: varsOf(schema, key, prefix),
|
|
489
|
+
computed: false,
|
|
490
|
+
required: !schema.safeParse(void 0).success,
|
|
491
|
+
...schema.description === void 0 ? {} : { description: schema.description },
|
|
492
|
+
..."default" in shape ? { default: shape.default } : {},
|
|
493
|
+
schema: shape
|
|
494
|
+
};
|
|
495
|
+
}
|
|
415
496
|
/** Build a typed, lazy, reactive config from a record of fields. Reads `process.env` by default. */
|
|
416
497
|
function env(input, options) {
|
|
417
498
|
return new EnvImpl(input, options);
|
|
418
499
|
}
|
|
419
500
|
//#endregion
|
|
501
|
+
//#region src/merge.ts
|
|
502
|
+
/** The engine behind {@link mergeEnvs}: each call goes to the slice that owns the field, or to every slice. */
|
|
503
|
+
var MergedEnv = class {
|
|
504
|
+
slices;
|
|
505
|
+
owner = /* @__PURE__ */ new Map();
|
|
506
|
+
constructor(slices) {
|
|
507
|
+
this.slices = slices;
|
|
508
|
+
for (const slice of slices) for (const { key } of slice.describe()) {
|
|
509
|
+
if (this.owner.has(key)) throw new Error(`mergeEnvs: field "${key}" is declared by more than one env`);
|
|
510
|
+
this.owner.set(key, slice);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
sliceOf(key) {
|
|
514
|
+
const slice = this.owner.get(key);
|
|
515
|
+
if (slice === void 0) throw new Error(`mergeEnvs: no env declares field "${key}"`);
|
|
516
|
+
return slice;
|
|
517
|
+
}
|
|
518
|
+
get(key) {
|
|
519
|
+
return this.sliceOf(key).get(key);
|
|
520
|
+
}
|
|
521
|
+
set(keyOrUpdates, value) {
|
|
522
|
+
if (typeof keyOrUpdates === "string") {
|
|
523
|
+
const slice = this.owner.get(keyOrUpdates);
|
|
524
|
+
if (slice !== void 0) return slice.set(keyOrUpdates, value);
|
|
525
|
+
for (const one of this.slices) one.set({ [keyOrUpdates]: value });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
for (const one of this.slices) one.set(keyOrUpdates);
|
|
529
|
+
}
|
|
530
|
+
parse() {
|
|
531
|
+
const out = {};
|
|
532
|
+
const issues = [];
|
|
533
|
+
for (const slice of this.slices) try {
|
|
534
|
+
Object.assign(out, slice.parse());
|
|
535
|
+
} catch (err) {
|
|
536
|
+
if (!(err instanceof EnvError)) throw err;
|
|
537
|
+
issues.push(...err.issues);
|
|
538
|
+
}
|
|
539
|
+
if (issues.length > 0) throw new EnvError(issues);
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
on(arg1, arg2, arg3) {
|
|
543
|
+
let offs;
|
|
544
|
+
if (typeof arg1 === "function") {
|
|
545
|
+
const listener = arg1;
|
|
546
|
+
offs = this.slices.map((slice) => slice.on(listener, arg2));
|
|
547
|
+
} else {
|
|
548
|
+
const keys = Array.isArray(arg1) ? arg1 : [String(arg1)];
|
|
549
|
+
const listener = arg2;
|
|
550
|
+
offs = keys.map((key) => this.sliceOf(key).on(key, listener, arg3));
|
|
551
|
+
}
|
|
552
|
+
return () => {
|
|
553
|
+
for (const off of offs) off();
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
describe() {
|
|
557
|
+
return this.slices.flatMap((slice) => slice.describe());
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
/**
|
|
561
|
+
* Join config slices into one {@link EnvView} of every field. Each slice stays the source of truth for its own
|
|
562
|
+
* fields: `get` and single-field `set` go to the owning slice, a bulk `set` reaches every slice, `parse` throws one
|
|
563
|
+
* {@link EnvError} with every slice's issues, `on` subscribes in the owning slices, and `describe` lists all fields
|
|
564
|
+
* in slice order. Two slices declaring the same field is a compile-time error (for literal tuples) and throws.
|
|
565
|
+
* A `once` listener subscribed to all fields fires once per slice.
|
|
566
|
+
*/
|
|
567
|
+
function mergeEnvs(...envs) {
|
|
568
|
+
return new MergedEnv(envs);
|
|
569
|
+
}
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/example.ts
|
|
572
|
+
/** The text a value has in a `.env` file: strings as written, anything else as JSON. */
|
|
573
|
+
function dotenvText(value) {
|
|
574
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* A `.env.example` for `fields`: each field read from a source key becomes a `KEY=default` line under its
|
|
578
|
+
* description as a comment. A field with no default is written as an empty `KEY=` marked `(required)`, or
|
|
579
|
+
* commented out when it is optional. Computed fields read no key and are left out.
|
|
580
|
+
*/
|
|
581
|
+
function envExample(fields) {
|
|
582
|
+
const blocks = fields.filter((field) => !field.computed).map((field) => {
|
|
583
|
+
const key = field.vars[0] ?? field.key;
|
|
584
|
+
const notes = [field.description, field.required ? "(required)" : void 0].filter((note) => note !== void 0);
|
|
585
|
+
const comment = notes.length === 0 ? [] : notes.join(" ").split("\n").map((text) => `# ${text}`);
|
|
586
|
+
const line = "default" in field ? `${key}=${dotenvText(field.default)}` : field.required ? `${key}=` : `# ${key}=`;
|
|
587
|
+
return [...comment, line].join("\n");
|
|
588
|
+
});
|
|
589
|
+
return blocks.length === 0 ? "" : `${blocks.join("\n\n")}\n`;
|
|
590
|
+
}
|
|
591
|
+
//#endregion
|
|
420
592
|
//#region src/provider.ts
|
|
421
593
|
/** Bind a field to a live {@link EnvProvider}; values are validated against `schema`. */
|
|
422
594
|
function dynamic(provider, schema) {
|
|
@@ -466,9 +638,11 @@ var AsyncEnvImpl = class {
|
|
|
466
638
|
started = /* @__PURE__ */ new Set();
|
|
467
639
|
watchers = /* @__PURE__ */ new Map();
|
|
468
640
|
words;
|
|
641
|
+
prefix;
|
|
469
642
|
groupCount = 0;
|
|
470
643
|
constructor(input, options) {
|
|
471
644
|
this.words = toWords(options);
|
|
645
|
+
this.prefix = options?.prefix ?? "";
|
|
472
646
|
this.define(input);
|
|
473
647
|
}
|
|
474
648
|
define(input) {
|
|
@@ -524,7 +698,7 @@ var AsyncEnvImpl = class {
|
|
|
524
698
|
/** Do the work of resolving one field (no memoization — see {@link resolve}). */
|
|
525
699
|
async compute(key) {
|
|
526
700
|
const def = this.defs.get(key);
|
|
527
|
-
if (def.kind === "schema") return this.validate(def.schema, key, resolveRaw(this.source(), varsOf(def.schema, key)));
|
|
701
|
+
if (def.kind === "schema") return this.validate(def.schema, key, resolveRaw(this.source(), varsOf(def.schema, key, this.prefix)));
|
|
528
702
|
if (def.kind === "dynamic") {
|
|
529
703
|
await this.ensureStarted(def);
|
|
530
704
|
return this.validate(def.schema, key, this.dynamicRaw.get(def.key));
|
|
@@ -543,7 +717,7 @@ var AsyncEnvImpl = class {
|
|
|
543
717
|
throw new EnvError([customIssue(key, errMessage(err))]);
|
|
544
718
|
}
|
|
545
719
|
this.deps.set(key, reads);
|
|
546
|
-
if (isZodType(produced)) return this.validate(produced, key, resolveRaw(this.source(), varsOf(produced, key)));
|
|
720
|
+
if (isZodType(produced)) return this.validate(produced, key, resolveRaw(this.source(), varsOf(produced, key, this.prefix)));
|
|
547
721
|
return produced;
|
|
548
722
|
}
|
|
549
723
|
/** Resolve `key` (memoized per snapshot). Cleared by `set`/provider updates. */
|
|
@@ -580,10 +754,16 @@ var AsyncEnvImpl = class {
|
|
|
580
754
|
return out;
|
|
581
755
|
}
|
|
582
756
|
set(keyOrUpdates, value) {
|
|
583
|
-
const updates = typeof keyOrUpdates === "string" ? { [keyOrUpdates]: value } : keyOrUpdates;
|
|
757
|
+
const updates = typeof keyOrUpdates === "string" ? { [this.sourceKey(keyOrUpdates)]: value } : keyOrUpdates;
|
|
584
758
|
Object.assign(this.overrides, updates);
|
|
585
759
|
this.emitChange();
|
|
586
760
|
}
|
|
761
|
+
/** The source key `set(key, value)` writes: a field's first source key, or `key` itself when no field has that name. */
|
|
762
|
+
sourceKey(key) {
|
|
763
|
+
const def = this.defs.get(key);
|
|
764
|
+
if (def === void 0) return key;
|
|
765
|
+
return sourceKeyOf(def.kind === "factory" ? void 0 : def.schema, key, this.prefix);
|
|
766
|
+
}
|
|
587
767
|
add(input) {
|
|
588
768
|
this.define(input);
|
|
589
769
|
this.cache.clear();
|
|
@@ -719,4 +899,4 @@ function asyncEnv(input, options) {
|
|
|
719
899
|
return new AsyncEnvImpl(input, options);
|
|
720
900
|
}
|
|
721
901
|
//#endregion
|
|
722
|
-
export { DEFAULT_FALSE, DEFAULT_TRUE, EnvError, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, isDynamic, mergeSources, parseDotenv, pollProvider, resolveRaw, staticProvider, varsOf };
|
|
902
|
+
export { DEFAULT_FALSE, DEFAULT_TRUE, EnvError, alias, asyncEnv, coerce, defaultSource, dynamic, effectiveType, env, envExample, isDynamic, mergeEnvs, mergeSources, parseDotenv, pollProvider, resolveRaw, sourceKeyOf, staticProvider, varsOf };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayepi/env",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Typed, lazy, reactive environment/config on zod — env({...}) reads process.env, records, .env/JSON files; coerces scalars and JSON-decodes complex types; computed fields, aliasing, and async/dynamic (live, subscribable) values",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
"node": ">=18"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"zod": "^4"
|
|
51
|
+
"zod": "^4.6.7"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@vitest/coverage-v8": "^2.1.8",
|
|
55
55
|
"publint": "^0.3.0",
|
|
56
56
|
"tsdown": "^0.12.0",
|
|
57
57
|
"vitest": "^2.1.8",
|
|
58
|
-
"zod": "^4.
|
|
58
|
+
"zod": "^4.6.7"
|
|
59
59
|
},
|
|
60
60
|
"keywords": [
|
|
61
61
|
"ayepi",
|