@ix-xs/node-comfort 1.2.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 +340 -0
- package/README.md +137 -548
- package/index.js +80 -35
- package/package.json +161 -33
- package/src/Arr.js +1050 -418
- package/src/Async.js +817 -0
- package/src/Cache.js +433 -0
- package/src/Checker.js +1202 -539
- package/src/Cli.js +1293 -0
- package/src/Color.js +556 -0
- package/src/Crypto.js +1082 -0
- package/src/Emitter.js +356 -0
- package/src/Env.js +689 -0
- package/src/FS.js +1626 -894
- package/src/Func.js +823 -356
- package/src/Http.js +700 -0
- package/src/Id.js +568 -200
- package/src/Logger.js +963 -378
- package/src/Num.js +965 -360
- package/src/Obj.js +1077 -379
- package/src/SQLite.js +1629 -1005
- package/src/Schema.js +2101 -0
- package/src/Str.js +1209 -432
- package/src/Sys.js +654 -0
- package/src/Time.js +1533 -440
- package/src/Utils.js +342 -325
- package/src/errors.js +335 -0
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +735 -526
- package/types/src/Arr.d.ts +533 -145
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +372 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +221 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +651 -232
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +664 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +300 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +620 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +189 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +412 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +717 -238
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +508 -135
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +444 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +199 -66
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +414 -46
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +514 -137
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +453 -104
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +754 -308
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +1189 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +617 -166
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +314 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +759 -204
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +158 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +316 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/src/internal/shared.d.ts +123 -0
- package/types/src/internal/shared.d.ts.map +1 -0
- package/types/index.d.ts.map +0 -1
package/src/Env.js
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Environment variables you can trust: `.env` loading, typed getters, and
|
|
5
|
+
* `validate()` to check your whole configuration at startup and report
|
|
6
|
+
* every problem at once.
|
|
7
|
+
*
|
|
8
|
+
* The `.env` file in the working directory is loaded when the package is
|
|
9
|
+
* required; variables that are already set are never overwritten. Set
|
|
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.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const config = env.validate({
|
|
15
|
+
* PORT: { type: "port", default: 3000 },
|
|
16
|
+
* DATABASE_URL: { type: "url" }, // required
|
|
17
|
+
* CACHE_TTL: { type: "duration", default: "5m" }, // in ms
|
|
18
|
+
* });
|
|
19
|
+
* config.PORT; // a number, typed in your editor
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require("node:fs");
|
|
23
|
+
const nodePath = require("node:path");
|
|
24
|
+
const { ValidationError } = require("./errors.js");
|
|
25
|
+
const { parseDuration } = require("./internal/shared.js");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Options for `load()`.
|
|
29
|
+
* @typedef {object} EnvLoadOptions
|
|
30
|
+
* @property {boolean} [override] Overwrite variables that are already set.
|
|
31
|
+
* @property {boolean} [expand] Replace `${OTHER}` references. Defaults to `true`.
|
|
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.
|
|
34
|
+
* @property {NodeJS.ProcessEnv} [target] Where to put the variables. Defaults to `process.env`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for `parse()`.
|
|
39
|
+
* @typedef {object} EnvParseOptions
|
|
40
|
+
* @property {boolean} [expand] Replace `$VAR`, `${VAR}` and `${VAR:-default}`. Defaults to `true`.
|
|
41
|
+
* @property {Record<string, string | undefined>} [env] Other variables usable in expansions. Defaults to `process.env`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Options shared by the typed getters.
|
|
46
|
+
* @template T
|
|
47
|
+
* @typedef {object} EnvGetterOptions
|
|
48
|
+
* @property {T} [default] Used when the variable is missing or empty. Without it, the variable is required.
|
|
49
|
+
* @property {boolean} [optional] Return `undefined` instead of throwing when it's missing.
|
|
50
|
+
* @property {NodeJS.ProcessEnv} [env] Where to read from. Defaults to `process.env`.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One variable in `validate()`. It's required unless it has a `default` or
|
|
55
|
+
* `optional: true`.
|
|
56
|
+
*
|
|
57
|
+
* @typedef {(
|
|
58
|
+
* { type: "string", default?: string, optional?: boolean, pattern?: RegExp, minLength?: number, description?: string } |
|
|
59
|
+
* { type: "number", default?: number, optional?: boolean, min?: number, max?: number, integer?: boolean, description?: string } |
|
|
60
|
+
* { type: "boolean", default?: boolean, optional?: boolean, description?: string } |
|
|
61
|
+
* { type: "port", default?: number, optional?: boolean, description?: string } |
|
|
62
|
+
* { type: "url", default?: string, optional?: boolean, protocols?: string[], description?: string } |
|
|
63
|
+
* { type: "email", default?: string, optional?: boolean, description?: string } |
|
|
64
|
+
* { type: "duration", default?: number | string, optional?: boolean, description?: string } |
|
|
65
|
+
* { type: "list", default?: string[], optional?: boolean, separator?: string, description?: string } |
|
|
66
|
+
* { type: "json", default?: unknown, optional?: boolean, description?: string } |
|
|
67
|
+
* { type: "enum", values: readonly string[], default?: string, optional?: boolean, description?: string }
|
|
68
|
+
* )} EnvVarSpec
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The type a variable gets once read.
|
|
73
|
+
* @template {EnvVarSpec} S
|
|
74
|
+
* @typedef {S["type"] extends "number" | "port" | "duration" ? number : S["type"] extends "boolean" ? boolean : S["type"] extends "list" ? string[] : S["type"] extends "json" ? any : S extends { values: readonly (infer V)[] } ? V : string} EnvVarValue
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What `validate()` returns.
|
|
79
|
+
* @template {Record<string, EnvVarSpec>} T
|
|
80
|
+
* @typedef {{ -readonly [K in keyof T]: T[K] extends { optional: true } ? EnvVarValue<T[K]> | undefined : EnvVarValue<T[K]> }} EnvConfig
|
|
81
|
+
*/
|
|
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
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} name
|
|
97
|
+
* @param {string} code
|
|
98
|
+
* @param {string} message
|
|
99
|
+
* @returns {never}
|
|
100
|
+
*/
|
|
101
|
+
const _fail = (name, code, message) => {
|
|
102
|
+
throw new ValidationError([{ path: [name], code, message }], { message: `Environment variable ${name}: ${message}` });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} name
|
|
107
|
+
* @param {string} raw
|
|
108
|
+
* @param {EnvVarSpec} spec
|
|
109
|
+
* @returns {unknown}
|
|
110
|
+
*/
|
|
111
|
+
const _convert = (name, raw, spec) => {
|
|
112
|
+
const value = raw.trim();
|
|
113
|
+
switch (spec.type) {
|
|
114
|
+
case "string": {
|
|
115
|
+
if (spec.minLength !== undefined && value.length < spec.minLength) _fail(name, "too_small", `must contain at least ${spec.minLength} characters`);
|
|
116
|
+
if (spec.pattern && !spec.pattern.test(value)) _fail(name, "invalid_string", `does not match ${spec.pattern}`);
|
|
117
|
+
return raw;
|
|
118
|
+
}
|
|
119
|
+
case "number": {
|
|
120
|
+
const n = Number(value);
|
|
121
|
+
if (value === "" || !Number.isFinite(n)) _fail(name, "invalid_type", `must be a number, got "${raw}"`);
|
|
122
|
+
if (spec.integer && !Number.isInteger(n)) _fail(name, "invalid_type", `must be an integer, got "${raw}"`);
|
|
123
|
+
if (spec.min !== undefined && n < spec.min) _fail(name, "too_small", `must be >= ${spec.min}, got ${n}`);
|
|
124
|
+
if (spec.max !== undefined && n > spec.max) _fail(name, "too_big", `must be <= ${spec.max}, got ${n}`);
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
case "port": {
|
|
128
|
+
const n = Number(value);
|
|
129
|
+
if (!/^\d+$/.test(value) || n < 0 || n > 65535) _fail(name, "invalid_type", `must be a port number (0-65535), got "${raw}"`);
|
|
130
|
+
return n;
|
|
131
|
+
}
|
|
132
|
+
case "boolean": {
|
|
133
|
+
const lower = value.toLowerCase();
|
|
134
|
+
if (_TRUE.includes(lower)) return true;
|
|
135
|
+
if (_FALSE.includes(lower)) return false;
|
|
136
|
+
return _fail(name, "invalid_type", `must be a boolean (true/false, 1/0, yes/no, on/off), got "${raw}"`);
|
|
137
|
+
}
|
|
138
|
+
case "url": {
|
|
139
|
+
try {
|
|
140
|
+
const url = new URL(value);
|
|
141
|
+
if (spec.protocols && !spec.protocols.map((p) => p.replace(/:?$/, ":")).includes(url.protocol)) {
|
|
142
|
+
_fail(name, "invalid_string", `must use one of these protocols: ${spec.protocols.join(", ")}`);
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof ValidationError) throw error;
|
|
147
|
+
return _fail(name, "invalid_string", `must be a valid URL, got "${raw}"`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
case "email": {
|
|
151
|
+
const { isEmail } = require("./Checker.js");
|
|
152
|
+
if (!isEmail(value)) _fail(name, "invalid_string", `must be an email address, got "${raw}"`);
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
case "duration": {
|
|
156
|
+
const ms = parseDuration(value);
|
|
157
|
+
if (ms === null) _fail(name, "invalid_type", `must be a duration such as "30s", "5m" or "1h", got "${raw}"`);
|
|
158
|
+
return ms;
|
|
159
|
+
}
|
|
160
|
+
case "list":
|
|
161
|
+
return value === "" ? [] : value.split(spec.separator ?? ",").map((item) => item.trim()).filter((item) => item !== "");
|
|
162
|
+
case "json":
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(value);
|
|
165
|
+
} catch {
|
|
166
|
+
return _fail(name, "invalid_type", "must be valid JSON");
|
|
167
|
+
}
|
|
168
|
+
case "enum":
|
|
169
|
+
if (!spec.values.includes(value)) _fail(name, "invalid_enum_value", `must be one of: ${spec.values.join(", ")} (got "${raw}")`);
|
|
170
|
+
return value;
|
|
171
|
+
default:
|
|
172
|
+
return raw;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {string} name
|
|
178
|
+
* @param {EnvVarSpec & { optional?: boolean }} spec
|
|
179
|
+
* @param {NodeJS.ProcessEnv} source
|
|
180
|
+
* @returns {unknown}
|
|
181
|
+
*/
|
|
182
|
+
const _read = (name, spec, source) => {
|
|
183
|
+
const raw = _raw(source, name);
|
|
184
|
+
if (raw === undefined || raw.trim() === "") {
|
|
185
|
+
if ("default" in spec && spec.default !== undefined) {
|
|
186
|
+
return spec.type === "duration" && typeof spec.default === "string" ? _convert(name, spec.default, spec) : spec.default;
|
|
187
|
+
}
|
|
188
|
+
if (spec.optional) return undefined;
|
|
189
|
+
return _fail(name, "required", `is required but is not set${spec.description ? ` (${spec.description})` : ""}`);
|
|
190
|
+
}
|
|
191
|
+
return _convert(name, raw, spec);
|
|
192
|
+
};
|
|
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
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Parses the text of a `.env` file: quotes, multi-line values, `export`,
|
|
202
|
+
* comments, escapes, and `${VAR}` expansion (except in single quotes).
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* env.parse('PORT=3000\nURL="http://localhost:${PORT}"');
|
|
206
|
+
* // { PORT: "3000", URL: "http://localhost:3000" }
|
|
207
|
+
*
|
|
208
|
+
* @param {string} content
|
|
209
|
+
* @param {EnvParseOptions} [options]
|
|
210
|
+
* @returns {Record<string, string>}
|
|
211
|
+
*/
|
|
212
|
+
function parse(content, options = {}) {
|
|
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();
|
|
226
|
+
/** @type {Set<string>} */
|
|
227
|
+
const literal = new Set();
|
|
228
|
+
const text = String(content).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
229
|
+
for (const [, key, quoted, bare] of text.matchAll(_LINE)) {
|
|
230
|
+
let value;
|
|
231
|
+
if (quoted !== undefined) {
|
|
232
|
+
const quote = quoted[0];
|
|
233
|
+
value = quoted.slice(1, -1);
|
|
234
|
+
if (quote === '"') value = value.replace(_ESCAPE, (_, char) => _ESCAPED[char] ?? char);
|
|
235
|
+
if (quote === "'") literal.add(key);
|
|
236
|
+
else literal.delete(key);
|
|
237
|
+
} else {
|
|
238
|
+
value = (bare ?? "").replace(/(?:^|[ \t]+)#.*$/, "").trim();
|
|
239
|
+
literal.delete(key);
|
|
240
|
+
}
|
|
241
|
+
out.set(key, value);
|
|
242
|
+
}
|
|
243
|
+
if (options.expand !== false) {
|
|
244
|
+
const base = options.env ?? process.env;
|
|
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) ?? "";
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* @param {string} value
|
|
259
|
+
* @param {string[]} seen
|
|
260
|
+
* @param {string} key
|
|
261
|
+
* @returns {string}
|
|
262
|
+
*/
|
|
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);
|
|
268
|
+
return resolved === "" && fallback !== undefined ? fallback : resolved;
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
for (const [key, value] of out) out.set(key, expand(value, [key], key));
|
|
272
|
+
}
|
|
273
|
+
return Object.fromEntries(out);
|
|
274
|
+
}
|
|
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
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Loads `.env` files into `process.env`. The first file to set a variable
|
|
295
|
+
* wins, and variables that are already set are kept, unless you pass
|
|
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.
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* env.load(); // ./.env
|
|
301
|
+
* env.load([".env.local", ".env"]); // local values win
|
|
302
|
+
* env.load(".env.test", { override: true });
|
|
303
|
+
* env.load(".env", { cascade: true }); // .env.production.local, .env.local, .env.production, .env
|
|
304
|
+
*
|
|
305
|
+
* @param {string | string[]} [files=".env"] Relative to the working directory.
|
|
306
|
+
* @param {EnvLoadOptions} [options]
|
|
307
|
+
* @returns {Record<string, string>} Everything read from the files.
|
|
308
|
+
* @throws {Error} If `required` is set and a file is missing.
|
|
309
|
+
*/
|
|
310
|
+
function load(files = ".env", options = {}) {
|
|
311
|
+
const target = options.target ?? process.env;
|
|
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
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
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);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Reads a variable, with a fallback. Never throws.
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* env.get("REGION", "eu-west-1");
|
|
362
|
+
*
|
|
363
|
+
* @param {string} name
|
|
364
|
+
* @param {string} [fallback]
|
|
365
|
+
* @returns {string | undefined}
|
|
366
|
+
*/
|
|
367
|
+
function get(name, fallback) {
|
|
368
|
+
return _raw(process.env, name) ?? fallback;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Is the variable set, even to an empty string?
|
|
373
|
+
*
|
|
374
|
+
* @param {string} name
|
|
375
|
+
* @returns {boolean}
|
|
376
|
+
*/
|
|
377
|
+
function has(name) {
|
|
378
|
+
return _raw(process.env, name) !== undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Reads a variable that must be set and not empty.
|
|
383
|
+
*
|
|
384
|
+
* @example
|
|
385
|
+
* const key = env.required("STRIPE_SECRET_KEY");
|
|
386
|
+
*
|
|
387
|
+
* @param {string} name
|
|
388
|
+
* @returns {string}
|
|
389
|
+
* @throws {ValidationError} If it's missing or empty.
|
|
390
|
+
*/
|
|
391
|
+
function required(name) {
|
|
392
|
+
return /** @type {string} */ (_read(name, { type: "string" }, process.env));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Reads a string. Required unless you give a default or `optional`.
|
|
397
|
+
*
|
|
398
|
+
* @example
|
|
399
|
+
* env.string("APP_NAME", { default: "my-app" });
|
|
400
|
+
*
|
|
401
|
+
* @param {string} name
|
|
402
|
+
* @param {EnvGetterOptions<string> & { pattern?: RegExp, minLength?: number }} [options]
|
|
403
|
+
* @returns {string}
|
|
404
|
+
* @throws {ValidationError}
|
|
405
|
+
*/
|
|
406
|
+
function string(name, options = {}) {
|
|
407
|
+
return /** @type {string} */ (_read(name, { ...options, type: "string" }, options.env ?? process.env));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Reads a number.
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* env.number("WORKERS", { default: 4, min: 1, integer: true });
|
|
415
|
+
*
|
|
416
|
+
* @param {string} name
|
|
417
|
+
* @param {EnvGetterOptions<number> & { min?: number, max?: number, integer?: boolean }} [options]
|
|
418
|
+
* @returns {number}
|
|
419
|
+
* @throws {ValidationError}
|
|
420
|
+
*/
|
|
421
|
+
function number(name, options = {}) {
|
|
422
|
+
return /** @type {number} */ (_read(name, { ...options, type: "number" }, options.env ?? process.env));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Reads a boolean: `true`/`false`, `1`/`0`, `yes`/`no` or `on`/`off`.
|
|
427
|
+
*
|
|
428
|
+
* @example
|
|
429
|
+
* if (env.bool("FEATURE_BETA", { default: false })) enableBeta();
|
|
430
|
+
*
|
|
431
|
+
* @param {string} name
|
|
432
|
+
* @param {EnvGetterOptions<boolean>} [options]
|
|
433
|
+
* @returns {boolean}
|
|
434
|
+
* @throws {ValidationError}
|
|
435
|
+
*/
|
|
436
|
+
function bool(name, options = {}) {
|
|
437
|
+
return /** @type {boolean} */ (_read(name, { ...options, type: "boolean" }, options.env ?? process.env));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Reads a port number.
|
|
442
|
+
*
|
|
443
|
+
* @example
|
|
444
|
+
* server.listen(env.port("PORT", { default: 3000 }));
|
|
445
|
+
*
|
|
446
|
+
* @param {string} name
|
|
447
|
+
* @param {EnvGetterOptions<number>} [options]
|
|
448
|
+
* @returns {number}
|
|
449
|
+
* @throws {ValidationError}
|
|
450
|
+
*/
|
|
451
|
+
function port(name, options = {}) {
|
|
452
|
+
return /** @type {number} */ (_read(name, { ...options, type: "port" }, options.env ?? process.env));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Reads an absolute URL.
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* env.url("DATABASE_URL", { protocols: ["postgres", "postgresql"] });
|
|
460
|
+
*
|
|
461
|
+
* @param {string} name
|
|
462
|
+
* @param {EnvGetterOptions<string> & { protocols?: string[] }} [options]
|
|
463
|
+
* @returns {string}
|
|
464
|
+
* @throws {ValidationError}
|
|
465
|
+
*/
|
|
466
|
+
function url(name, options = {}) {
|
|
467
|
+
return /** @type {string} */ (_read(name, { ...options, type: "url" }, options.env ?? process.env));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Reads a duration like `"30s"` or `"1h30m"` and returns milliseconds.
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* env.duration("CACHE_TTL", { default: "10m" }); // 600000
|
|
475
|
+
*
|
|
476
|
+
* @param {string} name
|
|
477
|
+
* @param {EnvGetterOptions<number | string>} [options]
|
|
478
|
+
* @returns {number}
|
|
479
|
+
* @throws {ValidationError}
|
|
480
|
+
*/
|
|
481
|
+
function duration(name, options = {}) {
|
|
482
|
+
return /** @type {number} */ (_read(name, { ...options, type: "duration" }, options.env ?? process.env));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Reads a comma-separated list. Items are trimmed and empty ones dropped.
|
|
487
|
+
*
|
|
488
|
+
* @example
|
|
489
|
+
* // ALLOWED_ORIGINS="https://a.com, https://b.com"
|
|
490
|
+
* env.list("ALLOWED_ORIGINS", { default: [] }); // ["https://a.com", "https://b.com"]
|
|
491
|
+
*
|
|
492
|
+
* @param {string} name
|
|
493
|
+
* @param {EnvGetterOptions<string[]> & { separator?: string }} [options]
|
|
494
|
+
* @returns {string[]}
|
|
495
|
+
* @throws {ValidationError}
|
|
496
|
+
*/
|
|
497
|
+
function list(name, options = {}) {
|
|
498
|
+
return /** @type {string[]} */ (_read(name, { ...options, type: "list" }, options.env ?? process.env));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Reads a JSON value.
|
|
503
|
+
*
|
|
504
|
+
* @example
|
|
505
|
+
* const flags = env.json("FEATURE_FLAGS", { default: {} });
|
|
506
|
+
*
|
|
507
|
+
* @template [T=any]
|
|
508
|
+
* @param {string} name
|
|
509
|
+
* @param {EnvGetterOptions<T>} [options]
|
|
510
|
+
* @returns {T}
|
|
511
|
+
* @throws {ValidationError}
|
|
512
|
+
*/
|
|
513
|
+
function json(name, options = {}) {
|
|
514
|
+
return /** @type {T} */ (_read(name, { ...options, type: "json" }, options.env ?? process.env));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Reads a variable that must be one of the given values. The result has
|
|
519
|
+
* their literal type.
|
|
520
|
+
*
|
|
521
|
+
* @example
|
|
522
|
+
* env.oneOf("LOG_LEVEL", ["debug", "info", "warn", "error"], { default: "info" });
|
|
523
|
+
*
|
|
524
|
+
* @template {string} V
|
|
525
|
+
* @param {string} name
|
|
526
|
+
* @param {readonly V[]} values
|
|
527
|
+
* @param {EnvGetterOptions<V>} [options]
|
|
528
|
+
* @returns {V}
|
|
529
|
+
* @throws {ValidationError}
|
|
530
|
+
*/
|
|
531
|
+
function oneOf(name, values, options = {}) {
|
|
532
|
+
return /** @type {V} */ (_read(name, { ...options, type: "enum", values }, options.env ?? process.env));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Checks all your variables at once and returns a typed, frozen config. If
|
|
537
|
+
* anything is wrong, one `ValidationError` lists every problem, so a bad
|
|
538
|
+
* deployment fails at startup with the full picture.
|
|
539
|
+
*
|
|
540
|
+
* @example
|
|
541
|
+
* const config = env.validate({
|
|
542
|
+
* NODE_ENV: { type: "enum", values: ["development", "production", "test"], default: "development" },
|
|
543
|
+
* PORT: { type: "port", default: 3000 },
|
|
544
|
+
* DATABASE_URL: { type: "url", protocols: ["postgres"] },
|
|
545
|
+
* JWT_SECRET: { type: "string", minLength: 32 },
|
|
546
|
+
* SENTRY_DSN: { type: "url", optional: true },
|
|
547
|
+
* });
|
|
548
|
+
* // ValidationError: Validation failed with 2 issues:
|
|
549
|
+
* // • DATABASE_URL: is required but is not set
|
|
550
|
+
* // • JWT_SECRET: must contain at least 32 characters
|
|
551
|
+
*
|
|
552
|
+
* @template {Record<string, EnvVarSpec>} T
|
|
553
|
+
* @param {T} spec
|
|
554
|
+
* @param {{ env?: NodeJS.ProcessEnv }} [options]
|
|
555
|
+
* @returns {Readonly<EnvConfig<T>>}
|
|
556
|
+
* @throws {ValidationError}
|
|
557
|
+
*/
|
|
558
|
+
function validate(spec, options = {}) {
|
|
559
|
+
const source = options.env ?? process.env;
|
|
560
|
+
/** @type {Record<string, unknown>} */
|
|
561
|
+
const out = {};
|
|
562
|
+
/** @type {import("./errors").ValidationIssue[]} */
|
|
563
|
+
const issues = [];
|
|
564
|
+
for (const [name, varSpec] of Object.entries(spec)) {
|
|
565
|
+
try {
|
|
566
|
+
out[name] = _read(name, varSpec, source);
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (error instanceof ValidationError) issues.push(...error.issues);
|
|
569
|
+
else throw error;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (issues.length) throw new ValidationError(issues);
|
|
573
|
+
return /** @type {any} */ (Object.freeze(out));
|
|
574
|
+
}
|
|
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
|
+
|
|
623
|
+
/**
|
|
624
|
+
* `NODE_ENV`, or `"development"` when it's not set.
|
|
625
|
+
*
|
|
626
|
+
* @returns {string}
|
|
627
|
+
*/
|
|
628
|
+
function mode() {
|
|
629
|
+
return process.env.NODE_ENV || "development";
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Is `NODE_ENV` set to `"production"`?
|
|
634
|
+
* @returns {boolean}
|
|
635
|
+
*/
|
|
636
|
+
function isProduction() {
|
|
637
|
+
return mode() === "production";
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Is `NODE_ENV` `"development"`, or not set?
|
|
642
|
+
* @returns {boolean}
|
|
643
|
+
*/
|
|
644
|
+
function isDevelopment() {
|
|
645
|
+
return mode() === "development";
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Are we running tests? True when `NODE_ENV` is `"test"` or a test runner
|
|
650
|
+
* like `node --test` or Jest is detected.
|
|
651
|
+
* @returns {boolean}
|
|
652
|
+
*/
|
|
653
|
+
function isTest() {
|
|
654
|
+
return mode() === "test" || process.env.NODE_TEST_CONTEXT !== undefined || process.env.JEST_WORKER_ID !== undefined;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (String(process.env.NODE_COMFORT_DOTENV).toLowerCase() !== "false") {
|
|
658
|
+
_loadingAutomatically = true;
|
|
659
|
+
try {
|
|
660
|
+
load(".env");
|
|
661
|
+
} catch {
|
|
662
|
+
// A broken .env file must never prevent the application from starting.
|
|
663
|
+
}
|
|
664
|
+
_loadingAutomatically = false;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
module.exports = {
|
|
668
|
+
parse,
|
|
669
|
+
load,
|
|
670
|
+
// not shorthand: Node's ES module export detection stops at a bare `get`
|
|
671
|
+
get: get,
|
|
672
|
+
has,
|
|
673
|
+
required,
|
|
674
|
+
string,
|
|
675
|
+
number,
|
|
676
|
+
bool,
|
|
677
|
+
port,
|
|
678
|
+
url,
|
|
679
|
+
duration,
|
|
680
|
+
list,
|
|
681
|
+
json,
|
|
682
|
+
oneOf,
|
|
683
|
+
validate,
|
|
684
|
+
example,
|
|
685
|
+
mode,
|
|
686
|
+
isProduction,
|
|
687
|
+
isDevelopment,
|
|
688
|
+
isTest,
|
|
689
|
+
};
|