@fluojs/config
Advanced tools
@@ -1,1 +0,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"} | ||
| {"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"} |
+218
-26
@@ -1,10 +0,69 @@ | ||
| import { createHash } from 'node:crypto'; | ||
| import { existsSync, readFileSync, watch } from 'node:fs'; | ||
| import { basename, dirname, join } from 'node:path'; | ||
| import { FluoError } from '@fluojs/core'; | ||
| import { parse as dotenvParse } from 'dotenv'; | ||
| import { expand as dotenvExpand } from 'dotenv-expand'; | ||
| import { cloneConfigDictionary } from './clone.js'; | ||
| import { snapshotConfigLoadOptions } from './options.js'; | ||
| const reloadFailureReasons = new WeakMap(); | ||
| const nodeBuiltinRuntimeRequirement = 'Node.js 20.16.0 or newer is required when @fluojs/config loads env files or starts watch mode.'; | ||
| let requireNodeBuiltin; | ||
| function resolveRequireNodeBuiltin() { | ||
| if (requireNodeBuiltin) { | ||
| return requireNodeBuiltin; | ||
| } | ||
| const nodeModule = globalThis.process?.getBuiltinModule?.('node:module'); | ||
| if (!nodeModule?.createRequire) { | ||
| throw new FluoError('Node.js configuration loading is unavailable in this runtime.', { | ||
| code: 'CONFIG_RUNTIME_UNAVAILABLE', | ||
| 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.`) | ||
| }); | ||
| } | ||
| requireNodeBuiltin = nodeModule.createRequire(import.meta.url); | ||
| return requireNodeBuiltin; | ||
| } | ||
| function requireNodeBuiltinFallback(id) { | ||
| try { | ||
| return resolveRequireNodeBuiltin()(id); | ||
| } catch (error) { | ||
| if (error instanceof FluoError) { | ||
| throw error; | ||
| } | ||
| throw new FluoError('Node.js configuration loading is unavailable in this runtime.', { | ||
| code: 'CONFIG_RUNTIME_UNAVAILABLE', | ||
| cause: new Error(`${nodeBuiltinRuntimeRequirement} The host runtime could not load ${id}. Use in-memory config options or run env-file loading on Node.js.`, { | ||
| cause: error | ||
| }) | ||
| }); | ||
| } | ||
| } | ||
| function resolveNodeBuiltin(id) { | ||
| const getBuiltinModule = globalThis.process?.getBuiltinModule; | ||
| if (!getBuiltinModule) { | ||
| return requireNodeBuiltinFallback(id); | ||
| } | ||
| const module = getBuiltinModule(id); | ||
| if (!module) { | ||
| return requireNodeBuiltinFallback(id); | ||
| } | ||
| return module; | ||
| } | ||
| function nodeCrypto() { | ||
| return resolveNodeBuiltin('node:crypto'); | ||
| } | ||
| function nodeFs() { | ||
| return resolveNodeBuiltin('node:fs'); | ||
| } | ||
| function nodePath() { | ||
| return resolveNodeBuiltin('node:path'); | ||
| } | ||
| function resolveCurrentWorkingDirectory() { | ||
| const cwd = globalThis.process?.cwd; | ||
| if (!cwd) { | ||
| throw new FluoError('Node.js configuration loading is unavailable in this runtime.', { | ||
| code: 'CONFIG_RUNTIME_UNAVAILABLE', | ||
| cause: new Error('The host runtime did not expose process.cwd(). Pass envFilePath or avoid env-file loading outside Node.js.') | ||
| }); | ||
| } | ||
| return cwd(); | ||
| } | ||
| function isNodeFsError(error) { | ||
| return typeof error === 'object' && error !== null && 'code' in error; | ||
| } | ||
| function markReloadFailure(error, reason) { | ||
@@ -21,2 +80,130 @@ if (typeof error === 'object' && error !== null) { | ||
| } | ||
| function unquoteEnvValue(value) { | ||
| if (value.length < 2) { | ||
| return value; | ||
| } | ||
| const quote = value[0]; | ||
| if (quote !== '"' && quote !== "'" && quote !== '`' || value[value.length - 1] !== quote) { | ||
| return value; | ||
| } | ||
| const unquoted = value.slice(1, -1); | ||
| return quote === '"' ? unquoted.replace(/\\n/g, '\n').replace(/\\r/g, '\r') : unquoted; | ||
| } | ||
| function stripInlineEnvComment(value) { | ||
| const commentIndex = value.search(/\s#/); | ||
| return commentIndex === -1 ? value : value.slice(0, commentIndex); | ||
| } | ||
| function findClosingEnvQuote(value, quote) { | ||
| for (let index = 1; index < value.length; index += 1) { | ||
| if (value[index] === quote && value[index - 1] !== '\\') { | ||
| return index; | ||
| } | ||
| } | ||
| return -1; | ||
| } | ||
| function collectQuotedEnvValue(lines, startIndex, valueStart, quote) { | ||
| let value = valueStart; | ||
| let currentIndex = startIndex; | ||
| while (currentIndex + 1 < lines.length && findClosingEnvQuote(value, quote) === -1) { | ||
| currentIndex += 1; | ||
| value += `\n${lines[currentIndex]}`; | ||
| } | ||
| const closingQuoteIndex = findClosingEnvQuote(value, quote); | ||
| return { | ||
| nextIndex: currentIndex, | ||
| value: closingQuoteIndex === -1 ? value : value.slice(0, closingQuoteIndex + 1) | ||
| }; | ||
| } | ||
| function parseDotenvContent(content) { | ||
| const parsed = {}; | ||
| const lines = content.split(/\r?\n/); | ||
| for (let index = 0; index < lines.length; index += 1) { | ||
| const rawLine = lines[index]; | ||
| const line = rawLine.trim(); | ||
| if (line.length === 0 || line.startsWith('#')) { | ||
| continue; | ||
| } | ||
| const entry = line.startsWith('export ') ? line.slice(7).trimStart() : line; | ||
| const separatorIndex = entry.search(/[:=]/); | ||
| if (separatorIndex <= 0) { | ||
| continue; | ||
| } | ||
| const key = entry.slice(0, separatorIndex).trim(); | ||
| const rawValue = entry.slice(separatorIndex + 1).trim(); | ||
| if (!/^[\w.-]+$/.test(key)) { | ||
| continue; | ||
| } | ||
| const quote = rawValue[0]; | ||
| const quotedValue = quote === '"' || quote === "'" || quote === '`'; | ||
| const collected = quotedValue ? collectQuotedEnvValue(lines, index, rawValue, quote) : { | ||
| nextIndex: index, | ||
| value: stripInlineEnvComment(rawValue).trim() | ||
| }; | ||
| index = collected.nextIndex; | ||
| parsed[key] = unquoteEnvValue(collected.value); | ||
| } | ||
| return parsed; | ||
| } | ||
| function expandEnvVariables(parsed, safeProcessEnv) { | ||
| const expanded = {}; | ||
| const source = { | ||
| ...parsed, | ||
| ...safeProcessEnv | ||
| }; | ||
| const parseBracedExpansion = expression => { | ||
| if (expression in source) { | ||
| return { | ||
| variableName: expression | ||
| }; | ||
| } | ||
| const colonDefaultIndex = expression.indexOf(':-'); | ||
| if (colonDefaultIndex !== -1) { | ||
| return { | ||
| defaultOperator: ':-', | ||
| defaultValue: expression.slice(colonDefaultIndex + 2), | ||
| variableName: expression.slice(0, colonDefaultIndex) | ||
| }; | ||
| } | ||
| const defaultIndex = expression.indexOf('-'); | ||
| if (defaultIndex !== -1) { | ||
| return { | ||
| defaultOperator: '-', | ||
| defaultValue: expression.slice(defaultIndex + 1), | ||
| variableName: expression.slice(0, defaultIndex) | ||
| }; | ||
| } | ||
| return { | ||
| variableName: expression | ||
| }; | ||
| }; | ||
| const expandValue = (value, visiting) => value.replace(/(^|[^\\])\$(?:\{([^}]*)\}|([\w.-]+))/g, (match, prefix, bracedExpression, bareVariableName) => { | ||
| const expansion = bracedExpression === undefined ? { | ||
| variableName: bareVariableName ?? '' | ||
| } : parseBracedExpansion(bracedExpression); | ||
| const { | ||
| defaultOperator, | ||
| defaultValue, | ||
| variableName | ||
| } = expansion; | ||
| if (!variableName) { | ||
| return match; | ||
| } | ||
| const hasSourceValue = variableName in source; | ||
| const sourceValue = hasSourceValue ? source[variableName] ?? '' : undefined; | ||
| const shouldUseDefaultValue = defaultValue !== undefined && (!hasSourceValue || defaultOperator === ':-' && sourceValue === ''); | ||
| if (shouldUseDefaultValue) { | ||
| return `${prefix}${expandValue(defaultValue, visiting)}`; | ||
| } | ||
| if (!hasSourceValue || visiting.has(variableName)) { | ||
| return prefix; | ||
| } | ||
| const replacement = variableName in safeProcessEnv ? safeProcessEnv[variableName] : variableName in expanded ? expanded[variableName] : expandValue(sourceValue ?? '', new Set([...visiting, variableName])); | ||
| return `${prefix}${replacement}`; | ||
| }).replace(/\\\$/g, '$'); | ||
| for (const [key, value] of Object.entries(parsed)) { | ||
| expanded[key] = expandValue(value, new Set([key])); | ||
| source[key] = expanded[key]; | ||
| } | ||
| return expanded; | ||
| } | ||
| function parseEnvContent(content, safeProcessEnv, customParser) { | ||
@@ -26,10 +213,3 @@ if (customParser) { | ||
| } | ||
| const parsed = dotenvParse(content); | ||
| const result = dotenvExpand({ | ||
| parsed, | ||
| processEnv: { | ||
| ...safeProcessEnv | ||
| } | ||
| }); | ||
| return result.parsed ?? {}; | ||
| return expandEnvVariables(parseDotenvContent(content), safeProcessEnv); | ||
| } | ||
@@ -49,4 +229,7 @@ function sanitizeProcessEnv(processEnv) { | ||
| rejectLegacyValidateOption(options); | ||
| const cwd = options.cwd ?? process.cwd(); | ||
| const envFile = options.envFilePath ?? options.envFile ?? join(cwd, '.env'); | ||
| const hasExplicitEnvFile = options.envFilePath !== undefined || options.envFile !== undefined; | ||
| const hasExplicitInMemorySource = options.defaults !== undefined || options.processEnv !== undefined || options.runtimeOverrides !== undefined; | ||
| const shouldUseDefaultEnvFile = !hasExplicitEnvFile && (options.cwd !== undefined || options.watch === true || !hasExplicitInMemorySource); | ||
| const cwd = shouldUseDefaultEnvFile && options.envFilePath === undefined && options.envFile === undefined ? options.cwd ?? resolveCurrentWorkingDirectory() : options.cwd; | ||
| const envFile = options.envFilePath ?? options.envFile ?? (cwd ? nodePath().join(cwd, '.env') : undefined); | ||
| const defaults = options.defaults ?? {}; | ||
@@ -66,6 +249,9 @@ const processEnv = options.processEnv ?? {}; | ||
| function readEnvFileValues(options) { | ||
| if (options.envFile === undefined) { | ||
| return {}; | ||
| } | ||
| try { | ||
| return parseEnvContent(readFileSync(options.envFile, 'utf8'), options.safeProcessEnv, options.parse); | ||
| return parseEnvContent(nodeFs().readFileSync(options.envFile, 'utf8'), options.safeProcessEnv, options.parse); | ||
| } catch (error) { | ||
| if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { | ||
| if (isNodeFsError(error) && error.code === 'ENOENT') { | ||
| return {}; | ||
@@ -149,3 +335,3 @@ } | ||
| function isInvalidConfigError(error) { | ||
| return error instanceof FluoError && error.code === 'INVALID_CONFIG'; | ||
| return typeof error === 'object' && error !== null && 'code' in error && error.code === 'INVALID_CONFIG'; | ||
| } | ||
@@ -191,5 +377,5 @@ function readConfigSchemaResult(result) { | ||
| try { | ||
| return createHash('sha256').update(readFileSync(envFile)).digest('hex'); | ||
| return nodeCrypto().createHash('sha256').update(nodeFs().readFileSync(envFile)).digest('hex'); | ||
| } catch (error) { | ||
| if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { | ||
| if (isNodeFsError(error) && error.code === 'ENOENT') { | ||
| return undefined; | ||
@@ -250,8 +436,14 @@ } | ||
| } | ||
| const watchTarget = dirname(normalized.envFile); | ||
| const watchedEnvFileName = basename(normalized.envFile); | ||
| if (!existsSync(watchTarget)) { | ||
| if (normalized.envFile === undefined) { | ||
| return undefined; | ||
| } | ||
| return watch(watchTarget, { | ||
| const path = nodePath(); | ||
| const fs = nodeFs(); | ||
| const envFile = normalized.envFile; | ||
| const watchTarget = path.dirname(envFile); | ||
| const watchedEnvFileName = path.basename(envFile); | ||
| if (!fs.existsSync(watchTarget)) { | ||
| return undefined; | ||
| } | ||
| return fs.watch(watchTarget, { | ||
| persistent: false | ||
@@ -263,3 +455,3 @@ }, (_eventType, filename) => { | ||
| try { | ||
| const nextEnvFileHash = hashEnvFileContent(normalized.envFile); | ||
| const nextEnvFileHash = hashEnvFileContent(envFile); | ||
| if (nextEnvFileHash === state.watchedEnvFileHash) { | ||
@@ -311,3 +503,3 @@ return; | ||
| reloading: false, | ||
| watchedEnvFileHash: hashEnvFileContent(normalized.envFile), | ||
| watchedEnvFileHash: normalized.envFile === undefined ? undefined : hashEnvFileContent(normalized.envFile), | ||
| watcher: undefined | ||
@@ -314,0 +506,0 @@ }; |
+4
-6
@@ -11,3 +11,3 @@ { | ||
| ], | ||
| "version": "1.0.2", | ||
| "version": "1.0.3", | ||
| "private": false, | ||
@@ -21,3 +21,3 @@ "license": "MIT", | ||
| "engines": { | ||
| "node": ">=20.0.0" | ||
| "node": ">=20.16.0" | ||
| }, | ||
@@ -41,9 +41,7 @@ "publishConfig": { | ||
| "@standard-schema/spec": "^1.1.0", | ||
| "dotenv": "^16.0.0", | ||
| "dotenv-expand": "^11.0.0", | ||
| "@fluojs/core": "^1.0.2" | ||
| "@fluojs/core": "^1.0.3" | ||
| }, | ||
| "devDependencies": { | ||
| "vitest": "^3.2.4", | ||
| "@fluojs/di": "^1.0.2" | ||
| "@fluojs/di": "^1.1.0" | ||
| }, | ||
@@ -50,0 +48,0 @@ "scripts": { |
+5
-1
@@ -23,2 +23,4 @@ # @fluojs/config | ||
| 패키지는 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을 따릅니다. | ||
| ## 사용 시점 | ||
@@ -83,4 +85,6 @@ | ||
| `envFilePath`는 `envFile`보다 우선하며, `parse`를 사용하면 flat key/value 파일을 위한 custom parser로 dotenv parsing을 대체할 수 있습니다. 누락된 env file은 load 시 빈 입력처럼 처리됩니다. watch mode에서는 parent directory도 관찰하므로 나중에 파일을 생성해도 reload를 트리거할 수 있습니다. | ||
| `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를 트리거할 수 있습니다. | ||
| 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에 포함되지 않습니다. | ||
| ### 객체 단위 딥 머지 | ||
@@ -87,0 +91,0 @@ |
+5
-1
@@ -23,2 +23,4 @@ # @fluojs/config | ||
| 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. | ||
| ## When to Use | ||
@@ -89,4 +91,6 @@ | ||
| `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. | ||
| `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. | ||
| 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. | ||
| ### Deep Merging | ||
@@ -93,0 +97,0 @@ Plain objects are deep-merged by key. Arrays and primitive values from higher-precedence sources completely replace lower-precedence ones. |
77046
15.03%2
-50%1180
19.43%140
2.94%- Removed
- Removed
- Removed
- Removed
Updated