@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.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/config.types.d.ts +177 -0
- package/dist/configs.d.ts +21 -0
- package/dist/configs.js +847 -0
- package/dist/errors.d.ts +2 -0
- package/dist/sources/env.d.ts +36 -0
- package/dist/sources/env.js +190 -0
- package/dist/sources/fetch.d.ts +15 -0
- package/dist/sources/fetch.js +182 -0
- package/dist/sources/file.d.ts +28 -0
- package/dist/sources/file.js +278 -0
- package/dist/sources/literal.d.ts +3 -0
- package/dist/sources/literal.js +149 -0
- package/dist/sources/source.d.ts +24 -0
- package/dist/sources/sse.d.ts +22 -0
- package/dist/sources/sse.js +236 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/schema.d.ts +1 -0
- package/dist/types/source.d.ts +19 -0
- package/dist/utils/data-types.d.ts +20 -0
- package/dist/utils/dotenv.d.ts +13 -0
- package/dist/utils/store.d.ts +44 -0
- package/package.json +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Delgado <hi@jon.soy> (https://jon.soy)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# @jondotsoy/configs
|
|
2
|
+
|
|
3
|
+
A tool for all your configurations.
|
|
4
|
+
|
|
5
|
+
- **Reactive configs** — every field is a live `Store`; subscribe to it and get notified whenever an upstream source changes.
|
|
6
|
+
- **Lightweight** — no dependencies, just a thin layer over plain objects and stores.
|
|
7
|
+
- **Typed with TS check** — schemas are statically checked, so `cfg.port.get()` is inferred as `number | null` (or `number` when a `default` is set), not `any`.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { configs, envSource, mapKey } from "@jondotsoy/configs";
|
|
11
|
+
|
|
12
|
+
// SERVER_PORT=3000 SERVER_HOST=localhost → { server: { port: "3000", host: "localhost" } }
|
|
13
|
+
const source = envSource({ mapKey: mapKey.snakeCase() });
|
|
14
|
+
|
|
15
|
+
const serverConfigs = await configs.create(
|
|
16
|
+
{
|
|
17
|
+
server: configs.create({
|
|
18
|
+
port: { type: "number", summary: "HTTP port", default: 3000 },
|
|
19
|
+
host: { type: "string", summary: "bind host", default: "localhost" },
|
|
20
|
+
}),
|
|
21
|
+
tls: configs.create({
|
|
22
|
+
key: { type: "string", summary: "TLS key path" },
|
|
23
|
+
cert: { type: "string", summary: "TLS cert path" },
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
{ sources: [source] },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// React to changes
|
|
30
|
+
serverConfigs.server.port.subscribe((port) => {
|
|
31
|
+
console.log(`listening on port ${port}`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
console.log(serverConfigs.server.port.get());
|
|
35
|
+
// 3000
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Table of contents
|
|
39
|
+
|
|
40
|
+
- [Install](#install)
|
|
41
|
+
- [Guide](#guide)
|
|
42
|
+
- [`Source` — building a custom source](#source--building-a-custom-source)
|
|
43
|
+
- [`envSource` — environment variables](#envsource--environment-variables)
|
|
44
|
+
- [`fetchSource` — a JSON endpoint over HTTP](#fetchsource--a-json-endpoint-over-http)
|
|
45
|
+
- [`sseSource` — live updates over Server-Sent Events](#ssesource--live-updates-over-server-sent-events)
|
|
46
|
+
- [`fileSource` — a local `.json` or `.env` file](#filesource--a-local-json-or-env-file)
|
|
47
|
+
- [`literalSource` — a static value](#literalsource--a-static-value)
|
|
48
|
+
- [Reacting to changes — restarting a periodic task](#reacting-to-changes--restarting-a-periodic-task)
|
|
49
|
+
- [Closing a config tree](#closing-a-config-tree)
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm install @jondotsoy/configs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Guide
|
|
58
|
+
|
|
59
|
+
### `Source` — building a custom source
|
|
60
|
+
|
|
61
|
+
The building block behind `envSource`, `fetchSource`, `sseSource`, `fileSource`, and
|
|
62
|
+
`literalSource`. It takes an
|
|
63
|
+
object with `start(control)` and an optional `close()`, mirroring `ReadableStream`'s
|
|
64
|
+
`UnderlyingSource`: `start` runs once and pushes snapshots via `control.set(value)`, while `close`
|
|
65
|
+
— called from within `start` via `control.close()`, or from the outside via the `Source`'s own
|
|
66
|
+
`close()` — is where you release whatever `start` set up, like a timer or an in-flight request.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { Source } from "@jondotsoy/configs";
|
|
70
|
+
|
|
71
|
+
function pollingSource(url: string, intervalMs: number): Source<{ port: number }> {
|
|
72
|
+
let timer: ReturnType<typeof setInterval>;
|
|
73
|
+
|
|
74
|
+
return new Source({
|
|
75
|
+
async start(control) {
|
|
76
|
+
const poll = async () => control.set((await (await fetch(url)).json()) as { port: number });
|
|
77
|
+
await poll();
|
|
78
|
+
timer = setInterval(poll, intervalMs);
|
|
79
|
+
},
|
|
80
|
+
close() {
|
|
81
|
+
clearInterval(timer);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
An optional `reduce(incoming, previous)` runs every `control.set(incoming)` call through it (along
|
|
88
|
+
with the last published value, `null` before the first `set()`) instead of publishing `incoming`
|
|
89
|
+
as-is — so a source whose `start()` only ever produces a partial patch (like `sseSource`, which
|
|
90
|
+
hands `control.set` one SSE message at a time) can publish the merged result without keeping its
|
|
91
|
+
own accumulator variable around:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const source = new Source<{ port?: number; host?: string }>({
|
|
95
|
+
start(control) {
|
|
96
|
+
control.set({ port: 3000 }); // -> reduce({ port: 3000 }, null)
|
|
97
|
+
control.set({ host: "x" }); // -> reduce({ host: "x" }, { port: 3000 })
|
|
98
|
+
},
|
|
99
|
+
reduce: (patch, previous) => ({ ...(previous ?? {}), ...patch }),
|
|
100
|
+
});
|
|
101
|
+
// published: { port: 3000, host: "x" }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `envSource` — environment variables
|
|
105
|
+
|
|
106
|
+
Reads `process.env` (or any object you pass as `env`) into the config tree. `mapKey` decides how
|
|
107
|
+
each key maps to a path; the default is the identity mapping, `"FOO_TAR" => ["FOO_TAR"]`.
|
|
108
|
+
|
|
109
|
+
Built-in strategies live under the `mapKey` namespace, each a factory returning an `EnvKeyMapper`:
|
|
110
|
+
|
|
111
|
+
- **`mapKey.snakeCase(options?)`** — splits a `SCREAMING_SNAKE_CASE` key into a lowercase nested
|
|
112
|
+
path on `separator` (default `"_"`): `"FOO_TAR" => ["foo", "tar"]`. Pass a different `separator`
|
|
113
|
+
(e.g. `"__"`) to keep a single underscore inside a segment from splitting it:
|
|
114
|
+
`mapKey.snakeCase({ separator: "__" })` maps `"API_KEY_V2__ENABLED"` to
|
|
115
|
+
`["api_key_v2", "enabled"]` instead of splitting on every `_`.
|
|
116
|
+
- **`mapKey.identity()`** — passes each key through unchanged, as a single-segment path:
|
|
117
|
+
`"FOO_TAR" => ["FOO_TAR"]`. Same as omitting `mapKey`, spelled out explicitly.
|
|
118
|
+
- **`mapKey.camelCase()`** — maps a key to a single camelCase segment instead of nesting it:
|
|
119
|
+
`"FOO_TAR" => ["fooTar"]`.
|
|
120
|
+
- **`mapKey.lookup(table, fallback?)`** — maps specific keys to explicit paths via a
|
|
121
|
+
`Record<string, string[]>` lookup table; a key not in `table` falls back to `fallback` (default:
|
|
122
|
+
the identity mapping). Handy when most keys follow no consistent naming, or when a few need an
|
|
123
|
+
exception to whatever strategy the rest use.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { envSource, mapKey } from "@jondotsoy/configs";
|
|
127
|
+
|
|
128
|
+
// SERVER_PORT=3000 SERVER_HOST=localhost → { server: { port: "3000", host: "localhost" } }
|
|
129
|
+
const source = envSource({ mapKey: mapKey.snakeCase() });
|
|
130
|
+
|
|
131
|
+
// PORT=3000 HOST=localhost → { server: { port: "3000" }, HOST: "localhost" }
|
|
132
|
+
const source2 = envSource({ mapKey: mapKey.lookup({ PORT: ["server", "port"] }) });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
You can also pass your own `EnvKeyMapper` instead of a built-in strategy — it's just a
|
|
136
|
+
`(key: string) => string[]` function:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const source = envSource({
|
|
140
|
+
mapKey: (key) => (key === "PORT" ? ["server", "port"] : [key]),
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `fetchSource` — a JSON endpoint over HTTP
|
|
145
|
+
|
|
146
|
+
Fetches a JSON snapshot from `url` (with `method` and `headers`, if needed). Only JSON is
|
|
147
|
+
supported — a non-JSON `Content-Type` still gets a fallback parse attempt. `attempts` retries the
|
|
148
|
+
download on a network error or a non-`ok` response (default `1`, no retry). If the download never
|
|
149
|
+
succeeds, or the body isn't valid JSON, it logs a `console.error` and leaves the store empty
|
|
150
|
+
instead of throwing.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { fetchSource } from "@jondotsoy/configs";
|
|
154
|
+
|
|
155
|
+
const source = fetchSource<{ port: number }>({
|
|
156
|
+
url: "https://config-service.internal/app",
|
|
157
|
+
method: "GET",
|
|
158
|
+
headers: { authorization: `Bearer ${process.env.CONFIG_TOKEN}` },
|
|
159
|
+
attempts: 3,
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### `sseSource` — live updates over Server-Sent Events
|
|
164
|
+
|
|
165
|
+
Connects to an SSE endpoint (`url`, `method`, `headers`). Every message tries to parse as JSON and,
|
|
166
|
+
if it's a plain object, is applied as a **patch** on top of what was already received — fields add
|
|
167
|
+
up and overwrite, the tree is never replaced wholesale:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { sseSource } from "@jondotsoy/configs";
|
|
171
|
+
|
|
172
|
+
const source = sseSource<{ port?: number; host?: string }>({
|
|
173
|
+
url: "https://config-service.internal/app/events",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// message: {"port":3000} => Store<{ port: 3000 }>
|
|
177
|
+
// message: {"host":"10.0.0.1"} => Store<{ port: 3000, host: "10.0.0.1" }>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
A message that isn't valid JSON, or doesn't parse to a plain object, is logged via
|
|
181
|
+
`console.error` and skipped — it never resets what was already received.
|
|
182
|
+
|
|
183
|
+
Opening the source waits for the first message (so the `Store` you get back already has data,
|
|
184
|
+
not `null`), then keeps the connection alive in the background, applying further messages as
|
|
185
|
+
patches until the resource closes the stream.
|
|
186
|
+
|
|
187
|
+
### `fileSource` — a local `.json` or `.env` file
|
|
188
|
+
|
|
189
|
+
Reads a config tree from `path`, parsed by its extension: `.json` or `.env` (matched by extension,
|
|
190
|
+
or by the bare `.env` filename itself — a `.env` file always parses to a flat string map, one
|
|
191
|
+
entry per `KEY=VALUE` line, blank lines and `#`-comments skipped). `watch` defaults to `true`: the
|
|
192
|
+
file is re-read and the store updated live on every change; `watch: false` reads it once. A
|
|
193
|
+
missing file, an unrecognized extension, or a parse failure (including on a later watched change)
|
|
194
|
+
is logged via `console.error` and leaves the store empty instead of throwing — a parse error on a
|
|
195
|
+
later change keeps the last good value instead.
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { fileSource } from "@jondotsoy/configs";
|
|
199
|
+
|
|
200
|
+
const source = fileSource<{ port: number; host: string }>("./config.json");
|
|
201
|
+
// config.json: { "port": 3000, "host": "localhost" }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `literalSource` — a static value
|
|
205
|
+
|
|
206
|
+
Publishes a plain, already-in-hand value as a snapshot immediately, then closes. No I/O, no
|
|
207
|
+
options — just wraps `value` in a `Source` so it can sit in a `sources` array alongside the rest.
|
|
208
|
+
Handy as a static fallback tree (put it last so real sources win), a hardcoded default for a
|
|
209
|
+
single environment, or a stand-in source in a test.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { configs, envSource, literalSource, mapKey } from "@jondotsoy/configs";
|
|
213
|
+
|
|
214
|
+
const cfg = await configs.create(
|
|
215
|
+
{
|
|
216
|
+
port: { type: "number", required: true },
|
|
217
|
+
host: { type: "string", required: true },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
sources: [
|
|
221
|
+
envSource({ mapKey: mapKey.snakeCase() }),
|
|
222
|
+
literalSource({ port: 3000, host: "localhost" }), // fallback if env vars are unset
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Reacting to changes — restarting a periodic task
|
|
229
|
+
|
|
230
|
+
Because every field is a live `Store`, `.subscribe()` is the hook point for keeping something
|
|
231
|
+
else in sync with the config — for example, restarting a `setInterval` job whenever its period
|
|
232
|
+
changes. This only really happens at runtime with a live source like `sseSource`; an
|
|
233
|
+
`envSource` resolves once and never changes:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { configs, sseSource } from "@jondotsoy/configs";
|
|
237
|
+
|
|
238
|
+
async function cleanupTempFiles() {
|
|
239
|
+
// ...
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const cfg = await configs.create(
|
|
243
|
+
{
|
|
244
|
+
service: configs.create({
|
|
245
|
+
cleanupIntervalMs: { type: "number", summary: "cleanup interval", default: 60_000 },
|
|
246
|
+
}),
|
|
247
|
+
},
|
|
248
|
+
{ sources: [sseSource({ url: "https://config-service.internal/app/events" })] },
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
252
|
+
|
|
253
|
+
const unsubscribe = cfg.service.cleanupIntervalMs.subscribe((intervalMs) => {
|
|
254
|
+
clearInterval(timer);
|
|
255
|
+
timer = setInterval(cleanupTempFiles, intervalMs);
|
|
256
|
+
return () => clearInterval(timer); // runs when `unsubscribe()` is called, not on the next change
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`subscribe` fires immediately with the current value (starting the first timer) and again on every
|
|
261
|
+
subsequent change — `clearInterval(timer)` cancels the previous one before `setInterval` starts the
|
|
262
|
+
new one, so there's never more than one timer running for this field. The callback's returned
|
|
263
|
+
cleanup only fires once, when `unsubscribe()` itself is called, so it's the right place to stop the
|
|
264
|
+
last timer for good — it's not a substitute for the `clearInterval` at the top of the callback.
|
|
265
|
+
|
|
266
|
+
### Closing a config tree
|
|
267
|
+
|
|
268
|
+
`configs.create(...)` results (and their nested groups) expose `close()`, which closes every
|
|
269
|
+
source backing them — for `sseSource`, this aborts the live connection instead of leaving
|
|
270
|
+
it open in the background:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { configs, sseSource } from "@jondotsoy/configs";
|
|
274
|
+
|
|
275
|
+
const source = sseSource({ url: "https://config-service.internal/app/events" });
|
|
276
|
+
const serverConfigs = await configs.create({ port: { type: "number" } }, { sources: [source] });
|
|
277
|
+
|
|
278
|
+
await serverConfigs.close();
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
It also implements `Symbol.asyncDispose`, so `await using` closes it automatically at the end of
|
|
282
|
+
the scope — including when the scope throws:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
import { configs, sseSource } from "@jondotsoy/configs";
|
|
286
|
+
|
|
287
|
+
async function run() {
|
|
288
|
+
await using serverConfigs = await configs.create(
|
|
289
|
+
{ port: { type: "number" } },
|
|
290
|
+
{ sources: [sseSource({ url: "https://config-service.internal/app/events" })] },
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
console.log(serverConfigs.port.get());
|
|
294
|
+
// closed automatically here, no explicit serverConfigs.close() needed
|
|
295
|
+
}
|
|
296
|
+
```
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Source } from "./sources/source";
|
|
2
|
+
import { Store, type Subscriber, type Unsubscribe } from "./utils/store";
|
|
3
|
+
export type FieldType = "string" | "number" | "boolean";
|
|
4
|
+
interface BaseFieldSchema {
|
|
5
|
+
summary?: string;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
/** Freezes the field at its first resolved value: later source updates no longer reach `.get()`. */
|
|
8
|
+
readonly?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type FieldSchema = (BaseFieldSchema & {
|
|
11
|
+
type: "string";
|
|
12
|
+
pattern?: RegExp;
|
|
13
|
+
default?: string;
|
|
14
|
+
}) | (BaseFieldSchema & {
|
|
15
|
+
type: "number";
|
|
16
|
+
default?: number;
|
|
17
|
+
}) | (BaseFieldSchema & {
|
|
18
|
+
type: "boolean";
|
|
19
|
+
default?: boolean;
|
|
20
|
+
});
|
|
21
|
+
export interface CreateOptions {
|
|
22
|
+
sources?: Source<any>[];
|
|
23
|
+
/**
|
|
24
|
+
* Static, lowest-priority fallback tree — checked after every source, before a field's own `default`.
|
|
25
|
+
* @deprecated No longer read by `configs.create()`.
|
|
26
|
+
*/
|
|
27
|
+
defaultValues?: unknown;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Discriminates a nested group from a leaf field inside a `SchemaShape`. Deliberately not generic
|
|
31
|
+
* (unlike `SchemaGroup<S>` below): a generic member here — even `SchemaGroup<any>` — becomes the
|
|
32
|
+
* contextual type TypeScript propagates into a nested `configs.create(...)` call written inline as
|
|
33
|
+
* a shape property, and `any` there widens every literal `type: "string"` inside it to `string`,
|
|
34
|
+
* breaking every field's inferred type. This plain marker carries no such poison.
|
|
35
|
+
*/
|
|
36
|
+
interface SchemaGroupNode {
|
|
37
|
+
readonly shape: SchemaShape;
|
|
38
|
+
}
|
|
39
|
+
export type SchemaNode = FieldSchema | SchemaGroupNode;
|
|
40
|
+
export type SchemaShape = Record<string, SchemaNode>;
|
|
41
|
+
type PrimitiveOfField<F extends FieldSchema> = F extends {
|
|
42
|
+
type: "number";
|
|
43
|
+
} ? number : F extends {
|
|
44
|
+
type: "string";
|
|
45
|
+
} ? string : F extends {
|
|
46
|
+
type: "boolean";
|
|
47
|
+
} ? boolean : never;
|
|
48
|
+
/**
|
|
49
|
+
* A field only ever resolves to `null` when no source has it and it has no `default` — data comes
|
|
50
|
+
* from async sources that may simply have nothing, so `required` documents intent but can't be enforced
|
|
51
|
+
* at runtime and doesn't affect this type.
|
|
52
|
+
*/
|
|
53
|
+
type HasDefault<F extends FieldSchema> = F extends {
|
|
54
|
+
default: any;
|
|
55
|
+
} ? true : false;
|
|
56
|
+
type InferFieldValue<F extends FieldSchema> = HasDefault<F> extends true ? PrimitiveOfField<F> : PrimitiveOfField<F> | null;
|
|
57
|
+
type InferField<F extends SchemaNode> = F extends ConfigNode<infer S> ? InferShape<S> : F extends FieldSchema ? InferFieldValue<F> : never;
|
|
58
|
+
export type InferShape<S extends SchemaShape> = {
|
|
59
|
+
[K in keyof S]: InferField<S[K]>;
|
|
60
|
+
};
|
|
61
|
+
/** A read-only view of a `Store<T>`: exposes `get`/`subscribe`/`listen` but never `set`. */
|
|
62
|
+
export interface ReadOnlyStore<T> {
|
|
63
|
+
get(): T;
|
|
64
|
+
/** Calls `subscriber` immediately with the current value, then on every subsequent update. */
|
|
65
|
+
subscribe(subscriber: Subscriber<T>): Unsubscribe;
|
|
66
|
+
/** Calls `subscriber` only on subsequent updates, not with the current value. */
|
|
67
|
+
listen(subscriber: Subscriber<T>): Unsubscribe;
|
|
68
|
+
}
|
|
69
|
+
type InferReadOnlyAccessor<F extends SchemaNode> = F extends ConfigNode<infer S> ? SchemaGroup<S> : F extends FieldSchema ? ReadOnlyStore<InferFieldValue<F>> : never;
|
|
70
|
+
/** Shape of the live proxy returned by `configs.create()`: a leaf field is a read-only `ReadOnlyStore<T>`, a nested group is a `SchemaGroup`. */
|
|
71
|
+
export type InferReadOnlyAccessors<S extends SchemaShape> = {
|
|
72
|
+
[K in keyof S]: InferReadOnlyAccessor<S[K]>;
|
|
73
|
+
};
|
|
74
|
+
/** A leaf config field, live as a `Store<T>`: read-only — `set()` always throws. */
|
|
75
|
+
declare class ConfigField<T> extends Store<T> {
|
|
76
|
+
set(): void;
|
|
77
|
+
/** Pushes a recomputed value when an upstream source changes, bypassing the public read-only `set()`. */
|
|
78
|
+
_update(value: T): void;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The engine behind a `ConfigNode`/`ConfigNodeResolved`: resolves fields from `rootStores` (in
|
|
82
|
+
* priority order), caches field/child-group instances, and recomputes them live as `rootStores`
|
|
83
|
+
* change. A nested group embedded in a parent (via `configs.create(shape)` with no `options`)
|
|
84
|
+
* shares its parent's `rootStores`; one created with its own `sources` (root form) resolves
|
|
85
|
+
* independently, tracked via `ownsResolution`.
|
|
86
|
+
*/
|
|
87
|
+
declare class ConfigNodeState<S extends SchemaShape> {
|
|
88
|
+
readonly shape: S;
|
|
89
|
+
private readonly basePath;
|
|
90
|
+
private readonly ownSources;
|
|
91
|
+
readonly ownsResolution: boolean;
|
|
92
|
+
/** The sources this node's (or an ancestor's) `close()` actually closes. */
|
|
93
|
+
private readonly closableSources;
|
|
94
|
+
private rootStores;
|
|
95
|
+
private readonly fields;
|
|
96
|
+
private readonly children;
|
|
97
|
+
private snapshotStore;
|
|
98
|
+
readonly readyPromise: Promise<void>;
|
|
99
|
+
constructor(shape: S, rootStores: Store<any>[], basePath: string[], ownSources: Source<any>[], ownsResolution: boolean,
|
|
100
|
+
/** The sources this node's (or an ancestor's) `close()` actually closes. */
|
|
101
|
+
closableSources: Source<any>[]);
|
|
102
|
+
private wireLiveUpdates;
|
|
103
|
+
private resolveField;
|
|
104
|
+
private refreshFields;
|
|
105
|
+
private refreshSnapshot;
|
|
106
|
+
fieldFor(key: string): ConfigField<any>;
|
|
107
|
+
childNode(key: string): object;
|
|
108
|
+
get(): InferShape<S>;
|
|
109
|
+
set(key: string): never;
|
|
110
|
+
private ensureSnapshotStore;
|
|
111
|
+
subscribe(subscriber: Subscriber<InferShape<S>>): Unsubscribe;
|
|
112
|
+
listen(subscriber: Subscriber<InferShape<S>>): Unsubscribe;
|
|
113
|
+
close(): Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Shared, non-thenable base behind both `ConfigNode` and `ConfigNodeResolved`: a resolved node
|
|
117
|
+
* must not itself be thenable (awaiting a thenable whose `then()` resolves to itself is a
|
|
118
|
+
* chaining cycle, both at runtime per the Promise spec and in TypeScript's `Awaited<T>`, which
|
|
119
|
+
* rejects the type outright with a "referenced directly or indirectly in the fulfillment
|
|
120
|
+
* callback of its own `then` method" error) — so `then()` lives only on `ConfigNode`, added by a
|
|
121
|
+
* sibling subclass rather than inherited.
|
|
122
|
+
*/
|
|
123
|
+
declare class ConfigNodeCore<S extends SchemaShape> {
|
|
124
|
+
readonly _state: ConfigNodeState<S>;
|
|
125
|
+
constructor(_state: ConfigNodeState<S>);
|
|
126
|
+
get shape(): S;
|
|
127
|
+
/** Snapshot of every field's current value, recursing into nested groups. */
|
|
128
|
+
get(): InferShape<S>;
|
|
129
|
+
/** Always throws: config values are read-only. */
|
|
130
|
+
set<K extends keyof S & string>(key: K, _value: InferShape<S>[K]): void;
|
|
131
|
+
/** Calls `subscriber` immediately with the current snapshot (per `get()`), then again on every subsequent change to it. */
|
|
132
|
+
subscribe(subscriber: Subscriber<InferShape<S>>): Unsubscribe;
|
|
133
|
+
/** Calls `subscriber` only on a subsequent change to the snapshot, not with the current one. */
|
|
134
|
+
listen(subscriber: Subscriber<InferShape<S>>): Unsubscribe;
|
|
135
|
+
/** Closes every source backing this config tree, including nested groups' own. A no-op for a nested-form group of its own. */
|
|
136
|
+
close(): Promise<void>;
|
|
137
|
+
/** Enables `await using s = await configs.create(...)`: disposal closes the config tree. */
|
|
138
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
139
|
+
}
|
|
140
|
+
/** A `ConfigNode` once every source behind it has published its first snapshot. Synchronous only — no longer thenable. */
|
|
141
|
+
export declare class ConfigNodeResolved<S extends SchemaShape> extends ConfigNodeCore<S> {
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `configs.create()`'s return value: synchronous and read-only, but also a `PromiseLike` —
|
|
145
|
+
* awaiting it resolves once every source has published its first snapshot, yielding a
|
|
146
|
+
* `ConfigNodeResolved`. A nested group (`configs.create(shape)` with no `options`) never needs
|
|
147
|
+
* awaiting: it shares its parent's already-resolved values unless given sources of its own.
|
|
148
|
+
*/
|
|
149
|
+
export declare class ConfigNode<S extends SchemaShape> extends ConfigNodeCore<S> implements PromiseLike<ConfigNodeResolved<S> & InferReadOnlyAccessors<S>> {
|
|
150
|
+
/**
|
|
151
|
+
* `ConfigNodeResolved` is a sibling class, not a subclass (see the note on `ConfigNodeCore`),
|
|
152
|
+
* so it fails a plain `instanceof ConfigNode` check. Custom `Symbol.hasInstance` widens that
|
|
153
|
+
* check to "any `ConfigNodeCore`" instead, which both classes satisfy — without giving
|
|
154
|
+
* `ConfigNodeResolved` a real `then()` and reintroducing the chaining cycle.
|
|
155
|
+
*/
|
|
156
|
+
static [Symbol.hasInstance](instance: unknown): boolean;
|
|
157
|
+
then<TResult1 = ConfigNodeResolved<S> & InferReadOnlyAccessors<S>, TResult2 = never>(onfulfilled?: ((value: ConfigNodeResolved<S> & InferReadOnlyAccessors<S>) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | undefined | null): PromiseLike<TResult1 | TResult2>;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A nested config group embedded in a parent shape: either shares the parent's resolved values,
|
|
161
|
+
* or — when created with its own `sources` — resolves independently of it.
|
|
162
|
+
*/
|
|
163
|
+
export type SchemaGroup<S extends SchemaShape = SchemaShape> = ConfigNode<S> & InferReadOnlyAccessors<S>;
|
|
164
|
+
/**
|
|
165
|
+
* Nested-group form (no `options`): synchronous, no sources of its own — inherits the
|
|
166
|
+
* parent's resolved values when embedded, unless `options` gives it its own.
|
|
167
|
+
*
|
|
168
|
+
* Root form (`options` given): opens every source. Returns synchronously as a `ConfigNode`
|
|
169
|
+
* and is awaitable — for each field, the first source (in array order) whose snapshot has
|
|
170
|
+
* that field wins; a field missing everywhere falls back to the field's own `default`, else `null`.
|
|
171
|
+
*/
|
|
172
|
+
export declare function createConfigNode<S extends SchemaShape>(shape: S, options?: CreateOptions): ConfigNode<S> & InferReadOnlyAccessors<S>;
|
|
173
|
+
/** The type of the `configs` namespace object. */
|
|
174
|
+
export interface configs {
|
|
175
|
+
create<S extends SchemaShape>(shape: S, options?: CreateOptions): ConfigNode<S> & InferReadOnlyAccessors<S>;
|
|
176
|
+
}
|
|
177
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { configs as ConfigsApi } from "./config.types";
|
|
2
|
+
export type { CreateOptions, FieldSchema, FieldType, InferReadOnlyAccessors, InferShape, ReadOnlyStore, SchemaGroup, SchemaNode, SchemaShape, } from "./config.types";
|
|
3
|
+
export type { SourceControl, UnderlyingSource } from "./types";
|
|
4
|
+
export type { DataType, DataTypeName } from "./utils/data-types";
|
|
5
|
+
export type { EnvSourceOptions, EnvKeyMapper } from "./sources/env";
|
|
6
|
+
export type { FetchSourceOptions } from "./sources/fetch";
|
|
7
|
+
export type { SseSourceOptions } from "./sources/sse";
|
|
8
|
+
export type { FileSourceOptions } from "./sources/file";
|
|
9
|
+
export { ConfigNode, ConfigNodeResolved } from "./config.types";
|
|
10
|
+
export { Source } from "./sources/source";
|
|
11
|
+
export { envSource, mapKey } from "./sources/env";
|
|
12
|
+
export { fetchSource } from "./sources/fetch";
|
|
13
|
+
export { sseSource } from "./sources/sse";
|
|
14
|
+
export { fileSource } from "./sources/file";
|
|
15
|
+
export { literalSource } from "./sources/literal";
|
|
16
|
+
export { Store } from "./utils/store";
|
|
17
|
+
export { DataTypes } from "./utils/data-types";
|
|
18
|
+
export { DotEnv } from "./utils/dotenv";
|
|
19
|
+
export { ConfigError } from "./errors";
|
|
20
|
+
export declare const configs: ConfigsApi;
|
|
21
|
+
export default configs;
|