@ix-xs/node-comfort 2.0.0 → 2.1.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Emitter.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  const { TimeoutError, AbortError } = require("./errors.js");
18
+ const { toMs, setLongTimeout } = require("./internal/shared.js");
18
19
 
19
20
  /**
20
21
  * Event names mapped to their arguments, like
@@ -22,6 +23,12 @@ const { TimeoutError, AbortError } = require("./errors.js");
22
23
  * @typedef {Record<string, any[]>} EventMap
23
24
  */
24
25
 
26
+ /**
27
+ * Options for `on()` and `once()`.
28
+ * @typedef {object} ListenOptions
29
+ * @property {AbortSignal} [signal] Removes the listener when aborted. One signal can clean up many listeners at once.
30
+ */
31
+
25
32
  /**
26
33
  * Options for `waitFor()`.
27
34
  * @template {any[]} A
@@ -31,6 +38,15 @@ const { TimeoutError, AbortError } = require("./errors.js");
31
38
  * @property {(...args: A) => boolean} [filter] Only resolve for events that pass this test.
32
39
  */
33
40
 
41
+ /**
42
+ * A listener as stored: `once()` wraps the function it was given and keeps
43
+ * it as `listener`, so `off()` can find it.
44
+ * @typedef {((...args: any[]) => unknown) & { listener?: Function }} _StoredListener
45
+ */
46
+
47
+ /** @type {readonly _StoredListener[]} */
48
+ const NONE = Object.freeze([]);
49
+
34
50
  /**
35
51
  * A typed event emitter. In JavaScript, give the variable a JSDoc type such
36
52
  * as `import("@ix-xs/node-comfort").Emitter<{ ready: [port: number] }>`.
@@ -38,10 +54,44 @@ const { TimeoutError, AbortError } = require("./errors.js");
38
54
  * @template {EventMap} [Events=EventMap]
39
55
  */
40
56
  class Emitter {
41
- /** @type {Map<keyof Events, Set<(...args: any[]) => unknown>>} */
57
+ // Listener lists are replaced, never changed in place: emit() can walk one
58
+ // without copying it, even if a listener adds or removes others meanwhile.
59
+ /** @type {Map<keyof Events, readonly _StoredListener[]>} */
42
60
  #listeners = new Map();
43
- /** @type {Set<(event: keyof Events, ...args: any[]) => unknown>} */
44
- #any = new Set();
61
+ /** @type {readonly _StoredListener[]} */
62
+ #any = NONE;
63
+
64
+ /**
65
+ * @param {keyof Events} event
66
+ * @param {_StoredListener} stored
67
+ * @param {ListenOptions | undefined} options
68
+ * @returns {() => void}
69
+ */
70
+ #add(event, stored, options) {
71
+ const signal = options?.signal;
72
+ if (signal?.aborted) return () => {};
73
+ const current = this.#listeners.get(event) ?? NONE;
74
+ if (!current.includes(stored)) this.#listeners.set(event, [...current, stored]);
75
+ const off = () => {
76
+ signal?.removeEventListener("abort", off);
77
+ this.#drop(event, stored);
78
+ };
79
+ signal?.addEventListener("abort", off, { once: true });
80
+ return off;
81
+ }
82
+
83
+ /**
84
+ * @param {keyof Events} event
85
+ * @param {Function} listener The stored function, or the one given to `once()`.
86
+ */
87
+ #drop(event, listener) {
88
+ const current = this.#listeners.get(event);
89
+ if (!current) return;
90
+ const next = current.filter((stored) => stored !== listener && stored.listener !== listener);
91
+ if (next.length === current.length) return;
92
+ if (next.length) this.#listeners.set(event, next);
93
+ else this.#listeners.delete(event);
94
+ }
45
95
 
46
96
  /**
47
97
  * Listens to an event.
@@ -50,16 +100,19 @@ class Emitter {
50
100
  * const off = emitter.on("data", (chunk) => chunks.push(chunk));
51
101
  * off(); // stop listening
52
102
  *
103
+ * const controller = new AbortController();
104
+ * emitter.on("data", onData, { signal: controller.signal });
105
+ * emitter.on("end", onEnd, { signal: controller.signal });
106
+ * controller.abort(); // removes both
107
+ *
53
108
  * @template {keyof Events & string} K
54
109
  * @param {K} event
55
110
  * @param {(...args: Events[K]) => unknown} listener
111
+ * @param {ListenOptions} [options]
56
112
  * @returns {() => void} Removes the listener.
57
113
  */
58
- on(event, listener) {
59
- let set = this.#listeners.get(event);
60
- if (!set) this.#listeners.set(event, (set = new Set()));
61
- set.add(listener);
62
- return () => this.off(event, listener);
114
+ on(event, listener, options) {
115
+ return this.#add(event, listener, options);
63
116
  }
64
117
 
65
118
  /**
@@ -68,15 +121,17 @@ class Emitter {
68
121
  * @template {keyof Events & string} K
69
122
  * @param {K} event
70
123
  * @param {(...args: Events[K]) => unknown} listener
124
+ * @param {ListenOptions} [options]
71
125
  * @returns {() => void} Removes the listener if it hasn't run yet.
72
126
  */
73
- once(event, listener) {
74
- /** @type {(...args: Events[K]) => unknown} */
127
+ once(event, listener, options) {
128
+ /** @type {_StoredListener} */
75
129
  const wrapper = (...args) => {
76
130
  off();
77
- return listener(...args);
131
+ return listener(.../** @type {Events[K]} */ (args));
78
132
  };
79
- const off = this.on(event, wrapper);
133
+ wrapper.listener = listener;
134
+ const off = this.#add(event, wrapper, options);
80
135
  return off;
81
136
  }
82
137
 
@@ -91,8 +146,10 @@ class Emitter {
91
146
  * @returns {() => void} Removes the listener.
92
147
  */
93
148
  onAny(listener) {
94
- this.#any.add(/** @type {any} */ (listener));
95
- return () => void this.#any.delete(/** @type {any} */ (listener));
149
+ if (!this.#any.includes(listener)) this.#any = [...this.#any, listener];
150
+ return () => {
151
+ this.#any = this.#any.filter((stored) => stored !== listener);
152
+ };
96
153
  }
97
154
 
98
155
  /**
@@ -100,16 +157,12 @@ class Emitter {
100
157
  *
101
158
  * @template {keyof Events & string} K
102
159
  * @param {K} event
103
- * @param {(...args: Events[K]) => unknown} [listener]
160
+ * @param {(...args: Events[K]) => unknown} [listener] The function given to `on()` or `once()`.
104
161
  * @returns {this}
105
162
  */
106
163
  off(event, listener) {
107
164
  if (!listener) this.#listeners.delete(event);
108
- else {
109
- const set = this.#listeners.get(event);
110
- set?.delete(listener);
111
- if (set && !set.size) this.#listeners.delete(event);
112
- }
165
+ else this.#drop(event, listener);
113
166
  return this;
114
167
  }
115
168
 
@@ -126,10 +179,11 @@ class Emitter {
126
179
  * @returns {boolean} `true` if anyone was listening.
127
180
  */
128
181
  emit(event, ...args) {
129
- const listeners = [...(this.#listeners.get(event) ?? [])];
130
- for (const listener of listeners) listener(...args);
131
- for (const listener of [...this.#any]) listener(event, ...args);
132
- return listeners.length > 0 || this.#any.size > 0;
182
+ const listeners = this.#listeners.get(event);
183
+ const any = this.#any;
184
+ if (listeners !== undefined) for (let i = 0; i < listeners.length; i++) listeners[i](...args);
185
+ for (let i = 0; i < any.length; i++) any[i](event, ...args);
186
+ return listeners !== undefined || any.length > 0;
133
187
  }
134
188
 
135
189
  /**
@@ -146,8 +200,9 @@ class Emitter {
146
200
  */
147
201
  async emitAsync(event, ...args) {
148
202
  const results = [];
149
- for (const listener of [...(this.#listeners.get(event) ?? [])]) results.push(await listener(...args));
150
- for (const listener of [...this.#any]) await listener(event, ...args);
203
+ const any = this.#any;
204
+ for (const listener of this.#listeners.get(event) ?? NONE) results.push(await listener(...args));
205
+ for (const listener of any) await listener(event, ...args);
151
206
  return results;
152
207
  }
153
208
 
@@ -167,10 +222,11 @@ class Emitter {
167
222
  */
168
223
  waitFor(event, options = {}) {
169
224
  return new Promise((resolve, reject) => {
170
- /** @type {NodeJS.Timeout | undefined} */
171
- let timer;
225
+ const limit = options.timeout === undefined ? undefined : toMs(options.timeout, "timeout");
226
+ /** @type {() => void} */
227
+ let cancelTimer = () => {};
172
228
  const cleanup = () => {
173
- clearTimeout(timer);
229
+ cancelTimer();
174
230
  off();
175
231
  options.signal?.removeEventListener("abort", onAbort);
176
232
  };
@@ -185,12 +241,11 @@ class Emitter {
185
241
  }));
186
242
  if (options.signal?.aborted) return onAbort();
187
243
  options.signal?.addEventListener("abort", onAbort, { once: true });
188
- if (options.timeout !== undefined) {
189
- const ms = typeof options.timeout === "number" ? options.timeout : require("./Time.js").parseDuration(options.timeout) ?? 0;
190
- timer = setTimeout(() => {
244
+ if (limit !== undefined) {
245
+ cancelTimer = setLongTimeout(() => {
191
246
  cleanup();
192
- reject(new TimeoutError(`Timed out waiting for "${event}" after ${ms}ms`, { timeout: ms }));
193
- }, ms);
247
+ reject(new TimeoutError(`Timed out waiting for "${event}" after ${limit}ms`, { timeout: limit }));
248
+ }, limit);
194
249
  }
195
250
  });
196
251
  }
@@ -255,12 +310,24 @@ class Emitter {
255
310
  * @returns {number}
256
311
  */
257
312
  listenerCount(event) {
258
- if (event !== undefined) return this.#listeners.get(event)?.size ?? 0;
313
+ if (event !== undefined) return this.#listeners.get(event)?.length ?? 0;
259
314
  let total = 0;
260
- for (const set of this.#listeners.values()) total += set.size;
315
+ for (const list of this.#listeners.values()) total += list.length;
261
316
  return total;
262
317
  }
263
318
 
319
+ /**
320
+ * The listeners of an event, in the order they run. For `once()`, you get
321
+ * the function you passed, not its wrapper.
322
+ *
323
+ * @template {keyof Events & string} K
324
+ * @param {K} event
325
+ * @returns {Array<(...args: Events[K]) => unknown>}
326
+ */
327
+ listeners(event) {
328
+ return (this.#listeners.get(event) ?? NONE).map((stored) => /** @type {any} */ (stored.listener ?? stored));
329
+ }
330
+
264
331
  /**
265
332
  * The events that have listeners.
266
333
  *
@@ -280,7 +347,7 @@ class Emitter {
280
347
  if (event !== undefined) this.#listeners.delete(event);
281
348
  else {
282
349
  this.#listeners.clear();
283
- this.#any.clear();
350
+ this.#any = NONE;
284
351
  }
285
352
  return this;
286
353
  }
package/src/Env.js CHANGED
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * The `.env` file in the working directory is loaded when the package is
9
9
  * required; variables that are already set are never overwritten. Set
10
- * `NODE_COMFORT_DOTENV=false` to turn that off.
10
+ * `NODE_COMFORT_DOTENV=false` to turn that off. What it loaded has the
11
+ * lowest priority: a file you load yourself afterwards wins over it.
11
12
  *
12
13
  * @example
13
14
  * const config = env.validate({
@@ -21,6 +22,7 @@
21
22
  const fs = require("node:fs");
22
23
  const nodePath = require("node:path");
23
24
  const { ValidationError } = require("./errors.js");
25
+ const { parseDuration } = require("./internal/shared.js");
24
26
 
25
27
  /**
26
28
  * Options for `load()`.
@@ -28,6 +30,7 @@ const { ValidationError } = require("./errors.js");
28
30
  * @property {boolean} [override] Overwrite variables that are already set.
29
31
  * @property {boolean} [expand] Replace `${OTHER}` references. Defaults to `true`.
30
32
  * @property {boolean} [required] Throw if a file is missing.
33
+ * @property {boolean | string} [cascade] Also load the files that go with each one: `.env.production.local`, `.env.local`, `.env.production`, then `.env`. `true` takes the mode from `NODE_ENV`; a string names it. `.env.local` is skipped in test mode, so tests run the same for everyone.
31
34
  * @property {NodeJS.ProcessEnv} [target] Where to put the variables. Defaults to `process.env`.
32
35
  */
33
36
 
@@ -77,6 +80,18 @@ const { ValidationError } = require("./errors.js");
77
80
  * @typedef {{ -readonly [K in keyof T]: T[K] extends { optional: true } ? EnvVarValue<T[K]> | undefined : EnvVarValue<T[K]> }} EnvConfig
78
81
  */
79
82
 
83
+ const _TRUE = ["true", "1", "yes", "y", "on"];
84
+ const _FALSE = ["false", "0", "no", "n", "off", ""];
85
+
86
+ /**
87
+ * Reads a variable, ignoring what objects inherit: without this,
88
+ * `TOSTRING` is fine but `toString` would come back as a function.
89
+ * @param {Record<string, string | undefined>} source
90
+ * @param {string} name
91
+ * @returns {string | undefined}
92
+ */
93
+ const _raw = (source, name) => (Object.hasOwn(source, name) ? source[name] : undefined);
94
+
80
95
  /**
81
96
  * @param {string} name
82
97
  * @param {string} code
@@ -116,8 +131,8 @@ const _convert = (name, raw, spec) => {
116
131
  }
117
132
  case "boolean": {
118
133
  const lower = value.toLowerCase();
119
- if (["true", "1", "yes", "y", "on"].includes(lower)) return true;
120
- if (["false", "0", "no", "n", "off", ""].includes(lower)) return false;
134
+ if (_TRUE.includes(lower)) return true;
135
+ if (_FALSE.includes(lower)) return false;
121
136
  return _fail(name, "invalid_type", `must be a boolean (true/false, 1/0, yes/no, on/off), got "${raw}"`);
122
137
  }
123
138
  case "url": {
@@ -138,7 +153,6 @@ const _convert = (name, raw, spec) => {
138
153
  return value;
139
154
  }
140
155
  case "duration": {
141
- const { parseDuration } = require("./Time.js");
142
156
  const ms = parseDuration(value);
143
157
  if (ms === null) _fail(name, "invalid_type", `must be a duration such as "30s", "5m" or "1h", got "${raw}"`);
144
158
  return ms;
@@ -166,17 +180,23 @@ const _convert = (name, raw, spec) => {
166
180
  * @returns {unknown}
167
181
  */
168
182
  const _read = (name, spec, source) => {
169
- const raw = source[name];
183
+ const raw = _raw(source, name);
170
184
  if (raw === undefined || raw.trim() === "") {
171
185
  if ("default" in spec && spec.default !== undefined) {
172
186
  return spec.type === "duration" && typeof spec.default === "string" ? _convert(name, spec.default, spec) : spec.default;
173
187
  }
174
188
  if (spec.optional) return undefined;
175
- return _fail(name, "required", "is required but is not set");
189
+ return _fail(name, "required", `is required but is not set${spec.description ? ` (${spec.description})` : ""}`);
176
190
  }
177
191
  return _convert(name, raw, spec);
178
192
  };
179
193
 
194
+ const _LINE = /^[ \t]*(?:export[ \t]+)?([\w.-]+)[ \t]*[=:][ \t]*(?:("(?:\\.|[^"\\])*"|'[^']*'|`[^`]*`)[ \t]*(?:#[^\n]*)?|([^\n]*))$/gm;
195
+ const _ESCAPE = /\\([nrt"\\$])/g;
196
+ const _REFERENCE = /\\\$|\$\{([\w.]+)(?::?-([^}]*))?\}|\$([A-Za-z_]\w*)/g;
197
+ /** @type {Record<string, string>} */
198
+ const _ESCAPED = { n: "\n", r: "\r", t: "\t" };
199
+
180
200
  /**
181
201
  * Parses the text of a `.env` file: quotes, multi-line values, `export`,
182
202
  * comments, escapes, and `${VAR}` expansion (except in single quotes).
@@ -190,61 +210,97 @@ const _read = (name, spec, source) => {
190
210
  * @returns {Record<string, string>}
191
211
  */
192
212
  function parse(content, options = {}) {
193
- /** @type {Record<string, string>} */
194
- const out = {};
213
+ return _parse(content, options);
214
+ }
215
+
216
+ /**
217
+ * @param {string} content
218
+ * @param {EnvParseOptions} options
219
+ * @param {(name: string) => string | undefined} [wins] Values that a reference takes before the file's own: what the variable will really be once loaded.
220
+ * @returns {Record<string, string>}
221
+ */
222
+ function _parse(content, options, wins) {
223
+ // A Map, so that a variable called __proto__ or toString is a variable like any other.
224
+ /** @type {Map<string, string>} */
225
+ const out = new Map();
195
226
  /** @type {Set<string>} */
196
227
  const literal = new Set();
197
228
  const text = String(content).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
198
- const regex = /^[ \t]*(?:export[ \t]+)?([\w.-]+)[ \t]*[=:][ \t]*(?:("(?:\\.|[^"\\])*"|'[^']*'|`[^`]*`)[ \t]*(?:#[^\n]*)?|([^\n]*))$/gm;
199
- let match;
200
- while ((match = regex.exec(text)) !== null) {
201
- const [, key, quoted, bare] = match;
229
+ for (const [, key, quoted, bare] of text.matchAll(_LINE)) {
202
230
  let value;
203
231
  if (quoted !== undefined) {
204
232
  const quote = quoted[0];
205
233
  value = quoted.slice(1, -1);
206
- if (quote === '"') value = value.replace(/\\([nrt"\\$])/g, (_, c) => ({ n: "\n", r: "\r", t: "\t" })[/** @type {"n"} */ (c)] ?? c);
234
+ if (quote === '"') value = value.replace(_ESCAPE, (_, char) => _ESCAPED[char] ?? char);
207
235
  if (quote === "'") literal.add(key);
236
+ else literal.delete(key);
208
237
  } else {
209
238
  value = (bare ?? "").replace(/(?:^|[ \t]+)#.*$/, "").trim();
239
+ literal.delete(key);
210
240
  }
211
- out[key] = value;
241
+ out.set(key, value);
212
242
  }
213
243
  if (options.expand !== false) {
214
244
  const base = options.env ?? process.env;
215
- const resolve = (/** @type {string} */ name, /** @type {Set<string>} */ seen) => {
216
- if (seen.has(name)) return "";
217
- if (name in out) return expandValue(out[name], new Set([...seen, name]), name);
218
- return base[name] ?? "";
245
+ /**
246
+ * @param {string} name
247
+ * @param {string[]} seen The variables being expanded, to stop at a loop.
248
+ * @returns {string}
249
+ */
250
+ const resolve = (name, seen) => {
251
+ if (seen.includes(name)) return "";
252
+ const winner = wins?.(name);
253
+ if (winner !== undefined) return winner;
254
+ const own = out.get(name);
255
+ return own !== undefined ? expand(own, [...seen, name], name) : _raw(base, name) ?? "";
219
256
  };
220
257
  /**
221
258
  * @param {string} value
222
- * @param {Set<string>} seen
259
+ * @param {string[]} seen
223
260
  * @param {string} key
224
261
  * @returns {string}
225
262
  */
226
- const expandValue = (value, seen, key) => {
227
- if (literal.has(key)) return value;
228
- return value.replace(/\\\$|\$\{([\w.]+)(?::?-([^}]*))?\}|\$([A-Za-z_]\w*)/g, (m, braced, fallback, bare) => {
229
- if (m === "\\$") return "$";
230
- const resolved = resolve(braced ?? bare, seen);
263
+ const expand = (value, seen, key) => {
264
+ if (literal.has(key) || !value.includes("$")) return value;
265
+ return value.replace(_REFERENCE, (match, braced, fallback, plain) => {
266
+ if (match === "\\$") return "$";
267
+ const resolved = resolve(braced ?? plain, seen);
231
268
  return resolved === "" && fallback !== undefined ? fallback : resolved;
232
269
  });
233
270
  };
234
- for (const key of Object.keys(out)) out[key] = expandValue(out[key], new Set([key]), key);
271
+ for (const [key, value] of out) out.set(key, expand(value, [key], key));
235
272
  }
236
- return out;
273
+ return Object.fromEntries(out);
237
274
  }
238
275
 
276
+ /**
277
+ * What the automatic load of `.env` put in `process.env`. A later `load()`
278
+ * may replace these values, as long as nobody changed them in between: they
279
+ * came from a file, not from the real environment.
280
+ * @type {Map<string, string>}
281
+ */
282
+ const _automatic = new Map();
283
+ let _loadingAutomatically = false;
284
+
285
+ /**
286
+ * The files that go with a `.env` file, most specific first.
287
+ * @param {string} file
288
+ * @param {string} mode
289
+ * @returns {string[]}
290
+ */
291
+ const _cascade = (file, mode) => [`${file}.${mode}.local`, ...(mode === "test" ? [] : [`${file}.local`]), `${file}.${mode}`, file];
292
+
239
293
  /**
240
294
  * Loads `.env` files into `process.env`. The first file to set a variable
241
295
  * wins, and variables that are already set are kept, unless you pass
242
- * `override`.
296
+ * `override`. The `.env` loaded by itself at startup doesn't count as
297
+ * already set: `.env.local` wins over it, as you'd expect.
243
298
  *
244
299
  * @example
245
300
  * env.load(); // ./.env
246
301
  * env.load([".env.local", ".env"]); // local values win
247
302
  * env.load(".env.test", { override: true });
303
+ * env.load(".env", { cascade: true }); // .env.production.local, .env.local, .env.production, .env
248
304
  *
249
305
  * @param {string | string[]} [files=".env"] Relative to the working directory.
250
306
  * @param {EnvLoadOptions} [options]
@@ -253,21 +309,49 @@ function parse(content, options = {}) {
253
309
  */
254
310
  function load(files = ".env", options = {}) {
255
311
  const target = options.target ?? process.env;
256
- /** @type {Record<string, string>} */
257
- const loaded = {};
258
- for (const file of Array.isArray(files) ? files : [files]) {
259
- const full = nodePath.resolve(process.cwd(), file);
260
- if (!fs.existsSync(full)) {
261
- if (options.required) throw new Error(`Environment file not found: ${full}`);
262
- continue;
263
- }
264
- const values = parse(fs.readFileSync(full, "utf8"), { expand: options.expand, env: { ...target, ...loaded } });
265
- for (const [key, value] of Object.entries(values)) {
266
- if (!(key in loaded)) loaded[key] = value;
267
- if (options.override || target[key] === undefined) target[key] = value;
312
+ const given = Array.isArray(files) ? files : [files];
313
+ const cascade = options.cascade === true ? mode() : options.cascade;
314
+ const own = target === process.env;
315
+ /** @type {Map<string, string>} */
316
+ const loaded = new Map();
317
+ /**
318
+ * The value a variable already has and keeps, whatever the files say.
319
+ * @param {string} key
320
+ * @returns {string | undefined}
321
+ */
322
+ const kept = (key) => {
323
+ if (options.override) return undefined;
324
+ const current = _raw(target, key);
325
+ return current !== undefined && !(own && _automatic.get(key) === current) ? current : undefined;
326
+ };
327
+ // A reference such as ${HOST} takes the value HOST ends up with, not the
328
+ // one of a file that lost.
329
+ const wins = (/** @type {string} */ key) => loaded.get(key) ?? kept(key);
330
+ for (const name of given) {
331
+ for (const file of cascade ? _cascade(name, cascade) : [name]) {
332
+ const full = nodePath.resolve(process.cwd(), file);
333
+ let content;
334
+ try {
335
+ content = fs.readFileSync(full, "utf8");
336
+ } catch (error) {
337
+ // Only the file you named has to exist, not the ones that go with it.
338
+ if (options.required && file === name) throw new Error(`Environment file not found: ${full}`, { cause: error });
339
+ continue;
340
+ }
341
+ const values = _parse(content, { expand: options.expand, env: target }, wins);
342
+ for (const [key, value] of Object.entries(values)) {
343
+ if (!loaded.has(key)) loaded.set(key, value);
344
+ }
268
345
  }
269
346
  }
270
- return loaded;
347
+ for (const [key, value] of loaded) {
348
+ if (kept(key) !== undefined) continue;
349
+ target[key] = value;
350
+ if (!own) continue;
351
+ if (_loadingAutomatically) _automatic.set(key, value);
352
+ else _automatic.delete(key);
353
+ }
354
+ return Object.fromEntries(loaded);
271
355
  }
272
356
 
273
357
  /**
@@ -281,7 +365,7 @@ function load(files = ".env", options = {}) {
281
365
  * @returns {string | undefined}
282
366
  */
283
367
  function get(name, fallback) {
284
- return process.env[name] ?? fallback;
368
+ return _raw(process.env, name) ?? fallback;
285
369
  }
286
370
 
287
371
  /**
@@ -291,7 +375,7 @@ function get(name, fallback) {
291
375
  * @returns {boolean}
292
376
  */
293
377
  function has(name) {
294
- return process.env[name] !== undefined;
378
+ return _raw(process.env, name) !== undefined;
295
379
  }
296
380
 
297
381
  /**
@@ -489,6 +573,53 @@ function validate(spec, options = {}) {
489
573
  return /** @type {any} */ (Object.freeze(out));
490
574
  }
491
575
 
576
+ /**
577
+ * Quotes a value when spaces, `#` or `$` would otherwise change its meaning.
578
+ * @param {string} text
579
+ */
580
+ const _quote = (text) => {
581
+ if (!/[\s#"'`$\\]/.test(text)) return text;
582
+ if (!text.includes("'") && !text.includes("\n")) return `'${text}'`;
583
+ return `"${text.replace(/[\\"$]/g, "\\$&").replace(/\n/g, "\\n")}"`;
584
+ };
585
+
586
+ /**
587
+ * Writes the `.env.example` that goes with a `validate()` spec: one entry
588
+ * per variable, with its description, type and default. Keep the file in
589
+ * the repository so nobody has to read the code to know what to set.
590
+ *
591
+ * @example
592
+ * const spec = {
593
+ * PORT: { type: "port", default: 3000, description: "Port to listen on" },
594
+ * DATABASE_URL: { type: "url", description: "Postgres connection string" },
595
+ * };
596
+ * fs.writeFileSync(".env.example", env.example(spec));
597
+ * // # Port to listen on
598
+ * // # port, default: 3000
599
+ * // PORT=3000
600
+ * //
601
+ * // # Postgres connection string
602
+ * // # url, required
603
+ * // DATABASE_URL=
604
+ *
605
+ * @param {Record<string, EnvVarSpec>} spec
606
+ * @returns {string}
607
+ */
608
+ function example(spec) {
609
+ const blocks = [];
610
+ for (const [name, entry] of Object.entries(spec)) {
611
+ const fallback = "default" in entry && entry.default !== undefined ? entry.default : undefined;
612
+ const text = fallback === undefined ? "" : Array.isArray(fallback) ? fallback.join(entry.type === "list" ? entry.separator ?? "," : ",") : typeof fallback === "object" ? JSON.stringify(fallback) : String(fallback);
613
+ const kind = entry.type === "enum" ? entry.values.join(" | ") : entry.type;
614
+ const lines = [];
615
+ if (entry.description) lines.push(...entry.description.split(/\r?\n/).map((line) => `# ${line}`));
616
+ lines.push(`# ${kind}, ${fallback !== undefined ? `default: ${text}` : entry.optional ? "optional" : "required"}`);
617
+ lines.push(`${name}=${_quote(text)}`);
618
+ blocks.push(lines.join("\n"));
619
+ }
620
+ return `${blocks.join("\n\n")}\n`;
621
+ }
622
+
492
623
  /**
493
624
  * `NODE_ENV`, or `"development"` when it's not set.
494
625
  *
@@ -524,11 +655,13 @@ function isTest() {
524
655
  }
525
656
 
526
657
  if (String(process.env.NODE_COMFORT_DOTENV).toLowerCase() !== "false") {
658
+ _loadingAutomatically = true;
527
659
  try {
528
660
  load(".env");
529
661
  } catch {
530
662
  // A broken .env file must never prevent the application from starting.
531
663
  }
664
+ _loadingAutomatically = false;
532
665
  }
533
666
 
534
667
  module.exports = {
@@ -548,6 +681,7 @@ module.exports = {
548
681
  json,
549
682
  oneOf,
550
683
  validate,
684
+ example,
551
685
  mode,
552
686
  isProduction,
553
687
  isDevelopment,