npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@x12i/client-config

v1.5.0

Published

Framework-agnostic browser runtime config: typed schemas, validation, local persistence, sensitive-field handling, and optional encryption.

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 .env or 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-config

Quick 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 (full encrypts the inner envelope JSON; field encrypts 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 as importJson (replace | merge | mergeKnown), plus optional strictParse (default false). 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; returns ValidationResult plus parseErrors and collisionErrors.
  • 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 (apiUrlAPI_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() exposes dirty, 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

  1. Anything the SPA can read, the user (or attacker with script access) can read.
  2. Do not describe this package as vault-grade or XSS-safe encryption.
  3. Prefer backend-held secrets, short-lived tokens, and proxies for high-value credentials.
  4. 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() after load()) into a flat Record<string, string> whose keys match the env naming rules used by importEnv / mapEnvEntriesToIncoming (envKey if set, otherwise schemaKeyToEnvAlias(schemaKey)).
  • Letting legacy packages that read process.env keep 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 .env files 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:

  1. Per-request or per-task overrides (your own object or withProcessEnvOverride).
  2. Values derived from client-config (buildEnvMapFromConfig from loaded store).
  3. Values from .env files (e.g. loaded by your runner or Vite).
  4. 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: loadbuildEnvMapFromConfigapplyEnvMapToProcessEnvimport() 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