Configuration
Config is your app's control panel. One file declares every environment variable the app reads; one file per concern shapes those values into typed settings. Nothing is read behind your back, and the assembled config is a frozen, typed object you can see end to end.
Three pieces from @nwire/config do the work. zod validates; the lib prepares.
defineEnv — the one place env is read
config/env.ts declares the whole environment as a zod schema. It is the only file in the app that touches process.env. A missing or malformed value fails at boot with one readable error listing every problem, not three layers deep at the first request.
// config/env.ts
import { z, defineEnv } from "@nwire/config"
const EnvSchema = z.object({
PORT: z.coerce.number().int().min(0).max(65_535).default(3000),
MAIL_DRIVER: z.enum(["memory", "smtp"]).default("memory"),
SMTP_HOST: z.string().default("localhost"),
SMTP_PASS: z.string().default(""), // a secret — see below
})
export default defineEnv(EnvSchema)
// Augment the lib's Env type once, so every slice's `env` is typed with no import:
declare module "@nwire/config" {
interface Env extends z.infer<typeof EnvSchema> {}
}The declare module block is the same idea as a Vite vite-env.d.ts: write it once and env is fully typed everywhere a slice reads it.
defineConfig — one slice per concern
Each config/<name>.ts returns the settings for one concern, as a function of the validated env. A slice imports nothing — env is injected.
// config/mail.ts
import { defineConfig, secret } from "@nwire/config"
export default defineConfig((env) => ({
driver: env.MAIL_DRIVER,
from: '"Acme" <no-reply@acme.io>',
smtp: {
host: env.SMTP_HOST,
pass: secret(env.SMTP_PASS),
},
}))loadConfig — assemble, validate, freeze
loadConfig validates the environment once, runs each slice against it, keys the results by name, and deep-freezes the object. You rarely call it by hand. The @nwire/scan unplugin (wired by nwire dev / nwire build) generates virtual:nwire-config, which imports config/env.ts plus every config/*.ts, keys each slice by its filename (mail.ts becomes config.mail), and calls loadConfig for you. There is no hand-written index.
import { config } from "virtual:nwire-config"
// config.mail.driver typed
// config.mail.smtp.host typedFor a test or an environment with no bundler, call loadConfig directly:
import { loadConfig } from "@nwire/config"
import env from "./config/env"
import mail from "./config/mail"
export const config = loadConfig(env, { mail })Config flows in at boot
Config is the deployment's values, so it enters when the app boots, not when it is constructed. The endpoint binds it:
// app/main.ts
import { config } from "virtual:nwire-config"
await endpoint(config.app.name, config.app.endpoint)
.use(httpKoa({ ...config.http }))
.mount(app, config) // binds config for this app
.run()After boot, every handler reads ctx.config, and plugins read the same object in their setup phase:
const create = defineAction({
name: "todos.create",
input: z.object({ text: z.string() }),
handler: async (ctx) => {
const prefix = ctx.config.http.prefix // typed, no resolve
// …
},
})ctx.config is a shortcut for ctx.resolve("config"). To type it across your handlers, augment CtxConfig:
declare module "@nwire/handler" {
interface CtxConfig extends import("virtual:nwire-config").Config {}
}Secrets
Secrets live in env, never in committed config or code. A slice surfaces a secret with secret(...). The value reads through normally — config.mail.smtp.pass is the real string — but Studio's config panel masks it. So the assembled config is safe to show whole, and a plugin reaches a secret through ctx.config, never through process.env.
Studio serves the resolved config at GET /_nwire/config with every secret() field masked. That makes the control panel inspectable without exposing a single credential.
See also
- Configuration guide — the
config/folder in practice - Going to production — how config is baked into the build
- App — where config sits relative to structure