@fluojs/config 1.0.1 → 1.0.3

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.ko.md CHANGED
@@ -20,6 +20,8 @@ fluo 애플리케이션을 위한 설정 로드, 병합, 검증, 타입 안전
20
20
  npm install @fluojs/config
21
21
  ```
22
22
 
23
+ 패키지는 Node.js 20.16.0 이상을 지원합니다. Env-file loading, 기본 `.env` loading, watch mode는 Node filesystem, path, crypto builtin을 host runtime boundary를 통해 lazy하게 해석하며 `process.getBuiltinModule(...)`을 요구합니다. 이 API가 존재하지만 filesystem/path/crypto direct lookup이 불가능할 때는 published ESM과 호환되는 `node:module` fallback을 사용합니다. `ConfigService`와 `loadConfig({ defaults, processEnv, runtimeOverrides })`의 in-memory 사용은 env-file access를 요구하지 않지만, 배포 계약은 package-level Node.js 20.16.0 engine을 따릅니다.
24
+
23
25
  ## 사용 시점
24
26
 
25
27
  - `.env` 파일과 명시적인 `processEnv` 스냅샷을 하나의 설정 스냅샷으로 합쳐야 할 때
@@ -79,7 +81,9 @@ class MyService {
79
81
 
80
82
  `@fluojs/config`는 주변 환경 변수를 자동으로 스캔하지 않습니다. 환경 기반 값을 우선순위에 포함하려면 부트스트랩 경계에서 `processEnv` 스냅샷을 명시적으로 전달하세요.
81
83
 
82
- `envFilePath`는 `envFile`보다 우선하며, `parse`를 사용하면 flat key/value 파일을 위한 custom parser로 dotenv parsing을 대체할 수 있습니다. 누락된 env file은 load 시 빈 입력처럼 처리됩니다. watch mode에서는 parent directory도 관찰하므로 나중에 파일을 생성해도 reload를 트리거할 수 있습니다.
84
+ `envFilePath`는 `envFile`보다 우선하며, `parse`를 사용하면 flat key/value 파일을 위한 custom parser로 dotenv parsing을 대체할 수 있습니다. 빈 load/module option은 `loadConfig({})`와 `ConfigModule.forRoot()`에 대해 기본 `<cwd>/.env` 동작을 보존합니다. 누락된 env file은 load 시 빈 입력처럼 처리됩니다. watch mode에서는 parent directory도 관찰하므로 나중에 파일을 생성해도 reload를 트리거할 수 있습니다.
85
+
86
+ Root `@fluojs/config` 패키지를 import하는 것만으로는 Node filesystem, path, crypto builtin을 해석하지 않습니다. `ConfigService`, option type, 또는 명시적 in-memory 입력을 쓰는 `loadConfig(...)` consumer는 root import를 안전하게 사용할 수 있고, Node builtin은 env-file load 또는 watch mode가 실제로 실행될 때 lazy하게 해석됩니다. `loadConfig({ defaults, processEnv, runtimeOverrides })`는 `process.cwd()`, 기본 `.env` path, Node filesystem/path/crypto builtin을 해석하지 않습니다. Published package engine이 Node.js 20.16.0 이상이므로 Node.js 20.0.0부터 20.15.x까지와 Node.js 밖의 runtime은 env-file, 기본 `.env`, watch execution path의 지원 package contract에 포함되지 않습니다.
83
87
 
84
88
  ### 객체 단위 딥 머지
85
89
 
package/README.md CHANGED
@@ -20,6 +20,8 @@ Configuration loading, merging, validation, and typed runtime access for fluo ap
20
20
  npm install @fluojs/config
21
21
  ```
22
22
 
23
+ The package supports Node.js 20.16.0 or newer. Env-file loading, default `.env` loading, and watch mode resolve Node filesystem, path, and crypto builtins lazily through the host runtime boundary, requiring `process.getBuiltinModule(...)`; when that API is present but direct filesystem/path/crypto lookup is unavailable, a published-ESM-compatible `node:module` fallback is used. In-memory use of `ConfigService` and `loadConfig({ defaults, processEnv, runtimeOverrides })` does not require env-file access, but it is still distributed under the package-level Node.js 20.16.0 engine contract.
24
+
23
25
  ## When to Use
24
26
 
25
27
  Use this package when you need to:
@@ -85,7 +87,9 @@ Configuration is merged in the following order (highest precedence wins):
85
87
 
86
88
  `@fluojs/config` does not scan ambient environment variables automatically. Pass an explicit `processEnv` snapshot at the bootstrap boundary when process-backed values should participate in precedence.
87
89
 
88
- `envFilePath` overrides `envFile`, and `parse` lets callers replace dotenv parsing with a custom parser for flat key/value files. Missing env files are treated as empty input during load; watch mode also observes the parent directory so creating the file later can trigger a reload.
90
+ `envFilePath` overrides `envFile`, and `parse` lets callers replace dotenv parsing with a custom parser for flat key/value files. Empty load/module options preserve the default `<cwd>/.env` behavior for `loadConfig({})` and `ConfigModule.forRoot()`. Missing env files are treated as empty input during load; watch mode also observes the parent directory so creating the file later can trigger a reload.
91
+
92
+ Importing the root `@fluojs/config` package is safe for in-memory consumers that only need `ConfigService`, option types, or `loadConfig(...)` with explicit in-memory inputs: Node filesystem, path, and crypto builtins are resolved lazily only when env-file loading or watch mode actually runs. `loadConfig({ defaults, processEnv, runtimeOverrides })` does not resolve `process.cwd()`, a default `.env` path, or Node filesystem/path/crypto builtins. Because the published package engine is Node.js 20.16.0 or newer, Node.js 20.0.0 through 20.15.x and non-Node runtimes are outside the supported package contract for env-file, default `.env`, and watch execution paths.
89
93
 
90
94
  ### Deep Merging
91
95
  Plain objects are deep-merged by key. Arrays and primitive values from higher-precedence sources completely replace lower-precedence ones.
@@ -1 +1 @@
1
- {"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../src/load.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,gBAAgB,EAChB,iBAAiB,EAEjB,cAAc,EAKf,MAAM,YAAY,CAAC;AAwZpB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,iBAAiB,GAAG,cAAc,CAgC/E;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CAEvE"}
1
+ {"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../src/load.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,gBAAgB,EAChB,iBAAiB,EAEjB,cAAc,EAKf,MAAM,YAAY,CAAC;AAisBpB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,iBAAiB,GAAG,cAAc,CAgC/E;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CAEvE"}
package/dist/load.js CHANGED
@@ -1,12 +1,71 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, readFileSync, watch } from 'node:fs';
3
- import { basename, dirname, join } from 'node:path';
4
1
  import { FluoError } from '@fluojs/core';
5
- import { parse as dotenvParse } from 'dotenv';
6
- import { expand as dotenvExpand } from 'dotenv-expand';
7
2
  import { cloneConfigDictionary } from './clone.js';
8
3
  import { snapshotConfigLoadOptions } from './options.js';
9
4
  const reloadFailureReasons = new WeakMap();
5
+ const nodeBuiltinRuntimeRequirement = 'Node.js 20.16.0 or newer is required when @fluojs/config loads env files or starts watch mode.';
6
+ let requireNodeBuiltin;
7
+ function resolveRequireNodeBuiltin() {
8
+ if (requireNodeBuiltin) {
9
+ return requireNodeBuiltin;
10
+ }
11
+ const nodeModule = globalThis.process?.getBuiltinModule?.('node:module');
12
+ if (!nodeModule?.createRequire) {
13
+ throw new FluoError('Node.js configuration loading is unavailable in this runtime.', {
14
+ code: 'CONFIG_RUNTIME_UNAVAILABLE',
15
+ cause: new Error(`${nodeBuiltinRuntimeRequirement} The host runtime did not expose a synchronous node:module loader. Use in-memory config options or run env-file loading on Node.js.`)
16
+ });
17
+ }
18
+ requireNodeBuiltin = nodeModule.createRequire(import.meta.url);
19
+ return requireNodeBuiltin;
20
+ }
21
+ function requireNodeBuiltinFallback(id) {
22
+ try {
23
+ return resolveRequireNodeBuiltin()(id);
24
+ } catch (error) {
25
+ if (error instanceof FluoError) {
26
+ throw error;
27
+ }
28
+ throw new FluoError('Node.js configuration loading is unavailable in this runtime.', {
29
+ code: 'CONFIG_RUNTIME_UNAVAILABLE',
30
+ cause: new Error(`${nodeBuiltinRuntimeRequirement} The host runtime could not load ${id}. Use in-memory config options or run env-file loading on Node.js.`, {
31
+ cause: error
32
+ })
33
+ });
34
+ }
35
+ }
36
+ function resolveNodeBuiltin(id) {
37
+ const getBuiltinModule = globalThis.process?.getBuiltinModule;
38
+ if (!getBuiltinModule) {
39
+ return requireNodeBuiltinFallback(id);
40
+ }
41
+ const module = getBuiltinModule(id);
42
+ if (!module) {
43
+ return requireNodeBuiltinFallback(id);
44
+ }
45
+ return module;
46
+ }
47
+ function nodeCrypto() {
48
+ return resolveNodeBuiltin('node:crypto');
49
+ }
50
+ function nodeFs() {
51
+ return resolveNodeBuiltin('node:fs');
52
+ }
53
+ function nodePath() {
54
+ return resolveNodeBuiltin('node:path');
55
+ }
56
+ function resolveCurrentWorkingDirectory() {
57
+ const cwd = globalThis.process?.cwd;
58
+ if (!cwd) {
59
+ throw new FluoError('Node.js configuration loading is unavailable in this runtime.', {
60
+ code: 'CONFIG_RUNTIME_UNAVAILABLE',
61
+ cause: new Error('The host runtime did not expose process.cwd(). Pass envFilePath or avoid env-file loading outside Node.js.')
62
+ });
63
+ }
64
+ return cwd();
65
+ }
66
+ function isNodeFsError(error) {
67
+ return typeof error === 'object' && error !== null && 'code' in error;
68
+ }
10
69
  function markReloadFailure(error, reason) {
11
70
  if (typeof error === 'object' && error !== null) {
12
71
  reloadFailureReasons.set(error, reason);
@@ -18,18 +77,139 @@ function getReloadFailureReason(error) {
18
77
  }
19
78
  return reloadFailureReasons.get(error);
20
79
  }
80
+ function unquoteEnvValue(value) {
81
+ if (value.length < 2) {
82
+ return value;
83
+ }
84
+ const quote = value[0];
85
+ if (quote !== '"' && quote !== "'" && quote !== '`' || value[value.length - 1] !== quote) {
86
+ return value;
87
+ }
88
+ const unquoted = value.slice(1, -1);
89
+ return quote === '"' ? unquoted.replace(/\\n/g, '\n').replace(/\\r/g, '\r') : unquoted;
90
+ }
91
+ function stripInlineEnvComment(value) {
92
+ const commentIndex = value.search(/\s#/);
93
+ return commentIndex === -1 ? value : value.slice(0, commentIndex);
94
+ }
95
+ function findClosingEnvQuote(value, quote) {
96
+ for (let index = 1; index < value.length; index += 1) {
97
+ if (value[index] === quote && value[index - 1] !== '\\') {
98
+ return index;
99
+ }
100
+ }
101
+ return -1;
102
+ }
103
+ function collectQuotedEnvValue(lines, startIndex, valueStart, quote) {
104
+ let value = valueStart;
105
+ let currentIndex = startIndex;
106
+ while (currentIndex + 1 < lines.length && findClosingEnvQuote(value, quote) === -1) {
107
+ currentIndex += 1;
108
+ value += `\n${lines[currentIndex]}`;
109
+ }
110
+ const closingQuoteIndex = findClosingEnvQuote(value, quote);
111
+ return {
112
+ nextIndex: currentIndex,
113
+ value: closingQuoteIndex === -1 ? value : value.slice(0, closingQuoteIndex + 1)
114
+ };
115
+ }
116
+ function parseDotenvContent(content) {
117
+ const parsed = {};
118
+ const lines = content.split(/\r?\n/);
119
+ for (let index = 0; index < lines.length; index += 1) {
120
+ const rawLine = lines[index];
121
+ const line = rawLine.trim();
122
+ if (line.length === 0 || line.startsWith('#')) {
123
+ continue;
124
+ }
125
+ const entry = line.startsWith('export ') ? line.slice(7).trimStart() : line;
126
+ const separatorIndex = entry.search(/[:=]/);
127
+ if (separatorIndex <= 0) {
128
+ continue;
129
+ }
130
+ const key = entry.slice(0, separatorIndex).trim();
131
+ const rawValue = entry.slice(separatorIndex + 1).trim();
132
+ if (!/^[\w.-]+$/.test(key)) {
133
+ continue;
134
+ }
135
+ const quote = rawValue[0];
136
+ const quotedValue = quote === '"' || quote === "'" || quote === '`';
137
+ const collected = quotedValue ? collectQuotedEnvValue(lines, index, rawValue, quote) : {
138
+ nextIndex: index,
139
+ value: stripInlineEnvComment(rawValue).trim()
140
+ };
141
+ index = collected.nextIndex;
142
+ parsed[key] = unquoteEnvValue(collected.value);
143
+ }
144
+ return parsed;
145
+ }
146
+ function expandEnvVariables(parsed, safeProcessEnv) {
147
+ const expanded = {};
148
+ const source = {
149
+ ...parsed,
150
+ ...safeProcessEnv
151
+ };
152
+ const parseBracedExpansion = expression => {
153
+ if (expression in source) {
154
+ return {
155
+ variableName: expression
156
+ };
157
+ }
158
+ const colonDefaultIndex = expression.indexOf(':-');
159
+ if (colonDefaultIndex !== -1) {
160
+ return {
161
+ defaultOperator: ':-',
162
+ defaultValue: expression.slice(colonDefaultIndex + 2),
163
+ variableName: expression.slice(0, colonDefaultIndex)
164
+ };
165
+ }
166
+ const defaultIndex = expression.indexOf('-');
167
+ if (defaultIndex !== -1) {
168
+ return {
169
+ defaultOperator: '-',
170
+ defaultValue: expression.slice(defaultIndex + 1),
171
+ variableName: expression.slice(0, defaultIndex)
172
+ };
173
+ }
174
+ return {
175
+ variableName: expression
176
+ };
177
+ };
178
+ const expandValue = (value, visiting) => value.replace(/(^|[^\\])\$(?:\{([^}]*)\}|([\w.-]+))/g, (match, prefix, bracedExpression, bareVariableName) => {
179
+ const expansion = bracedExpression === undefined ? {
180
+ variableName: bareVariableName ?? ''
181
+ } : parseBracedExpansion(bracedExpression);
182
+ const {
183
+ defaultOperator,
184
+ defaultValue,
185
+ variableName
186
+ } = expansion;
187
+ if (!variableName) {
188
+ return match;
189
+ }
190
+ const hasSourceValue = variableName in source;
191
+ const sourceValue = hasSourceValue ? source[variableName] ?? '' : undefined;
192
+ const shouldUseDefaultValue = defaultValue !== undefined && (!hasSourceValue || defaultOperator === ':-' && sourceValue === '');
193
+ if (shouldUseDefaultValue) {
194
+ return `${prefix}${expandValue(defaultValue, visiting)}`;
195
+ }
196
+ if (!hasSourceValue || visiting.has(variableName)) {
197
+ return prefix;
198
+ }
199
+ const replacement = variableName in safeProcessEnv ? safeProcessEnv[variableName] : variableName in expanded ? expanded[variableName] : expandValue(sourceValue ?? '', new Set([...visiting, variableName]));
200
+ return `${prefix}${replacement}`;
201
+ }).replace(/\\\$/g, '$');
202
+ for (const [key, value] of Object.entries(parsed)) {
203
+ expanded[key] = expandValue(value, new Set([key]));
204
+ source[key] = expanded[key];
205
+ }
206
+ return expanded;
207
+ }
21
208
  function parseEnvContent(content, safeProcessEnv, customParser) {
22
209
  if (customParser) {
23
210
  return customParser(content);
24
211
  }
25
- const parsed = dotenvParse(content);
26
- const result = dotenvExpand({
27
- parsed,
28
- processEnv: {
29
- ...safeProcessEnv
30
- }
31
- });
32
- return result.parsed ?? {};
212
+ return expandEnvVariables(parseDotenvContent(content), safeProcessEnv);
33
213
  }
34
214
  function sanitizeProcessEnv(processEnv) {
35
215
  return Object.fromEntries(Object.entries(processEnv).filter(entry => entry[1] !== undefined));
@@ -44,8 +224,11 @@ function rejectLegacyValidateOption(options) {
44
224
  }
45
225
  function normalizeLoadOptions(options) {
46
226
  rejectLegacyValidateOption(options);
47
- const cwd = options.cwd ?? process.cwd();
48
- const envFile = options.envFilePath ?? options.envFile ?? join(cwd, '.env');
227
+ const hasExplicitEnvFile = options.envFilePath !== undefined || options.envFile !== undefined;
228
+ const hasExplicitInMemorySource = options.defaults !== undefined || options.processEnv !== undefined || options.runtimeOverrides !== undefined;
229
+ const shouldUseDefaultEnvFile = !hasExplicitEnvFile && (options.cwd !== undefined || options.watch === true || !hasExplicitInMemorySource);
230
+ const cwd = shouldUseDefaultEnvFile && options.envFilePath === undefined && options.envFile === undefined ? options.cwd ?? resolveCurrentWorkingDirectory() : options.cwd;
231
+ const envFile = options.envFilePath ?? options.envFile ?? (cwd ? nodePath().join(cwd, '.env') : undefined);
49
232
  const defaults = options.defaults ?? {};
50
233
  const processEnv = options.processEnv ?? {};
51
234
  const safeProcessEnv = sanitizeProcessEnv(processEnv);
@@ -60,10 +243,13 @@ function normalizeLoadOptions(options) {
60
243
  };
61
244
  }
62
245
  function readEnvFileValues(options) {
246
+ if (options.envFile === undefined) {
247
+ return {};
248
+ }
63
249
  try {
64
- return parseEnvContent(readFileSync(options.envFile, 'utf8'), options.safeProcessEnv, options.parse);
250
+ return parseEnvContent(nodeFs().readFileSync(options.envFile, 'utf8'), options.safeProcessEnv, options.parse);
65
251
  } catch (error) {
66
- if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
252
+ if (isNodeFsError(error) && error.code === 'ENOENT') {
67
253
  return {};
68
254
  }
69
255
  throw error;
@@ -142,7 +328,7 @@ function createInvalidConfigError(cause, issues) {
142
328
  });
143
329
  }
144
330
  function isInvalidConfigError(error) {
145
- return error instanceof FluoError && error.code === 'INVALID_CONFIG';
331
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'INVALID_CONFIG';
146
332
  }
147
333
  function readConfigSchemaResult(result) {
148
334
  if (isConfigSchemaFailureResult(result)) {
@@ -183,9 +369,9 @@ function createSubscription(listeners, listener) {
183
369
  }
184
370
  function hashEnvFileContent(envFile) {
185
371
  try {
186
- return createHash('sha256').update(readFileSync(envFile)).digest('hex');
372
+ return nodeCrypto().createHash('sha256').update(nodeFs().readFileSync(envFile)).digest('hex');
187
373
  } catch (error) {
188
- if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
374
+ if (isNodeFsError(error) && error.code === 'ENOENT') {
189
375
  return undefined;
190
376
  }
191
377
  throw error;
@@ -241,19 +427,25 @@ function startReloaderWatcher(normalized, options, state, listeners, errorListen
241
427
  if (!options.watch) {
242
428
  return undefined;
243
429
  }
244
- const watchTarget = dirname(normalized.envFile);
245
- const watchedEnvFileName = basename(normalized.envFile);
246
- if (!existsSync(watchTarget)) {
430
+ if (normalized.envFile === undefined) {
431
+ return undefined;
432
+ }
433
+ const path = nodePath();
434
+ const fs = nodeFs();
435
+ const envFile = normalized.envFile;
436
+ const watchTarget = path.dirname(envFile);
437
+ const watchedEnvFileName = path.basename(envFile);
438
+ if (!fs.existsSync(watchTarget)) {
247
439
  return undefined;
248
440
  }
249
- return watch(watchTarget, {
441
+ return fs.watch(watchTarget, {
250
442
  persistent: false
251
443
  }, (_eventType, filename) => {
252
444
  if (filename !== null && filename.toString() !== watchedEnvFileName) {
253
445
  return;
254
446
  }
255
447
  try {
256
- const nextEnvFileHash = hashEnvFileContent(normalized.envFile);
448
+ const nextEnvFileHash = hashEnvFileContent(envFile);
257
449
  if (nextEnvFileHash === state.watchedEnvFileHash) {
258
450
  return;
259
451
  }
@@ -300,7 +492,7 @@ export function createConfigReloader(options) {
300
492
  current: resolveConfig(normalized),
301
493
  pendingReloadReason: undefined,
302
494
  reloading: false,
303
- watchedEnvFileHash: hashEnvFileContent(normalized.envFile),
495
+ watchedEnvFileHash: normalized.envFile === undefined ? undefined : hashEnvFileContent(normalized.envFile),
304
496
  watcher: undefined
305
497
  };
306
498
  const listeners = new Set();
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "environment",
9
9
  "typed-config"
10
10
  ],
11
- "version": "1.0.1",
11
+ "version": "1.0.3",
12
12
  "private": false,
13
13
  "license": "MIT",
14
14
  "repository": {
@@ -17,7 +17,7 @@
17
17
  "directory": "packages/config"
18
18
  },
19
19
  "engines": {
20
- "node": ">=20.0.0"
20
+ "node": ">=20.16.0"
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
@@ -36,13 +36,11 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "@standard-schema/spec": "^1.1.0",
39
- "dotenv": "^16.0.0",
40
- "dotenv-expand": "^11.0.0",
41
- "@fluojs/core": "^1.0.1"
39
+ "@fluojs/core": "^1.0.3"
42
40
  },
43
41
  "devDependencies": {
44
42
  "vitest": "^3.2.4",
45
- "@fluojs/di": "^1.0.1"
43
+ "@fluojs/di": "^1.1.0"
46
44
  },
47
45
  "scripts": {
48
46
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",