@iskra-bun/config-kit 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/CHANGELOG.md +14 -0
- package/README.md +38 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +96 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
- package/src/coercers.ts +77 -0
- package/src/index.ts +4 -0
- package/src/loader.ts +65 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @iskra-bun/config-kit
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f9654df: Initial public release. Typed, Zod-validated environment/config loading: `loadConfig` returns a deep-frozen config, env coercers (`envBool`, `envNumber`, `envPort`, `envEnum`), and validation errors that never echo secret values.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [f9654df]
|
|
12
|
+
- Updated dependencies
|
|
13
|
+
- Updated dependencies [f9654df]
|
|
14
|
+
- @iskra-bun/core@0.1.1
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @iskra-bun/config-kit
|
|
2
|
+
|
|
3
|
+
Carga y validacion de configuracion de entorno con Zod para Iskra.
|
|
4
|
+
|
|
5
|
+
## Instalacion
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @iskra-bun/config-kit @iskra-bun/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Uso rapido
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { loadConfig, envBool, envPort, envEnum, z } from '@iskra-bun/config-kit';
|
|
15
|
+
|
|
16
|
+
const config = loadConfig({
|
|
17
|
+
schema: z.object({
|
|
18
|
+
DATABASE_URL: z.string().url(),
|
|
19
|
+
PORT: envPort,
|
|
20
|
+
DEBUG: envBool,
|
|
21
|
+
NODE_ENV: envEnum(['development', 'production', 'test'] as const),
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// config es tipado, validado, y completamente inmutable (deep-frozen)
|
|
26
|
+
console.log(config.PORT); // number
|
|
27
|
+
console.log(config.DEBUG); // boolean
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Bun carga `.env` automaticamente en `process.env`, por lo que `loadConfig` lo recoge sin configuracion adicional.
|
|
31
|
+
|
|
32
|
+
## Documentacion
|
|
33
|
+
|
|
34
|
+
Guia completa: [docs/config-kit.md](../../docs/config-kit.md)
|
|
35
|
+
|
|
36
|
+
## Licencia
|
|
37
|
+
|
|
38
|
+
AGPL-3.0-or-later
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
interface LoadConfigOptions<TSchema extends z.ZodTypeAny> {
|
|
5
|
+
/** The Zod schema to validate the source against. */
|
|
6
|
+
schema: TSchema;
|
|
7
|
+
/**
|
|
8
|
+
* The environment source to validate.
|
|
9
|
+
* Defaults to `process.env`.
|
|
10
|
+
*/
|
|
11
|
+
source?: Record<string, string | undefined>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Validates `source` (defaults to `process.env`) against the provided Zod schema
|
|
15
|
+
* and returns a deep-frozen, typed config object.
|
|
16
|
+
*
|
|
17
|
+
* On failure, throws a `ConfigError` listing each invalid or missing field by name
|
|
18
|
+
* and reason — never echoing secret values.
|
|
19
|
+
*/
|
|
20
|
+
declare function loadConfig<TSchema extends z.ZodTypeAny>(options: LoadConfigOptions<TSchema>): z.output<TSchema>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Coerce a string env var to boolean.
|
|
24
|
+
* Accepts "true"/"1"/"yes" as true, "false"/"0"/"no" as false.
|
|
25
|
+
* Rejects any other value.
|
|
26
|
+
*/
|
|
27
|
+
declare const envBool: z.ZodPipeline<z.ZodEffects<z.ZodString, boolean, string>, z.ZodBoolean>;
|
|
28
|
+
/**
|
|
29
|
+
* Coerce a string env var to a finite number.
|
|
30
|
+
*/
|
|
31
|
+
declare const envNumber: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>;
|
|
32
|
+
/**
|
|
33
|
+
* Coerce a string env var to a valid TCP port (1–65535).
|
|
34
|
+
*/
|
|
35
|
+
declare const envPort: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>;
|
|
36
|
+
/**
|
|
37
|
+
* Coerce a string env var to one of the provided literal values.
|
|
38
|
+
* Usage: envEnum(['development', 'production', 'test'])
|
|
39
|
+
*/
|
|
40
|
+
declare function envEnum<T extends string>(values: readonly [T, ...T[]]): z.ZodPipeline<z.ZodEffects<z.ZodString, T, string>, z.ZodEnum<[T, ...T[]]>>;
|
|
41
|
+
|
|
42
|
+
export { type LoadConfigOptions, envBool, envEnum, envNumber, envPort, loadConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/loader.ts
|
|
2
|
+
import { ConfigError } from "@iskra-bun/core";
|
|
3
|
+
function loadConfig(options) {
|
|
4
|
+
const { schema, source = process.env } = options;
|
|
5
|
+
const result = schema.safeParse(source);
|
|
6
|
+
if (!result.success) {
|
|
7
|
+
const issues = result.error.issues.map((issue) => {
|
|
8
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
|
|
9
|
+
return ` \u2022 ${path}: ${issue.message}`;
|
|
10
|
+
});
|
|
11
|
+
throw new ConfigError(
|
|
12
|
+
`Config validation failed:
|
|
13
|
+
${issues.join("\n")}`,
|
|
14
|
+
{
|
|
15
|
+
context: {
|
|
16
|
+
// Only report field names and messages — never values.
|
|
17
|
+
fields: result.error.issues.map((i) => ({
|
|
18
|
+
path: i.path.join(".") || "<root>",
|
|
19
|
+
message: i.message
|
|
20
|
+
}))
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return deepFreeze(result.data);
|
|
26
|
+
}
|
|
27
|
+
function deepFreeze(obj) {
|
|
28
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
29
|
+
for (const key of Object.keys(obj)) {
|
|
30
|
+
const value = obj[key];
|
|
31
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
32
|
+
deepFreeze(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return Object.freeze(obj);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/coercers.ts
|
|
39
|
+
import { z } from "zod";
|
|
40
|
+
var envBool = z.string().transform((val, ctx) => {
|
|
41
|
+
const lower = val.toLowerCase().trim();
|
|
42
|
+
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
|
43
|
+
if (lower === "false" || lower === "0" || lower === "no") return false;
|
|
44
|
+
ctx.addIssue({
|
|
45
|
+
code: z.ZodIssueCode.custom,
|
|
46
|
+
message: `Expected boolean-like string ("true"/"1"/"yes"/"false"/"0"/"no"), received "${val}"`
|
|
47
|
+
});
|
|
48
|
+
return z.NEVER;
|
|
49
|
+
}).pipe(z.boolean());
|
|
50
|
+
var envNumber = z.string().transform((val, ctx) => {
|
|
51
|
+
const trimmed = val.trim();
|
|
52
|
+
const n = Number(trimmed);
|
|
53
|
+
if (trimmed === "" || !Number.isFinite(n)) {
|
|
54
|
+
ctx.addIssue({
|
|
55
|
+
code: z.ZodIssueCode.custom,
|
|
56
|
+
message: `Expected a finite number, received "${val}"`
|
|
57
|
+
});
|
|
58
|
+
return z.NEVER;
|
|
59
|
+
}
|
|
60
|
+
return n;
|
|
61
|
+
}).pipe(z.number());
|
|
62
|
+
var envPort = z.string().transform((val, ctx) => {
|
|
63
|
+
const n = Number(val);
|
|
64
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
65
|
+
ctx.addIssue({
|
|
66
|
+
code: z.ZodIssueCode.custom,
|
|
67
|
+
message: `Expected a port number (1\u201365535), received "${val}"`
|
|
68
|
+
});
|
|
69
|
+
return z.NEVER;
|
|
70
|
+
}
|
|
71
|
+
return n;
|
|
72
|
+
}).pipe(z.number().int().min(1).max(65535));
|
|
73
|
+
function envEnum(values) {
|
|
74
|
+
return z.string().transform((val, ctx) => {
|
|
75
|
+
if (!values.includes(val)) {
|
|
76
|
+
ctx.addIssue({
|
|
77
|
+
code: z.ZodIssueCode.custom,
|
|
78
|
+
message: `Expected one of [${values.map((v) => `"${v}"`).join(", ")}], received "${val}"`
|
|
79
|
+
});
|
|
80
|
+
return z.NEVER;
|
|
81
|
+
}
|
|
82
|
+
return val;
|
|
83
|
+
}).pipe(z.enum(values));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/index.ts
|
|
87
|
+
import { z as z2 } from "zod";
|
|
88
|
+
export {
|
|
89
|
+
envBool,
|
|
90
|
+
envEnum,
|
|
91
|
+
envNumber,
|
|
92
|
+
envPort,
|
|
93
|
+
loadConfig,
|
|
94
|
+
z2 as z
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/loader.ts","../src/coercers.ts","../src/index.ts"],"sourcesContent":["import { z } from 'zod';\nimport { ConfigError } from '@iskra-bun/core';\n\nexport interface LoadConfigOptions<TSchema extends z.ZodTypeAny> {\n /** The Zod schema to validate the source against. */\n schema: TSchema;\n /**\n * The environment source to validate.\n * Defaults to `process.env`.\n */\n source?: Record<string, string | undefined>;\n}\n\n/**\n * Validates `source` (defaults to `process.env`) against the provided Zod schema\n * and returns a deep-frozen, typed config object.\n *\n * On failure, throws a `ConfigError` listing each invalid or missing field by name\n * and reason — never echoing secret values.\n */\nexport function loadConfig<TSchema extends z.ZodTypeAny>(\n options: LoadConfigOptions<TSchema>,\n): z.output<TSchema> {\n const { schema, source = process.env } = options;\n\n const result = schema.safeParse(source);\n\n if (!result.success) {\n const issues = result.error.issues.map(issue => {\n const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';\n return ` • ${path}: ${issue.message}`;\n });\n\n throw new ConfigError(\n `Config validation failed:\\n${issues.join('\\n')}`,\n {\n context: {\n // Only report field names and messages — never values.\n fields: result.error.issues.map(i => ({\n path: i.path.join('.') || '<root>',\n message: i.message,\n })),\n },\n },\n );\n }\n\n return deepFreeze(result.data) as z.output<TSchema>;\n}\n\n/**\n * Recursively freezes an object so the returned config is truly immutable.\n */\nfunction deepFreeze<T>(obj: T): T {\n if (obj === null || typeof obj !== 'object') return obj;\n\n for (const key of Object.keys(obj as object)) {\n const value = (obj as Record<string, unknown>)[key];\n if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {\n deepFreeze(value);\n }\n }\n\n return Object.freeze(obj);\n}\n","import { z } from 'zod';\n\n/**\n * Coerce a string env var to boolean.\n * Accepts \"true\"/\"1\"/\"yes\" as true, \"false\"/\"0\"/\"no\" as false.\n * Rejects any other value.\n */\nexport const envBool = z\n .string()\n .transform((val, ctx) => {\n const lower = val.toLowerCase().trim();\n if (lower === 'true' || lower === '1' || lower === 'yes') return true;\n if (lower === 'false' || lower === '0' || lower === 'no') return false;\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Expected boolean-like string (\"true\"/\"1\"/\"yes\"/\"false\"/\"0\"/\"no\"), received \"${val}\"`,\n });\n return z.NEVER;\n })\n .pipe(z.boolean());\n\n/**\n * Coerce a string env var to a finite number.\n */\nexport const envNumber = z\n .string()\n .transform((val, ctx) => {\n const trimmed = val.trim();\n const n = Number(trimmed);\n if (trimmed === '' || !Number.isFinite(n)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Expected a finite number, received \"${val}\"`,\n });\n return z.NEVER;\n }\n return n;\n })\n .pipe(z.number());\n\n/**\n * Coerce a string env var to a valid TCP port (1–65535).\n */\nexport const envPort = z\n .string()\n .transform((val, ctx) => {\n const n = Number(val);\n if (!Number.isInteger(n) || n < 1 || n > 65535) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Expected a port number (1–65535), received \"${val}\"`,\n });\n return z.NEVER;\n }\n return n;\n })\n .pipe(z.number().int().min(1).max(65535));\n\n/**\n * Coerce a string env var to one of the provided literal values.\n * Usage: envEnum(['development', 'production', 'test'])\n */\nexport function envEnum<T extends string>(values: readonly [T, ...T[]]) {\n return z\n .string()\n .transform((val, ctx) => {\n if (!(values as readonly string[]).includes(val)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `Expected one of [${values.map(v => `\"${v}\"`).join(', ')}], received \"${val}\"`,\n });\n return z.NEVER;\n }\n return val as T;\n })\n .pipe(z.enum(values));\n}\n","export { loadConfig } from './loader';\nexport type { LoadConfigOptions } from './loader';\nexport { envBool, envNumber, envPort, envEnum } from './coercers';\nexport { z } from 'zod';\n"],"mappings":"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","names":["z"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iskra-bun/config-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Carga y validacion de configuracion de entorno con Zod para Iskra.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"iskra",
|
|
7
|
+
"bun",
|
|
8
|
+
"typescript",
|
|
9
|
+
"config",
|
|
10
|
+
"env",
|
|
11
|
+
"zod",
|
|
12
|
+
"validation"
|
|
13
|
+
],
|
|
14
|
+
"author": "Joan Lascano",
|
|
15
|
+
"license": "AGPL-3.0-or-later",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/fearful/iskra.git",
|
|
19
|
+
"directory": "packages/config-kit"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/fearful/iskra/tree/main/packages/config-kit#readme",
|
|
22
|
+
"bugs": "https://github.com/fearful/iskra/issues",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"source": "./src/index.ts",
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "bun test",
|
|
47
|
+
"build": "tsup --config ../../tsup.config.ts"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@iskra-bun/core": "0.1.1",
|
|
51
|
+
"zod": "^3.24.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/bun": "^1.3.5",
|
|
55
|
+
"@types/node": "^22.10.2"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/coercers.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Coerce a string env var to boolean.
|
|
5
|
+
* Accepts "true"/"1"/"yes" as true, "false"/"0"/"no" as false.
|
|
6
|
+
* Rejects any other value.
|
|
7
|
+
*/
|
|
8
|
+
export const envBool = z
|
|
9
|
+
.string()
|
|
10
|
+
.transform((val, ctx) => {
|
|
11
|
+
const lower = val.toLowerCase().trim();
|
|
12
|
+
if (lower === 'true' || lower === '1' || lower === 'yes') return true;
|
|
13
|
+
if (lower === 'false' || lower === '0' || lower === 'no') return false;
|
|
14
|
+
ctx.addIssue({
|
|
15
|
+
code: z.ZodIssueCode.custom,
|
|
16
|
+
message: `Expected boolean-like string ("true"/"1"/"yes"/"false"/"0"/"no"), received "${val}"`,
|
|
17
|
+
});
|
|
18
|
+
return z.NEVER;
|
|
19
|
+
})
|
|
20
|
+
.pipe(z.boolean());
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Coerce a string env var to a finite number.
|
|
24
|
+
*/
|
|
25
|
+
export const envNumber = z
|
|
26
|
+
.string()
|
|
27
|
+
.transform((val, ctx) => {
|
|
28
|
+
const trimmed = val.trim();
|
|
29
|
+
const n = Number(trimmed);
|
|
30
|
+
if (trimmed === '' || !Number.isFinite(n)) {
|
|
31
|
+
ctx.addIssue({
|
|
32
|
+
code: z.ZodIssueCode.custom,
|
|
33
|
+
message: `Expected a finite number, received "${val}"`,
|
|
34
|
+
});
|
|
35
|
+
return z.NEVER;
|
|
36
|
+
}
|
|
37
|
+
return n;
|
|
38
|
+
})
|
|
39
|
+
.pipe(z.number());
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Coerce a string env var to a valid TCP port (1–65535).
|
|
43
|
+
*/
|
|
44
|
+
export const envPort = z
|
|
45
|
+
.string()
|
|
46
|
+
.transform((val, ctx) => {
|
|
47
|
+
const n = Number(val);
|
|
48
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
49
|
+
ctx.addIssue({
|
|
50
|
+
code: z.ZodIssueCode.custom,
|
|
51
|
+
message: `Expected a port number (1–65535), received "${val}"`,
|
|
52
|
+
});
|
|
53
|
+
return z.NEVER;
|
|
54
|
+
}
|
|
55
|
+
return n;
|
|
56
|
+
})
|
|
57
|
+
.pipe(z.number().int().min(1).max(65535));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Coerce a string env var to one of the provided literal values.
|
|
61
|
+
* Usage: envEnum(['development', 'production', 'test'])
|
|
62
|
+
*/
|
|
63
|
+
export function envEnum<T extends string>(values: readonly [T, ...T[]]) {
|
|
64
|
+
return z
|
|
65
|
+
.string()
|
|
66
|
+
.transform((val, ctx) => {
|
|
67
|
+
if (!(values as readonly string[]).includes(val)) {
|
|
68
|
+
ctx.addIssue({
|
|
69
|
+
code: z.ZodIssueCode.custom,
|
|
70
|
+
message: `Expected one of [${values.map(v => `"${v}"`).join(', ')}], received "${val}"`,
|
|
71
|
+
});
|
|
72
|
+
return z.NEVER;
|
|
73
|
+
}
|
|
74
|
+
return val as T;
|
|
75
|
+
})
|
|
76
|
+
.pipe(z.enum(values));
|
|
77
|
+
}
|
package/src/index.ts
ADDED
package/src/loader.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ConfigError } from '@iskra-bun/core';
|
|
3
|
+
|
|
4
|
+
export interface LoadConfigOptions<TSchema extends z.ZodTypeAny> {
|
|
5
|
+
/** The Zod schema to validate the source against. */
|
|
6
|
+
schema: TSchema;
|
|
7
|
+
/**
|
|
8
|
+
* The environment source to validate.
|
|
9
|
+
* Defaults to `process.env`.
|
|
10
|
+
*/
|
|
11
|
+
source?: Record<string, string | undefined>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validates `source` (defaults to `process.env`) against the provided Zod schema
|
|
16
|
+
* and returns a deep-frozen, typed config object.
|
|
17
|
+
*
|
|
18
|
+
* On failure, throws a `ConfigError` listing each invalid or missing field by name
|
|
19
|
+
* and reason — never echoing secret values.
|
|
20
|
+
*/
|
|
21
|
+
export function loadConfig<TSchema extends z.ZodTypeAny>(
|
|
22
|
+
options: LoadConfigOptions<TSchema>,
|
|
23
|
+
): z.output<TSchema> {
|
|
24
|
+
const { schema, source = process.env } = options;
|
|
25
|
+
|
|
26
|
+
const result = schema.safeParse(source);
|
|
27
|
+
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
const issues = result.error.issues.map(issue => {
|
|
30
|
+
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
|
|
31
|
+
return ` • ${path}: ${issue.message}`;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
throw new ConfigError(
|
|
35
|
+
`Config validation failed:\n${issues.join('\n')}`,
|
|
36
|
+
{
|
|
37
|
+
context: {
|
|
38
|
+
// Only report field names and messages — never values.
|
|
39
|
+
fields: result.error.issues.map(i => ({
|
|
40
|
+
path: i.path.join('.') || '<root>',
|
|
41
|
+
message: i.message,
|
|
42
|
+
})),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return deepFreeze(result.data) as z.output<TSchema>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Recursively freezes an object so the returned config is truly immutable.
|
|
53
|
+
*/
|
|
54
|
+
function deepFreeze<T>(obj: T): T {
|
|
55
|
+
if (obj === null || typeof obj !== 'object') return obj;
|
|
56
|
+
|
|
57
|
+
for (const key of Object.keys(obj as object)) {
|
|
58
|
+
const value = (obj as Record<string, unknown>)[key];
|
|
59
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
60
|
+
deepFreeze(value);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Object.freeze(obj);
|
|
65
|
+
}
|