@effected/config-file 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/ConfigCodec.js +27 -0
- package/ConfigEvent.js +119 -0
- package/ConfigFile.js +421 -0
- package/ConfigMigration.js +97 -0
- package/ConfigProvider.js +87 -0
- package/ConfigResolver.js +95 -0
- package/EncryptedCodec.js +123 -0
- package/JsonCodec.js +38 -0
- package/JsoncCodec.js +38 -0
- package/LICENSE +21 -0
- package/MergeStrategy.js +53 -0
- package/README.md +176 -0
- package/TomlCodec.js +39 -0
- package/YamlCodec.js +34 -0
- package/index.d.ts +932 -0
- package/index.js +14 -0
- package/internal/crypto.js +115 -0
- package/internal/deepMerge.js +80 -0
- package/package.json +54 -0
- package/tsdoc-metadata.json +11 -0
package/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ConfigCodecError } from "./ConfigCodec.js";
|
|
2
|
+
import { ConfigEvent, ConfigEventPayload, ConfigEvents, ConfigSourceRef } from "./ConfigEvent.js";
|
|
3
|
+
import { ConfigResolver } from "./ConfigResolver.js";
|
|
4
|
+
import { ConfigDefaultPathMissingError, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigValidationError } from "./ConfigFile.js";
|
|
5
|
+
import { ConfigMigration, ConfigMigrationError, VersionAccess } from "./ConfigMigration.js";
|
|
6
|
+
import { asConfigProvider, layerConfigProvider } from "./ConfigProvider.js";
|
|
7
|
+
import { ConfigEncryptionError, EncryptedCodec, EncryptedCodecKey } from "./EncryptedCodec.js";
|
|
8
|
+
import { JsonCodec } from "./JsonCodec.js";
|
|
9
|
+
import { JsoncCodec } from "./JsoncCodec.js";
|
|
10
|
+
import { MergeStrategy } from "./MergeStrategy.js";
|
|
11
|
+
import { TomlCodec } from "./TomlCodec.js";
|
|
12
|
+
import { YamlCodec } from "./YamlCodec.js";
|
|
13
|
+
|
|
14
|
+
export { ConfigCodecError, ConfigDefaultPathMissingError, ConfigEncryptionError, ConfigEvent, ConfigEventPayload, ConfigEvents, ConfigFile, ConfigFileNotFoundError, ConfigFileReadError, ConfigFileWriteError, ConfigMigration, ConfigMigrationError, ConfigResolver, ConfigSourceRef, ConfigValidationError, EncryptedCodec, EncryptedCodecKey, JsonCodec, JsoncCodec, MergeStrategy, TomlCodec, VersionAccess, YamlCodec, asConfigProvider, layerConfigProvider };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/crypto.ts
|
|
4
|
+
/**
|
|
5
|
+
* AES-GCM primitives over WebCrypto.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* This module imports nothing from the rest of the package. `EncryptedCodec.ts`
|
|
9
|
+
* imports these helpers, so a back-import of `ConfigEncryptionError` would close
|
|
10
|
+
* a cycle that Biome's error-level `noImportCycles` rule rejects. Staying
|
|
11
|
+
* dependency-free also keeps the module cheap to extract later.
|
|
12
|
+
*
|
|
13
|
+
* Failures surface as the plain {@link CryptoFailure} record; the public module
|
|
14
|
+
* lifts them into `ConfigEncryptionError`.
|
|
15
|
+
*
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
/** AES-GCM's standard IV length, in bytes. @internal */
|
|
19
|
+
const IV_LENGTH = 12;
|
|
20
|
+
/** Tag a caught host failure with the phase that produced it, preserving it by identity. */
|
|
21
|
+
const fail = (phase) => (cause) => ({
|
|
22
|
+
phase,
|
|
23
|
+
cause
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Copy a `Uint8Array` into a fresh `Uint8Array` backed by a plain `ArrayBuffer`,
|
|
27
|
+
* which is required by Web Crypto APIs that accept `BufferSource`.
|
|
28
|
+
*
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
function toArrayBufferView(src) {
|
|
32
|
+
const buf = new ArrayBuffer(src.length);
|
|
33
|
+
const view = new Uint8Array(buf);
|
|
34
|
+
view.set(src);
|
|
35
|
+
return view;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Derive an AES-GCM key from a passphrase via PBKDF2.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* The caller memoizes this so PBKDF2 runs once per codec instance.
|
|
42
|
+
*
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
const deriveKey = (passphrase, salt) => Effect.tryPromise({
|
|
46
|
+
try: async () => {
|
|
47
|
+
const enc = new TextEncoder();
|
|
48
|
+
const keyMaterial = await globalThis.crypto.subtle.importKey("raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"]);
|
|
49
|
+
return await globalThis.crypto.subtle.deriveKey({
|
|
50
|
+
name: "PBKDF2",
|
|
51
|
+
salt: toArrayBufferView(salt),
|
|
52
|
+
iterations: 6e5,
|
|
53
|
+
hash: "SHA-256"
|
|
54
|
+
}, keyMaterial, {
|
|
55
|
+
name: "AES-GCM",
|
|
56
|
+
length: 256
|
|
57
|
+
}, false, ["encrypt", "decrypt"]);
|
|
58
|
+
},
|
|
59
|
+
catch: fail("key-derivation")
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* Decode base64 into bytes.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* `atob` is available in all modern environments (Node 20+, Bun, Deno).
|
|
66
|
+
*
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
const fromBase64 = (raw) => Effect.try({
|
|
70
|
+
try: () => {
|
|
71
|
+
const binary = atob(raw);
|
|
72
|
+
const buf = new ArrayBuffer(binary.length);
|
|
73
|
+
const bytes = new Uint8Array(buf);
|
|
74
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
75
|
+
return bytes;
|
|
76
|
+
},
|
|
77
|
+
catch: fail("encoding")
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* Prepend the IV to the ciphertext and base64-encode the envelope.
|
|
81
|
+
*
|
|
82
|
+
* @internal
|
|
83
|
+
*/
|
|
84
|
+
const toBase64 = (iv, ciphertext) => Effect.try({
|
|
85
|
+
try: () => {
|
|
86
|
+
const ciphertextBytes = new Uint8Array(ciphertext);
|
|
87
|
+
const resultBuf = new ArrayBuffer(12 + ciphertextBytes.length);
|
|
88
|
+
const result = new Uint8Array(resultBuf);
|
|
89
|
+
result.set(iv, 0);
|
|
90
|
+
result.set(ciphertextBytes, 12);
|
|
91
|
+
return btoa(Array.from(result, (b) => String.fromCharCode(b)).join(""));
|
|
92
|
+
},
|
|
93
|
+
catch: fail("encoding")
|
|
94
|
+
});
|
|
95
|
+
/** Decrypt AES-GCM ciphertext under `iv`. @internal */
|
|
96
|
+
const decrypt = (key, iv, ciphertext) => Effect.tryPromise({
|
|
97
|
+
try: () => globalThis.crypto.subtle.decrypt({
|
|
98
|
+
name: "AES-GCM",
|
|
99
|
+
iv
|
|
100
|
+
}, key, ciphertext),
|
|
101
|
+
catch: fail("decrypt")
|
|
102
|
+
});
|
|
103
|
+
/** Encrypt `plaintext` with AES-GCM under `iv`. @internal */
|
|
104
|
+
const encrypt = (key, iv, plaintext) => Effect.tryPromise({
|
|
105
|
+
try: () => globalThis.crypto.subtle.encrypt({
|
|
106
|
+
name: "AES-GCM",
|
|
107
|
+
iv
|
|
108
|
+
}, key, plaintext),
|
|
109
|
+
catch: fail("encrypt")
|
|
110
|
+
});
|
|
111
|
+
/** A fresh cryptographically random 12-byte IV. @internal */
|
|
112
|
+
const randomIv = () => globalThis.crypto.getRandomValues(new Uint8Array(/* @__PURE__ */ new ArrayBuffer(12)));
|
|
113
|
+
|
|
114
|
+
//#endregion
|
|
115
|
+
export { decrypt, deriveKey, encrypt, fromBase64, randomIv, toArrayBufferView, toBase64 };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region src/internal/deepMerge.ts
|
|
2
|
+
/**
|
|
3
|
+
* A true plain object: `{}` or `Object.create(null)`. Class instances, `Date`,
|
|
4
|
+
* `Map`, `Set`, `RegExp` and arrays are values, not merge targets.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* This is deliberately narrower than `typeof v === "object"`. A `Date` spread
|
|
8
|
+
* into a fresh object loses every internal slot and yields `{}`; a class
|
|
9
|
+
* instance loses its prototype, so `instanceof` fails and its getters vanish.
|
|
10
|
+
* Recursion is gated on this predicate so nested values of those kinds stay
|
|
11
|
+
* atomic — the higher-priority source wins them whole.
|
|
12
|
+
*/
|
|
13
|
+
const isPlainObject = (value) => {
|
|
14
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
15
|
+
const proto = Object.getPrototypeOf(value);
|
|
16
|
+
return proto === Object.prototype || proto === null;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Record-like: a `[object Object]` whose fields can be merged. Admits plain
|
|
20
|
+
* objects and class instances (a decoded `Schema.Class` document), but not
|
|
21
|
+
* `Date` / `Map` / `Set` / `RegExp` / arrays, whose behaviour lives in internal
|
|
22
|
+
* slots that a field-wise merge would destroy.
|
|
23
|
+
*/
|
|
24
|
+
const isRecordLike = (value) => typeof value === "object" && value !== null && Object.prototype.toString.call(value) === "[object Object]";
|
|
25
|
+
/**
|
|
26
|
+
* Two values may be merged only if both are record-like and share a prototype.
|
|
27
|
+
* Same-prototype instances merge field-wise; anything else is atomic.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* Requiring an identical prototype is what keeps the merge honest: a document
|
|
31
|
+
* decoded through `Schema.Class` merges with another of the same class and
|
|
32
|
+
* keeps its identity, and nothing else is ever silently reshaped.
|
|
33
|
+
*/
|
|
34
|
+
const canMerge = (a, b) => isRecordLike(a) && isRecordLike(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
|
|
35
|
+
const FORBIDDEN = /* @__PURE__ */ new Set([
|
|
36
|
+
"__proto__",
|
|
37
|
+
"constructor",
|
|
38
|
+
"prototype"
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* Recursively merge `source` into `target`; keys already present on `target`
|
|
42
|
+
* win. Nested plain objects merge; every other value is atomic.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* The result is built on `target`'s prototype rather than spread into `{}`, so
|
|
46
|
+
* a decoded `Schema.Class` document survives the merge as a real instance —
|
|
47
|
+
* `instanceof` holds and its getters still work. Without this, `load` would
|
|
48
|
+
* declare `Effect<A>` and hand back a structurally-equal plain object, and a
|
|
49
|
+
* consumer calling a class method would get a `TypeError` that typechecked.
|
|
50
|
+
*
|
|
51
|
+
* Only own enumerable keys are consulted (`Object.hasOwn`), so a prototype
|
|
52
|
+
* getter on `target` never shadows a real key on `source`.
|
|
53
|
+
*/
|
|
54
|
+
const deepMerge = (target, source) => {
|
|
55
|
+
const result = Object.create(Object.getPrototypeOf(target));
|
|
56
|
+
for (const key of Object.keys(target)) {
|
|
57
|
+
if (FORBIDDEN.has(key)) continue;
|
|
58
|
+
define(result, key, target[key]);
|
|
59
|
+
}
|
|
60
|
+
for (const key of Object.keys(source)) {
|
|
61
|
+
if (FORBIDDEN.has(key)) continue;
|
|
62
|
+
const sourceValue = source[key];
|
|
63
|
+
const targetValue = result[key];
|
|
64
|
+
if (Object.hasOwn(result, key) && isPlainObject(targetValue) && isPlainObject(sourceValue)) define(result, key, deepMerge(targetValue, sourceValue));
|
|
65
|
+
else if (!Object.hasOwn(result, key)) define(result, key, sourceValue);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
};
|
|
69
|
+
/** Create an own data property, never invoking a setter inherited from the prototype chain. */
|
|
70
|
+
const define = (target, key, value) => {
|
|
71
|
+
Object.defineProperty(target, key, {
|
|
72
|
+
value,
|
|
73
|
+
writable: true,
|
|
74
|
+
enumerable: true,
|
|
75
|
+
configurable: true
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
export { canMerge, deepMerge, isPlainObject };
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@effected/config-file",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Composable config file loading for Effect: JSON, JSONC, YAML and TOML codecs, resolution strategies, and merge behaviors.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"config",
|
|
8
|
+
"config-file",
|
|
9
|
+
"codec",
|
|
10
|
+
"json",
|
|
11
|
+
"jsonc",
|
|
12
|
+
"yaml",
|
|
13
|
+
"toml",
|
|
14
|
+
"resolver",
|
|
15
|
+
"merge-strategy",
|
|
16
|
+
"effect",
|
|
17
|
+
"schema",
|
|
18
|
+
"effected"
|
|
19
|
+
],
|
|
20
|
+
"homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/config-file#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/spencerbeggs/effected/issues"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/spencerbeggs/effected.git",
|
|
27
|
+
"directory": "packages/config-file"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": {
|
|
31
|
+
"name": "C. Spencer Beggs",
|
|
32
|
+
"email": "spencer@beggs.codes",
|
|
33
|
+
"url": "https://spencerbeg.gs"
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"type": "module",
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./index.d.ts",
|
|
40
|
+
"import": "./index.js"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@effected/jsonc": "0.1.0",
|
|
46
|
+
"@effected/toml": "0.1.0",
|
|
47
|
+
"@effected/walker": "0.1.0",
|
|
48
|
+
"@effected/yaml": "0.1.0",
|
|
49
|
+
"effect": "4.0.0-beta.98"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=24.11.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.58.9"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|