@evinvest/settings 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/README.md +114 -0
- package/dist/index.d.ts +254 -0
- package/dist/index.js +241 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# `@evinvest/settings`
|
|
2
|
+
|
|
3
|
+
Typed, validated env settings with aggregate error reporting and a
|
|
4
|
+
server/client split — **zero runtime dependencies**, one server-safe,
|
|
5
|
+
browser-safe ESM entry. The TypeScript mirror of the `settings` Cargo feature
|
|
6
|
+
of [`ev_lib`](../../rust/src/settings).
|
|
7
|
+
|
|
8
|
+
> This package reads **environment variables only** — no config files, no hot
|
|
9
|
+
> reload, and no decryption: secrets management stays at the shell/CI boundary
|
|
10
|
+
> (sops + age). See the [GUIDE](./GUIDE.md) and the Rust
|
|
11
|
+
> [GUIDE](../../rust/src/settings/GUIDE.md#secrets-the-sops-boundary) for the
|
|
12
|
+
> full sops workflow.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm i @evinvest/settings
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
One `settings.ts` per app, evaluated at module scope so a bad environment fails
|
|
23
|
+
the boot — and, imported from `next.config.*`, the build:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createSettings, list, port, presets, secret, str, url, withDefault } from '@evinvest/settings';
|
|
27
|
+
|
|
28
|
+
export const settings = createSettings({
|
|
29
|
+
server: {
|
|
30
|
+
SESSION_REDIS_URL: url(),
|
|
31
|
+
PORT: withDefault(port(), '8080'),
|
|
32
|
+
BANKING_ISSUANCE_TOKEN: secret(str()),
|
|
33
|
+
MFE_ALLOWED_ORIGINS: withDefault(list(), ''),
|
|
34
|
+
...presets.posthog(), // POSTHOG_KEY / POSTHOG_HOST, canonical names
|
|
35
|
+
},
|
|
36
|
+
clientPrefix: 'NEXT_PUBLIC_',
|
|
37
|
+
client: {
|
|
38
|
+
...presets.posthogClient(), // NEXT_PUBLIC_POSTHOG_KEY / _HOST
|
|
39
|
+
},
|
|
40
|
+
// explicit destructure: bundlers inline NEXT_PUBLIC_* / import.meta.env.*
|
|
41
|
+
// only for static member expressions
|
|
42
|
+
runtimeEnv: {
|
|
43
|
+
SESSION_REDIS_URL: process.env.SESSION_REDIS_URL,
|
|
44
|
+
PORT: process.env.PORT,
|
|
45
|
+
BANKING_ISSUANCE_TOKEN: process.env.BANKING_ISSUANCE_TOKEN,
|
|
46
|
+
MFE_ALLOWED_ORIGINS: process.env.MFE_ALLOWED_ORIGINS,
|
|
47
|
+
POSTHOG_KEY: process.env.POSTHOG_KEY,
|
|
48
|
+
POSTHOG_HOST: process.env.POSTHOG_HOST,
|
|
49
|
+
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
|
50
|
+
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
settings.PORT; // number — typed, validated
|
|
55
|
+
settings.POSTHOG_KEY; // string | undefined (optional)
|
|
56
|
+
// on the client: settings.SESSION_REDIS_URL throws (server-only)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A missing/invalid environment throws one `SettingsError` listing **every**
|
|
60
|
+
problem:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
invalid settings (2 problems)
|
|
64
|
+
- SESSION_REDIS_URL: missing
|
|
65
|
+
- PORT: invalid value "banana": expected a finite number
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Validators: `str`, `num`, `int`, `port`, `bool`, `url`, `list`, `oneOf` —
|
|
69
|
+
refined by `optional(v)`, `withDefault(v, 'literal')`, `secret(v)`.
|
|
70
|
+
|
|
71
|
+
## Rust ↔ TS parity
|
|
72
|
+
|
|
73
|
+
The Rust crate is the source of truth; this package preserves its *semantics*.
|
|
74
|
+
The full mapping table lives in the Rust
|
|
75
|
+
[README](../../rust/src/settings/README.md#rust--ts-parity); the load-bearing
|
|
76
|
+
shared rules:
|
|
77
|
+
|
|
78
|
+
- var names are written-out SCREAMING_SNAKE keys; required by default;
|
|
79
|
+
`optional(v)` ↔ `Option<T>`; `withDefault(v, lit)` ↔ `= "lit"` (the literal
|
|
80
|
+
parses by the same rules, only when unset).
|
|
81
|
+
- the **empty string is unset**; `bool` accepts `true`/`false`/`1`/`0`
|
|
82
|
+
case-insensitively; `list` splits on `,`, trims items, drops empty items;
|
|
83
|
+
scalars are **not** trimmed; number grammar matches Rust `FromStr` (`int`:
|
|
84
|
+
plain decimal; `num`: decimal/point/exponent, no hex). Documented
|
|
85
|
+
divergences: JS numbers are doubles, so `num()` requires finite values
|
|
86
|
+
(Rust `f64` also accepts `inf`/`NaN`) and `int()` stops at the safe range
|
|
87
|
+
`±(2^53 - 1)` — use `str()` for 64-bit ids.
|
|
88
|
+
- errors aggregate into one `SettingsError` (message shape shared with the
|
|
89
|
+
Rust `Display` impl); `secret(v)` redacts values in error output.
|
|
90
|
+
- the contract is pinned by mirrored vectors:
|
|
91
|
+
[`test/contract.node.test.ts`](./test/contract.node.test.ts) ↔
|
|
92
|
+
`rust/src/settings/tests.rs` (`mod contract`). Change both sides or neither.
|
|
93
|
+
|
|
94
|
+
TS-only (browser-bundler concerns, no Rust equivalent): the `server`/`client`
|
|
95
|
+
split with `clientPrefix`, the explicit `runtimeEnv` destructure, and the
|
|
96
|
+
`NEXT_PUBLIC_*` client presets.
|
|
97
|
+
|
|
98
|
+
## Limitations
|
|
99
|
+
|
|
100
|
+
- **Env-only, flat.** No config files, no `__` nesting — by design.
|
|
101
|
+
- **`runtimeEnv` must destructure explicitly** for client vars: bundlers
|
|
102
|
+
(Next.js `NEXT_PUBLIC_*`, Vite `import.meta.env`) inline only static member
|
|
103
|
+
expressions at build time.
|
|
104
|
+
- **`secret(v)` redacts what the library emits** (errors/issues). JS has no
|
|
105
|
+
`Debug` boundary — `console.log(settings.TOKEN)` still prints the value
|
|
106
|
+
(unlike Rust, where the generated `Debug` prints `***`).
|
|
107
|
+
- **Worker runtimes:** the default server detection is "no `window` in
|
|
108
|
+
`globalThis`" — pass `isServer` explicitly in web workers.
|
|
109
|
+
|
|
110
|
+
## Develop
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
npm run typecheck && npm test && npm run build
|
|
114
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate settings failure — every missing/invalid variable found in one
|
|
3
|
+
* pass, mirroring `SettingsError` of the Rust `settings` feature. The message
|
|
4
|
+
* format is kept in step with the Rust `Display` impl:
|
|
5
|
+
*
|
|
6
|
+
* ```text
|
|
7
|
+
* invalid settings (2 problems)
|
|
8
|
+
* - DATABASE_URL: missing
|
|
9
|
+
* - PORT: invalid value "abc": expected an integer
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
/** What went wrong with one variable. */
|
|
13
|
+
type SettingsIssueKind = 'missing' | 'invalid';
|
|
14
|
+
/** One problem with one variable. */
|
|
15
|
+
interface SettingsIssue {
|
|
16
|
+
/** The env var name as it was looked up. */
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly kind: SettingsIssueKind;
|
|
19
|
+
/** For `invalid` issues: what went wrong while parsing. */
|
|
20
|
+
readonly detail?: string;
|
|
21
|
+
/** The offending raw value. Omitted for `secret(...)` settings. */
|
|
22
|
+
readonly value?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Thrown by {@link createSettings} when any variable is missing or invalid.
|
|
26
|
+
* Carries every problem at once — fix the whole list in one edit instead of
|
|
27
|
+
* replaying the boot loop per variable.
|
|
28
|
+
*/
|
|
29
|
+
declare class SettingsError extends Error {
|
|
30
|
+
readonly issues: readonly SettingsIssue[];
|
|
31
|
+
constructor(issues: readonly SettingsIssue[]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validators — how a raw env string becomes a typed value, and the three
|
|
36
|
+
* wrappers that mirror the Rust field grammar: {@link optional} ↔ `Option<T>`,
|
|
37
|
+
* {@link withDefault} ↔ `= "literal"`, {@link secret} ↔ `#[secret]`.
|
|
38
|
+
*
|
|
39
|
+
* The shared Rust↔TS parsing contract lives here: `bool` accepts
|
|
40
|
+
* `true`/`false`/`1`/`0` case-insensitively, {@link list} is comma-separated
|
|
41
|
+
* with trimmed items and empty items dropped, and scalars are **not** trimmed —
|
|
42
|
+
* `" 8080"` is invalid for a number on both sides.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Parses one env value into a typed setting. Build one with {@link str},
|
|
46
|
+
* {@link num}, {@link int}, {@link port}, {@link bool}, {@link url},
|
|
47
|
+
* {@link list}, or {@link oneOf}, then refine it with {@link optional},
|
|
48
|
+
* {@link withDefault}, and {@link secret}.
|
|
49
|
+
*
|
|
50
|
+
* The named validators are idiomatic TS and deliberately *not* a 1:1 copy of
|
|
51
|
+
* the Rust side, where types parse themselves through the `FromEnvValue` trait
|
|
52
|
+
* (`port: u16`, `bind: SocketAddr`); what both sides share is the semantics
|
|
53
|
+
* listed in the module docs.
|
|
54
|
+
*/
|
|
55
|
+
interface Validator<T> {
|
|
56
|
+
/** Human name used in error messages (e.g. `number`). */
|
|
57
|
+
readonly kind: string;
|
|
58
|
+
/** Unset is fine and yields `undefined` — the mirror of `Option<T>`. */
|
|
59
|
+
readonly optional: boolean;
|
|
60
|
+
/** Redact the raw value in error output — the mirror of `#[secret]`. */
|
|
61
|
+
readonly secret: boolean;
|
|
62
|
+
/** Used when the variable is unset; parsed by the same rules as an env value. */
|
|
63
|
+
readonly defaultLiteral: string | undefined;
|
|
64
|
+
/** Parse a raw (non-empty) env string; throws `Error(message)` when invalid. */
|
|
65
|
+
readonly parse: (raw: string) => T;
|
|
66
|
+
}
|
|
67
|
+
/** Any string, taken verbatim (no trimming). */
|
|
68
|
+
declare function str(): Validator<string>;
|
|
69
|
+
/** Contract: `true`/`1` and `false`/`0`, case-insensitive, no trimming. */
|
|
70
|
+
declare function bool(): Validator<boolean>;
|
|
71
|
+
/**
|
|
72
|
+
* A finite decimal number (point/exponent forms included). Not trimmed:
|
|
73
|
+
* `" 3"` is invalid, like Rust's `FromStr`. Divergence from the Rust mirror:
|
|
74
|
+
* Rust `f64` also accepts `inf`/`NaN` — this validator requires finite.
|
|
75
|
+
*/
|
|
76
|
+
declare function num(): Validator<number>;
|
|
77
|
+
/**
|
|
78
|
+
* A plain decimal integer (no exponent/point/hex forms — Rust integer
|
|
79
|
+
* `FromStr` grammar), limited to the safe range `±(2^53 - 1)`: JS numbers are
|
|
80
|
+
* doubles, so bigger values would silently round (Rust 64-bit integers parse
|
|
81
|
+
* them exactly — use {@link str} for 64-bit ids).
|
|
82
|
+
*/
|
|
83
|
+
declare function int(): Validator<number>;
|
|
84
|
+
/** A TCP/UDP port: an integer in `1..=65535`. */
|
|
85
|
+
declare function port(): Validator<number>;
|
|
86
|
+
/** An absolute URL (validated with `new URL`, returned as the original string). */
|
|
87
|
+
declare function url(): Validator<string>;
|
|
88
|
+
/**
|
|
89
|
+
* One of a fixed set of strings, narrowed to the literal union:
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* const env = oneOf(['development', 'production']); // Validator<'development' | 'production'>
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
declare function oneOf<const V extends string>(values: readonly V[]): Validator<V>;
|
|
96
|
+
/**
|
|
97
|
+
* Contract: split on `,`, trim each item, drop empty items, parse the rest
|
|
98
|
+
* with `item` (default {@link str}). Item numbering in errors counts the kept
|
|
99
|
+
* items from 1 and never leaks the item value, so secret lists stay
|
|
100
|
+
* redactable. Wrap the *list* in {@link secret}/{@link optional}, not the item.
|
|
101
|
+
*/
|
|
102
|
+
declare function list(): Validator<readonly string[]>;
|
|
103
|
+
declare function list<T>(item: Validator<T>): Validator<readonly T[]>;
|
|
104
|
+
/**
|
|
105
|
+
* Unset (or empty) is fine and yields `undefined` — the mirror of a Rust
|
|
106
|
+
* `Option<T>` field. Cannot be combined with {@link withDefault}: a defaulted
|
|
107
|
+
* setting is always present ({@link createSettings} rejects the combination).
|
|
108
|
+
*/
|
|
109
|
+
declare function optional<T>(validator: Validator<T>): Validator<T | undefined>;
|
|
110
|
+
/**
|
|
111
|
+
* Fall back to `literal` when the variable is unset. The literal goes through
|
|
112
|
+
* the exact same parsing rules as an env value — the mirror of the Rust
|
|
113
|
+
* `= "literal"` field default.
|
|
114
|
+
*/
|
|
115
|
+
declare function withDefault<T>(validator: Validator<T>, literal: string): Validator<T>;
|
|
116
|
+
/**
|
|
117
|
+
* Redact the raw value in error output — the mirror of `#[secret]`. Note that
|
|
118
|
+
* unlike Rust (whose generated `Debug` prints `***`), JS has no debug-print
|
|
119
|
+
* boundary: `console.log(settings.TOKEN)` prints the real value. The redaction
|
|
120
|
+
* covers what the *library* emits: `SettingsError` messages and issues.
|
|
121
|
+
*/
|
|
122
|
+
declare function secret<T>(validator: Validator<T>): Validator<T>;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* {@link createSettings} — read a validated, typed settings object out of an
|
|
126
|
+
* explicitly injected environment record, with a server/client split for
|
|
127
|
+
* browser bundles.
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/** A settings declaration: env var name → {@link Validator}. */
|
|
131
|
+
type SettingsSchema = Record<string, Validator<unknown>>;
|
|
132
|
+
/** The typed settings object a schema produces. */
|
|
133
|
+
type InferSettings<S extends SettingsSchema> = {
|
|
134
|
+
readonly [K in keyof S]: S[K] extends Validator<infer T> ? T : never;
|
|
135
|
+
};
|
|
136
|
+
interface CreateSettingsOptions<S extends SettingsSchema, C extends SettingsSchema> {
|
|
137
|
+
/** Server-only settings. Accessing one from client code throws. */
|
|
138
|
+
readonly server?: S;
|
|
139
|
+
/**
|
|
140
|
+
* Client-exposable settings. Every key must start with {@link clientPrefix}
|
|
141
|
+
* so nothing leaks into a browser bundle by accident.
|
|
142
|
+
*/
|
|
143
|
+
readonly client?: C;
|
|
144
|
+
/** Required when `client` is non-empty — e.g. `NEXT_PUBLIC_`. */
|
|
145
|
+
readonly clientPrefix?: string;
|
|
146
|
+
/**
|
|
147
|
+
* The environment record, destructured **explicitly** — bundlers inline
|
|
148
|
+
* `process.env.NEXT_PUBLIC_*` / `import.meta.env.*` at build time only when
|
|
149
|
+
* each variable is a static member expression, so a bare `process.env` pass
|
|
150
|
+
* works on the server but leaves client values undefined in the browser:
|
|
151
|
+
*
|
|
152
|
+
* ```ts
|
|
153
|
+
* runtimeEnv: {
|
|
154
|
+
* DATABASE_URL: process.env.DATABASE_URL,
|
|
155
|
+
* NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
|
156
|
+
* }
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
readonly runtimeEnv: Readonly<Record<string, string | undefined>>;
|
|
160
|
+
/** Contract: `VAR=` (empty string) behaves like unset. Default `true`. */
|
|
161
|
+
readonly emptyStringAsUnset?: boolean;
|
|
162
|
+
/**
|
|
163
|
+
* Where we are running. Default: `window` is absent from `globalThis`.
|
|
164
|
+
* Override for workers or exotic runtimes.
|
|
165
|
+
*/
|
|
166
|
+
readonly isServer?: boolean;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Validate the injected environment against the schema and return the typed,
|
|
170
|
+
* read-only settings object. Mirrors the Rust `settings!` macro's
|
|
171
|
+
* `from_source`, plus the client/server split (a bundler concern with no Rust
|
|
172
|
+
* equivalent).
|
|
173
|
+
*
|
|
174
|
+
* - Every missing/invalid variable is reported at once in one
|
|
175
|
+
* {@link SettingsError} — no fix-one-reboot-fix-next loops.
|
|
176
|
+
* - Validation runs eagerly, at the `createSettings` call. Call it at module
|
|
177
|
+
* scope of a single `settings.ts` so a bad environment fails the boot (or,
|
|
178
|
+
* in Next.js, the build — import your settings module from `next.config`).
|
|
179
|
+
* - On the client only the `client` schema is validated (server values never
|
|
180
|
+
* reach the bundle); accessing a server key there throws.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* export const settings = createSettings({
|
|
185
|
+
* server: {
|
|
186
|
+
* DATABASE_URL: url(),
|
|
187
|
+
* SIGNING_KEY: secret(str()),
|
|
188
|
+
* PORT: withDefault(port(), '8080'),
|
|
189
|
+
* ...presets.posthog(),
|
|
190
|
+
* },
|
|
191
|
+
* clientPrefix: 'NEXT_PUBLIC_',
|
|
192
|
+
* client: { ...presets.posthogClient() },
|
|
193
|
+
* runtimeEnv: {
|
|
194
|
+
* DATABASE_URL: process.env.DATABASE_URL,
|
|
195
|
+
* SIGNING_KEY: process.env.SIGNING_KEY,
|
|
196
|
+
* PORT: process.env.PORT,
|
|
197
|
+
* POSTHOG_KEY: process.env.POSTHOG_KEY,
|
|
198
|
+
* POSTHOG_HOST: process.env.POSTHOG_HOST,
|
|
199
|
+
* NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
|
200
|
+
* NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
|
201
|
+
* },
|
|
202
|
+
* });
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
declare function createSettings<S extends SettingsSchema = Record<never, never>, C extends SettingsSchema = Record<never, never>>(options: CreateSettingsOptions<S, C>): InferSettings<S> & InferSettings<C>;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Org-canonical shared variable groups — one place that fixes the names, so
|
|
209
|
+
* `POSTHOG_KEY` vs `POSTHOG_API_KEY` vs `NEXT_PUBLIC_POSTHOG_KEY` drift stops
|
|
210
|
+
* at the source. Spread a preset into the `server` / `client` block of
|
|
211
|
+
* {@link createSettings}; the Rust mirror ships the same server-side groups as
|
|
212
|
+
* ready-made structs (`settings::presets`).
|
|
213
|
+
*/
|
|
214
|
+
|
|
215
|
+
declare const presets: {
|
|
216
|
+
/**
|
|
217
|
+
* PostHog capture credentials, canonical names: `POSTHOG_KEY` /
|
|
218
|
+
* `POSTHOG_HOST`. Both optional — capture is simply off without them. The
|
|
219
|
+
* project key (`phc_…`) is write-only and ships in frontend bundles anyway,
|
|
220
|
+
* so it is not `secret(...)`.
|
|
221
|
+
*/
|
|
222
|
+
readonly posthog: () => {
|
|
223
|
+
POSTHOG_KEY: Validator<string | undefined>;
|
|
224
|
+
POSTHOG_HOST: Validator<string | undefined>;
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Sentry reporting, canonical name: `SENTRY_DSN`. Optional — monitoring is
|
|
228
|
+
* off without it. A DSN authorises event *submission* only, so it is not
|
|
229
|
+
* `secret(...)`.
|
|
230
|
+
*/
|
|
231
|
+
readonly sentry: () => {
|
|
232
|
+
SENTRY_DSN: Validator<string | undefined>;
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* The deployment environment, canonical name: `APP_ENV`. Defaults to
|
|
236
|
+
* `development`; kept a free string on purpose — constraining the values
|
|
237
|
+
* would break consumers that add a stage.
|
|
238
|
+
*/
|
|
239
|
+
readonly appEnv: () => {
|
|
240
|
+
APP_ENV: Validator<string>;
|
|
241
|
+
};
|
|
242
|
+
/** The `client`-block variant of {@link presets.posthog} for Next.js bundles. */
|
|
243
|
+
readonly posthogClient: () => {
|
|
244
|
+
NEXT_PUBLIC_POSTHOG_KEY: Validator<string | undefined>;
|
|
245
|
+
NEXT_PUBLIC_POSTHOG_HOST: Validator<string | undefined>;
|
|
246
|
+
};
|
|
247
|
+
/** The `client`-block variant of {@link presets.sentry} + `APP_ENV` for Next.js bundles. */
|
|
248
|
+
readonly sentryClient: () => {
|
|
249
|
+
NEXT_PUBLIC_SENTRY_DSN: Validator<string | undefined>;
|
|
250
|
+
NEXT_PUBLIC_APP_ENV: Validator<string>;
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export { type CreateSettingsOptions, type InferSettings, SettingsError, type SettingsIssue, type SettingsIssueKind, type SettingsSchema, type Validator, bool, createSettings, int, list, num, oneOf, optional, port, presets, secret, str, url, withDefault };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// src/error.ts
|
|
2
|
+
var SettingsError = class extends Error {
|
|
3
|
+
issues;
|
|
4
|
+
constructor(issues) {
|
|
5
|
+
super(formatIssues(issues));
|
|
6
|
+
this.name = "SettingsError";
|
|
7
|
+
this.issues = issues;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
function formatIssues(issues) {
|
|
11
|
+
const noun = issues.length === 1 ? "problem" : "problems";
|
|
12
|
+
const lines = issues.map((issue) => ` - ${formatIssue(issue)}`);
|
|
13
|
+
return [`invalid settings (${issues.length} ${noun})`, ...lines].join("\n");
|
|
14
|
+
}
|
|
15
|
+
function formatIssue(issue) {
|
|
16
|
+
if (issue.kind === "missing") return `${issue.key}: missing`;
|
|
17
|
+
const value = issue.value === void 0 ? "" : ` ${JSON.stringify(issue.value)}`;
|
|
18
|
+
return `${issue.key}: invalid value${value}: ${issue.detail ?? "failed to parse"}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/validators.ts
|
|
22
|
+
function base(kind, parse) {
|
|
23
|
+
return { kind, optional: false, secret: false, defaultLiteral: void 0, parse };
|
|
24
|
+
}
|
|
25
|
+
function messageOf(error) {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|
|
28
|
+
function str() {
|
|
29
|
+
return base("string", (raw) => raw);
|
|
30
|
+
}
|
|
31
|
+
function bool() {
|
|
32
|
+
return base("boolean", (raw) => {
|
|
33
|
+
const lower = raw.toLowerCase();
|
|
34
|
+
if (lower === "true" || lower === "1") return true;
|
|
35
|
+
if (lower === "false" || lower === "0") return false;
|
|
36
|
+
throw new Error("expected one of `true`, `false`, `1`, `0` (case-insensitive)");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
var FLOAT_GRAMMAR = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
|
40
|
+
var INT_GRAMMAR = /^[+-]?\d+$/;
|
|
41
|
+
function num() {
|
|
42
|
+
return base("number", (raw) => {
|
|
43
|
+
if (!FLOAT_GRAMMAR.test(raw)) throw new Error("expected a finite number");
|
|
44
|
+
const value = Number(raw);
|
|
45
|
+
if (!Number.isFinite(value)) throw new Error("expected a finite number");
|
|
46
|
+
return value;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function int() {
|
|
50
|
+
return base("integer", (raw) => {
|
|
51
|
+
if (!INT_GRAMMAR.test(raw)) throw new Error("expected an integer");
|
|
52
|
+
const value = Number(raw);
|
|
53
|
+
if (!Number.isSafeInteger(value)) throw new Error("expected a safe integer (within \xB1(2^53 - 1))");
|
|
54
|
+
return value;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function port() {
|
|
58
|
+
const inner = int();
|
|
59
|
+
return base("port", (raw) => {
|
|
60
|
+
const value = inner.parse(raw);
|
|
61
|
+
if (value < 1 || value > 65535) throw new Error("expected a port (1-65535)");
|
|
62
|
+
return value;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function url() {
|
|
66
|
+
return base("url", (raw) => {
|
|
67
|
+
if (raw === "" || /\s/.test(raw)) throw new Error("expected an absolute URL");
|
|
68
|
+
try {
|
|
69
|
+
new URL(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error("expected an absolute URL");
|
|
72
|
+
}
|
|
73
|
+
return raw;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function oneOf(values) {
|
|
77
|
+
const expected = `expected one of ${values.map((value) => `\`${value}\``).join(", ")}`;
|
|
78
|
+
return base("choice", (raw) => {
|
|
79
|
+
if (values.includes(raw)) return raw;
|
|
80
|
+
throw new Error(expected);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function list(item) {
|
|
84
|
+
const inner = item ?? str();
|
|
85
|
+
return base(
|
|
86
|
+
"list",
|
|
87
|
+
(raw) => raw.split(",").map((piece) => piece.trim()).filter((piece) => piece !== "").map((piece, index) => {
|
|
88
|
+
try {
|
|
89
|
+
return inner.parse(piece);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(`item ${index + 1}: ${messageOf(error)}`);
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
function optional(validator) {
|
|
97
|
+
return { ...validator, optional: true };
|
|
98
|
+
}
|
|
99
|
+
function withDefault(validator, literal) {
|
|
100
|
+
return { ...validator, defaultLiteral: literal };
|
|
101
|
+
}
|
|
102
|
+
function secret(validator) {
|
|
103
|
+
return { ...validator, secret: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/create-settings.ts
|
|
107
|
+
function createSettings(options) {
|
|
108
|
+
const server = options.server ?? {};
|
|
109
|
+
const client = options.client ?? {};
|
|
110
|
+
const { clientPrefix } = options;
|
|
111
|
+
const emptyAsUnset = options.emptyStringAsUnset ?? true;
|
|
112
|
+
const isServer = options.isServer ?? !("window" in globalThis);
|
|
113
|
+
const serverKeys = Object.keys(server);
|
|
114
|
+
const clientKeys = Object.keys(client);
|
|
115
|
+
if (clientKeys.length > 0 && clientPrefix === void 0) {
|
|
116
|
+
throw new Error("@evinvest/settings: `clientPrefix` is required when `client` settings are declared");
|
|
117
|
+
}
|
|
118
|
+
for (const key of serverKeys) {
|
|
119
|
+
if (Object.hasOwn(client, key)) {
|
|
120
|
+
throw new Error(`@evinvest/settings: setting "${key}" is declared in both server and client`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (clientPrefix !== void 0) {
|
|
124
|
+
for (const key of clientKeys) {
|
|
125
|
+
if (!key.startsWith(clientPrefix)) {
|
|
126
|
+
throw new Error(`@evinvest/settings: client setting "${key}" must start with clientPrefix "${clientPrefix}"`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const key of serverKeys) {
|
|
130
|
+
if (key.startsWith(clientPrefix)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`@evinvest/settings: server setting "${key}" starts with clientPrefix "${clientPrefix}" \u2014 declare it under \`client\``
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [key, validator] of [...Object.entries(server), ...Object.entries(client)]) {
|
|
138
|
+
if (validator.optional && validator.defaultLiteral !== void 0) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`@evinvest/settings: setting "${key}" is both optional and defaulted \u2014 a defaulted setting is always present, drop one`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const active = isServer ? { ...server, ...client } : client;
|
|
145
|
+
const issues = [];
|
|
146
|
+
const values = {};
|
|
147
|
+
for (const [key, validator] of Object.entries(active)) {
|
|
148
|
+
let raw = options.runtimeEnv[key];
|
|
149
|
+
if (emptyAsUnset && raw === "") raw = void 0;
|
|
150
|
+
const fromDefault = raw === void 0 && validator.defaultLiteral !== void 0;
|
|
151
|
+
if (fromDefault) raw = validator.defaultLiteral;
|
|
152
|
+
if (raw === void 0) {
|
|
153
|
+
if (validator.optional) {
|
|
154
|
+
values[key] = void 0;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
issues.push({ key, kind: "missing" });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
values[key] = validator.parse(raw);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
+
issues.push({
|
|
165
|
+
key,
|
|
166
|
+
kind: "invalid",
|
|
167
|
+
detail: fromDefault ? `invalid default: ${message}` : message,
|
|
168
|
+
...validator.secret && !fromDefault ? {} : { value: raw }
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (issues.length > 0) throw new SettingsError(issues);
|
|
173
|
+
Object.freeze(values);
|
|
174
|
+
const serverOnly = new Set(serverKeys);
|
|
175
|
+
return new Proxy(values, {
|
|
176
|
+
get(target, prop, receiver) {
|
|
177
|
+
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
|
|
178
|
+
if (!isServer && serverOnly.has(prop)) {
|
|
179
|
+
throw new Error(`@evinvest/settings: attempted to access server-only setting "${prop}" on the client`);
|
|
180
|
+
}
|
|
181
|
+
return Reflect.get(target, prop, receiver);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/presets.ts
|
|
187
|
+
var presets = {
|
|
188
|
+
/**
|
|
189
|
+
* PostHog capture credentials, canonical names: `POSTHOG_KEY` /
|
|
190
|
+
* `POSTHOG_HOST`. Both optional — capture is simply off without them. The
|
|
191
|
+
* project key (`phc_…`) is write-only and ships in frontend bundles anyway,
|
|
192
|
+
* so it is not `secret(...)`.
|
|
193
|
+
*/
|
|
194
|
+
posthog: () => ({
|
|
195
|
+
POSTHOG_KEY: optional(str()),
|
|
196
|
+
POSTHOG_HOST: optional(str())
|
|
197
|
+
}),
|
|
198
|
+
/**
|
|
199
|
+
* Sentry reporting, canonical name: `SENTRY_DSN`. Optional — monitoring is
|
|
200
|
+
* off without it. A DSN authorises event *submission* only, so it is not
|
|
201
|
+
* `secret(...)`.
|
|
202
|
+
*/
|
|
203
|
+
sentry: () => ({
|
|
204
|
+
SENTRY_DSN: optional(str())
|
|
205
|
+
}),
|
|
206
|
+
/**
|
|
207
|
+
* The deployment environment, canonical name: `APP_ENV`. Defaults to
|
|
208
|
+
* `development`; kept a free string on purpose — constraining the values
|
|
209
|
+
* would break consumers that add a stage.
|
|
210
|
+
*/
|
|
211
|
+
appEnv: () => ({
|
|
212
|
+
APP_ENV: withDefault(str(), "development")
|
|
213
|
+
}),
|
|
214
|
+
/** The `client`-block variant of {@link presets.posthog} for Next.js bundles. */
|
|
215
|
+
posthogClient: () => ({
|
|
216
|
+
NEXT_PUBLIC_POSTHOG_KEY: optional(str()),
|
|
217
|
+
NEXT_PUBLIC_POSTHOG_HOST: optional(str())
|
|
218
|
+
}),
|
|
219
|
+
/** The `client`-block variant of {@link presets.sentry} + `APP_ENV` for Next.js bundles. */
|
|
220
|
+
sentryClient: () => ({
|
|
221
|
+
NEXT_PUBLIC_SENTRY_DSN: optional(str()),
|
|
222
|
+
NEXT_PUBLIC_APP_ENV: withDefault(str(), "development")
|
|
223
|
+
})
|
|
224
|
+
};
|
|
225
|
+
export {
|
|
226
|
+
SettingsError,
|
|
227
|
+
bool,
|
|
228
|
+
createSettings,
|
|
229
|
+
int,
|
|
230
|
+
list,
|
|
231
|
+
num,
|
|
232
|
+
oneOf,
|
|
233
|
+
optional,
|
|
234
|
+
port,
|
|
235
|
+
presets,
|
|
236
|
+
secret,
|
|
237
|
+
str,
|
|
238
|
+
url,
|
|
239
|
+
withDefault
|
|
240
|
+
};
|
|
241
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/error.ts","../src/validators.ts","../src/create-settings.ts","../src/presets.ts"],"sourcesContent":["/**\n * Aggregate settings failure — every missing/invalid variable found in one\n * pass, mirroring `SettingsError` of the Rust `settings` feature. The message\n * format is kept in step with the Rust `Display` impl:\n *\n * ```text\n * invalid settings (2 problems)\n * - DATABASE_URL: missing\n * - PORT: invalid value \"abc\": expected an integer\n * ```\n */\n\n/** What went wrong with one variable. */\nexport type SettingsIssueKind = 'missing' | 'invalid';\n\n/** One problem with one variable. */\nexport interface SettingsIssue {\n /** The env var name as it was looked up. */\n readonly key: string;\n readonly kind: SettingsIssueKind;\n /** For `invalid` issues: what went wrong while parsing. */\n readonly detail?: string;\n /** The offending raw value. Omitted for `secret(...)` settings. */\n readonly value?: string;\n}\n\n/**\n * Thrown by {@link createSettings} when any variable is missing or invalid.\n * Carries every problem at once — fix the whole list in one edit instead of\n * replaying the boot loop per variable.\n */\nexport class SettingsError extends Error {\n readonly issues: readonly SettingsIssue[];\n\n constructor(issues: readonly SettingsIssue[]) {\n super(formatIssues(issues));\n this.name = 'SettingsError';\n this.issues = issues;\n }\n}\n\nfunction formatIssues(issues: readonly SettingsIssue[]): string {\n const noun = issues.length === 1 ? 'problem' : 'problems';\n const lines = issues.map((issue) => ` - ${formatIssue(issue)}`);\n return [`invalid settings (${issues.length} ${noun})`, ...lines].join('\\n');\n}\n\nfunction formatIssue(issue: SettingsIssue): string {\n if (issue.kind === 'missing') return `${issue.key}: missing`;\n // JSON.stringify quotes/escapes like Rust's `{:?}` for printable values\n // (control characters escape differently — cosmetic, not part of the contract).\n const value = issue.value === undefined ? '' : ` ${JSON.stringify(issue.value)}`;\n return `${issue.key}: invalid value${value}: ${issue.detail ?? 'failed to parse'}`;\n}\n","/**\n * Validators — how a raw env string becomes a typed value, and the three\n * wrappers that mirror the Rust field grammar: {@link optional} ↔ `Option<T>`,\n * {@link withDefault} ↔ `= \"literal\"`, {@link secret} ↔ `#[secret]`.\n *\n * The shared Rust↔TS parsing contract lives here: `bool` accepts\n * `true`/`false`/`1`/`0` case-insensitively, {@link list} is comma-separated\n * with trimmed items and empty items dropped, and scalars are **not** trimmed —\n * `\" 8080\"` is invalid for a number on both sides.\n */\n\n/**\n * Parses one env value into a typed setting. Build one with {@link str},\n * {@link num}, {@link int}, {@link port}, {@link bool}, {@link url},\n * {@link list}, or {@link oneOf}, then refine it with {@link optional},\n * {@link withDefault}, and {@link secret}.\n *\n * The named validators are idiomatic TS and deliberately *not* a 1:1 copy of\n * the Rust side, where types parse themselves through the `FromEnvValue` trait\n * (`port: u16`, `bind: SocketAddr`); what both sides share is the semantics\n * listed in the module docs.\n */\nexport interface Validator<T> {\n /** Human name used in error messages (e.g. `number`). */\n readonly kind: string;\n /** Unset is fine and yields `undefined` — the mirror of `Option<T>`. */\n readonly optional: boolean;\n /** Redact the raw value in error output — the mirror of `#[secret]`. */\n readonly secret: boolean;\n /** Used when the variable is unset; parsed by the same rules as an env value. */\n readonly defaultLiteral: string | undefined;\n /** Parse a raw (non-empty) env string; throws `Error(message)` when invalid. */\n readonly parse: (raw: string) => T;\n}\n\nfunction base<T>(kind: string, parse: (raw: string) => T): Validator<T> {\n return { kind, optional: false, secret: false, defaultLiteral: undefined, parse };\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Any string, taken verbatim (no trimming). */\nexport function str(): Validator<string> {\n return base('string', (raw) => raw);\n}\n\n/** Contract: `true`/`1` and `false`/`0`, case-insensitive, no trimming. */\nexport function bool(): Validator<boolean> {\n return base('boolean', (raw) => {\n const lower = raw.toLowerCase();\n if (lower === 'true' || lower === '1') return true;\n if (lower === 'false' || lower === '0') return false;\n throw new Error('expected one of `true`, `false`, `1`, `0` (case-insensitive)');\n });\n}\n\n// Rust `f64::from_str` grammar minus `inf`/`NaN`: sign, digits with an\n// optional point (`5.`/`.5` are valid), optional exponent. Rejects the extra\n// JS `Number()` literals (`0x10`, `0b101`, `0o17`) so both sides agree.\nconst FLOAT_GRAMMAR = /^[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?$/;\nconst INT_GRAMMAR = /^[+-]?\\d+$/;\n\n/**\n * A finite decimal number (point/exponent forms included). Not trimmed:\n * `\" 3\"` is invalid, like Rust's `FromStr`. Divergence from the Rust mirror:\n * Rust `f64` also accepts `inf`/`NaN` — this validator requires finite.\n */\nexport function num(): Validator<number> {\n return base('number', (raw) => {\n if (!FLOAT_GRAMMAR.test(raw)) throw new Error('expected a finite number');\n const value = Number(raw);\n if (!Number.isFinite(value)) throw new Error('expected a finite number');\n return value;\n });\n}\n\n/**\n * A plain decimal integer (no exponent/point/hex forms — Rust integer\n * `FromStr` grammar), limited to the safe range `±(2^53 - 1)`: JS numbers are\n * doubles, so bigger values would silently round (Rust 64-bit integers parse\n * them exactly — use {@link str} for 64-bit ids).\n */\nexport function int(): Validator<number> {\n return base('integer', (raw) => {\n if (!INT_GRAMMAR.test(raw)) throw new Error('expected an integer');\n const value = Number(raw);\n if (!Number.isSafeInteger(value)) throw new Error('expected a safe integer (within ±(2^53 - 1))');\n return value;\n });\n}\n\n/** A TCP/UDP port: an integer in `1..=65535`. */\nexport function port(): Validator<number> {\n const inner = int();\n return base('port', (raw) => {\n const value = inner.parse(raw);\n if (value < 1 || value > 65535) throw new Error('expected a port (1-65535)');\n return value;\n });\n}\n\n/** An absolute URL (validated with `new URL`, returned as the original string). */\nexport function url(): Validator<string> {\n return base('url', (raw) => {\n // `new URL` silently strips whitespace/tabs/newlines; a padded value would\n // validate here and then break whatever consumes the raw string.\n if (raw === '' || /\\s/.test(raw)) throw new Error('expected an absolute URL');\n try {\n new URL(raw);\n } catch {\n throw new Error('expected an absolute URL');\n }\n return raw;\n });\n}\n\n/**\n * One of a fixed set of strings, narrowed to the literal union:\n *\n * ```ts\n * const env = oneOf(['development', 'production']); // Validator<'development' | 'production'>\n * ```\n */\nexport function oneOf<const V extends string>(values: readonly V[]): Validator<V> {\n const expected = `expected one of ${values.map((value) => `\\`${value}\\``).join(', ')}`;\n return base('choice', (raw) => {\n if ((values as readonly string[]).includes(raw)) return raw as V;\n throw new Error(expected);\n });\n}\n\n/**\n * Contract: split on `,`, trim each item, drop empty items, parse the rest\n * with `item` (default {@link str}). Item numbering in errors counts the kept\n * items from 1 and never leaks the item value, so secret lists stay\n * redactable. Wrap the *list* in {@link secret}/{@link optional}, not the item.\n */\nexport function list(): Validator<readonly string[]>;\nexport function list<T>(item: Validator<T>): Validator<readonly T[]>;\nexport function list<T>(item?: Validator<T>): Validator<readonly (T | string)[]> {\n const inner = item ?? str();\n return base('list', (raw) =>\n raw\n .split(',')\n .map((piece) => piece.trim())\n .filter((piece) => piece !== '')\n .map((piece, index) => {\n try {\n return inner.parse(piece);\n } catch (error) {\n throw new Error(`item ${index + 1}: ${messageOf(error)}`);\n }\n }),\n );\n}\n\n/**\n * Unset (or empty) is fine and yields `undefined` — the mirror of a Rust\n * `Option<T>` field. Cannot be combined with {@link withDefault}: a defaulted\n * setting is always present ({@link createSettings} rejects the combination).\n */\nexport function optional<T>(validator: Validator<T>): Validator<T | undefined> {\n return { ...validator, optional: true };\n}\n\n/**\n * Fall back to `literal` when the variable is unset. The literal goes through\n * the exact same parsing rules as an env value — the mirror of the Rust\n * `= \"literal\"` field default.\n */\nexport function withDefault<T>(validator: Validator<T>, literal: string): Validator<T> {\n return { ...validator, defaultLiteral: literal };\n}\n\n/**\n * Redact the raw value in error output — the mirror of `#[secret]`. Note that\n * unlike Rust (whose generated `Debug` prints `***`), JS has no debug-print\n * boundary: `console.log(settings.TOKEN)` prints the real value. The redaction\n * covers what the *library* emits: `SettingsError` messages and issues.\n */\nexport function secret<T>(validator: Validator<T>): Validator<T> {\n return { ...validator, secret: true };\n}\n","/**\n * {@link createSettings} — read a validated, typed settings object out of an\n * explicitly injected environment record, with a server/client split for\n * browser bundles.\n */\n\nimport { SettingsError, type SettingsIssue } from './error';\nimport type { Validator } from './validators';\n\n/** A settings declaration: env var name → {@link Validator}. */\nexport type SettingsSchema = Record<string, Validator<unknown>>;\n\n/** The typed settings object a schema produces. */\nexport type InferSettings<S extends SettingsSchema> = {\n readonly [K in keyof S]: S[K] extends Validator<infer T> ? T : never;\n};\n\nexport interface CreateSettingsOptions<S extends SettingsSchema, C extends SettingsSchema> {\n /** Server-only settings. Accessing one from client code throws. */\n readonly server?: S;\n /**\n * Client-exposable settings. Every key must start with {@link clientPrefix}\n * so nothing leaks into a browser bundle by accident.\n */\n readonly client?: C;\n /** Required when `client` is non-empty — e.g. `NEXT_PUBLIC_`. */\n readonly clientPrefix?: string;\n /**\n * The environment record, destructured **explicitly** — bundlers inline\n * `process.env.NEXT_PUBLIC_*` / `import.meta.env.*` at build time only when\n * each variable is a static member expression, so a bare `process.env` pass\n * works on the server but leaves client values undefined in the browser:\n *\n * ```ts\n * runtimeEnv: {\n * DATABASE_URL: process.env.DATABASE_URL,\n * NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,\n * }\n * ```\n */\n readonly runtimeEnv: Readonly<Record<string, string | undefined>>;\n /** Contract: `VAR=` (empty string) behaves like unset. Default `true`. */\n readonly emptyStringAsUnset?: boolean;\n /**\n * Where we are running. Default: `window` is absent from `globalThis`.\n * Override for workers or exotic runtimes.\n */\n readonly isServer?: boolean;\n}\n\n/**\n * Validate the injected environment against the schema and return the typed,\n * read-only settings object. Mirrors the Rust `settings!` macro's\n * `from_source`, plus the client/server split (a bundler concern with no Rust\n * equivalent).\n *\n * - Every missing/invalid variable is reported at once in one\n * {@link SettingsError} — no fix-one-reboot-fix-next loops.\n * - Validation runs eagerly, at the `createSettings` call. Call it at module\n * scope of a single `settings.ts` so a bad environment fails the boot (or,\n * in Next.js, the build — import your settings module from `next.config`).\n * - On the client only the `client` schema is validated (server values never\n * reach the bundle); accessing a server key there throws.\n *\n * @example\n * ```ts\n * export const settings = createSettings({\n * server: {\n * DATABASE_URL: url(),\n * SIGNING_KEY: secret(str()),\n * PORT: withDefault(port(), '8080'),\n * ...presets.posthog(),\n * },\n * clientPrefix: 'NEXT_PUBLIC_',\n * client: { ...presets.posthogClient() },\n * runtimeEnv: {\n * DATABASE_URL: process.env.DATABASE_URL,\n * SIGNING_KEY: process.env.SIGNING_KEY,\n * PORT: process.env.PORT,\n * POSTHOG_KEY: process.env.POSTHOG_KEY,\n * POSTHOG_HOST: process.env.POSTHOG_HOST,\n * NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,\n * NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,\n * },\n * });\n * ```\n */\nexport function createSettings<\n S extends SettingsSchema = Record<never, never>,\n C extends SettingsSchema = Record<never, never>,\n>(options: CreateSettingsOptions<S, C>): InferSettings<S> & InferSettings<C> {\n const server: SettingsSchema = options.server ?? {};\n const client: SettingsSchema = options.client ?? {};\n const { clientPrefix } = options;\n const emptyAsUnset = options.emptyStringAsUnset ?? true;\n const isServer = options.isServer ?? !('window' in globalThis);\n\n const serverKeys = Object.keys(server);\n const clientKeys = Object.keys(client);\n\n // Declaration bugs (not environment problems) fail fast as plain errors.\n if (clientKeys.length > 0 && clientPrefix === undefined) {\n throw new Error('@evinvest/settings: `clientPrefix` is required when `client` settings are declared');\n }\n for (const key of serverKeys) {\n // `in` would also match Object.prototype members (\"toString\", …).\n if (Object.hasOwn(client, key)) {\n throw new Error(`@evinvest/settings: setting \"${key}\" is declared in both server and client`);\n }\n }\n if (clientPrefix !== undefined) {\n for (const key of clientKeys) {\n if (!key.startsWith(clientPrefix)) {\n throw new Error(`@evinvest/settings: client setting \"${key}\" must start with clientPrefix \"${clientPrefix}\"`);\n }\n }\n for (const key of serverKeys) {\n if (key.startsWith(clientPrefix)) {\n throw new Error(\n `@evinvest/settings: server setting \"${key}\" starts with clientPrefix \"${clientPrefix}\" — declare it under \\`client\\``,\n );\n }\n }\n }\n for (const [key, validator] of [...Object.entries(server), ...Object.entries(client)]) {\n if (validator.optional && validator.defaultLiteral !== undefined) {\n throw new Error(\n `@evinvest/settings: setting \"${key}\" is both optional and defaulted — a defaulted setting is always present, drop one`,\n );\n }\n }\n\n // On the client, server values never reach the bundle: validate (and store)\n // the client schema only.\n const active = isServer ? { ...server, ...client } : client;\n const issues: SettingsIssue[] = [];\n const values: Record<string, unknown> = {};\n for (const [key, validator] of Object.entries(active)) {\n let raw = options.runtimeEnv[key];\n if (emptyAsUnset && raw === '') raw = undefined;\n // A default literal lives in source code, so it is never redacted.\n const fromDefault = raw === undefined && validator.defaultLiteral !== undefined;\n if (fromDefault) raw = validator.defaultLiteral;\n if (raw === undefined) {\n if (validator.optional) {\n values[key] = undefined;\n continue;\n }\n issues.push({ key, kind: 'missing' });\n continue;\n }\n try {\n values[key] = validator.parse(raw);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n issues.push({\n key,\n kind: 'invalid',\n detail: fromDefault ? `invalid default: ${message}` : message,\n ...(validator.secret && !fromDefault ? {} : { value: raw }),\n });\n }\n }\n if (issues.length > 0) throw new SettingsError(issues);\n\n Object.freeze(values); // the documented read-only contract, enforced (ESM is strict mode: writes throw)\n const serverOnly = new Set(serverKeys);\n return new Proxy(values, {\n get(target, prop, receiver) {\n if (typeof prop !== 'string') return Reflect.get(target, prop, receiver);\n if (!isServer && serverOnly.has(prop)) {\n throw new Error(`@evinvest/settings: attempted to access server-only setting \"${prop}\" on the client`);\n }\n return Reflect.get(target, prop, receiver);\n },\n }) as InferSettings<S> & InferSettings<C>;\n}\n","/**\n * Org-canonical shared variable groups — one place that fixes the names, so\n * `POSTHOG_KEY` vs `POSTHOG_API_KEY` vs `NEXT_PUBLIC_POSTHOG_KEY` drift stops\n * at the source. Spread a preset into the `server` / `client` block of\n * {@link createSettings}; the Rust mirror ships the same server-side groups as\n * ready-made structs (`settings::presets`).\n */\n\nimport { optional, str, withDefault, type Validator } from './validators';\n\nexport const presets = {\n /**\n * PostHog capture credentials, canonical names: `POSTHOG_KEY` /\n * `POSTHOG_HOST`. Both optional — capture is simply off without them. The\n * project key (`phc_…`) is write-only and ships in frontend bundles anyway,\n * so it is not `secret(...)`.\n */\n posthog: (): { POSTHOG_KEY: Validator<string | undefined>; POSTHOG_HOST: Validator<string | undefined> } => ({\n POSTHOG_KEY: optional(str()),\n POSTHOG_HOST: optional(str()),\n }),\n\n /**\n * Sentry reporting, canonical name: `SENTRY_DSN`. Optional — monitoring is\n * off without it. A DSN authorises event *submission* only, so it is not\n * `secret(...)`.\n */\n sentry: (): { SENTRY_DSN: Validator<string | undefined> } => ({\n SENTRY_DSN: optional(str()),\n }),\n\n /**\n * The deployment environment, canonical name: `APP_ENV`. Defaults to\n * `development`; kept a free string on purpose — constraining the values\n * would break consumers that add a stage.\n */\n appEnv: (): { APP_ENV: Validator<string> } => ({\n APP_ENV: withDefault(str(), 'development'),\n }),\n\n /** The `client`-block variant of {@link presets.posthog} for Next.js bundles. */\n posthogClient: (): {\n NEXT_PUBLIC_POSTHOG_KEY: Validator<string | undefined>;\n NEXT_PUBLIC_POSTHOG_HOST: Validator<string | undefined>;\n } => ({\n NEXT_PUBLIC_POSTHOG_KEY: optional(str()),\n NEXT_PUBLIC_POSTHOG_HOST: optional(str()),\n }),\n\n /** The `client`-block variant of {@link presets.sentry} + `APP_ENV` for Next.js bundles. */\n sentryClient: (): {\n NEXT_PUBLIC_SENTRY_DSN: Validator<string | undefined>;\n NEXT_PUBLIC_APP_ENV: Validator<string>;\n } => ({\n NEXT_PUBLIC_SENTRY_DSN: optional(str()),\n NEXT_PUBLIC_APP_ENV: withDefault(str(), 'development'),\n }),\n} as const;\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@evinvest/settings",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed, validated env settings with aggregate error reporting and a server/client split — zero runtime deps, mirroring the settings Cargo feature. Secrets stay in sops at the shell/CI boundary; this package only reads the already-injected environment.",
|
|
5
|
+
"license": "BlueOak-1.0.0",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/EV-invest/lib.git",
|
|
12
|
+
"directory": "ts/settings"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.core.json --noEmit",
|
|
34
|
+
"prepare": "tsup"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.10.0",
|
|
38
|
+
"tsup": "^8.3.5",
|
|
39
|
+
"typescript": "^5.7.2",
|
|
40
|
+
"vitest": "^2.1.8"
|
|
41
|
+
}
|
|
42
|
+
}
|