@x12i/client-config
v1.5.0
Published
Framework-agnostic browser runtime config: typed schemas, validation, local persistence, sensitive-field handling, and optional encryption.
Maintainers
Readme
@x12i/client-config
Client-side runtime configuration for browser apps (especially SPAs): typed schemas, validation, normalization, namespaced persistence, sensitive-field handling, optional encryption, migrations, import/export, .env-style text import, and configuration reporting helpers.
What it is
- A small, framework-agnostic library for operator-editable settings after deploy (API base URL, tenant id, feature flags, demo tokens, etc.).
- Schema-first: defaults, validation, and TypeScript inference from field definitions.
- Storage adapters for
localStorage,sessionStorage, and in-memory (tests, SSR fallbacks). - Envelope-based persistence (version, namespace, timestamps, encryption metadata) — not a raw JSON blob only.
- Hooks for load/save/reset/change/migration/validation errors.
- Pluggable encryption (Web Crypto AES-GCM helpers included) for stored bytes only — not a threat model against XSS.
What it is not
- Not secure secret storage, a vault, or a substitute for server-side secret management.
- Not a replacement for backend
.envor identity/auth. - Not a cloud config service (that can be a future adapter).
- Not React-specific — use a thin wrapper package later (e.g.
@x12i/client-config-react) for providers and hooks.
Real secrets that would cause serious harm if exposed should not rely on browser-only protection.
Installation
npm install @x12i/client-configQuick start
import {
createClientConfig,
createLocalStorageAdapter,
defineSchema,
field,
} from "@x12i/client-config";
const schema = defineSchema({
apiUrl: field.string({
label: "API base URL",
default: "",
required: true,
trim: true,
}),
tenantId: field.string({ default: "", label: "Tenant" }),
debug: field.boolean({ default: false }),
});
const store = createClientConfig({
namespace: "x12i.myapp.config",
schema,
storage: createLocalStorageAdapter({ fallbackToMemory: true }),
});
await store.load();
store.setField("apiUrl", "https://api.example.com");
await store.save();Schema definition
Use field.* helpers so TypeScript can infer store.get():
| Kind | Helper | Notes |
|------------|---------------|--------|
| string | field.string | trim, emptyAsUndefined, ttlMs, persist |
| number | field.number | coerceFromString (recommended for .env imports) |
| boolean | field.boolean | coerceFromString (recommended for .env imports) |
| enum | field.enum | values, caseInsensitive |
| string[] | field.stringArray | |
| json | field.json | arbitrary JSON-serializable default |
Common options on all fields: label, description, default, required, sensitive, persist (default true), validate, normalize, ttlMs, envKey (explicit env variable name for importEnv / analyzeEnv).
Invalid defaults throw at defineSchema time (INVALID_SCHEMA).
Storage adapters
createLocalStorageAdapter({ fallbackToMemory?: boolean })createSessionStorageAdapter({ fallbackToMemory?: boolean })createMemoryAdapter()
If storage is unavailable and you did not enable fallbackToMemory, operations that write will surface structured errors (STORAGE_UNAVAILABLE, STORAGE_QUOTA, etc.).
The storage key is buildStorageKey(namespace) (for example @x12i/client-config:my.app).
Sensitive fields
Mark fields with sensitive: true:
store.getRedacted()and safe / default export mask values for display and sharing.- Use full export with
{ mode: "full", includeSensitive: true }only when you explicitly want secrets in the JSON.
This reduces casual exposure; it does not make values secret in the browser.
Encryption caveats
- Encryption changes the stored representation (
fullencrypts the inner envelope JSON;fieldencrypts marked fields with a prefixed ciphertext string). - At runtime the app still holds decrypted values in memory.
- XSS, malicious extensions, or physical access defeat client-only encryption.
Provide an EncryptionProvider or use createAesGcmEncryptionProvider + importAesGcmKey / deriveAesGcmKeyFromPassphrase.
If decryption fails, you get DECRYPTION_FAILED (recoverable); clear() / reset() can recover.
Migration
Pass migrations: [{ toVersion, migrate(config, ctx) }] to createClientConfig. On load, stored schemaVersion is compared to the schema’s version; steps run in order until the config matches the current version.
hooks.onMigration reports success or failure.
Import / export
store.exportJson({ mode: "safe" | "full" | "partial", keys?, includeSensitive? })store.importJson(json, { mode: "replace" | "merge" | "mergeKnown", save?: boolean, source?: string })
Import accepts either a full export payload or a bare { config: { ... } } object.
.env-style text
Values in env files are always strings. Map them to schema fields with:
store.importEnv(text, options?)— same merge modes asimportJson(replace|merge|mergeKnown), plus optionalstrictParse(defaultfalse). Lenient parsing skips bad lines and still applies valid entries; strict mode rejects the whole import if any line fails to parse.store.validateEnv(text, options?)— same merge/parse options but does not mutate the store; returnsValidationResultplusparseErrorsandcollisionErrors.analyzeEnv(schema, text)— parse and describe mapping:matchedFields,missingInEnv,unknownEnvKeys,collisionErrors,previews(sensitive values masked), without a store.
Variable name resolution (first match wins per field): optional envKey on the field, then the schema key as-is, then a generated alias (apiUrl → API_URL via schemaKeyToEnvAlias).
Collisions: if one env variable would map to more than one schema field, import/validate fails with ENV_KEY_COLLISION.
Supported in the parser: KEY=value, optional export prefix, # comments, double/single-quoted values (with \\, \", \n, \r, \t in double quotes). Not supported: $VAR expansion, multiline values, line continuations.
Types: json fields accept a JSON object/array string (trimmed, starts with { or [). string[] fields accept comma-separated values.
Runtime reporting: store.getConfigurationReport() returns per-field required, valid, error, isEmpty, satisfied, atDefault for the current in-memory config (independent of .env). Lower-level: buildConfigurationReport(schema, config).
Standalone utilities (also used internally): parseDotenv, mapEnvEntriesToIncoming, mergeImportIncoming, schemaKeyToEnvAlias — useful for tests or custom pipelines without loading the store.
Common patterns
- Namespaced apps: one namespace per app or tenant slice (
x12i.reportix.local). - Diagnostics:
getClientConfigDiagnostics(adapter)for storage availability and Web Crypto. - Change tracking:
store.getStatus()exposesdirty,changedKeys,lastSavedAt,lastLoadedAt,lastError, etc. - Subscribe:
store.subscribe((status) => { ... })for UI updates. - Non-persisted fields:
persist: false— not written on save; after a full page reload they come from defaults unless you use session storage or another mechanism.
Security notes
- Anything the SPA can read, the user (or attacker with script access) can read.
- Do not describe this package as vault-grade or XSS-safe encryption.
- Prefer backend-held secrets, short-lived tokens, and proxies for high-value credentials.
- Use masking/redacted exports in logs and support tooling.
API overview
| Method | Purpose |
|--------|---------|
| load() | Read storage, migrate, normalize, validate |
| get() / getField / getMany | Read current config |
| setField / setMany / replace | Update (returns ValidationResult) |
| validate / validateField / validateImport | Validation only |
| save() | Persist (optional validateOnSave / per-call validate) |
| reset(fields?) | Revert to defaults |
| clear() | Remove storage key + defaults |
| exportJson / importJson | Portability |
| importEnv / validateEnv | .env text → config |
| getConfigurationReport | Per-field required/default/valid summary |
| getStatus / subscribe / getRedacted | Status and UI |
Constructor options include encryption, migrations, hooks, strictLoad, and validateOnSave.
Using @x12i/client-config with Node, Vite, tests, and tooling
The main entry (@x12i/client-config) targets browser runtime config. For Node, Vite dev, CLIs, tests, and local tooling, use the optional subpath:
import {
buildEnvMapFromConfig,
applyEnvMapToProcessEnv,
withProcessEnvOverride,
mergeEnvSources,
clientConfigEnvBridgePlugin,
} from "@x12i/client-config/node";What this bridge is for
- Turning schema-driven config (the same object you get from
store.get()afterload()) into a flatRecord<string, string>whose keys match the env naming rules used byimportEnv/mapEnvEntriesToIncoming(envKeyif set, otherwiseschemaKeyToEnvAlias(schemaKey)). - Letting legacy packages that read
process.envkeep working without rewrites, as long as your process applies the map before those packages run their module-level reads. - Explicit bootstrap in dev and tooling (not automatic background sync).
What this bridge is not for
- Not a secret manager, vault, or production credential system.
- Not automatic mutation of the user’s shell environment.
- Not writing or updating
.envfiles on disk. - Not a replacement for server-side secret infrastructure or short-lived tokens from your backend.
- Not browser-to-server secret promotion without explicit operator intent.
Bootstrap timing (required reading)
Many Node packages read process.env once, at import / module initialization time, and cache the result. If you apply the bridge after those modules load, they will still see the old values.
Operational rule: call buildEnvMapFromConfig + applyEnvMapToProcessEnv (or withProcessEnvOverride) before importing or executing code that reads env eagerly.
Recommended pattern (dynamic import after the bridge):
import {
buildEnvMapFromConfig,
applyEnvMapToProcessEnv,
} from "@x12i/client-config/node";
import { schema } from "./appConfig.schema.js";
import { store } from "./appConfig.store.js";
await store.load();
const envMap = buildEnvMapFromConfig(schema.fields, store.get(), {
omitEmpty: true,
});
applyEnvMapToProcessEnv(envMap);
// Only now import packages that read process.env at module load time
const { startServer } = await import("./server.js");
await startServer();Scoped variant (restores process.env after the callback, even on throw):
import {
buildEnvMapFromConfig,
withProcessEnvOverride,
} from "@x12i/client-config/node";
await store.load();
const envMap = buildEnvMapFromConfig(schema.fields, store.get(), {
omitEmpty: true,
});
await withProcessEnvOverride(envMap, async () => {
const { runTool } = await import("./toolThatReadsEnv.js");
await runTool();
});Precedence: one recommended model
For local dev and tooling, a practical stack is:
- Per-request or per-task overrides (your own object or
withProcessEnvOverride). - Values derived from client-config (
buildEnvMapFromConfigfrom loaded store). - Values from
.envfiles (e.g. loaded by your runner or Vite). - Inherited shell
process.env.
To materialize that into a single object for child_process.spawn / execFile:
- Use
mergeEnvSources(process.env, configEnvMap, { precedence: "config-first" })when client-config (or dotenv-loaded) values should override the current process for spawned children. - Use
mergeEnvSources(process.env, configEnvMap, { precedence: "process-first" })when the existing process should win on key clashes (e.g. CI injected secrets must not be replaced by dev defaults).
omitEmpty (default true) skips empty-string entries when merging so blank config fields do not wipe inherited values.
Logging safety
Maps returned by buildEnvMapFromConfig may contain secrets (tokens, API keys). Do not console.log them, attach them to error reports, or send them to analytics without the same care you use for raw process.env.
API summary (Node entry)
| Export | Role |
|--------|------|
| buildEnvMapFromConfig | Schema + config → flat string map (no global mutation). |
| serializeConfigValueToEnvString | Lower-level stringification for one field. |
| applyEnvMapToProcessEnv | Writes into process.env; returns { restore() }. |
| withProcessEnvOverride | Applies map for the duration of an async function; always restores. |
| mergeEnvSources | Merge process.env with a config-derived map with explicit precedence. |
| clientConfigEnvBridgePlugin | Dev-only Vite plugin (apply: "serve"): reapplies when subscribe fires. Optional vite peer. |
Examples
Vite (dev-only plugin + optional live refresh)
import { defineConfig } from "vite";
import { clientConfigEnvBridgePlugin } from "@x12i/client-config/node";
import { schema } from "./appConfig.schema.js";
import { store } from "./appConfig.store.js";
export default defineConfig({
plugins: [
clientConfigEnvBridgePlugin({
schema: schema.fields,
getConfig: () => store.get(),
omitEmpty: true,
overwrite: false,
subscribe: (onChange) => store.subscribe(() => onChange()),
}),
],
});Node CLI / script
Same as the bootstrap snippet above: load → buildEnvMapFromConfig → applyEnvMapToProcessEnv → import() the rest of the CLI.
Vitest
Use a small vitest.setup.ts registered in vitest.config setupFiles that loads config, builds the map, and calls applyEnvMapToProcessEnv before your test modules import libraries that snapshot env at load time.
One request / one tool invocation (scoped)
Use withProcessEnvOverride so other async work on the same process is not affected after the callback finishes.
Child process with merged env
import { spawn } from "node:child_process";
import { mergeEnvSources } from "@x12i/client-config/node";
const childEnv = mergeEnvSources(process.env, envMap, {
precedence: "config-first",
});
spawn("my-cli", ["run"], { env: childEnv, shell: false });Non-goals (Node bridge)
No writing back to .env files, no persistent shell changes, no automatic production secret synchronization, no hidden process.env mutation outside the APIs you call, and no new $VAR interpolation beyond what parseDotenv already supports.
Development and tests
npm run typecheck # TypeScript
npm test # Vitest (core store + `.env` / Node env bridge)
npm run build # ESM + declarations in dist/npm publish runs prepublishOnly, which executes typecheck, tests, and build before the tarball is created.
License
MIT
