Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ import { formatLabel } from './lib/format.js';
- Keep comments minimal and free of unnecessary commentary — capture the key details as concisely as possible.
- Todo comments are formatted like `TODO (busticated): <message>`, using the handle of the person the work is for. `npm run todo` lists every TODO in the repo, regardless of format.

**Types.** Conditional types are never nested — `no-nested-ternary` matches `ConditionalExpression` only, so lint cannot catch this. Keep each conditional type to a single level and reach for one of these instead:

- A union of independent branches, where each tests the same input and contributes `never` when it doesn't apply, so the union collapses to the one that matches — see `SettingsWidened` in [packages/config/src/types.ts](packages/config/src/types.ts).
- An interface keyed by the discriminating literal, looked up with `T extends keyof Map ? Map[T] : ...` — see `SettingsFormatValues` in the same file.
- A named helper type per level, delegating rather than nesting — see `SettingsSpecAt`.

---

## Testing
Expand Down
19 changes: 14 additions & 5 deletions packages/config/docs/browser/functions/createConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,30 @@

# Function: createConfig()

> **createConfig**(`schema`): [`Config`](../../config/classes/Config.md)
> **createConfig**\<`S`\>(`schema`): [`Config`](../../config/classes/Config.md)\<`S`\>

Defined in: [browser.mts:47](/packages/config/src/browser.mts#L47)
Defined in: [browser.mts:50](/packages/config/src/browser.mts#L50)

Builds a [Config](../../config/classes/Config.md) instance for use in the browser. Settings are
sourced from the `public: true` subset of a Node-side config, baked in
at build time via `getBrowserDefine()` and your bundler - this entry
point has no dependency on any Node.js-only API.

## Type Parameters

### S

`S` *extends* [`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md)

## Parameters

### schema

[`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md)
`S`

## Returns

[`Config`](../../config/classes/Config.md)
[`Config`](../../config/classes/Config.md)\<`S`\>

## Example

Expand All @@ -37,7 +43,10 @@ export const config = createConfig(schema);
// elsewhere.ts
import { config } from './config.js';

config.get('app.name');
config.get('app.name'); // string - typed from the schema above
```

The schema's shape is captured as `S`, so `config.get()` accepts only the
keys it declares and returns the type each one holds.

See `getBrowserDefine()` in `node.ts` for the bundler side of this.
44 changes: 31 additions & 13 deletions packages/config/docs/config/classes/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

***

# Class: Config
# Class: Config\<S\>

Defined in: [config.ts:46](/packages/config/src/config.ts#L46)
Defined in: [config.ts:54](/packages/config/src/config.ts#L54)

Schema-driven configuration store. Given a [SettingsSchemaTree](../../node/interfaces/SettingsSchemaTree.md) and a
map of environment variables, hydrates each leaf setting's value from the
Expand All @@ -30,13 +30,25 @@ const config = new Config({
config.get('app.name'); // 'My App', or the value of `process.env.MY_APP_NAME`, if set
```

`S` carries the shape of the schema so `get()` can resolve a key to the
type that key holds. It is inferred by `createConfig()` in
`node.ts`/`browser.mts`; constructing a `Config` directly leaves it at the
default, where keys are plain strings and values the full
SettingsValue union.

## Type Parameters

### S

`S` *extends* [`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md) = [`SettingsSchemaTree`](../../node/interfaces/SettingsSchemaTree.md)

## Constructors

### Constructor

> **new Config**(`__namedParameters?`): `Config`
> **new Config**\<`S`\>(`__namedParameters?`): `Config`\<`S`\>

Defined in: [config.ts:49](/packages/config/src/config.ts#L49)
Defined in: [config.ts:57](/packages/config/src/config.ts#L57)

#### Parameters

Expand All @@ -46,35 +58,41 @@ Defined in: [config.ts:49](/packages/config/src/config.ts#L49)

#### Returns

`Config`
`Config`\<`S`\>

## Properties

### settings

> **settings**: `Settings`

Defined in: [config.ts:47](/packages/config/src/config.ts#L47)
Defined in: [config.ts:55](/packages/config/src/config.ts#L55)

## Methods

### get()

> **get**(`key`): `SettingsValue`
> **get**\<`K`\>(`key`): `SettingsValueAt`\<`S`, `K`\>

Defined in: [config.ts:58](/packages/config/src/config.ts#L58)
Defined in: [config.ts:66](/packages/config/src/config.ts#L66)

Looks up a single setting's hydrated value by its dot-delimited key.

#### Type Parameters

##### K

`K` *extends* `string`

#### Parameters

##### key

`string`
`K`

#### Returns

`SettingsValue`
`SettingsValueAt`\<`S`, `K`\>

#### Throws

Expand All @@ -86,7 +104,7 @@ if `key` isn't present in the hydrated schema

> **getPublicEnvVars**(): [`ConfigEnvVars`](../../node/interfaces/ConfigEnvVars.md)

Defined in: [config.ts:87](/packages/config/src/config.ts#L87)
Defined in: [config.ts:95](/packages/config/src/config.ts#L95)

Returns every public setting's value keyed by its *environment variable
name* rather than its schema path.
Expand All @@ -101,7 +119,7 @@ name* rather than its schema path.

> **getPublicSettings**(): `PublicSettings`

Defined in: [config.ts:69](/packages/config/src/config.ts#L69)
Defined in: [config.ts:77](/packages/config/src/config.ts#L77)

Returns every hydrated setting marked `public: true`, keyed by dot-delimited path.

Expand All @@ -115,7 +133,7 @@ Returns every hydrated setting marked `public: true`, keyed by dot-delimited pat

> **hydrate**(`data`, `env?`): `Settings`

Defined in: [config.ts:110](/packages/config/src/config.ts#L110)
Defined in: [config.ts:118](/packages/config/src/config.ts#L118)

Walks a [SettingsSchemaTree](../../node/interfaces/SettingsSchemaTree.md), resolving each leaf's format,
coercing/validating its value from `env` (or its default), and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

> `const` **CONFIG\_GLOBAL\_NAME**: `"__BUST_CONFIG__"` = `'__BUST_CONFIG__'`

Defined in: [config.ts:20](/packages/config/src/config.ts#L20)
Defined in: [config.ts:22](/packages/config/src/config.ts#L22)

The name of the global variable `@bust/config` expects to find settings stored
in when runnning in a browser.
19 changes: 14 additions & 5 deletions packages/config/docs/node/functions/createConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,30 @@

# Function: createConfig()

> **createConfig**(`schema`): [`Config`](../../config/classes/Config.md)
> **createConfig**\<`S`\>(`schema`): [`Config`](../../config/classes/Config.md)\<`S`\>

Defined in: [node.ts:36](/packages/config/src/node.ts#L36)
Defined in: [node.ts:39](/packages/config/src/node.ts#L39)

Builds a [Config](../../config/classes/Config.md) for use in Node.js: a local `.env` file (if
present in the current working directory) is loaded into `process.env`
before `schema` is hydrated, so local development values can live in a
git-ignored `.env` file instead of real environment variables.

## Type Parameters

### S

`S` *extends* [`SettingsSchemaTree`](../interfaces/SettingsSchemaTree.md)

## Parameters

### schema

[`SettingsSchemaTree`](../interfaces/SettingsSchemaTree.md)
`S`

## Returns

[`Config`](../../config/classes/Config.md)
[`Config`](../../config/classes/Config.md)\<`S`\>

## Example

Expand All @@ -41,5 +47,8 @@ export const config = createConfig({
// elsewhere.ts
import { config } from './config.ts';

config.get('app.name');
config.get('app.name'); // string - typed from the schema above
```

The schema's shape is captured as `S`, so `config.get()` accepts only the
keys it declares and returns the type each one holds.
12 changes: 9 additions & 3 deletions packages/config/docs/node/functions/getBrowserDefine.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,27 @@

# Function: getBrowserDefine()

> **getBrowserDefine**(`config`): `Record`\<`string`, [`ConfigEnvVars`](../interfaces/ConfigEnvVars.md)\>
> **getBrowserDefine**\<`S`\>(`config`): `Record`\<`string`, [`ConfigEnvVars`](../interfaces/ConfigEnvVars.md)\>

Defined in: [node.ts:59](/packages/config/src/node.ts#L59)
Defined in: [node.ts:62](/packages/config/src/node.ts#L62)

Produces a Vite `define` entry that exposes only `config`'s `public: true`
settings to a browser build - `createConfig()` in the browser reads this
same blob back out at runtime via [CONFIG\_GLOBAL\_NAME](../../config/variables/CONFIG_GLOBAL_NAME.md). Values never
leave the Node process beyond what `getPublicEnvVars()` already returns, so
there's no separate `VITE_`-prefixed env var to keep in sync with `schema`.

## Type Parameters

### S

`S` *extends* [`SettingsSchemaTree`](../interfaces/SettingsSchemaTree.md)

## Parameters

### config

[`Config`](../../config/classes/Config.md)
[`Config`](../../config/classes/Config.md)\<`S`\>

## Returns

Expand Down
2 changes: 1 addition & 1 deletion packages/config/docs/node/interfaces/ConfigEnvVars.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Interface: ConfigEnvVars

Defined in: [types.ts:104](/packages/config/src/types.ts#L104)
Defined in: [types.ts:241](/packages/config/src/types.ts#L241)

A map of environment variable names to values. Values are typically raw
strings (as they'd come from `process.env`), but pre-coerced values are
Expand Down
9 changes: 6 additions & 3 deletions packages/config/src/browser.mts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@ export function getBrowserEnv(): ConfigEnvVars {
* // elsewhere.ts
* import { config } from './config.js';
*
* config.get('app.name');
* config.get('app.name'); // string - typed from the schema above
* ```
*
* The schema's shape is captured as `S`, so `config.get()` accepts only the
* keys it declares and returns the type each one holds.
*
* See `getBrowserDefine()` in `node.ts` for the bundler side of this.
*/
export function createConfig(schema: SettingsSchemaTree): Config {
return new Config({ schema, env: getBrowserEnv() });
export function createConfig<const S extends SettingsSchemaTree>(schema: S): Config<S> {
return new Config<S>({ schema, env: getBrowserEnv() });
}
18 changes: 18 additions & 0 deletions packages/config/src/browser.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ describe('@bust/config/browser', () => {
});
});

describe('Schema-derived types', () => {
it('Types keys and values from the schema, same as the Node entry point', () => {
const config = createConfig({
app: {
name: { default: 'My App', env: 'MY_APP_NAME' },
mode: { default: 'light', format: ['light', 'dark'] },
},
});
const name: string = config.get('app.name');
const mode: 'light' | 'dark' = config.get('app.mode');

// @ts-expect-error - 'app.nmae' is a typo, not a declared key
assert.throws(() => config.get('app.nmae'));
assert.strictEqual(name, 'My App');
assert.strictEqual(mode, 'light');
});
});

describe('getBrowserEnv', () => {
it('Falls back to an empty object when nothing was baked in', () => {
assert.deepEqual(getBrowserEnv(), {});
Expand Down
16 changes: 12 additions & 4 deletions packages/config/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
SettingsKey,
SettingsKeyOf,
SettingsValue,
SettingsValueAt,
SettingsFormat,
SettingsFormatName,
SettingsSpecInput,
Expand Down Expand Up @@ -42,8 +44,14 @@ export const CONFIG_GLOBAL_NAME = '__BUST_CONFIG__';
*
* config.get('app.name'); // 'My App', or the value of `process.env.MY_APP_NAME`, if set
* ```
*
* `S` carries the shape of the schema so `get()` can resolve a key to the
* type that key holds. It is inferred by `createConfig()` in
* `node.ts`/`browser.mts`; constructing a `Config` directly leaves it at the
* default, where keys are plain strings and values the full
* {@link SettingsValue} union.
*/
export class Config {
export class Config<S extends SettingsSchemaTree = SettingsSchemaTree> {
settings: Settings;

constructor({ schema = {}, env = {} }: ConfigOptions = {}) {
Expand All @@ -55,14 +63,14 @@ export class Config {
*
* @throws if `key` isn't present in the hydrated schema
*/
get(key: SettingsKey): SettingsValue {
get<K extends SettingsKeyOf<S> & string>(key: K): SettingsValueAt<S, K> {
const spec = this.settings.get(key);

if (!spec) {
throw new Error(`'${key}' is not available - please ensure you've set it`);
}

return spec.value;
return spec.value as SettingsValueAt<S, K>;
}

/** Returns every hydrated setting marked `public: true`, keyed by dot-delimited path. */
Expand Down Expand Up @@ -200,7 +208,7 @@ function format(x: SettingsValue | undefined, key: SettingsKey, spec: SettingsSp

const validators: Record<string, Validator> = {
enum: function(key, spec) {
const allowed = spec.format as string[];
const allowed = spec.format as readonly string[];

if (!allowed.includes(spec.value as string)) {
throw new Error(`'${key}': must be one of ${allowed.join('|')}`);
Expand Down
Loading