@zero-server/env 0.9.0 → 0.9.2

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Tony Wiedman
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tony Wiedman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -30,7 +30,7 @@ This package narrows `@zero-server/sdk` to **1** exports. See the [scope page](h
30
30
 
31
31
  - [Scope page](https://github.com/tonywied17/zero-server/blob/main/docs/scopes/env.md)
32
32
  - [Full API reference](https://github.com/tonywied17/zero-server/blob/main/API.md)
33
- - [Live docs](https://z-server.com)
33
+ - [Live docs](https://z-server.dev)
34
34
 
35
35
  ## License
36
36
 
package/index.js CHANGED
@@ -1,7 +1,4 @@
1
1
  // AUTO-GENERATED by .tools/generate-package-stubs.js — edit .tools/scope-manifest.js and re-run `npm run packages:generate`.
2
2
  'use strict';
3
- const sdk = require("@zero-server/sdk");
4
-
5
- module.exports = {
6
- env: sdk.env,
7
- };
3
+ const env = require('./lib/env');
4
+ module.exports = { env };
@@ -0,0 +1,465 @@
1
+ /**
2
+ * @module env
3
+ * @description Zero-dependency typed environment variable system.
4
+ * Loads `.env` files, validates against a typed schema, and
5
+ * exposes a fast accessor with built-in type coercion.
6
+ *
7
+ * Supports: string, number, boolean, integer, array, json, url, port, enum.
8
+ * Multi-environment: `.env`, `.env.local`, `.env.{NODE_ENV}`, `.env.{NODE_ENV}.local`.
9
+ *
10
+ * @example
11
+ * const { env } = require('@zero-server/sdk');
12
+ *
13
+ * env.load({
14
+ * PORT: { type: 'port', default: 3000 },
15
+ * DATABASE_URL: { type: 'string', required: true },
16
+ * DEBUG: { type: 'boolean', default: false },
17
+ * ALLOWED_ORIGINS: { type: 'array', separator: ',' },
18
+ * LOG_LEVEL: { type: 'enum', values: ['debug','info','warn','error'], default: 'info' },
19
+ * });
20
+ *
21
+ * env.PORT // => 3000 (number)
22
+ * env('PORT') // => 3000
23
+ * env.DEBUG // => false (boolean)
24
+ * env.require('DATABASE_URL') // throws if missing
25
+ */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ // ===========================================================
30
+ // .env file parser
31
+ // ===========================================================
32
+
33
+ /**
34
+ * Parse a `.env` file string into key-value pairs.
35
+ * Supports `#` comments, single/double/backtick quotes, multiline values,
36
+ * inline comments, interpolation `${VAR}`, and `export` prefix.
37
+ *
38
+ * @param {string} src - Raw file contents.
39
+ * @returns {Object<string, string>} Parsed key-value pairs.
40
+ */
41
+ function parse(src)
42
+ {
43
+ const result = {};
44
+ const lines = src.replace(/\r\n?/g, '\n').split('\n');
45
+
46
+ let i = 0;
47
+ while (i < lines.length)
48
+ {
49
+ let line = lines[i].trim();
50
+ i++;
51
+
52
+ // Skip comments and blank lines
53
+ if (!line || line.startsWith('#')) continue;
54
+
55
+ // Strip optional `export ` prefix
56
+ if (line.startsWith('export ')) line = line.slice(7).trim();
57
+
58
+ const eqIdx = line.indexOf('=');
59
+ if (eqIdx === -1) continue;
60
+
61
+ const key = line.slice(0, eqIdx).trim();
62
+ let value = line.slice(eqIdx + 1).trim();
63
+
64
+ // Validate key name — only word chars + dots
65
+ if (!/^[\w.]+$/.test(key)) continue;
66
+
67
+ // Quoted values
68
+ const q = value[0];
69
+ if ((q === '"' || q === "'" || q === '`') && value.endsWith(q) && value.length >= 2)
70
+ {
71
+ value = value.slice(1, -1);
72
+ }
73
+ else if (q === '"' || q === "'" || q === '`')
74
+ {
75
+ // Multiline — read until closing quote
76
+ let multiline = value.slice(1);
77
+ while (i < lines.length)
78
+ {
79
+ const nextLine = lines[i];
80
+ i++;
81
+ if (nextLine.trimEnd().endsWith(q))
82
+ {
83
+ multiline += '\n' + nextLine.trimEnd().slice(0, -1);
84
+ break;
85
+ }
86
+ multiline += '\n' + nextLine;
87
+ }
88
+ value = multiline;
89
+ }
90
+ else
91
+ {
92
+ // Unquoted — strip inline comment
93
+ const hashIdx = value.indexOf(' #');
94
+ if (hashIdx !== -1) value = value.slice(0, hashIdx).trim();
95
+ }
96
+
97
+ // Variable interpolation: ${VAR} → process.env.VAR or already-parsed value
98
+ value = value.replace(/\$\{([^}]+)\}/g, (_, name) =>
99
+ {
100
+ return result[name] || process.env[name] || '';
101
+ });
102
+
103
+ result[key] = value;
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ // ===========================================================
110
+ // Type coercion
111
+ // ===========================================================
112
+
113
+ /**
114
+ * Coerce a string value to the specified type.
115
+ *
116
+ * @private
117
+ * @param {string} raw - Raw string value.
118
+ * @param {object} fieldDef - Schema field definition.
119
+ * @param {string} key - Variable name (for error messages).
120
+ * @returns {*} Coerced value.
121
+ * @throws {Error} When the value cannot be coerced or fails validation.
122
+ */
123
+ function coerce(raw, fieldDef, key)
124
+ {
125
+ const type = fieldDef.type || 'string';
126
+
127
+ switch (type)
128
+ {
129
+ case 'string':
130
+ {
131
+ const val = String(raw);
132
+ if (fieldDef.min !== undefined && val.length < fieldDef.min)
133
+ throw new Error(`env "${key}" must be at least ${fieldDef.min} characters`);
134
+ if (fieldDef.max !== undefined && val.length > fieldDef.max)
135
+ throw new Error(`env "${key}" must be at most ${fieldDef.max} characters`);
136
+ if (fieldDef.match && !fieldDef.match.test(val))
137
+ throw new Error(`env "${key}" does not match pattern ${fieldDef.match}`);
138
+ return val;
139
+ }
140
+ case 'number':
141
+ {
142
+ const val = Number(raw);
143
+ if (isNaN(val)) throw new Error(`env "${key}" must be a number, got "${raw}"`);
144
+ if (fieldDef.min !== undefined && val < fieldDef.min)
145
+ throw new Error(`env "${key}" must be >= ${fieldDef.min}`);
146
+ if (fieldDef.max !== undefined && val > fieldDef.max)
147
+ throw new Error(`env "${key}" must be <= ${fieldDef.max}`);
148
+ return val;
149
+ }
150
+ case 'integer':
151
+ {
152
+ const val = parseInt(raw, 10);
153
+ if (isNaN(val)) throw new Error(`env "${key}" must be an integer, got "${raw}"`);
154
+ if (fieldDef.min !== undefined && val < fieldDef.min)
155
+ throw new Error(`env "${key}" must be >= ${fieldDef.min}`);
156
+ if (fieldDef.max !== undefined && val > fieldDef.max)
157
+ throw new Error(`env "${key}" must be <= ${fieldDef.max}`);
158
+ return val;
159
+ }
160
+ case 'port':
161
+ {
162
+ const val = parseInt(raw, 10);
163
+ if (isNaN(val) || val < 0 || val > 65535)
164
+ throw new Error(`env "${key}" must be a valid port (0-65535), got "${raw}"`);
165
+ return val;
166
+ }
167
+ case 'boolean':
168
+ {
169
+ const lower = String(raw).toLowerCase().trim();
170
+ if (['true', '1', 'yes', 'on'].includes(lower)) return true;
171
+ if (['false', '0', 'no', 'off', ''].includes(lower)) return false;
172
+ throw new Error(`env "${key}" must be a boolean, got "${raw}"`);
173
+ }
174
+ case 'array':
175
+ {
176
+ const sep = fieldDef.separator || ',';
177
+ return String(raw).split(sep).map(s => s.trim()).filter(Boolean);
178
+ }
179
+ case 'json':
180
+ {
181
+ try { return JSON.parse(raw); }
182
+ catch (e) { throw new Error(`env "${key}" must be valid JSON: ${e.message}`); }
183
+ }
184
+ case 'url':
185
+ {
186
+ try { new URL(raw); return raw; }
187
+ catch (e) { throw new Error(`env "${key}" must be a valid URL, got "${raw}"`); }
188
+ }
189
+ case 'enum':
190
+ {
191
+ const values = fieldDef.values || [];
192
+ if (!values.includes(raw))
193
+ throw new Error(`env "${key}" must be one of [${values.join(', ')}], got "${raw}"`);
194
+ return raw;
195
+ }
196
+ default:
197
+ return raw;
198
+ }
199
+ }
200
+
201
+ // ===========================================================
202
+ // Env store
203
+ // ===========================================================
204
+
205
+ /** @type {Object<string, *>} Typed/validated values store */
206
+ const _store = {};
207
+
208
+ /** @type {Object<string, object>} Schema definitions */
209
+ let _schema = null;
210
+
211
+ /** @type {boolean} */
212
+ let _loaded = false;
213
+
214
+ /**
215
+ * Load environment variables from `.env` files and validate against a typed schema.
216
+ *
217
+ * Files are loaded in precedence order (later overrides earlier):
218
+ * 1. `.env` — shared defaults
219
+ * 2. `.env.local` — local overrides (gitignored)
220
+ * 3. `.env.{NODE_ENV}` — environment-specific (e.g. `.env.production`)
221
+ * 4. `.env.{NODE_ENV}.local` — env-specific local overrides
222
+ *
223
+ * Process environment variables (`process.env`) always take precedence.
224
+ *
225
+ * @param {Object<string, object>} [schema] - Typed schema definition.
226
+ * @param {object} [options] - Configuration options.
227
+ * @param {string} [options.path] - Custom directory to load from (default: `process.cwd()`).
228
+ * @param {boolean} [options.override=false] - When true, overwrite existing `process.env` values with file values.
229
+ * When false (default), file values are written to `process.env` only for keys not already set.
230
+ * Set to `false` explicitly to disable all `process.env` syncing.
231
+ * @returns {Object<string, *>} The validated env store.
232
+ *
233
+ * @throws {Error} On validation failures (missing required vars, bad types, etc.).
234
+ */
235
+ function load(schema, options = {})
236
+ {
237
+ /* Allow load('/path/to/dir') or load('/path/to/.env') as shorthand */
238
+ if (typeof schema === 'string')
239
+ {
240
+ const p = schema;
241
+ schema = undefined;
242
+ options = typeof options === 'object' ? options : {};
243
+ /* If it points to a file, use its parent directory */
244
+ options.path = (p.endsWith('.env') || p.includes('.env.')) ? path.dirname(p) : p;
245
+ }
246
+
247
+ const dir = options.path || process.cwd();
248
+ const nodeEnv = process.env.NODE_ENV || 'development';
249
+
250
+ // Load files in precedence order
251
+ const files = [
252
+ '.env',
253
+ '.env.local',
254
+ `.env.${nodeEnv}`,
255
+ `.env.${nodeEnv}.local`,
256
+ ];
257
+
258
+ const raw = {};
259
+
260
+ for (const file of files)
261
+ {
262
+ const filePath = path.resolve(dir, file);
263
+ try
264
+ {
265
+ if (fs.existsSync(filePath))
266
+ {
267
+ const content = fs.readFileSync(filePath, 'utf8');
268
+ Object.assign(raw, parse(content));
269
+ }
270
+ }
271
+ catch (e) { /* silently skip unreadable files */ }
272
+ }
273
+
274
+ // Process.env always wins
275
+ const merged = {};
276
+ if (schema)
277
+ {
278
+ _schema = schema;
279
+ for (const key of Object.keys(schema))
280
+ {
281
+ if (process.env[key] !== undefined) merged[key] = process.env[key];
282
+ else if (raw[key] !== undefined) merged[key] = raw[key];
283
+ }
284
+ }
285
+ else
286
+ {
287
+ // No schema — load everything
288
+ Object.assign(merged, raw);
289
+ for (const key of Object.keys(raw))
290
+ {
291
+ if (process.env[key] !== undefined) merged[key] = process.env[key];
292
+ }
293
+ }
294
+
295
+ // Write file values into process.env unless explicitly disabled
296
+ if (options.override !== false)
297
+ {
298
+ for (const [k, v] of Object.entries(raw))
299
+ {
300
+ if (options.override || process.env[k] === undefined) process.env[k] = v;
301
+ }
302
+ }
303
+
304
+ // Validate and coerce
305
+ const errors = [];
306
+
307
+ if (schema)
308
+ {
309
+ for (const [key, def] of Object.entries(schema))
310
+ {
311
+ const rawVal = merged[key];
312
+
313
+ if (rawVal === undefined || rawVal === '')
314
+ {
315
+ if (def.required)
316
+ {
317
+ errors.push(`env "${key}" is required but not set`);
318
+ continue;
319
+ }
320
+ if (def.default !== undefined)
321
+ {
322
+ _store[key] = typeof def.default === 'function' ? def.default() : def.default;
323
+ }
324
+ else
325
+ {
326
+ _store[key] = undefined;
327
+ }
328
+ continue;
329
+ }
330
+
331
+ try
332
+ {
333
+ _store[key] = coerce(rawVal, def, key);
334
+ }
335
+ catch (e)
336
+ {
337
+ errors.push(e.message);
338
+ }
339
+ }
340
+ }
341
+ else
342
+ {
343
+ // No schema — store raw strings
344
+ Object.assign(_store, merged);
345
+ }
346
+
347
+ if (errors.length > 0)
348
+ {
349
+ throw new Error('Environment validation failed:\n • ' + errors.join('\n • '));
350
+ }
351
+
352
+ // Sync coerced values back to process.env so other modules see them
353
+ if (options.override !== false)
354
+ {
355
+ for (const [k, v] of Object.entries(_store))
356
+ {
357
+ if (v !== undefined)
358
+ {
359
+ process.env[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
360
+ }
361
+ }
362
+ }
363
+
364
+ _loaded = true;
365
+ return _store;
366
+ }
367
+
368
+ /**
369
+ * Get a typed environment variable.
370
+ * Can also be called as `env(key)`.
371
+ *
372
+ * @param {string} key - Variable name.
373
+ * @returns {*} The typed value.
374
+ */
375
+ function get(key)
376
+ {
377
+ if (_store.hasOwnProperty(key)) return _store[key];
378
+ return process.env[key];
379
+ }
380
+
381
+ /**
382
+ * Get a required environment variable. Throws if missing.
383
+ *
384
+ * @param {string} key - Variable name.
385
+ * @returns {*} The typed value.
386
+ * @throws {Error} If the variable is not set.
387
+ */
388
+ function require_(key)
389
+ {
390
+ const val = get(key);
391
+ if (val === undefined || val === null || val === '')
392
+ {
393
+ throw new Error(`Required environment variable "${key}" is not set`);
394
+ }
395
+ return val;
396
+ }
397
+
398
+ /**
399
+ * Check if a variable is set (not undefined).
400
+ *
401
+ * @param {string} key - Variable name.
402
+ * @returns {boolean} Boolean result.
403
+ */
404
+ function has(key)
405
+ {
406
+ return _store.hasOwnProperty(key) || process.env.hasOwnProperty(key);
407
+ }
408
+
409
+ /**
410
+ * Get all loaded values as a plain object.
411
+ *
412
+ * @returns {Object<string, *>} Result value.
413
+ */
414
+ function all()
415
+ {
416
+ return { ..._store };
417
+ }
418
+
419
+ /**
420
+ * Reset the env store (useful for testing).
421
+ */
422
+ function reset()
423
+ {
424
+ for (const k of Object.keys(_store)) delete _store[k];
425
+ _schema = null;
426
+ _loaded = false;
427
+ }
428
+
429
+ // ===========================================================
430
+ // Proxy-based accessor — env.PORT, env('PORT'), env.get('PORT')
431
+ // ===========================================================
432
+
433
+ /**
434
+ * The env function — callable as `env(key)` or `env.key`.
435
+ *
436
+ * @param {string} key - Environment variable name.
437
+ * @returns {*} Result value.
438
+ */
439
+ function envFn(key)
440
+ {
441
+ return get(key);
442
+ }
443
+
444
+ // Attach methods
445
+ envFn.load = load;
446
+ envFn.get = get;
447
+ envFn.require = require_;
448
+ envFn.has = has;
449
+ envFn.all = all;
450
+ envFn.reset = reset;
451
+ envFn.parse = parse;
452
+
453
+ // Proxy for dotenv.PORT style access
454
+ const envProxy = new Proxy(envFn, {
455
+ get(target, prop)
456
+ {
457
+ // Return own methods first
458
+ if (prop in target) return target[prop];
459
+ // Then check the store
460
+ if (typeof prop === 'string') return get(prop);
461
+ return undefined;
462
+ },
463
+ });
464
+
465
+ module.exports = envProxy;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zero-server/env",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Typed .env loader with schema validation.",
5
5
  "keywords": [
6
6
  "zero-server",
7
- "zero-http",
7
+ "zero-server",
8
8
  "env"
9
9
  ],
10
10
  "author": "Anthony Wiedman",
@@ -20,6 +20,7 @@
20
20
  "./package.json": "./package.json"
21
21
  },
22
22
  "files": [
23
+ "lib",
23
24
  "index.js",
24
25
  "index.d.ts",
25
26
  "README.md",
@@ -42,7 +43,12 @@
42
43
  "access": "public"
43
44
  },
44
45
  "sideEffects": false,
45
- "dependencies": {
46
- "@zero-server/sdk": "0.9.0"
46
+ "peerDependencies": {
47
+ "@zero-server/sdk": ">=0.9.2"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@zero-server/sdk": {
51
+ "optional": true
52
+ }
47
53
  }
48
54
  }