env-health 0.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/README.md ADDED
@@ -0,0 +1,309 @@
1
+ # env-health
2
+
3
+ Tiny runtime validator for `process.env` with great error messages and comprehensive utilities.
4
+
5
+ ## Features
6
+
7
+ - ✅ **Type-safe** - Full TypeScript support with inference
8
+ - ✅ **Great error messages** - Clear, actionable error messages with example `.env` files
9
+ - ✅ **Multiple type parsers** - Supports `string`, `int`, `float`, `bool`, `url`, `json`, and `enum`
10
+ - ✅ **Comprehensive utilities** - Helper functions for common env var operations
11
+ - ✅ **Zero dependencies** - Lightweight and fast
12
+ - ✅ **Flexible** - Works with any environment object, not just `process.env`
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm i env-health
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```ts
23
+ import { envHealth } from "env-health";
24
+
25
+ const env = envHealth({
26
+ PORT: "int",
27
+ DEBUG: "bool",
28
+ NODE_ENV: ["dev", "prod", "test"] as const,
29
+ DATABASE_URL: "url",
30
+ });
31
+
32
+ console.log(env.PORT); // number: 3000
33
+ console.log(env.DEBUG); // boolean: true
34
+ console.log(env.NODE_ENV); // "dev" | "prod" | "test"
35
+ ```
36
+
37
+ ## API Reference
38
+
39
+ ### `envHealth(schema, options?)`
40
+
41
+ The main validation function. Validates and parses environment variables according to a schema.
42
+
43
+ #### Schema Types
44
+
45
+ **Primitive Types:**
46
+
47
+ - `"string"` - String value (default)
48
+ - `"int"` - Integer number
49
+ - `"float"` - Floating point number
50
+ - `"bool"` - Boolean (accepts: `true/false`, `1/0`, `yes/no`, `y/n`, `on/off`)
51
+ - `"url"` - Valid URL string
52
+ - `"json"` - Valid JSON (parsed to `unknown`)
53
+
54
+ **Enum Types:**
55
+
56
+ - Array of strings: `["dev", "prod"] as const`
57
+ - Object with `type: "enum"` and `values` array
58
+
59
+ **Detailed Spec:**
60
+
61
+ ```ts
62
+ {
63
+ type: "int" | "float" | "bool" | "string" | "url" | "json",
64
+ optional?: true,
65
+ default?: string | number | boolean,
66
+ example?: string
67
+ }
68
+ ```
69
+
70
+ #### Examples
71
+
72
+ ```ts
73
+ import { envHealth } from "env-health";
74
+
75
+ // Simple types
76
+ const env = envHealth({
77
+ PORT: "int",
78
+ DEBUG: "bool",
79
+ API_URL: "url",
80
+ });
81
+
82
+ // With defaults
83
+ const env = envHealth({
84
+ PORT: { type: "int", default: 3000 },
85
+ TIMEOUT: { type: "int", default: 5000 },
86
+ });
87
+
88
+ // Optional variables
89
+ const env = envHealth({
90
+ REQUIRED: "string",
91
+ OPTIONAL: { type: "string", optional: true },
92
+ });
93
+
94
+ // Enums
95
+ const env = envHealth({
96
+ NODE_ENV: ["dev", "prod", "test"] as const,
97
+ LOG_LEVEL: {
98
+ type: "enum",
99
+ values: ["debug", "info", "warn", "error"],
100
+ default: "info",
101
+ },
102
+ });
103
+
104
+ // Custom environment object
105
+ const env = envHealth(
106
+ {
107
+ PORT: "int",
108
+ DEBUG: "bool",
109
+ },
110
+ { env: { PORT: "3000", DEBUG: "true" } },
111
+ );
112
+ ```
113
+
114
+ #### Error Messages
115
+
116
+ When validation fails, `envHealth` throws an `EnvHealthError` with:
117
+
118
+ - List of missing required variables
119
+ - List of invalid variables with expected vs received values
120
+ - Example `.env` file showing the correct format
121
+
122
+ ```ts
123
+ import { envHealth, EnvHealthError } from "env-health";
124
+
125
+ try {
126
+ const env = envHealth({
127
+ DATABASE_URL: "url",
128
+ PORT: "int",
129
+ });
130
+ } catch (err) {
131
+ if (err instanceof EnvHealthError) {
132
+ console.error(err.message);
133
+ // Environment validation failed:
134
+ //
135
+ // Missing required variables:
136
+ // - DATABASE_URL (expected: url)
137
+ //
138
+ // Example .env:
139
+ // ------------
140
+ // DATABASE_URL=postgres://user:pass@localhost:5432/db
141
+ // PORT=3000
142
+ }
143
+ }
144
+ ```
145
+
146
+ ### `requireEnv(key, env?)`
147
+
148
+ Require an environment variable. Throws if missing.
149
+
150
+ ```ts
151
+ import { requireEnv } from "env-health";
152
+
153
+ const apiKey = requireEnv("API_KEY");
154
+ // Throws if API_KEY is missing or empty
155
+ ```
156
+
157
+ ### `getEnv(key, options?)`
158
+
159
+ Get an environment variable with optional default and type conversion.
160
+
161
+ ```ts
162
+ import { getEnv } from "env-health";
163
+
164
+ // String (default)
165
+ const apiKey = getEnv("API_KEY", { default: "default-key" });
166
+
167
+ // Integer
168
+ const port = getEnv("PORT", { type: "int", default: 3000 });
169
+
170
+ // Float
171
+ const ratio = getEnv("RATIO", { type: "float", default: 0.5 });
172
+
173
+ // Boolean
174
+ const debug = getEnv("DEBUG", { type: "bool", default: false });
175
+
176
+ // Custom environment
177
+ const value = getEnv("KEY", { env: customEnv, default: "fallback" });
178
+ ```
179
+
180
+ ### `envExists(keys, env?)`
181
+
182
+ Check if one or more environment variables exist.
183
+
184
+ ```ts
185
+ import { envExists } from "env-health";
186
+
187
+ // Single key
188
+ if (envExists("API_KEY")) {
189
+ // API_KEY exists
190
+ }
191
+
192
+ // Multiple keys (all must exist)
193
+ if (envExists(["DB_HOST", "DB_PORT", "DB_NAME"])) {
194
+ // All database vars exist
195
+ }
196
+
197
+ // Custom environment
198
+ if (envExists("KEY", customEnv)) {
199
+ // ...
200
+ }
201
+ ```
202
+
203
+ ### `envPrefix(prefix, options?)`
204
+
205
+ Get all environment variables with a specific prefix.
206
+
207
+ ```ts
208
+ import { envPrefix } from "env-health";
209
+
210
+ // Get all DB_* vars
211
+ const dbVars = envPrefix("DB_");
212
+ // { DB_HOST: "localhost", DB_PORT: "5432", DB_NAME: "mydb" }
213
+
214
+ // Strip prefix from keys
215
+ const dbConfig = envPrefix("DB_", { stripPrefix: true });
216
+ // { HOST: "localhost", PORT: "5432", NAME: "mydb" }
217
+
218
+ // Custom environment
219
+ const vars = envPrefix("PREFIX_", { env: customEnv });
220
+ ```
221
+
222
+ ### `mergeEnv(...sources)`
223
+
224
+ Merge multiple environment sources. Later sources override earlier ones.
225
+
226
+ ```ts
227
+ import { mergeEnv } from "env-health";
228
+
229
+ const merged = mergeEnv(
230
+ { PORT: "3000" }, // defaults
231
+ process.env, // system env
232
+ { DEBUG: "true" }, // overrides
233
+ );
234
+ ```
235
+
236
+ ### `envToObject(keys, options?)`
237
+
238
+ Convert environment variables to a typed object, optionally with prefix handling.
239
+
240
+ ```ts
241
+ import { envToObject } from "env-health";
242
+
243
+ // Basic usage
244
+ const config = envToObject(["HOST", "PORT", "NAME"], {
245
+ env: {
246
+ HOST: "localhost",
247
+ PORT: "5432",
248
+ NAME: "mydb",
249
+ },
250
+ });
251
+ // { HOST: "localhost", PORT: "5432", NAME: "mydb" }
252
+
253
+ // With prefix
254
+ const dbConfig = envToObject(["HOST", "PORT"], {
255
+ prefix: "DB_",
256
+ env: {
257
+ DB_HOST: "localhost",
258
+ DB_PORT: "5432",
259
+ },
260
+ });
261
+ // { HOST: "localhost", PORT: "5432" }
262
+
263
+ // With required keys
264
+ const config = envToObject(["HOST", "PORT"], {
265
+ prefix: "DB_",
266
+ required: ["HOST", "PORT"],
267
+ env: { DB_HOST: "localhost" },
268
+ });
269
+ // Throws: Missing required environment variable: DB_PORT
270
+ ```
271
+
272
+ ### `loadEnvFile(content)`
273
+
274
+ Load and parse a `.env` file content string.
275
+
276
+ ```ts
277
+ import { loadEnvFile } from "env-health";
278
+ import { readFileSync } from "fs";
279
+
280
+ const content = readFileSync(".env", "utf-8");
281
+ const env = loadEnvFile(content);
282
+
283
+ // Supports:
284
+ // - KEY=value
285
+ // - Comments (#)
286
+ // - Quoted values ("value" or 'value')
287
+ // - Empty lines
288
+ ```
289
+
290
+ ## TypeScript
291
+
292
+ Full TypeScript support with type inference:
293
+
294
+ ```ts
295
+ import { envHealth } from "env-health";
296
+
297
+ const env = envHealth({
298
+ PORT: "int",
299
+ DEBUG: "bool",
300
+ NODE_ENV: ["dev", "prod"] as const,
301
+ });
302
+
303
+ // Type: { PORT: number; DEBUG: boolean; NODE_ENV: "dev" | "prod" }
304
+ type EnvType = typeof env;
305
+ ```
306
+
307
+ ## License
308
+
309
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,397 @@
1
+ 'use strict';
2
+
3
+ // src/error.ts
4
+ var EnvHealthError = class extends Error {
5
+ issues;
6
+ exampleEnv;
7
+ constructor(message, issues, exampleEnv) {
8
+ super(message);
9
+ this.name = "EnvHealthError";
10
+ this.issues = issues;
11
+ this.exampleEnv = exampleEnv;
12
+ }
13
+ };
14
+
15
+ // src/parse.ts
16
+ var URL_RE = /^https?:\/\/.+/i;
17
+ function parseIntStrict(raw) {
18
+ if (!/^-?\d+$/.test(raw.trim())) return null;
19
+ const n = Number(raw);
20
+ return Number.isFinite(n) ? n : null;
21
+ }
22
+ function parseFloatStrict(raw) {
23
+ const s = raw.trim();
24
+ if (s.length === 0) return null;
25
+ const n = Number(s);
26
+ return Number.isFinite(n) ? n : null;
27
+ }
28
+ function parseBoolStrict(raw) {
29
+ const v = raw.trim().toLowerCase();
30
+ if (["true", "1", "yes", "y", "on"].includes(v)) return true;
31
+ if (["false", "0", "no", "n", "off"].includes(v)) return false;
32
+ return null;
33
+ }
34
+ function parseUrlStrict(raw) {
35
+ const s = raw.trim();
36
+ if (!URL_RE.test(s)) return null;
37
+ try {
38
+ new URL(s);
39
+ return s;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ function parseJsonStrict(raw) {
45
+ try {
46
+ return JSON.parse(raw);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // src/utils.ts
53
+ function requireEnv(key, env = process.env) {
54
+ const value = env[key];
55
+ if (value == null || value === "") {
56
+ throw new Error(`Missing required environment variable: ${key}`);
57
+ }
58
+ return value;
59
+ }
60
+ function getEnv(key, options) {
61
+ const env = options?.env ?? process.env;
62
+ const raw = env[key];
63
+ const defaultValue = options?.default;
64
+ if (raw == null || raw === "") {
65
+ if (defaultValue !== void 0) {
66
+ return defaultValue;
67
+ }
68
+ throw new Error(`Missing environment variable: ${key}`);
69
+ }
70
+ const type = options?.type ?? "string";
71
+ if (type === "string") {
72
+ return raw;
73
+ }
74
+ if (type === "int") {
75
+ const parsed = parseIntStrict(raw);
76
+ if (parsed === null) {
77
+ throw new Error(
78
+ `Invalid integer value for ${key}: "${raw}". Use a valid integer.`
79
+ );
80
+ }
81
+ return parsed;
82
+ }
83
+ if (type === "float") {
84
+ const parsed = parseFloatStrict(raw);
85
+ if (parsed === null) {
86
+ throw new Error(
87
+ `Invalid float value for ${key}: "${raw}". Use a valid number.`
88
+ );
89
+ }
90
+ return parsed;
91
+ }
92
+ if (type === "bool") {
93
+ const parsed = parseBoolStrict(raw);
94
+ if (parsed === null) {
95
+ throw new Error(
96
+ `Invalid boolean value for ${key}: "${raw}". Use true/false, 1/0, yes/no, y/n, or on/off.`
97
+ );
98
+ }
99
+ return parsed;
100
+ }
101
+ return raw;
102
+ }
103
+ function envExists(keys, env = process.env) {
104
+ const keysArray = Array.isArray(keys) ? keys : [keys];
105
+ return keysArray.every((key) => {
106
+ const value = env[key];
107
+ return value != null && value !== "";
108
+ });
109
+ }
110
+ function envPrefix(prefix, options) {
111
+ const env = options?.env ?? process.env;
112
+ const result = {};
113
+ const stripPrefix = options?.stripPrefix ?? false;
114
+ for (const [key, value] of Object.entries(env)) {
115
+ if (value != null && typeof value === "string" && key.startsWith(prefix) && value !== "") {
116
+ const newKey = stripPrefix ? key.slice(prefix.length) : key;
117
+ result[newKey] = value;
118
+ }
119
+ }
120
+ return result;
121
+ }
122
+ function mergeEnv(...sources) {
123
+ const result = {};
124
+ for (const source of sources) {
125
+ if (source) {
126
+ Object.assign(result, source);
127
+ }
128
+ }
129
+ return result;
130
+ }
131
+ function envToObject(keys, options) {
132
+ const env = options?.env ?? process.env;
133
+ const prefix = options?.prefix ?? "";
134
+ const stripPrefix = options?.stripPrefix ?? false;
135
+ const required = new Set(options?.required ?? []);
136
+ const result = {};
137
+ for (const key of keys) {
138
+ const envKey = prefix + key;
139
+ const value = env[envKey];
140
+ if (value == null || value === "") {
141
+ if (required.has(key)) {
142
+ throw new Error(`Missing required environment variable: ${envKey}`);
143
+ }
144
+ continue;
145
+ }
146
+ const resultKey = stripPrefix ? key : envKey;
147
+ result[resultKey] = value;
148
+ }
149
+ return result;
150
+ }
151
+ function loadEnvFile(content) {
152
+ const result = {};
153
+ const lines = content.split(/\r?\n/);
154
+ for (const line of lines) {
155
+ const trimmed = line.trim();
156
+ if (!trimmed || trimmed.startsWith("#")) {
157
+ continue;
158
+ }
159
+ const equalIndex = trimmed.indexOf("=");
160
+ if (equalIndex === -1) {
161
+ continue;
162
+ }
163
+ const key = trimmed.slice(0, equalIndex).trim();
164
+ let value = trimmed.slice(equalIndex + 1).trim();
165
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
166
+ value = value.slice(1, -1);
167
+ }
168
+ if (!key) {
169
+ continue;
170
+ }
171
+ result[key] = value;
172
+ }
173
+ return result;
174
+ }
175
+
176
+ // src/index.ts
177
+ function isArraySpec(spec) {
178
+ return Array.isArray(spec);
179
+ }
180
+ function isStringSpec(spec) {
181
+ return typeof spec === "string";
182
+ }
183
+ function isEnumDetailedSpec(spec) {
184
+ return spec.type === "enum";
185
+ }
186
+ function isPrimitiveDetailedSpec(spec) {
187
+ return spec.type !== "enum";
188
+ }
189
+ function expectedLabel(spec) {
190
+ if (isArraySpec(spec)) {
191
+ return `one of (${spec.join(", ")})`;
192
+ }
193
+ if (isStringSpec(spec)) {
194
+ return spec;
195
+ }
196
+ if (isEnumDetailedSpec(spec)) {
197
+ return `one of (${spec.values.join(", ")})`;
198
+ }
199
+ if (isPrimitiveDetailedSpec(spec)) {
200
+ return spec.type;
201
+ }
202
+ return "unknown";
203
+ }
204
+ function normalizeSpec(spec) {
205
+ if (isArraySpec(spec)) {
206
+ return { kind: "enum", values: spec, optional: false };
207
+ }
208
+ if (isStringSpec(spec)) {
209
+ return { kind: "primitive", type: spec, optional: false };
210
+ }
211
+ if (isEnumDetailedSpec(spec)) {
212
+ return {
213
+ kind: "enum",
214
+ values: spec.values,
215
+ optional: spec.optional === true,
216
+ defaultValue: spec.default,
217
+ example: spec.example ?? void 0
218
+ };
219
+ }
220
+ if (isPrimitiveDetailedSpec(spec)) {
221
+ return {
222
+ kind: "primitive",
223
+ type: spec.type,
224
+ optional: spec.optional === true,
225
+ defaultValue: spec.default,
226
+ example: spec.example ?? void 0
227
+ };
228
+ }
229
+ return { kind: "primitive", type: "string", optional: false };
230
+ }
231
+ var EXAMPLE_CACHE = /* @__PURE__ */ new Map();
232
+ function suggestExample(key, spec) {
233
+ const cacheKey = `${key}:${JSON.stringify(spec)}`;
234
+ const cached = EXAMPLE_CACHE.get(cacheKey);
235
+ if (cached) return cached;
236
+ const n = normalizeSpec(spec);
237
+ let example;
238
+ if (n.example) {
239
+ example = n.example;
240
+ } else if (n.kind === "enum" && n.values && n.values.length > 0) {
241
+ example = String(n.values[0]);
242
+ } else {
243
+ const upperKey = key.toUpperCase();
244
+ switch (n.type) {
245
+ case "int":
246
+ example = "3000";
247
+ break;
248
+ case "float":
249
+ example = "0.5";
250
+ break;
251
+ case "bool":
252
+ example = "true";
253
+ break;
254
+ case "url":
255
+ example = upperKey.includes("DATABASE") && upperKey.includes("URL") ? "postgres://user:pass@localhost:5432/db" : "https://example.com";
256
+ break;
257
+ case "json":
258
+ example = '{"key":"value"}';
259
+ break;
260
+ default:
261
+ example = "your_value_here";
262
+ }
263
+ }
264
+ EXAMPLE_CACHE.set(cacheKey, example);
265
+ return example;
266
+ }
267
+ function parseByType(type, raw) {
268
+ switch (type) {
269
+ case "string":
270
+ return { ok: true, value: raw };
271
+ case "int": {
272
+ const n = parseIntStrict(raw);
273
+ return n === null ? { ok: false } : { ok: true, value: n };
274
+ }
275
+ case "float": {
276
+ const n = parseFloatStrict(raw);
277
+ return n === null ? { ok: false } : { ok: true, value: n };
278
+ }
279
+ case "bool": {
280
+ const b = parseBoolStrict(raw);
281
+ return b === null ? { ok: false } : { ok: true, value: b };
282
+ }
283
+ case "url": {
284
+ const u = parseUrlStrict(raw);
285
+ return u === null ? { ok: false } : { ok: true, value: u };
286
+ }
287
+ case "json": {
288
+ const j = parseJsonStrict(raw);
289
+ return j === null ? { ok: false } : { ok: true, value: j };
290
+ }
291
+ }
292
+ }
293
+ function buildExampleEnv(schema, opts) {
294
+ const lines = [];
295
+ for (const [key, spec] of Object.entries(schema)) {
296
+ const n = normalizeSpec(spec);
297
+ const example = opts.blankExampleValues ? "" : suggestExample(key, spec);
298
+ const optionalSuffix = n.optional ? " # optional" : "";
299
+ lines.push(`${key}=${example}${optionalSuffix}`.trimEnd());
300
+ }
301
+ return lines.join("\n");
302
+ }
303
+ function envHealth(schema, options = {}) {
304
+ const env = options.env ?? process.env;
305
+ const issues = [];
306
+ const out = {};
307
+ for (const [key, spec] of Object.entries(schema)) {
308
+ const n = normalizeSpec(spec);
309
+ const expected = expectedLabel(spec);
310
+ const raw = env[key];
311
+ if (raw == null || raw === "") {
312
+ if (n.defaultValue !== void 0) {
313
+ out[key] = n.defaultValue;
314
+ continue;
315
+ }
316
+ if (n.optional) {
317
+ out[key] = void 0;
318
+ continue;
319
+ }
320
+ issues.push({ kind: "missing", key, expected });
321
+ continue;
322
+ }
323
+ if (n.kind === "enum") {
324
+ const values = n.values;
325
+ if (values && values.includes(raw)) {
326
+ out[key] = raw;
327
+ } else {
328
+ issues.push({ kind: "invalid", key, expected, received: raw });
329
+ }
330
+ continue;
331
+ }
332
+ const type = n.type ?? "string";
333
+ const parsed = parseByType(type, raw);
334
+ if (parsed.ok) {
335
+ out[key] = parsed.value;
336
+ } else {
337
+ issues.push({ kind: "invalid", key, expected, received: raw });
338
+ }
339
+ }
340
+ if (issues.length > 0) {
341
+ const exampleEnv = buildExampleEnv(schema, options);
342
+ const header = options.header ? `${options.header}
343
+ ` : "";
344
+ const lines = [
345
+ header ? `${header}Environment validation failed:
346
+ ` : "Environment validation failed:\n"
347
+ ];
348
+ const missing = [];
349
+ const invalid = [];
350
+ for (let i = 0; i < issues.length; i++) {
351
+ const issue = issues[i];
352
+ if (!issue) continue;
353
+ if (issue.kind === "missing") {
354
+ missing.push(issue);
355
+ } else {
356
+ invalid.push(issue);
357
+ }
358
+ }
359
+ if (missing.length > 0) {
360
+ lines.push("Missing required variables:");
361
+ for (let i = 0; i < missing.length; i++) {
362
+ const m = missing[i];
363
+ if (m) {
364
+ lines.push(` - ${m.key} (expected: ${m.expected})`);
365
+ }
366
+ }
367
+ lines.push("");
368
+ }
369
+ if (invalid.length > 0) {
370
+ lines.push("Invalid variables:");
371
+ for (let i = 0; i < invalid.length; i++) {
372
+ const v = invalid[i];
373
+ if (v) {
374
+ lines.push(` - ${v.key}="${v.received}" (expected: ${v.expected})`);
375
+ }
376
+ }
377
+ lines.push("");
378
+ }
379
+ lines.push("Example .env:");
380
+ lines.push("------------");
381
+ lines.push(exampleEnv);
382
+ throw new EnvHealthError(lines.join("\n"), issues, exampleEnv);
383
+ }
384
+ return out;
385
+ }
386
+
387
+ exports.EnvHealthError = EnvHealthError;
388
+ exports.envExists = envExists;
389
+ exports.envHealth = envHealth;
390
+ exports.envPrefix = envPrefix;
391
+ exports.envToObject = envToObject;
392
+ exports.getEnv = getEnv;
393
+ exports.loadEnvFile = loadEnvFile;
394
+ exports.mergeEnv = mergeEnv;
395
+ exports.requireEnv = requireEnv;
396
+ //# sourceMappingURL=index.cjs.map
397
+ //# sourceMappingURL=index.cjs.map