@x12i/env-router
v1.3.2
Published
Node-first env routing bootstrap for selecting env bundles by client key.
Maintainers
Readme
@x12i/env-router
@x12i/env-router is a Node-first bootstrap package for this workflow:
- Keep multiple env bundles in deployment environment variables.
- Accept a client/operator key at startup.
- Resolve exactly one matching route.
- Apply the selected env map to
process.envbefore app modules load.
It is designed for process startup, CLI jobs, and workers where one route should be active for the process lifetime.
Install
npm install @x12i/env-routerNode requirement: >=18.
What it is
- A startup env router for Node runtimes.
- A bridge from route payloads (JSON and/or base64-encoded
.envtext) toprocess.envstring values. - A safe-ish diagnostics layer with redaction.
- A utility that fails closed on invalid keys or invalid payloads.
What it is not
- Not a secret manager or vault.
- Not a browser package.
- Not a per-request global env switcher for shared servers.
- Not a dotenv editor (the CLI only base64-encodes a file for deployment paste).
- Not a replacement for server-side authz.
Quick start
Deployment env example:
ENV_ROUTER_CLIENT_KEY=abc123
ENVX_ENV_ROUTER_ROUTES=DEMO_APP1,DEMO_APP2
ENVX_DEMO_APP1_KEY=abc123
ENVX_DEMO_APP1_ENV={"API_URL":"https://api1.example.com","TENANT_ID":"demo-app1"}
ENVX_DEMO_APP2_KEY=def456
ENVX_DEMO_APP2_ENV={"API_URL":"https://api2.example.com","TENANT_ID":"demo-app2"}Two kinds of variables can coexist in the same deployment environment:
- Router vars: prefixed with
ENVX_(route definitions + route payloads) - Native vars: everything else (your normal runtime env like
DATABASE_URL,PORT, etc.)
When a route is resolved, native vars are merged into the routed env; if the same key exists in both, the routed value wins.
Bootstrap before app import:
import { bootstrapEnvRouter } from "@x12i/env-router";
await bootstrapEnvRouter({
clientKey: process.env.ENV_ROUTER_CLIENT_KEY!,
requiredKeys: ["API_URL", "TENANT_ID"],
exposeRouteIdAs: "ACTIVE_ENV_ROUTE"
});
const { startServer } = await import("./server.js");
await startServer();After bootstrap, normal app code can keep reading:
process.env.API_URL;
process.env.TENANT_ID;Critical bootstrap timing rule
Run env-router before importing modules that read env at module load time.
Correct:
import { bootstrapEnvRouter } from "@x12i/env-router";
await bootstrapEnvRouter({ clientKey: process.env.ENV_ROUTER_CLIENT_KEY! });
const { startServer } = await import("./server.js");
await startServer();Wrong:
import { startServer } from "./server.js";
import { bootstrapEnvRouter } from "@x12i/env-router";
await bootstrapEnvRouter({ clientKey: process.env.ENV_ROUTER_CLIENT_KEY! });
await startServer();Browser localStorage flow (automatic UX)
If your client app stores a route key in localStorage, that can power a fully automatic UX. The important boundary:
- Browser reads/stores key.
- Browser sends key to your backend/bootstrap endpoint.
- Node process calls
resolveEnvRoute/bootstrapEnvRouter.
Example client flow:
const key = localStorage.getItem("env-router-client-key");
if (key) {
await fetch("/api/env-route/bootstrap", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ key })
});
}Security note: any browser-readable key is not a secret. Treat it like a routing token, not a credential with strong secrecy guarantees.
Node bootstrap after .env / Vite loadEnv: dev servers often merge .env* into process.env before your /api/env-route/bootstrap handler runs. If you then call applyEnvRouteToProcessEnv(resolved.env, { overwrite: false }), keys that already exist on process.env (including empty placeholders) are skipped—so routed values never replace them. For that pattern, use overwrite: true (the package default) unless you intentionally want the host process to win for every overlapping key.
Router env format
Canonical route format:
ENVX_ENV_ROUTER_ROUTES=DEMO_APP1,DEMO_APP2
ENVX_DEMO_APP1_KEY=abc123
ENVX_DEMO_APP1_ENV={"API_URL":"https://api1.example.com","TENANT_ID":"demo-app1","FEATURE_X":true}
ENVX_DEMO_APP2_KEY=def456
ENVX_DEMO_APP2_ENV={"API_URL":"https://api2.example.com","TENANT_ID":"demo-app2","FEATURE_X":false}Supported route fields:
<ROUTE>_KEY<ROUTE>_KEY_HASH<ROUTE>_ENV<ROUTE>_ENV_B64<ROUTE>_META(parsed as metadata when present)
Payload rules (<ROUTE>_ENV and <ROUTE>_ENV_B64):
- JSON object: if the stored value (after optional base64 decode for
_ENV_B64) is trimmed and looks like{…}, it is parsed as JSON. Supported shapes:- Bare env object:
{"API_URL":"...","DEBUG":false} - Envelope object:
{"version":1,"env":{"API_URL":"..."},"meta":{"label":"..."}}
- Bare env object:
- Otherwise the value is treated as base64 (for
<ROUTE>_ENVonly when the stored string is not JSON-shaped; for_ENV_B64the column value is always decoded from base64 first). After decoding, if the UTF-8 text looks like{…}, it is parsed as JSON (legacy/base64 JSON bundles); otherwise it is parsed as dotenv-style lines (KEY=value,#comments, first=splits key/value). - Canonical keys: every resolved env property name is normalized to UPPERCASE (Unix-style env).
requiredKeys,allowedKeys, andforbiddenKeysare compared case-insensitively against those uppercase names.
API overview
resolveEnvRoute(options)
Finds and parses a matching route without mutating process.env.
import { resolveEnvRoute } from "@x12i/env-router";
const resolved = await resolveEnvRoute({
clientKey: "abc123",
requiredKeys: ["API_URL"]
});
console.log(resolved.routeId);
console.log(resolved.env.API_URL);Returned fields (high-level):
env: the routed env map (selected route payload)nativeEnv: the native vars discovered in the source (non-ENVX_)mergedEnv:{ ...nativeEnv, ...env }(routed wins on overlap)
mergedEnv is the merged view for reads, logging, or spawning a child with a full map. applyEnvRouteToProcessEnv does not use mergedEnv automatically: it only writes the map you pass (usually resolved.env). To mirror mergedEnv in process.env, pass resolved.mergedEnv, or pass resolved.env with overwrite: true so routed keys replace any host values.
Common options:
clientKey(required)source(createProcessEnvSource,createObjectEnvSource,createDotenvTextSource)routeIdsrouterVarPrefix(default:ENVX_)- suffix overrides (
keySuffix,envSuffix, etc.) hash(sha256orhmac-sha256)requiredKeys,allowedKeys,forbiddenKeys(matched case-insensitively; resolved keys are uppercase)prefer: "env" | "envB64"
applyEnvRouteToProcessEnv(env, options)
Applies a resolved env map to process.env and returns a reversible handle.
import { applyEnvRouteToProcessEnv } from "@x12i/env-router";
const applied = applyEnvRouteToProcessEnv(
{ API_URL: "https://api.example.com" },
{ overwrite: true, omitEmpty: true }
);
applied.restore();The returned handle includes skippedKeys and overwrittenKeys. If routed values seem ignored, log those arrays first—often overwrite: false left overlapping keys unchanged.
bootstrapEnvRouter(options)
One-call resolve + apply.
import { bootstrapEnvRouter } from "@x12i/env-router";
const result = await bootstrapEnvRouter({
clientKey: process.env.ENV_ROUTER_CLIENT_KEY!,
exposeRouteIdAs: "ACTIVE_ENV_ROUTE",
scrubRouterVarsAfterApply: false
});
console.log(result.routeId);withEnvRoute(options, fn)
Scoped helper that auto-restores previous env after callback completes.
import { withEnvRoute } from "@x12i/env-router";
await withEnvRoute(
{ clientKey: process.env.ENV_ROUTER_CLIENT_KEY! },
async () => {
// run short-lived job with selected env
}
);parseRouterEnv(sourceMap)
Lists discovered route summaries (hasKey, hasEnv, etc.) for diagnostics.
import { parseRouterEnv } from "@x12i/env-router";
const parsed = parseRouterEnv(process.env);
console.log(parsed.routes);Source adapters
createProcessEnvSource(process.env)createObjectEnvSource({ ... })createDotenvTextSource("KEY=value")
Utility exports
redactEnvMap(envMap, options)EnvRouterErrorandisEnvRouterError(error)
CLI usage
Validate route definitions:
env-router validateResolve route by key with redacted output:
env-router resolve --key "$CLIENT_KEY" --safeRun a child process with selected env:
env-router run --key "$CLIENT_KEY" -- node dist/server.jsGenerate a random route key and base64 of a project .env file (two lines to paste into deployment env):
env-router generate --file ./.env --app DEMO_APPExample output:
ENVX_DEMO_APP_KEY=envX_<uuid>
ENVX_DEMO_APP_ENV_B64=<base64-of-file>Copy each line into your deployment environment. Route discovery picks up the route from ENVX_<APP>_KEY and ENVX_<APP>_ENV_B64 even without ENVX_ENV_ROUTER_ROUTES.
Push the generated pair directly to Cloudflare Workers (via @x12i/env-inject):
env-router publish --file ./.env --app DEMO_APP --worker my-worker --env production --mode merge --yesNotes:
env-router pushis an alias forenv-router publish.- Cloudflare auth comes from
CLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_API_TOKEN(or--account-id/--api-token). - The key var (
ENVX_<APP>_KEY) is published as a secret.
Write/update a local .env-router file (and update ignore files):
env-router write --file ./.env --app DEMO_APPThis will:
- Create or update
.env-routerwith the generatedENVX_<APP>_KEYandENVX_<APP>_ENV_B64pair - Ensure both
.gitignoreand.npmignoreignore:.env-router.env- Any other env-like files found in the project that do not include the word
examplein their filename
No legacy support
Router variables must use the ENVX_ prefix (or your configured routerVarPrefix). Unprefixed router vars like DEMO_APP_KEY / DEMO_APP_ENV_B64 are not supported.
Security and safety notes
- Redaction masks sensitive-looking keys in safe previews.
- Key comparisons use timing-safe compare for raw values.
- Hash-based key matching is supported (
sha256,hmac-sha256). - Dangerous keys are blocked during apply by default:
NODE_OPTIONSLD_PRELOADDYLD_INSERT_LIBRARIES
- This package improves operational safety, but it is not a secure boundary if deployment env is already readable by an attacker.
Per-request warning (shared servers)
Do not mutate global process.env per request in a shared HTTP process.
Bad pattern:
app.post("/run", async (req, res) => {
await bootstrapEnvRouter({ clientKey: req.body.key });
// concurrent requests can now observe wrong env
});Prefer:
app.post("/run", async (req, res) => {
const route = await resolveEnvRoute({ clientKey: req.body.key });
await doWork({ env: route.env });
});Testing examples
Use object source in unit tests:
import { createObjectEnvSource, resolveEnvRoute } from "@x12i/env-router";
const source = createObjectEnvSource({
ENVX_ENV_ROUTER_ROUTES: "DEMO_APP1",
ENVX_DEMO_APP1_KEY: "abc123",
ENVX_DEMO_APP1_ENV: "{\"API_URL\":\"https://api.example.com\"}"
});
const route = await resolveEnvRoute({ clientKey: "abc123", source });Troubleshooting
NO_CLIENT_KEY:clientKeyis empty or missing.NO_ROUTES_FOUND: no valid route IDs discovered from env.INVALID_CLIENT_KEY: key does not match any route.MULTIPLE_ROUTE_MATCHES: duplicate/misconfigured keys across routes.ROUTE_ENV_MISSING: matching route has no_ENV/_ENV_B64.INVALID_ENV_JSON/INVALID_ENV_BASE64: payload format issue.REQUIRED_ENV_KEY_MISSING: route payload missing required app keys.FORBIDDEN_ENV_KEY: payload tries to set blocked env key.
Release checklist
Before publishing:
npm run lint
npm run test
npm run build
npm pack --dry-run
npm publish --access public