@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +340 -0
  2. package/README.md +137 -548
  3. package/index.js +80 -35
  4. package/package.json +161 -33
  5. package/src/Arr.js +1050 -418
  6. package/src/Async.js +817 -0
  7. package/src/Cache.js +433 -0
  8. package/src/Checker.js +1202 -539
  9. package/src/Cli.js +1293 -0
  10. package/src/Color.js +556 -0
  11. package/src/Crypto.js +1082 -0
  12. package/src/Emitter.js +356 -0
  13. package/src/Env.js +689 -0
  14. package/src/FS.js +1626 -894
  15. package/src/Func.js +823 -356
  16. package/src/Http.js +700 -0
  17. package/src/Id.js +568 -200
  18. package/src/Logger.js +963 -378
  19. package/src/Num.js +965 -360
  20. package/src/Obj.js +1077 -379
  21. package/src/SQLite.js +1629 -1005
  22. package/src/Schema.js +2101 -0
  23. package/src/Str.js +1209 -432
  24. package/src/Sys.js +654 -0
  25. package/src/Time.js +1533 -440
  26. package/src/Utils.js +342 -325
  27. package/src/errors.js +335 -0
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +735 -526
  30. package/types/src/Arr.d.ts +533 -145
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +372 -0
  33. package/types/src/Async.d.ts.map +1 -0
  34. package/types/src/Cache.d.ts +221 -0
  35. package/types/src/Cache.d.ts.map +1 -0
  36. package/types/src/Checker.d.ts +651 -232
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +664 -0
  39. package/types/src/Cli.d.ts.map +1 -0
  40. package/types/src/Color.d.ts +300 -0
  41. package/types/src/Color.d.ts.map +1 -0
  42. package/types/src/Crypto.d.ts +620 -0
  43. package/types/src/Crypto.d.ts.map +1 -0
  44. package/types/src/Emitter.d.ts +189 -0
  45. package/types/src/Emitter.d.ts.map +1 -0
  46. package/types/src/Env.d.ts +412 -0
  47. package/types/src/Env.d.ts.map +1 -0
  48. package/types/src/FS.d.ts +717 -238
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +508 -135
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +444 -0
  53. package/types/src/Http.d.ts.map +1 -0
  54. package/types/src/Id.d.ts +199 -66
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +414 -46
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +514 -137
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +453 -104
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +754 -308
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +1189 -0
  65. package/types/src/Schema.d.ts.map +1 -0
  66. package/types/src/Str.d.ts +617 -166
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +314 -0
  69. package/types/src/Sys.d.ts.map +1 -0
  70. package/types/src/Time.d.ts +759 -204
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +158 -102
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +316 -0
  75. package/types/src/errors.d.ts.map +1 -0
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
  78. 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
+ };