@jondotsoy/configs 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.
@@ -0,0 +1,2 @@
1
+ export declare class ConfigError extends Error {
2
+ }
@@ -0,0 +1,36 @@
1
+ import { Source } from "./source";
2
+ /** Maps an env var key to a path into the config tree, e.g. `mapKey("FOO_TAR")`. */
3
+ export type EnvKeyMapper = (key: string) => string[];
4
+ export interface EnvSourceOptions {
5
+ /** The env vars to read. Defaults to `process.env`. */
6
+ env?: Record<string, string | undefined>;
7
+ /** Only keys starting with `prefix` are included; the prefix is stripped before `mapKey` runs. */
8
+ prefix?: string;
9
+ /** Only keys ending with `suffix` are included; the suffix is stripped before `mapKey` runs. */
10
+ suffix?: string;
11
+ /** Maps each key to a path. Defaults to the identity mapping: `"FOO_TAR" => ["FOO_TAR"]`. */
12
+ mapKey?: EnvKeyMapper;
13
+ }
14
+ /** Built-in `mapKey` strategies for `EnvSourceOptions.mapKey`, each a factory returning an `EnvKeyMapper`. */
15
+ export declare const mapKey: {
16
+ /**
17
+ * Splits an env key into a lowercase nested path on `separator` (default `"_"`):
18
+ * `"FOO_TAR" => ["foo", "tar"]`. Pass a different `separator` (e.g. `"__"`) to keep a single
19
+ * underscore inside a segment from splitting it, e.g. `"API_KEY_V2"` with `separator: "__"`.
20
+ */
21
+ snakeCase(options?: {
22
+ separator?: string;
23
+ }): EnvKeyMapper;
24
+ /** Passes each key through unchanged, as a single-segment path: `"FOO_TAR" => ["FOO_TAR"]`. Equivalent to the default when `mapKey` is omitted — spells it out explicitly. */
25
+ identity(): EnvKeyMapper;
26
+ /** Maps an env key to a single camelCase segment: `"FOO_TAR" => ["fooTar"]`. */
27
+ camelCase(): EnvKeyMapper;
28
+ /**
29
+ * Maps specific env keys to explicit paths via a `table` lookup, e.g.
30
+ * `mapKey.lookup({ PORT: ["server", "port"] })` maps `"PORT" => ["server", "port"]`. A key not
31
+ * found in `table` falls back to `fallback` (default: the identity mapping, `key => [key]`).
32
+ */
33
+ lookup(table: Record<string, string[]>, fallback?: EnvKeyMapper): EnvKeyMapper;
34
+ };
35
+ /** A `Source` that snapshots `env` into a config tree, one field per key (as mapped by `mapKey`). */
36
+ export declare function envSource(options?: EnvSourceOptions): Source<Record<string, unknown>>;
@@ -0,0 +1,190 @@
1
+ // src/utils/store.ts
2
+ class Store {
3
+ value;
4
+ subscribers = new Set;
5
+ cleanups = new Map;
6
+ mountListeners = new Set;
7
+ unmountListeners = new Set;
8
+ constructor(initial) {
9
+ this.value = initial;
10
+ }
11
+ get() {
12
+ return this.value;
13
+ }
14
+ set(next) {
15
+ this.value = next;
16
+ for (const subscriber of this.subscribers) {
17
+ this.runSubscriber(subscriber, this.value);
18
+ }
19
+ }
20
+ subscribe(subscriber) {
21
+ this.addSubscriber(subscriber);
22
+ this.runSubscriber(subscriber, this.value);
23
+ return () => this.removeSubscriber(subscriber);
24
+ }
25
+ listen(subscriber) {
26
+ this.addSubscriber(subscriber);
27
+ return () => this.removeSubscriber(subscriber);
28
+ }
29
+ runSubscriber(subscriber, value) {
30
+ const cleanup = subscriber(value);
31
+ if (typeof cleanup === "function")
32
+ this.cleanups.set(subscriber, cleanup);
33
+ }
34
+ onMount(listener) {
35
+ this.mountListeners.add(listener);
36
+ return () => {
37
+ this.mountListeners.delete(listener);
38
+ };
39
+ }
40
+ onUnmount(listener) {
41
+ this.unmountListeners.add(listener);
42
+ return () => {
43
+ this.unmountListeners.delete(listener);
44
+ };
45
+ }
46
+ addSubscriber(subscriber) {
47
+ const wasEmpty = this.subscribers.size === 0;
48
+ if (wasEmpty) {
49
+ for (const listener of this.mountListeners)
50
+ listener();
51
+ }
52
+ this.subscribers.add(subscriber);
53
+ }
54
+ removeSubscriber(subscriber) {
55
+ if (!this.subscribers.delete(subscriber))
56
+ return;
57
+ const cleanup = this.cleanups.get(subscriber);
58
+ this.cleanups.delete(subscriber);
59
+ cleanup?.();
60
+ if (this.subscribers.size === 0) {
61
+ for (const listener of this.unmountListeners)
62
+ listener();
63
+ }
64
+ }
65
+ static onMount(store, callback) {
66
+ let cleanup;
67
+ const unsubMount = store.onMount(() => {
68
+ cleanup = callback();
69
+ });
70
+ const unsubUnmount = store.onUnmount(() => {
71
+ cleanup?.();
72
+ cleanup = undefined;
73
+ });
74
+ return () => {
75
+ cleanup?.();
76
+ cleanup = undefined;
77
+ unsubMount();
78
+ unsubUnmount();
79
+ };
80
+ }
81
+ }
82
+ function create(initial) {
83
+ return new Store(initial);
84
+ }
85
+ function computed(source, selector) {
86
+ const result = new Store(selector(source.get()));
87
+ Store.onMount(result, () => {
88
+ return source.subscribe((value) => {
89
+ result.set(selector(value));
90
+ });
91
+ });
92
+ return result;
93
+ }
94
+ var store = { create, computed, onMount: Store.onMount };
95
+
96
+ // src/sources/source.ts
97
+ class Source {
98
+ underlying;
99
+ store = new Store(null);
100
+ closed = false;
101
+ closePromise;
102
+ startPromise;
103
+ constructor(underlying) {
104
+ this.underlying = underlying;
105
+ const control = {
106
+ set: (value) => {
107
+ if (this.closed)
108
+ return;
109
+ const next = this.underlying.reduce ? this.underlying.reduce(value, this.store.get()) : value;
110
+ this.store.set(next);
111
+ },
112
+ close: () => {
113
+ this.close();
114
+ }
115
+ };
116
+ this.startPromise = Promise.resolve().then(() => underlying.start(control)).then(() => {
117
+ return;
118
+ });
119
+ this.startPromise.catch(() => {});
120
+ }
121
+ async open() {
122
+ await this.startPromise;
123
+ return this.store;
124
+ }
125
+ close() {
126
+ if (!this.closePromise) {
127
+ this.closed = true;
128
+ this.closePromise = Promise.resolve(this.underlying.close?.()).then(() => {
129
+ return;
130
+ }, () => {
131
+ return;
132
+ });
133
+ }
134
+ return this.closePromise;
135
+ }
136
+ }
137
+
138
+ // src/sources/env.ts
139
+ var mapKey = {
140
+ snakeCase(options = {}) {
141
+ const separator = options.separator ?? "_";
142
+ return (key) => key.toLowerCase().split(separator);
143
+ },
144
+ identity() {
145
+ return (key) => [key];
146
+ },
147
+ camelCase() {
148
+ return (key) => [key.toLowerCase().replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase())];
149
+ },
150
+ lookup(table, fallback = (key) => [key]) {
151
+ return (key) => table[key] ?? fallback(key);
152
+ }
153
+ };
154
+ function setPath(target, path, value) {
155
+ let node = target;
156
+ for (const segment of path.slice(0, -1)) {
157
+ const next = node[segment];
158
+ if (typeof next !== "object" || next === null) {
159
+ node[segment] = {};
160
+ }
161
+ node = node[segment];
162
+ }
163
+ node[path[path.length - 1]] = value;
164
+ }
165
+ function envSource(options = {}) {
166
+ const env = options.env ?? process.env;
167
+ const { prefix, suffix } = options;
168
+ const mapKey2 = options.mapKey ?? ((key) => [key]);
169
+ return new Source({
170
+ start(control) {
171
+ const tree = {};
172
+ for (const [key, value] of Object.entries(env)) {
173
+ if (value === undefined)
174
+ continue;
175
+ if (prefix !== undefined && !key.startsWith(prefix))
176
+ continue;
177
+ if (suffix !== undefined && !key.endsWith(suffix))
178
+ continue;
179
+ const trimmedKey = key.slice(prefix !== undefined ? prefix.length : 0, suffix !== undefined ? key.length - suffix.length : key.length);
180
+ setPath(tree, mapKey2(trimmedKey), value);
181
+ }
182
+ control.set(tree);
183
+ control.close();
184
+ }
185
+ });
186
+ }
187
+ export {
188
+ mapKey,
189
+ envSource
190
+ };
@@ -0,0 +1,15 @@
1
+ import { Source } from "./source";
2
+ export interface FetchSourceOptions {
3
+ url: string | URL;
4
+ method?: string;
5
+ headers?: Bun.HeadersInit;
6
+ /** Attempts to download the data before giving up. Defaults to 1 (no retry). */
7
+ attempts?: number;
8
+ }
9
+ /**
10
+ * A `Source` that fetches a JSON snapshot from `url`. Only JSON is supported: the response is
11
+ * parsed as JSON regardless of what `Content-Type` reports (a non-JSON content type is a fallback
12
+ * attempt, not a hard failure). If the download never succeeds, or the body isn't valid JSON, this
13
+ * logs a `console.error` and leaves the store empty (`null`) instead of throwing.
14
+ */
15
+ export declare function fetchSource<T = unknown>(options: FetchSourceOptions): Source<T>;
@@ -0,0 +1,182 @@
1
+ // src/utils/store.ts
2
+ class Store {
3
+ value;
4
+ subscribers = new Set;
5
+ cleanups = new Map;
6
+ mountListeners = new Set;
7
+ unmountListeners = new Set;
8
+ constructor(initial) {
9
+ this.value = initial;
10
+ }
11
+ get() {
12
+ return this.value;
13
+ }
14
+ set(next) {
15
+ this.value = next;
16
+ for (const subscriber of this.subscribers) {
17
+ this.runSubscriber(subscriber, this.value);
18
+ }
19
+ }
20
+ subscribe(subscriber) {
21
+ this.addSubscriber(subscriber);
22
+ this.runSubscriber(subscriber, this.value);
23
+ return () => this.removeSubscriber(subscriber);
24
+ }
25
+ listen(subscriber) {
26
+ this.addSubscriber(subscriber);
27
+ return () => this.removeSubscriber(subscriber);
28
+ }
29
+ runSubscriber(subscriber, value) {
30
+ const cleanup = subscriber(value);
31
+ if (typeof cleanup === "function")
32
+ this.cleanups.set(subscriber, cleanup);
33
+ }
34
+ onMount(listener) {
35
+ this.mountListeners.add(listener);
36
+ return () => {
37
+ this.mountListeners.delete(listener);
38
+ };
39
+ }
40
+ onUnmount(listener) {
41
+ this.unmountListeners.add(listener);
42
+ return () => {
43
+ this.unmountListeners.delete(listener);
44
+ };
45
+ }
46
+ addSubscriber(subscriber) {
47
+ const wasEmpty = this.subscribers.size === 0;
48
+ if (wasEmpty) {
49
+ for (const listener of this.mountListeners)
50
+ listener();
51
+ }
52
+ this.subscribers.add(subscriber);
53
+ }
54
+ removeSubscriber(subscriber) {
55
+ if (!this.subscribers.delete(subscriber))
56
+ return;
57
+ const cleanup = this.cleanups.get(subscriber);
58
+ this.cleanups.delete(subscriber);
59
+ cleanup?.();
60
+ if (this.subscribers.size === 0) {
61
+ for (const listener of this.unmountListeners)
62
+ listener();
63
+ }
64
+ }
65
+ static onMount(store, callback) {
66
+ let cleanup;
67
+ const unsubMount = store.onMount(() => {
68
+ cleanup = callback();
69
+ });
70
+ const unsubUnmount = store.onUnmount(() => {
71
+ cleanup?.();
72
+ cleanup = undefined;
73
+ });
74
+ return () => {
75
+ cleanup?.();
76
+ cleanup = undefined;
77
+ unsubMount();
78
+ unsubUnmount();
79
+ };
80
+ }
81
+ }
82
+ function create(initial) {
83
+ return new Store(initial);
84
+ }
85
+ function computed(source, selector) {
86
+ const result = new Store(selector(source.get()));
87
+ Store.onMount(result, () => {
88
+ return source.subscribe((value) => {
89
+ result.set(selector(value));
90
+ });
91
+ });
92
+ return result;
93
+ }
94
+ var store = { create, computed, onMount: Store.onMount };
95
+
96
+ // src/sources/source.ts
97
+ class Source {
98
+ underlying;
99
+ store = new Store(null);
100
+ closed = false;
101
+ closePromise;
102
+ startPromise;
103
+ constructor(underlying) {
104
+ this.underlying = underlying;
105
+ const control = {
106
+ set: (value) => {
107
+ if (this.closed)
108
+ return;
109
+ const next = this.underlying.reduce ? this.underlying.reduce(value, this.store.get()) : value;
110
+ this.store.set(next);
111
+ },
112
+ close: () => {
113
+ this.close();
114
+ }
115
+ };
116
+ this.startPromise = Promise.resolve().then(() => underlying.start(control)).then(() => {
117
+ return;
118
+ });
119
+ this.startPromise.catch(() => {});
120
+ }
121
+ async open() {
122
+ await this.startPromise;
123
+ return this.store;
124
+ }
125
+ close() {
126
+ if (!this.closePromise) {
127
+ this.closed = true;
128
+ this.closePromise = Promise.resolve(this.underlying.close?.()).then(() => {
129
+ return;
130
+ }, () => {
131
+ return;
132
+ });
133
+ }
134
+ return this.closePromise;
135
+ }
136
+ }
137
+
138
+ // src/sources/fetch.ts
139
+ async function download(url, init) {
140
+ const response = await fetch(url, init);
141
+ if (!response.ok) {
142
+ throw new Error(`fetchSource: received ${response.status} ${response.statusText} from "${url}"`);
143
+ }
144
+ return response;
145
+ }
146
+ function fetchSource(options) {
147
+ const { url, method = "GET", headers, attempts = 1 } = options;
148
+ return new Source({
149
+ async start(control) {
150
+ let response;
151
+ let lastError;
152
+ for (let attempt = 0;attempt < Math.max(1, attempts); attempt++) {
153
+ try {
154
+ response = await download(url, { method, headers });
155
+ break;
156
+ } catch (error) {
157
+ lastError = error;
158
+ }
159
+ }
160
+ if (!response) {
161
+ console.error(`fetchSource: failed to fetch "${url}" after ${attempts} attempt(s)`, lastError);
162
+ control.close();
163
+ return;
164
+ }
165
+ const contentType = response.headers.get("content-type") ?? "";
166
+ const text = await response.text();
167
+ let data;
168
+ try {
169
+ data = JSON.parse(text);
170
+ } catch (error) {
171
+ console.error(`fetchSource: response body from "${url}" is not valid JSON (content-type: "${contentType}")`, error);
172
+ control.close();
173
+ return;
174
+ }
175
+ control.set(data);
176
+ control.close();
177
+ }
178
+ });
179
+ }
180
+ export {
181
+ fetchSource
182
+ };
@@ -0,0 +1,28 @@
1
+ import { Source } from "./source";
2
+ export interface FileSourceOptions {
3
+ /** Republishes the config tree whenever the file changes on disk. Defaults to `true`. */
4
+ watch?: boolean;
5
+ /**
6
+ * Selects a subtree of the parsed file to use as the config tree, e.g. `["containers", "settings"]`
7
+ * to use `{ containers: { settings: {...} } }`'s inner object and ignore the rest of the file.
8
+ * Defaults to `[]`: the whole parsed file is used, unchanged.
9
+ */
10
+ treePath?: string[];
11
+ }
12
+ /**
13
+ * A `Source` that reads a config tree from a local file — `.json` or `.env` (matched by
14
+ * `path`'s extension, or by the bare `.env` filename itself). `path` may be a plain string or a
15
+ * `file:` `URL` (e.g. `import.meta.resolve(...)` or `new URL("./config.json", import.meta.url)`).
16
+ * Like `fetchSource` and `sseSource`, a read or parse failure is logged via `console.error` and
17
+ * leaves the store empty (`null`) instead of throwing.
18
+ *
19
+ * `treePath` selects a subtree of the parsed file to use, instead of the whole thing. A missing
20
+ * or non-object segment along the way is logged via `console.error`, same as a parse failure —
21
+ * but unlike one, it still publishes a snapshot: an empty object (`{}`), not `null` / the last
22
+ * good value, since the file itself was read and parsed fine.
23
+ *
24
+ * With `watch` (the default), the file is re-read on every change and the store updated live; a
25
+ * parse error on one of those later reads is logged but keeps the last good value, same as
26
+ * `sseSource` does for a bad SSE message. `watch: false` reads the file once and closes.
27
+ */
28
+ export declare function fileSource<T = unknown>(path: string | URL, options?: FileSourceOptions): Source<T>;