@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,278 @@
1
+ // src/sources/file.ts
2
+ import { watch } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+
5
+ // src/utils/store.ts
6
+ class Store {
7
+ value;
8
+ subscribers = new Set;
9
+ cleanups = new Map;
10
+ mountListeners = new Set;
11
+ unmountListeners = new Set;
12
+ constructor(initial) {
13
+ this.value = initial;
14
+ }
15
+ get() {
16
+ return this.value;
17
+ }
18
+ set(next) {
19
+ this.value = next;
20
+ for (const subscriber of this.subscribers) {
21
+ this.runSubscriber(subscriber, this.value);
22
+ }
23
+ }
24
+ subscribe(subscriber) {
25
+ this.addSubscriber(subscriber);
26
+ this.runSubscriber(subscriber, this.value);
27
+ return () => this.removeSubscriber(subscriber);
28
+ }
29
+ listen(subscriber) {
30
+ this.addSubscriber(subscriber);
31
+ return () => this.removeSubscriber(subscriber);
32
+ }
33
+ runSubscriber(subscriber, value) {
34
+ const cleanup = subscriber(value);
35
+ if (typeof cleanup === "function")
36
+ this.cleanups.set(subscriber, cleanup);
37
+ }
38
+ onMount(listener) {
39
+ this.mountListeners.add(listener);
40
+ return () => {
41
+ this.mountListeners.delete(listener);
42
+ };
43
+ }
44
+ onUnmount(listener) {
45
+ this.unmountListeners.add(listener);
46
+ return () => {
47
+ this.unmountListeners.delete(listener);
48
+ };
49
+ }
50
+ addSubscriber(subscriber) {
51
+ const wasEmpty = this.subscribers.size === 0;
52
+ if (wasEmpty) {
53
+ for (const listener of this.mountListeners)
54
+ listener();
55
+ }
56
+ this.subscribers.add(subscriber);
57
+ }
58
+ removeSubscriber(subscriber) {
59
+ if (!this.subscribers.delete(subscriber))
60
+ return;
61
+ const cleanup = this.cleanups.get(subscriber);
62
+ this.cleanups.delete(subscriber);
63
+ cleanup?.();
64
+ if (this.subscribers.size === 0) {
65
+ for (const listener of this.unmountListeners)
66
+ listener();
67
+ }
68
+ }
69
+ static onMount(store, callback) {
70
+ let cleanup;
71
+ const unsubMount = store.onMount(() => {
72
+ cleanup = callback();
73
+ });
74
+ const unsubUnmount = store.onUnmount(() => {
75
+ cleanup?.();
76
+ cleanup = undefined;
77
+ });
78
+ return () => {
79
+ cleanup?.();
80
+ cleanup = undefined;
81
+ unsubMount();
82
+ unsubUnmount();
83
+ };
84
+ }
85
+ }
86
+ function create(initial) {
87
+ return new Store(initial);
88
+ }
89
+ function computed(source, selector) {
90
+ const result = new Store(selector(source.get()));
91
+ Store.onMount(result, () => {
92
+ return source.subscribe((value) => {
93
+ result.set(selector(value));
94
+ });
95
+ });
96
+ return result;
97
+ }
98
+ var store = { create, computed, onMount: Store.onMount };
99
+
100
+ // src/sources/source.ts
101
+ class Source {
102
+ underlying;
103
+ store = new Store(null);
104
+ closed = false;
105
+ closePromise;
106
+ startPromise;
107
+ constructor(underlying) {
108
+ this.underlying = underlying;
109
+ const control = {
110
+ set: (value) => {
111
+ if (this.closed)
112
+ return;
113
+ const next = this.underlying.reduce ? this.underlying.reduce(value, this.store.get()) : value;
114
+ this.store.set(next);
115
+ },
116
+ close: () => {
117
+ this.close();
118
+ }
119
+ };
120
+ this.startPromise = Promise.resolve().then(() => underlying.start(control)).then(() => {
121
+ return;
122
+ });
123
+ this.startPromise.catch(() => {});
124
+ }
125
+ async open() {
126
+ await this.startPromise;
127
+ return this.store;
128
+ }
129
+ close() {
130
+ if (!this.closePromise) {
131
+ this.closed = true;
132
+ this.closePromise = Promise.resolve(this.underlying.close?.()).then(() => {
133
+ return;
134
+ }, () => {
135
+ return;
136
+ });
137
+ }
138
+ return this.closePromise;
139
+ }
140
+ }
141
+
142
+ // src/utils/dotenv.ts
143
+ var QUOTES = ['"', "'", "`"];
144
+ function extractQuoted(value, quote) {
145
+ let i = 1;
146
+ while (i < value.length) {
147
+ const char = value[i];
148
+ if (char === "\\" && i + 1 < value.length) {
149
+ i += 2;
150
+ continue;
151
+ }
152
+ if (char === quote)
153
+ return value.slice(1, i);
154
+ i++;
155
+ }
156
+ return;
157
+ }
158
+ function unescapeQuoted(content, quote) {
159
+ if (quote !== '"')
160
+ return content;
161
+ return content.replace(/\\n/g, `
162
+ `).replace(/\\r/g, "\r");
163
+ }
164
+ function stripInlineComment(value) {
165
+ const match = value.match(/(^|\s)#/);
166
+ if (!match)
167
+ return value;
168
+ return value.slice(0, match.index).trimEnd();
169
+ }
170
+ function parseLine(line) {
171
+ const eq = line.indexOf("=");
172
+ if (eq === -1)
173
+ return;
174
+ const key = line.slice(0, eq).trim();
175
+ const rawValue = line.slice(eq + 1).trim();
176
+ if (rawValue.length >= 2 && QUOTES.includes(rawValue[0])) {
177
+ const quote = rawValue[0];
178
+ const quoted = extractQuoted(rawValue, quote);
179
+ if (quoted !== undefined)
180
+ return [key, unescapeQuoted(quoted, quote)];
181
+ }
182
+ return [key, stripInlineComment(rawValue)];
183
+ }
184
+ function parse(text) {
185
+ const result = {};
186
+ for (const rawLine of text.split(/\r?\n/)) {
187
+ const line = rawLine.trim();
188
+ if (line === "" || line.startsWith("#"))
189
+ continue;
190
+ const parsed = parseLine(line);
191
+ if (!parsed)
192
+ continue;
193
+ const [key, value] = parsed;
194
+ result[key] = value;
195
+ }
196
+ return result;
197
+ }
198
+ var DotEnv = { parse };
199
+
200
+ // src/sources/file.ts
201
+ function detectFormat(path) {
202
+ const pathname = path instanceof URL ? path.pathname : path;
203
+ if (pathname.endsWith(".json"))
204
+ return "json";
205
+ if (pathname.endsWith(".env"))
206
+ return "env";
207
+ return;
208
+ }
209
+ function parseFile(format, text) {
210
+ switch (format) {
211
+ case "json":
212
+ return JSON.parse(text);
213
+ case "env":
214
+ return DotEnv.parse(text);
215
+ }
216
+ }
217
+ function selectTreePath(data, treePath) {
218
+ let node = data;
219
+ for (const key of treePath) {
220
+ if (typeof node !== "object" || node === null)
221
+ return;
222
+ node = node[key];
223
+ }
224
+ return node;
225
+ }
226
+ function fileSource(path, options = {}) {
227
+ const shouldWatch = options.watch ?? true;
228
+ const treePath = options.treePath ?? [];
229
+ let watcher;
230
+ return new Source({
231
+ async start(control) {
232
+ const format = detectFormat(path);
233
+ if (!format) {
234
+ console.error(`fileSource: unrecognized file extension for "${path}"`);
235
+ control.close();
236
+ return;
237
+ }
238
+ async function readOnce() {
239
+ let text;
240
+ try {
241
+ text = await readFile(path, "utf8");
242
+ } catch (error) {
243
+ console.error(`fileSource: failed to read "${path}"`, error);
244
+ return false;
245
+ }
246
+ let parsed;
247
+ try {
248
+ parsed = parseFile(format, text);
249
+ } catch (error) {
250
+ console.error(`fileSource: failed to parse "${path}" as ${format}`, error);
251
+ return false;
252
+ }
253
+ const selected = selectTreePath(parsed, treePath);
254
+ if (selected === undefined) {
255
+ console.error(`fileSource: treePath [${treePath.map((k) => JSON.stringify(k)).join(", ")}] did not resolve to anything in "${path}"`);
256
+ control.set({});
257
+ return true;
258
+ }
259
+ control.set(selected);
260
+ return true;
261
+ }
262
+ await readOnce();
263
+ if (!shouldWatch) {
264
+ control.close();
265
+ return;
266
+ }
267
+ watcher = watch(path, { persistent: false }, () => {
268
+ readOnce();
269
+ });
270
+ },
271
+ close() {
272
+ watcher?.close();
273
+ }
274
+ });
275
+ }
276
+ export {
277
+ fileSource
278
+ };
@@ -0,0 +1,3 @@
1
+ import { Source } from "./source";
2
+ /** A `Source` that publishes a single static `value` immediately, then closes. Useful for hardcoded defaults, a static fallback tree, or tests. */
3
+ export declare function literalSource<T = unknown>(value: T): Source<T>;
@@ -0,0 +1,149 @@
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/literal.ts
139
+ function literalSource(value) {
140
+ return new Source({
141
+ start(control) {
142
+ control.set(value);
143
+ control.close();
144
+ }
145
+ });
146
+ }
147
+ export {
148
+ literalSource
149
+ };
@@ -0,0 +1,24 @@
1
+ import { Store } from "../utils/store";
2
+ import type { UnderlyingSource } from "../types";
3
+ /**
4
+ * A read-only, async config source modeled after `ReadableStream`: `start(control)` runs once,
5
+ * pushing whole-tree snapshots via `control.set(value)` and signaling `control.close()` when done.
6
+ * `open()` resolves once `start()` itself finishes running, with the live `Store` it populated.
7
+ * With `reduce`, every `control.set(value)` runs `value` (and the last published value) through it
8
+ * first, so `start()` can hand it partial patches instead of whole snapshots.
9
+ */
10
+ export declare class Source<T = unknown> {
11
+ private readonly underlying;
12
+ private readonly store;
13
+ private closed;
14
+ private closePromise;
15
+ private readonly startPromise;
16
+ constructor(underlying: UnderlyingSource<T>);
17
+ open(): Promise<Store<T | null>>;
18
+ /**
19
+ * Closes the source: runs its `close()` hook (if any) at most once, whether triggered from
20
+ * within `start()` (via `control.close()`) or from the outside. Safe to call any number of
21
+ * times, before or after `open()` — every call resolves once the same underlying close settles.
22
+ */
23
+ close(): Promise<void>;
24
+ }
@@ -0,0 +1,22 @@
1
+ import { Source } from "./source";
2
+ export interface SseSourceOptions {
3
+ url: string | URL;
4
+ method?: string;
5
+ headers?: Bun.HeadersInit;
6
+ }
7
+ /**
8
+ * A `Source` that connects to a Server-Sent Events endpoint. Every message tries to parse as
9
+ * JSON; a successful parse of a plain object is published via `control.set`, which — through this
10
+ * source's `reduce` — is applied as a shallow patch onto the config tree accumulated so far (new
11
+ * fields are added, existing ones overwritten, everything else kept) — e.g. `{"port":3000}` then
12
+ * `{"host":"10.0.0.1"}` end up as `{ port: 3000, host: "10.0.0.1" }`.
13
+ * A message that isn't valid JSON, or doesn't parse to a plain object, is logged via
14
+ * `console.error` and skipped, without disturbing the accumulated state or the connection.
15
+ *
16
+ * `start()` only resolves once the first message has been applied (or the connection closed
17
+ * without ever receiving one) — so `open()` always hands back a `Store` with data already in it,
18
+ * not one waiting on a race. The connection then stays open in the background, applying further
19
+ * messages as patches, until the resource closes the stream — or `close()` is called on the
20
+ * returned `Source` (or via a config tree's own `close()`), which aborts the connection.
21
+ */
22
+ export declare function sseSource<T = unknown>(options: SseSourceOptions): Source<T>;
@@ -0,0 +1,236 @@
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/sse.ts
139
+ function isPatch(value) {
140
+ return typeof value === "object" && value !== null && !Array.isArray(value);
141
+ }
142
+ function extractData(rawEvent) {
143
+ const dataLines = rawEvent.split(`
144
+ `).filter((line) => line.startsWith("data:")).map((line) => line.slice("data:".length).replace(/^ /, ""));
145
+ return dataLines.length > 0 ? dataLines.join(`
146
+ `) : null;
147
+ }
148
+ async function readEvents(body, onEvent) {
149
+ const reader = body.getReader();
150
+ const decoder = new TextDecoder;
151
+ let buffer = "";
152
+ try {
153
+ while (true) {
154
+ const { done, value } = await reader.read();
155
+ if (done)
156
+ break;
157
+ buffer += decoder.decode(value, { stream: true });
158
+ let boundary;
159
+ while ((boundary = buffer.indexOf(`
160
+
161
+ `)) !== -1) {
162
+ onEvent(buffer.slice(0, boundary));
163
+ buffer = buffer.slice(boundary + 2);
164
+ }
165
+ }
166
+ } finally {
167
+ reader.releaseLock();
168
+ }
169
+ }
170
+ function sseSource(options) {
171
+ const { url, method = "GET", headers } = options;
172
+ const abortController = new AbortController;
173
+ return new Source({
174
+ async start(control) {
175
+ let response;
176
+ try {
177
+ response = await fetch(url, { method, headers, signal: abortController.signal });
178
+ } catch (error) {
179
+ if (!abortController.signal.aborted) {
180
+ console.error(`sseSource: failed to connect to "${url}"`, error);
181
+ }
182
+ control.close();
183
+ return;
184
+ }
185
+ if (!response.ok || !response.body) {
186
+ console.error(`sseSource: received ${response.status} ${response.statusText} from "${url}"`);
187
+ control.close();
188
+ return;
189
+ }
190
+ await new Promise((resolveFirstMessage) => {
191
+ let settled = false;
192
+ const settle = () => {
193
+ if (settled)
194
+ return;
195
+ settled = true;
196
+ resolveFirstMessage();
197
+ };
198
+ readEvents(response.body, (rawEvent) => {
199
+ const raw = extractData(rawEvent);
200
+ if (raw === null || raw.trim() === "")
201
+ return;
202
+ let parsed;
203
+ try {
204
+ parsed = JSON.parse(raw);
205
+ } catch (error) {
206
+ console.error(`sseSource: message from "${url}" is not valid JSON`, error);
207
+ return;
208
+ }
209
+ if (!isPatch(parsed)) {
210
+ console.error(`sseSource: message from "${url}" did not parse to a JSON object`, parsed);
211
+ return;
212
+ }
213
+ control.set(parsed);
214
+ settle();
215
+ }).catch((error) => {
216
+ if (!abortController.signal.aborted) {
217
+ console.error(`sseSource: connection to "${url}" ended with an error`, error);
218
+ }
219
+ }).finally(() => {
220
+ control.close();
221
+ settle();
222
+ });
223
+ });
224
+ },
225
+ reduce: (patch, previous) => ({
226
+ ...previous ?? {},
227
+ ...patch
228
+ }),
229
+ close() {
230
+ abortController.abort();
231
+ }
232
+ });
233
+ }
234
+ export {
235
+ sseSource
236
+ };
@@ -0,0 +1,2 @@
1
+ export type { SourceControl, UnderlyingSource } from "./source";
2
+ export type {} from "./schema";