xcvzmoon-tools
v0.0.2
Published
A collection of reusable tools by xcvzmoon
Maintainers
Readme
xcvzmoon-tools
A collection of tools created to simplify what I personally use day-to-day, shared here in case they're useful to others too.
Installation
npm install xcvzmoon-toolsTools
string-case
String case conversion utilities with full TypeScript literal-type inference ("fooBar" typed as "Foo-Bar" after trainCase, not just string).
Ported from unjs/scule. See Credits.
import {
splitByCase,
pascalCase,
camelCase,
kebabCase,
snakeCase,
trainCase,
titleCase,
flatCase,
upperFirst,
lowerFirst,
} from "xcvzmoon-tools/string-case";
splitByCase("foo_bar-baz/qux"); // ["foo", "bar", "baz", "qux"]
pascalCase("foo_bar-baz/qux"); // "FooBarBazQux"
camelCase("foo_bar-baz/qux"); // "fooBarBazQux"
kebabCase("fooBarBaz"); // "foo-bar-baz"
snakeCase("fooBarBaz"); // "foo_bar_baz"
trainCase("fooBarBaz"); // "Foo-Bar-Baz"
titleCase("foo-bar"); // "Foo Bar"
flatCase("foo-bar-baz"); // "foobarbaz"
upperFirst("foo"); // "Foo"
lowerFirst("Foo"); // "foo"runtime-config
A portable, typesafe runtime config in the spirit of Nuxt/Nitro's runtimeConfig/useRuntimeConfig(): call defineRuntimeConfig() once at startup with a nested defaults object, then read the resolved result from anywhere in your app with useRuntimeConfig(). Unlike a hand-rolled Bun.env/process.env reader, it works with any env source (Node, Bun, Deno, or import.meta.env in a Vite/browser app), throws on invalid values instead of silently coercing to NaN/false, and supports required fields and array leaves.
- Quick start
- Defining a schema
- Env var naming
required()- Validation and coercion
- Options
- Env sources / portability
- Typing
useRuntimeConfig() - Testing
- Errors
- What this doesn't do
Quick start
// e.g. server/runtime-config.ts — run once, at app startup
import { defineRuntimeConfig, required, envKey } from "xcvzmoon-tools/runtime-config";
defineRuntimeConfig(
{
node: { env: envKey("NODE_ENV", "development") }, // explicit name, bypasses the derived key and prefix
db: {
hostname: "localhost",
port: 5432,
tls: false,
allowedOrigins: [] as string[], // "a.com,b.com" or '["a.com","b.com"]'
},
betterAuth: {
secret: required(""), // throws at resolve-time if the env var is missing
},
},
{
prefix: "APP", // optional; derives APP_DB_HOSTNAME, APP_DB_PORT, etc.
source: process.env, // optional explicit source; auto-detected if omitted
dts: true, // writes ./runtime-config.d.ts with the RuntimeConfigSchema augmentation
},
);// anywhere else in your app
import { useRuntimeConfig } from "xcvzmoon-tools/runtime-config";
const config = useRuntimeConfig();
config.db.hostname; // stringdefineRuntimeConfig() resolves eagerly (so a missing required value fails your app at startup, not on the first request that happens to touch it) and stores the result in module state. useRuntimeConfig() reads that state — call it as many times, from as many files, as you like. Calling defineRuntimeConfig() a second time simply overwrites the previous result.
Defining a schema
A schema is a plain nested object. Every leaf's value is both its default and the thing that decides its type and its coercion rule:
| Leaf default | TS type | Env var coercion |
| ----------------- | ---------- | --------------------------------------------------------------------- |
| "localhost" | string | used as-is |
| 5432 | number | Number(raw), throws if NaN |
| false | boolean | only "true" / "false" accepted, else throws |
| [] as string[] | string[] | JSON.parse(raw) if valid JSON array, else raw.split(","), trimmed |
| [0] as number[] | number[] | same as above, then each item run through the number rule |
Nest plain objects as deep as you like — anything that isn't a Leaf (string/number/boolean/array) is treated as a nested group, not a value:
defineRuntimeConfig({
services: {
email: { host: "smtp.example.com", port: 587 },
storage: { bucket: "", region: "us-east-1" },
},
});An empty array default ([] as string[]) can't tell you its element type at runtime, so it's always treated as string[] — seed it with a placeholder element cast away ([0] as number[], as above) if you need a number[] default that starts empty-feeling.
Env var naming
By default, an env var name is derived from the object path: segments are joined and run through this package's own snakeCase (from the string-case tool) and uppercased.
| Path | Derived env var |
| --------------------- | --------------------- |
| db.hostname | DB_HOSTNAME |
| betterAuth.secret | BETTER_AUTH_SECRET |
| services.email.host | SERVICES_EMAIL_HOST |
Pass prefix in the options to prepend a (also snake-cased + uppercased) namespace to every derived name — { prefix: "APP" } turns DB_HOSTNAME into APP_DB_HOSTNAME.
Some env vars don't follow the path convention at all (NODE_ENV, PORT, DATABASE_URL, ...). Use envKey(name, value) to pin the exact name for a single leaf, bypassing both the derived name and the prefix:
defineRuntimeConfig(
{ node: { env: envKey("NODE_ENV", "development") } },
{ prefix: "APP" }, // ignored for this leaf — reads NODE_ENV, not APP_NODE_ENV
);required()
Wrap a leaf's default in required() to make defineRuntimeConfig() throw if the matching env var isn't supplied — regardless of whatever placeholder default you passed (the placeholder only exists to give TypeScript a type to infer):
defineRuntimeConfig({ betterAuth: { secret: required("") } });
// throws: Missing required config value at "betterAuth.secret" (expected env var "BETTER_AUTH_SECRET")required() and envKey() compose in either order — both of these are equivalent:
required(envKey("PORT", 3000));
envKey("PORT", required(3000));Validation and coercion
Every leaf is coerced according to the table in Defining a schema. Invalid values throw a RuntimeConfigError naming the config path, the env var, and the offending raw value — nothing is silently corrupted into NaN or false:
Invalid value for "db.port" (env "DB_PORT" = "not-a-number"): expected a numberEnv vars that don't correspond to any key in your schema are ignored.
Options
The second argument to defineRuntimeConfig(schema, options):
| Option | Type | Default | Description |
| -------- | ------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------- |
| prefix | string | none | Prepended (snake-cased + uppercased) to every derived env var name. Ignored by envKey() leaves. |
| source | Record<string, string \| undefined> | auto-detected (see below) | Explicit env source to read from instead of auto-detection. |
| dts | string \| boolean | undefined (no file written) | Writes a RuntimeConfigSchema augmentation. true → ./runtime-config.d.ts; a string → that path. |
Env sources / portability
When source isn't given, the env source is auto-detected in this order:
process.env— covers Node, Bun, and Deno (via its Node-compat layer).import.meta.env— Vite/browser apps.{}— no source available; every leaf falls back to its default.
This means the same schema works unchanged whether your app runs on Node, Bun, Deno, or gets bundled by Vite for the browser.
Typing useRuntimeConfig()
defineRuntimeConfig() and useRuntimeConfig() are usually called from different files, and TypeScript types don't survive that trip on their own — there's no way for a zero-argument useRuntimeConfig() imported elsewhere to automatically know what schema a different file passed to defineRuntimeConfig(). Nuxt solves this the same way: nuxt dev/prepare inspects your resolved runtimeConfig and writes a declare module augmentation to disk, which is exactly what the dts: true option does for you — it writes:
// ./runtime-config.d.ts — generated, do not edit by hand
declare module "xcvzmoon-tools/runtime-config" {
interface RuntimeConfigSchema {
node: { env: string };
db: { hostname: string; port: number; tls: boolean; allowedOrigins: string[] };
betterAuth: { secret: string };
}
}
export {};Once that file is part of your tsconfig.json (it will be by default — restart your TS server/editor after the first run to pick it up), useRuntimeConfig() is typed everywhere with zero repeated generics. This works via TypeScript's declaration merging: interfaces with the same name (targeting the same module) get merged into one by the compiler, the same mechanism libraries like vue-router/fastify/express use for typed globals.
Two alternatives to the dts option:
- Hand-write the augmentation yourself, anywhere in your project (e.g. a
types/runtime-config.d.tsyou commit):declare module "xcvzmoon-tools/runtime-config" { interface RuntimeConfigSchema { db: { hostname: string }; } } - Skip augmentation entirely and pass an explicit type argument per call site:
useRuntimeConfig<typeof schema>(). Useful when you don't want a global augmentation at all — e.g. a project that legitimately resolves several independent, differently-shaped configs.
The dts-writing step is Node-only and lazily loaded (node:fs/node:path are only ever imported if dts is actually set), so using this tool elsewhere (a Vite/browser bundle, an edge runtime) without the dts option never touches them.
Testing
resetRuntimeConfig() clears the resolved config — useRuntimeConfig() throws again until defineRuntimeConfig() is called again. Call it in a beforeEach to isolate tests that each call defineRuntimeConfig() with their own schema:
import { beforeEach } from "vitest";
import {
defineRuntimeConfig,
useRuntimeConfig,
resetRuntimeConfig,
} from "xcvzmoon-tools/runtime-config";
beforeEach(() => resetRuntimeConfig());
test("reads DB_HOSTNAME", () => {
const schema = { db: { hostname: "localhost" } };
defineRuntimeConfig(schema, { source: { DB_HOSTNAME: "db.example.com" } });
expect(useRuntimeConfig<typeof schema>().db.hostname).toBe("db.example.com");
});Passing useRuntimeConfig's optional Schema generic (as <typeof schema> above) is what makes per-test ad-hoc schemas work without a global RuntimeConfigSchema augmentation — handy for a test suite that exercises many unrelated shapes, which is exactly what this tool's own test suite does.
resetRuntimeConfig() isn't just a test hack, either — it's also valid to call in legitimate hot-reload scenarios where you intentionally want to re-resolve against a changed source.
Errors
Every failure this tool raises is a RuntimeConfigError (a plain Error subclass, error.name === "RuntimeConfigError"), thrown synchronously from defineRuntimeConfig() or useRuntimeConfig():
| Scenario | Thrown from |
| ---------------------------------------------------------- | ----------------------- |
| A required() leaf's env var is missing | defineRuntimeConfig() |
| An env var can't be coerced to its leaf's type | defineRuntimeConfig() |
| useRuntimeConfig() called before defineRuntimeConfig() | useRuntimeConfig() |
What this doesn't do
This does not implement Nuxt's runtimeConfig.public client/server split. Stripping server-only keys out of a client bundle requires bundler cooperation that a plain utility library can't provide — nest your own public: {...} key by convention if you need that, and let your bundler enforce it.
Credits
- unjs/scule —
string-caseis a direct port of scule's source and test suite, used under its MIT license. It's vendored in rather than depended on, purely to keep this package dependency-free. SeeTHIRD-PARTY-NOTICES.mdfor the original license text.
