@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.
- package/CHANGELOG.md +150 -0
- package/README.md +32 -30
- package/index.js +3 -3
- package/package.json +8 -7
- package/src/Arr.js +222 -58
- package/src/Async.js +322 -90
- package/src/Cache.js +148 -44
- package/src/Checker.js +257 -26
- package/src/Cli.js +404 -145
- package/src/Color.js +224 -83
- package/src/Crypto.js +481 -106
- package/src/Emitter.js +104 -37
- package/src/Env.js +176 -42
- package/src/FS.js +203 -60
- package/src/Func.js +226 -76
- package/src/Http.js +311 -149
- package/src/Id.js +281 -96
- package/src/Logger.js +302 -78
- package/src/Num.js +182 -60
- package/src/Obj.js +340 -125
- package/src/SQLite.js +426 -105
- package/src/Schema.js +602 -87
- package/src/Str.js +316 -86
- package/src/Sys.js +197 -54
- package/src/Time.js +572 -317
- package/src/Utils.js +62 -41
- package/src/errors.js +54 -11
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +59 -17
- package/types/src/Arr.d.ts +78 -0
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +52 -1
- package/types/src/Async.d.ts.map +1 -1
- package/types/src/Cache.d.ts +33 -7
- package/types/src/Cache.d.ts.map +1 -1
- package/types/src/Checker.d.ts +155 -2
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +97 -15
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +7 -1
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +99 -13
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +36 -17
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +31 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +36 -5
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +147 -19
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +113 -43
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts +60 -8
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +53 -2
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +48 -7
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +67 -4
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +123 -14
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +256 -7
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +49 -1
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +51 -6
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +130 -34
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +15 -11
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +27 -3
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +123 -0
- 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
|
-
|
|
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 {
|
|
44
|
-
#any =
|
|
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
|
-
|
|
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 {
|
|
127
|
+
once(event, listener, options) {
|
|
128
|
+
/** @type {_StoredListener} */
|
|
75
129
|
const wrapper = (...args) => {
|
|
76
130
|
off();
|
|
77
|
-
return listener(
|
|
131
|
+
return listener(.../** @type {Events[K]} */ (args));
|
|
78
132
|
};
|
|
79
|
-
|
|
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.
|
|
95
|
-
return () =>
|
|
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 =
|
|
130
|
-
|
|
131
|
-
for (
|
|
132
|
-
|
|
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
|
-
|
|
150
|
-
for (const listener of
|
|
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
|
-
|
|
171
|
-
|
|
225
|
+
const limit = options.timeout === undefined ? undefined : toMs(options.timeout, "timeout");
|
|
226
|
+
/** @type {() => void} */
|
|
227
|
+
let cancelTimer = () => {};
|
|
172
228
|
const cleanup = () => {
|
|
173
|
-
|
|
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 (
|
|
189
|
-
|
|
190
|
-
timer = setTimeout(() => {
|
|
244
|
+
if (limit !== undefined) {
|
|
245
|
+
cancelTimer = setLongTimeout(() => {
|
|
191
246
|
cleanup();
|
|
192
|
-
reject(new TimeoutError(`Timed out waiting for "${event}" after ${
|
|
193
|
-
},
|
|
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)?.
|
|
313
|
+
if (event !== undefined) return this.#listeners.get(event)?.length ?? 0;
|
|
259
314
|
let total = 0;
|
|
260
|
-
for (const
|
|
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
|
|
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 (
|
|
120
|
-
if (
|
|
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
|
|
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",
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
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(
|
|
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
|
|
241
|
+
out.set(key, value);
|
|
212
242
|
}
|
|
213
243
|
if (options.expand !== false) {
|
|
214
244
|
const base = options.env ?? process.env;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 {
|
|
259
|
+
* @param {string[]} seen
|
|
223
260
|
* @param {string} key
|
|
224
261
|
* @returns {string}
|
|
225
262
|
*/
|
|
226
|
-
const
|
|
227
|
-
if (literal.has(key)) return value;
|
|
228
|
-
return value.replace(
|
|
229
|
-
if (
|
|
230
|
-
const resolved = resolve(braced ??
|
|
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
|
|
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
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|