@zamatica/configuration 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 +77 -0
- package/dist/configuration-service.d.ts +41 -0
- package/dist/configuration-service.d.ts.map +1 -0
- package/dist/configuration-service.js +254 -0
- package/dist/decorators.d.ts +27 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/decorators.js +32 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/internal/camelize.d.ts +2 -0
- package/dist/internal/camelize.d.ts.map +1 -0
- package/dist/internal/camelize.js +12 -0
- package/dist/logger.d.ts +31 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +45 -0
- package/dist/parameter-builder.d.ts +59 -0
- package/dist/parameter-builder.d.ts.map +1 -0
- package/dist/parameter-builder.js +133 -0
- package/dist/parameter-types.d.ts +26 -0
- package/dist/parameter-types.d.ts.map +1 -0
- package/dist/parameter-types.js +1 -0
- package/package.json +40 -0
- package/src/configuration-service.ts +333 -0
- package/src/decorators.ts +42 -0
- package/src/index.ts +24 -0
- package/src/logger.ts +67 -0
- package/src/parameter-builder.ts +190 -0
- package/src/parameter-types.ts +41 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const ENV_VAR_NAME_REGEX = /^[A-Z]([A-Z0-9_]*[A-Z0-9])?$/;
|
|
2
|
+
function assertValidConfigurationKey(key) {
|
|
3
|
+
if (!ENV_VAR_NAME_REGEX.test(key)) {
|
|
4
|
+
throw new Error(`Invalid configuration key '${key}': must match SCREAMING_SNAKE_CASE (env-var convention).`);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Common methods shared by every parameter builder. Subclasses add type-specific
|
|
9
|
+
* methods (e.g. `requireWritable()` on directory builders) and implement `build()`.
|
|
10
|
+
*
|
|
11
|
+
* The builder mutates `this.state` and returns `this` for chaining; subclasses
|
|
12
|
+
* preserve that pattern so chains compose cleanly.
|
|
13
|
+
*/
|
|
14
|
+
class ParameterBuilderBase {
|
|
15
|
+
state;
|
|
16
|
+
constructor(state) {
|
|
17
|
+
this.state = state;
|
|
18
|
+
}
|
|
19
|
+
description(text) {
|
|
20
|
+
this.state = { ...this.state, description: text };
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
default(value) {
|
|
24
|
+
this.state = { ...this.state, default: value };
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
required() {
|
|
28
|
+
this.state = { ...this.state, required: true };
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
optional() {
|
|
32
|
+
this.state = { ...this.state, required: false };
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class SimpleParameterBuilder extends ParameterBuilderBase {
|
|
37
|
+
constructorFn;
|
|
38
|
+
constructor(state, constructorFn) {
|
|
39
|
+
super(state);
|
|
40
|
+
this.constructorFn = constructorFn;
|
|
41
|
+
}
|
|
42
|
+
build() {
|
|
43
|
+
return {
|
|
44
|
+
kind: 'simple',
|
|
45
|
+
configurationKey: this.state.key,
|
|
46
|
+
description: this.state.description,
|
|
47
|
+
required: this.state.required,
|
|
48
|
+
default: this.state.default,
|
|
49
|
+
constructor: this.constructorFn,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
class DirectoryParameterBuilder extends ParameterBuilderBase {
|
|
54
|
+
requireWritableFlag = false;
|
|
55
|
+
createIfMissingFlag = false;
|
|
56
|
+
constructor(state) {
|
|
57
|
+
super(state);
|
|
58
|
+
}
|
|
59
|
+
requireWritable() {
|
|
60
|
+
this.requireWritableFlag = true;
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
createIfMissing() {
|
|
64
|
+
this.createIfMissingFlag = true;
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
build() {
|
|
68
|
+
return {
|
|
69
|
+
kind: 'directory',
|
|
70
|
+
configurationKey: this.state.key,
|
|
71
|
+
description: this.state.description,
|
|
72
|
+
required: this.state.required,
|
|
73
|
+
default: this.state.default,
|
|
74
|
+
constructor: String,
|
|
75
|
+
requireWritable: this.requireWritableFlag,
|
|
76
|
+
createIfMissing: this.createIfMissingFlag,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
class YamlParameterBuilder extends ParameterBuilderBase {
|
|
81
|
+
schemaValue;
|
|
82
|
+
constructor(state) {
|
|
83
|
+
super(state);
|
|
84
|
+
}
|
|
85
|
+
schema(schema) {
|
|
86
|
+
const next = new YamlParameterBuilder(this.state);
|
|
87
|
+
next.schemaValue = schema;
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
90
|
+
build() {
|
|
91
|
+
return {
|
|
92
|
+
kind: 'yaml',
|
|
93
|
+
configurationKey: this.state.key,
|
|
94
|
+
description: this.state.description,
|
|
95
|
+
required: this.state.required,
|
|
96
|
+
default: this.state.default,
|
|
97
|
+
constructor: String,
|
|
98
|
+
schema: this.schemaValue,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
class ParameterTypeSelector {
|
|
103
|
+
state;
|
|
104
|
+
constructor(state) {
|
|
105
|
+
this.state = state;
|
|
106
|
+
}
|
|
107
|
+
string() {
|
|
108
|
+
return new SimpleParameterBuilder(this.state, String);
|
|
109
|
+
}
|
|
110
|
+
number() {
|
|
111
|
+
return new SimpleParameterBuilder(this.state, Number);
|
|
112
|
+
}
|
|
113
|
+
boolean() {
|
|
114
|
+
return new SimpleParameterBuilder(this.state, Boolean);
|
|
115
|
+
}
|
|
116
|
+
directory() {
|
|
117
|
+
return new DirectoryParameterBuilder(this.state);
|
|
118
|
+
}
|
|
119
|
+
yaml() {
|
|
120
|
+
return new YamlParameterBuilder(this.state);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export const ConfigurationParameter = {
|
|
124
|
+
named(key) {
|
|
125
|
+
assertValidConfigurationKey(key);
|
|
126
|
+
return new ParameterTypeSelector({
|
|
127
|
+
key,
|
|
128
|
+
description: '',
|
|
129
|
+
required: false,
|
|
130
|
+
default: undefined,
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
export type ConfigurationValuePrimitive = string | number | boolean;
|
|
3
|
+
export type ConfigurationValueConstructor = StringConstructor | NumberConstructor | BooleanConstructor;
|
|
4
|
+
export interface ConfigurationParameterDefinitionBase {
|
|
5
|
+
configurationKey: string;
|
|
6
|
+
description: string;
|
|
7
|
+
required: boolean;
|
|
8
|
+
default: ConfigurationValuePrimitive | undefined;
|
|
9
|
+
}
|
|
10
|
+
export interface SimpleConfigurationParameterDefinition extends ConfigurationParameterDefinitionBase {
|
|
11
|
+
kind: 'simple';
|
|
12
|
+
constructor: ConfigurationValueConstructor;
|
|
13
|
+
}
|
|
14
|
+
export interface DirectoryConfigurationParameterDefinition extends ConfigurationParameterDefinitionBase {
|
|
15
|
+
kind: 'directory';
|
|
16
|
+
constructor: StringConstructor;
|
|
17
|
+
requireWritable: boolean;
|
|
18
|
+
createIfMissing: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface YamlConfigurationParameterDefinition<TSchema extends z.ZodTypeAny = z.ZodTypeAny> extends ConfigurationParameterDefinitionBase {
|
|
21
|
+
kind: 'yaml';
|
|
22
|
+
constructor: StringConstructor;
|
|
23
|
+
schema: TSchema | undefined;
|
|
24
|
+
}
|
|
25
|
+
export type ConfigurationParameterDefinition = SimpleConfigurationParameterDefinition | DirectoryConfigurationParameterDefinition | YamlConfigurationParameterDefinition;
|
|
26
|
+
//# sourceMappingURL=parameter-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parameter-types.d.ts","sourceRoot":"","sources":["../src/parameter-types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,MAAM,MAAM,2BAA2B,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE,MAAM,MAAM,6BAA6B,GACrC,iBAAiB,GACjB,iBAAiB,GACjB,kBAAkB,CAAC;AAEvB,MAAM,WAAW,oCAAoC;IACnD,gBAAgB,EAAE,MAAM,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,2BAA2B,GAAG,SAAS,CAAC;CAClD;AAED,MAAM,WAAW,sCACf,SAAQ,oCAAoC;IAC5C,IAAI,EAAE,QAAQ,CAAC;IACf,WAAW,EAAE,6BAA6B,CAAC;CAC5C;AAED,MAAM,WAAW,yCACf,SAAQ,oCAAoC;IAC5C,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,iBAAiB,CAAC;IAC/B,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,oCAAoC,CAAC,OAAO,SAAS,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,CAC/F,SAAQ,oCAAoC;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,iBAAiB,CAAC;IAC/B,MAAM,EAAE,OAAO,GAAG,SAAS,CAAC;CAC7B;AAED,MAAM,MAAM,gCAAgC,GACxC,sCAAsC,GACtC,yCAAyC,GACzC,oCAAoC,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zamatica/configuration",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic runtime configuration registry with builder-pattern parameter definitions, layered resolution (CLI > env > default), directory access checks, and Zod-validated YAML.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
"./package.json": "./package.json",
|
|
12
|
+
".": {
|
|
13
|
+
"@zamatica/source": "./src/index.ts",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"!**/*.tsbuildinfo",
|
|
23
|
+
"!**/*.spec.*",
|
|
24
|
+
"!**/*.test.*"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/zoobroker/zamatica-libs.git",
|
|
29
|
+
"directory": "libs/configuration"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@zamatica/util": "workspace:^",
|
|
36
|
+
"tslib": "^2.3.0",
|
|
37
|
+
"yaml": "^2.8.3",
|
|
38
|
+
"zod": "^4.3.6"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { camelizeEnvVarName } from '@zamatica/util';
|
|
4
|
+
import { parse as parseYAML } from 'yaml';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
BufferingLogger,
|
|
8
|
+
type ConfigurationLogger,
|
|
9
|
+
ConsoleLogger,
|
|
10
|
+
flushBuffer,
|
|
11
|
+
} from './logger.js';
|
|
12
|
+
import type {
|
|
13
|
+
ConfigurationParameterDefinition,
|
|
14
|
+
ConfigurationValuePrimitive,
|
|
15
|
+
DirectoryConfigurationParameterDefinition,
|
|
16
|
+
} from './parameter-types.js';
|
|
17
|
+
|
|
18
|
+
const CONFIG_BASE_DIR_KEY = 'CONFIGURATION_BASE_DIRECTORY';
|
|
19
|
+
|
|
20
|
+
export class ConfigurationService {
|
|
21
|
+
private static appEnvVarPrefix = '';
|
|
22
|
+
private static cliArgs: Record<string, ConfigurationValuePrimitive | undefined> = {};
|
|
23
|
+
private static buffer = new BufferingLogger();
|
|
24
|
+
private static loggerImpl: ConfigurationLogger = ConfigurationService.buffer;
|
|
25
|
+
|
|
26
|
+
private static readonly registry = new Map<string, ConfigurationParameterDefinition>();
|
|
27
|
+
private static readonly registryByTarget = new Map<string, ConfigurationParameterDefinition[]>();
|
|
28
|
+
|
|
29
|
+
private static readonly resolvedDirectories = new Map<string, string>();
|
|
30
|
+
private static readonly resolvedYaml = new Map<string, unknown>();
|
|
31
|
+
|
|
32
|
+
private static initialized = false;
|
|
33
|
+
|
|
34
|
+
private constructor() {
|
|
35
|
+
/* static-only */
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static setAppEnvVarPrefix(prefix: string): void {
|
|
39
|
+
ConfigurationService.appEnvVarPrefix = prefix.toUpperCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static getAppEnvVarPrefix(): string {
|
|
43
|
+
return ConfigurationService.appEnvVarPrefix;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static setCliArgs(args: Record<string, ConfigurationValuePrimitive | undefined>): void {
|
|
47
|
+
ConfigurationService.cliArgs = { ...args };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static setLogger(logger: ConfigurationLogger): void {
|
|
51
|
+
if (ConfigurationService.loggerImpl === ConfigurationService.buffer) {
|
|
52
|
+
flushBuffer(ConfigurationService.buffer, logger);
|
|
53
|
+
}
|
|
54
|
+
ConfigurationService.loggerImpl = logger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static useConsoleLogger(): void {
|
|
58
|
+
ConfigurationService.setLogger(new ConsoleLogger());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static register(
|
|
62
|
+
parameters: ConfigurationParameterDefinition[],
|
|
63
|
+
target: string,
|
|
64
|
+
): void {
|
|
65
|
+
for (const parameter of parameters) {
|
|
66
|
+
const existing = ConfigurationService.registry.get(parameter.configurationKey);
|
|
67
|
+
if (existing) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Configuration key '${parameter.configurationKey}' is already registered.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
ConfigurationService.registry.set(parameter.configurationKey, parameter);
|
|
73
|
+
const list = ConfigurationService.registryByTarget.get(target) ?? [];
|
|
74
|
+
list.push(parameter);
|
|
75
|
+
ConfigurationService.registryByTarget.set(target, list);
|
|
76
|
+
|
|
77
|
+
ConfigurationService.loggerImpl.debug(
|
|
78
|
+
`[configuration] registered ${parameter.kind} key '${parameter.configurationKey}' for target '${target}' (default=${String(
|
|
79
|
+
parameter.default,
|
|
80
|
+
)})`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static initialize(): void {
|
|
86
|
+
const errors: string[] = [];
|
|
87
|
+
for (const parameter of ConfigurationService.registry.values()) {
|
|
88
|
+
if (parameter.required && parameter.default === undefined) {
|
|
89
|
+
try {
|
|
90
|
+
ConfigurationService.get(parameter.configurationKey);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (errors.length > 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Required configuration not satisfied:\n - ${errors.join('\n - ')}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
ConfigurationService.initialized = true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static isInitialized(): boolean {
|
|
105
|
+
return ConfigurationService.initialized;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static get<T extends ConfigurationValuePrimitive>(configurationKey: string): T {
|
|
109
|
+
const parameter = ConfigurationService.registry.get(configurationKey);
|
|
110
|
+
if (!parameter) {
|
|
111
|
+
throw new Error(`Configuration key '${configurationKey}' is not registered.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const argumentKey = camelizeEnvVarName(configurationKey);
|
|
115
|
+
const envVarName = ConfigurationService.environmentVariableName(configurationKey);
|
|
116
|
+
|
|
117
|
+
let raw: ConfigurationValuePrimitive | undefined;
|
|
118
|
+
let source: 'cli' | 'env' | 'default' | undefined;
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
Object.prototype.hasOwnProperty.call(ConfigurationService.cliArgs, argumentKey) &&
|
|
122
|
+
ConfigurationService.cliArgs[argumentKey] !== undefined
|
|
123
|
+
) {
|
|
124
|
+
raw = ConfigurationService.cliArgs[argumentKey];
|
|
125
|
+
source = 'cli';
|
|
126
|
+
} else if (process.env[envVarName] !== undefined && process.env[envVarName] !== '') {
|
|
127
|
+
raw = process.env[envVarName];
|
|
128
|
+
source = 'env';
|
|
129
|
+
} else if (parameter.default !== undefined) {
|
|
130
|
+
raw = parameter.default;
|
|
131
|
+
source = 'default';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (raw === undefined) {
|
|
135
|
+
if (parameter.required) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`No value found for required configuration key '${configurationKey}' in CLI argument '${argumentKey}', environment variable '${envVarName}', or default.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return undefined as unknown as T;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ConfigurationService.loggerImpl.debug(
|
|
144
|
+
`[configuration] '${configurationKey}' resolved from ${source}: ${String(raw)}`,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return ConfigurationService.coerce(raw, parameter) as T;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static resolveDirectory(configurationKey: string): string {
|
|
151
|
+
const cached = ConfigurationService.resolvedDirectories.get(configurationKey);
|
|
152
|
+
if (cached !== undefined) return cached;
|
|
153
|
+
|
|
154
|
+
const parameter = ConfigurationService.registry.get(configurationKey);
|
|
155
|
+
if (!parameter) {
|
|
156
|
+
throw new Error(`Configuration key '${configurationKey}' is not registered.`);
|
|
157
|
+
}
|
|
158
|
+
if (parameter.kind !== 'directory') {
|
|
159
|
+
throw new Error(`Configuration key '${configurationKey}' is not a directory parameter.`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const configured = ConfigurationService.get<string>(configurationKey);
|
|
163
|
+
if (configured === undefined || configured === '') {
|
|
164
|
+
if (parameter.required) {
|
|
165
|
+
throw new Error(`Required directory configuration '${configurationKey}' is empty.`);
|
|
166
|
+
}
|
|
167
|
+
return '';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let resolved = configured;
|
|
171
|
+
if (!path.isAbsolute(resolved) && configurationKey !== CONFIG_BASE_DIR_KEY) {
|
|
172
|
+
const base = ConfigurationService.resolveDirectory(CONFIG_BASE_DIR_KEY);
|
|
173
|
+
resolved = path.join(base, resolved);
|
|
174
|
+
} else {
|
|
175
|
+
resolved = path.resolve(resolved);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
ConfigurationService.ensureDirectoryAccess(resolved, parameter);
|
|
179
|
+
ConfigurationService.resolvedDirectories.set(configurationKey, resolved);
|
|
180
|
+
return resolved;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
static resolveYaml<TResult = unknown>(configurationKey: string): TResult {
|
|
184
|
+
const cached = ConfigurationService.resolvedYaml.get(configurationKey);
|
|
185
|
+
if (cached !== undefined) return cached as TResult;
|
|
186
|
+
|
|
187
|
+
const parameter = ConfigurationService.registry.get(configurationKey);
|
|
188
|
+
if (!parameter) {
|
|
189
|
+
throw new Error(`Configuration key '${configurationKey}' is not registered.`);
|
|
190
|
+
}
|
|
191
|
+
if (parameter.kind !== 'yaml') {
|
|
192
|
+
throw new Error(`Configuration key '${configurationKey}' is not a yaml parameter.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const configured = ConfigurationService.get<string>(configurationKey);
|
|
196
|
+
if (configured === undefined || configured === '') {
|
|
197
|
+
throw new Error(`YAML configuration '${configurationKey}' has no value.`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let resolved = configured;
|
|
201
|
+
if (!path.isAbsolute(resolved)) {
|
|
202
|
+
const base = ConfigurationService.resolveDirectory(CONFIG_BASE_DIR_KEY);
|
|
203
|
+
resolved = path.join(base, resolved);
|
|
204
|
+
} else {
|
|
205
|
+
resolved = path.resolve(resolved);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!fs.existsSync(resolved)) {
|
|
209
|
+
throw new Error(`YAML file for '${configurationKey}' not found: ${resolved}`);
|
|
210
|
+
}
|
|
211
|
+
const stats = fs.statSync(resolved);
|
|
212
|
+
if (!stats.isFile()) {
|
|
213
|
+
throw new Error(`YAML path for '${configurationKey}' is not a file: ${resolved}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const raw = fs.readFileSync(resolved, 'utf8');
|
|
217
|
+
const parsed = parseYAML(raw) as unknown;
|
|
218
|
+
|
|
219
|
+
if (parameter.schema !== undefined) {
|
|
220
|
+
const result = parameter.schema.safeParse(parsed);
|
|
221
|
+
if (!result.success) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`YAML validation failed for '${configurationKey}' (${resolved}): ${JSON.stringify(
|
|
224
|
+
result.error.issues,
|
|
225
|
+
null,
|
|
226
|
+
2,
|
|
227
|
+
)}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
ConfigurationService.resolvedYaml.set(configurationKey, result.data);
|
|
231
|
+
return result.data as TResult;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
ConfigurationService.loggerImpl.warn(
|
|
235
|
+
`[configuration] no schema provided for YAML key '${configurationKey}'; returning unvalidated parsed data.`,
|
|
236
|
+
);
|
|
237
|
+
ConfigurationService.resolvedYaml.set(configurationKey, parsed);
|
|
238
|
+
return parsed as TResult;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
static getRegistry(): readonly ConfigurationParameterDefinition[] {
|
|
242
|
+
return Array.from(ConfigurationService.registry.values());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
static getRegistryByTarget(): ReadonlyMap<string, readonly ConfigurationParameterDefinition[]> {
|
|
246
|
+
return new Map(
|
|
247
|
+
Array.from(ConfigurationService.registryByTarget.entries()).map(([target, params]) => [
|
|
248
|
+
target,
|
|
249
|
+
params.slice(),
|
|
250
|
+
]),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
static environmentVariableName(configurationKey: string): string {
|
|
255
|
+
return ConfigurationService.appEnvVarPrefix
|
|
256
|
+
? `${ConfigurationService.appEnvVarPrefix}_${configurationKey}`
|
|
257
|
+
: configurationKey;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Test-only reset. Wipes all module-singleton state (registry, resolution
|
|
262
|
+
* caches, logger, app prefix). Not re-exported from `src/index.ts` — call sites
|
|
263
|
+
* must reach in via `ConfigurationService.__resetForTesting()` directly,
|
|
264
|
+
* which is intentional friction signaling "this is for vitest, not runtime."
|
|
265
|
+
*
|
|
266
|
+
* Production code must never call this; the underscore prefix and explicit
|
|
267
|
+
* `forTesting` suffix exist to make accidental use obvious in code review.
|
|
268
|
+
*/
|
|
269
|
+
static __resetForTesting(): void {
|
|
270
|
+
ConfigurationService.appEnvVarPrefix = '';
|
|
271
|
+
ConfigurationService.cliArgs = {};
|
|
272
|
+
ConfigurationService.buffer = new BufferingLogger();
|
|
273
|
+
ConfigurationService.loggerImpl = ConfigurationService.buffer;
|
|
274
|
+
ConfigurationService.registry.clear();
|
|
275
|
+
ConfigurationService.registryByTarget.clear();
|
|
276
|
+
ConfigurationService.resolvedDirectories.clear();
|
|
277
|
+
ConfigurationService.resolvedYaml.clear();
|
|
278
|
+
ConfigurationService.initialized = false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private static coerce(
|
|
282
|
+
value: ConfigurationValuePrimitive,
|
|
283
|
+
parameter: ConfigurationParameterDefinition,
|
|
284
|
+
): ConfigurationValuePrimitive {
|
|
285
|
+
switch (parameter.constructor) {
|
|
286
|
+
case Number: {
|
|
287
|
+
if (typeof value === 'number') return value;
|
|
288
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
289
|
+
const parsed = Number.parseFloat(value);
|
|
290
|
+
if (Number.isNaN(parsed)) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Cannot coerce value '${value}' to number for '${parameter.configurationKey}'.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return parsed;
|
|
296
|
+
}
|
|
297
|
+
case Boolean: {
|
|
298
|
+
if (typeof value === 'boolean') return value;
|
|
299
|
+
if (typeof value === 'number') return value !== 0;
|
|
300
|
+
return value.toLowerCase() === 'true' || value === '1';
|
|
301
|
+
}
|
|
302
|
+
default:
|
|
303
|
+
// Default covers String and any future constructor. Stringify whatever value we got.
|
|
304
|
+
return String(value);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private static ensureDirectoryAccess(
|
|
309
|
+
resolved: string,
|
|
310
|
+
parameter: DirectoryConfigurationParameterDefinition,
|
|
311
|
+
): void {
|
|
312
|
+
const exists = fs.existsSync(resolved);
|
|
313
|
+
if (!exists) {
|
|
314
|
+
if (parameter.createIfMissing) {
|
|
315
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
316
|
+
} else {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Directory '${resolved}' for configuration '${parameter.configurationKey}' does not exist and createIfMissing is false.`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const stats = fs.statSync(resolved);
|
|
323
|
+
if (!stats.isDirectory()) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Path '${resolved}' for configuration '${parameter.configurationKey}' is not a directory.`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const mode = parameter.requireWritable
|
|
329
|
+
? fs.constants.R_OK | fs.constants.W_OK
|
|
330
|
+
: fs.constants.R_OK;
|
|
331
|
+
fs.accessSync(resolved, mode);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ConfigurationService } from './configuration-service.js';
|
|
2
|
+
import type { ConfigurationParameterDefinition } from './parameter-types.js';
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
5
|
+
type AnyClass = Function;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Class decorator that registers a set of configuration parameters and groups
|
|
9
|
+
* them under a target name (used by `print-config` / `getRegistryByTarget()`
|
|
10
|
+
* for display, and by error messages).
|
|
11
|
+
*
|
|
12
|
+
* Pass the target name as the first argument:
|
|
13
|
+
*
|
|
14
|
+
* @Configuration('HttpListenerConfig', ...HTTP_LISTENER_PARAMETERS)
|
|
15
|
+
* class HttpListenerConfig { ... }
|
|
16
|
+
*
|
|
17
|
+
* Why explicit instead of `target.name`? The TypeScript `experimentalDecorators`
|
|
18
|
+
* emit produces `let Foo = class Foo { ... }` which, after a JS bundler hoists
|
|
19
|
+
* the `let` to the module scope, gets the inner class expression renamed to
|
|
20
|
+
* `Foo2` to avoid the outer/inner binding collision. A decorator reading
|
|
21
|
+
* `target.name` would then register against `Foo2`, surfacing nonsense
|
|
22
|
+
* suffixes in `print-config` output. Passing the name explicitly sidesteps
|
|
23
|
+
* the whole problem and survives any future minifier/bundler renames.
|
|
24
|
+
*
|
|
25
|
+
* If the first argument is omitted (legacy callers), the decorator falls back
|
|
26
|
+
* to `target.name` — which works fine when the class is bundled from source
|
|
27
|
+
* but is fragile when bundled from prebuilt `dist/` JS.
|
|
28
|
+
*/
|
|
29
|
+
export function Configuration(
|
|
30
|
+
nameOrFirstParameter: string | ConfigurationParameterDefinition,
|
|
31
|
+
...rest: ConfigurationParameterDefinition[]
|
|
32
|
+
): (target: AnyClass) => void {
|
|
33
|
+
const explicitName =
|
|
34
|
+
typeof nameOrFirstParameter === 'string' ? nameOrFirstParameter : undefined;
|
|
35
|
+
const parameters: ConfigurationParameterDefinition[] =
|
|
36
|
+
explicitName === undefined
|
|
37
|
+
? [nameOrFirstParameter as ConfigurationParameterDefinition, ...rest]
|
|
38
|
+
: rest;
|
|
39
|
+
return (target: AnyClass) => {
|
|
40
|
+
ConfigurationService.register(parameters, explicitName ?? target.name);
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { Configuration } from './decorators.js';
|
|
2
|
+
export { ConfigurationService } from './configuration-service.js';
|
|
3
|
+
export {
|
|
4
|
+
ConfigurationParameter,
|
|
5
|
+
type AnyConfigurationParameterBuilder,
|
|
6
|
+
type AnyConfigurationParameterDefinition,
|
|
7
|
+
} from './parameter-builder.js';
|
|
8
|
+
export {
|
|
9
|
+
type ConfigurationParameterDefinition,
|
|
10
|
+
type ConfigurationParameterDefinitionBase,
|
|
11
|
+
type ConfigurationValuePrimitive,
|
|
12
|
+
type ConfigurationValueConstructor,
|
|
13
|
+
type SimpleConfigurationParameterDefinition,
|
|
14
|
+
type DirectoryConfigurationParameterDefinition,
|
|
15
|
+
type YamlConfigurationParameterDefinition,
|
|
16
|
+
} from './parameter-types.js';
|
|
17
|
+
export {
|
|
18
|
+
type ConfigurationLogger,
|
|
19
|
+
type LogLevel,
|
|
20
|
+
type BufferedLogEntry,
|
|
21
|
+
ConsoleLogger,
|
|
22
|
+
BufferingLogger,
|
|
23
|
+
flushBuffer,
|
|
24
|
+
} from './logger.js';
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
export interface ConfigurationLogger {
|
|
4
|
+
debug(message: string, ...args: unknown[]): void;
|
|
5
|
+
info(message: string, ...args: unknown[]): void;
|
|
6
|
+
warn(message: string, ...args: unknown[]): void;
|
|
7
|
+
error(message: string, ...args: unknown[]): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BufferedLogEntry {
|
|
11
|
+
level: LogLevel;
|
|
12
|
+
message: string;
|
|
13
|
+
args: unknown[];
|
|
14
|
+
timestamp: Date;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ConsoleLogger implements ConfigurationLogger {
|
|
18
|
+
debug(message: string, ...args: unknown[]): void {
|
|
19
|
+
console.debug(message, ...args);
|
|
20
|
+
}
|
|
21
|
+
info(message: string, ...args: unknown[]): void {
|
|
22
|
+
console.info(message, ...args);
|
|
23
|
+
}
|
|
24
|
+
warn(message: string, ...args: unknown[]): void {
|
|
25
|
+
console.warn(message, ...args);
|
|
26
|
+
}
|
|
27
|
+
error(message: string, ...args: unknown[]): void {
|
|
28
|
+
console.error(message, ...args);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class BufferingLogger implements ConfigurationLogger {
|
|
33
|
+
private readonly entries: BufferedLogEntry[] = [];
|
|
34
|
+
|
|
35
|
+
debug(message: string, ...args: unknown[]): void {
|
|
36
|
+
this.append('debug', message, args);
|
|
37
|
+
}
|
|
38
|
+
info(message: string, ...args: unknown[]): void {
|
|
39
|
+
this.append('info', message, args);
|
|
40
|
+
}
|
|
41
|
+
warn(message: string, ...args: unknown[]): void {
|
|
42
|
+
this.append('warn', message, args);
|
|
43
|
+
}
|
|
44
|
+
error(message: string, ...args: unknown[]): void {
|
|
45
|
+
this.append('error', message, args);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private append(level: LogLevel, message: string, args: unknown[]): void {
|
|
49
|
+
this.entries.push({ level, message, args, timestamp: new Date() });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
drain(): readonly BufferedLogEntry[] {
|
|
53
|
+
const drained = this.entries.slice();
|
|
54
|
+
this.entries.length = 0;
|
|
55
|
+
return drained;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
size(): number {
|
|
59
|
+
return this.entries.length;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function flushBuffer(buffer: BufferingLogger, target: ConfigurationLogger): void {
|
|
64
|
+
for (const entry of buffer.drain()) {
|
|
65
|
+
target[entry.level](entry.message, ...entry.args);
|
|
66
|
+
}
|
|
67
|
+
}
|