Skip to content

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.

ts
// 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.

ts
// 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.

ts
import { config } from "virtual:nwire-config"
//  config.mail.driver   typed
//  config.mail.smtp.host typed

For a test or an environment with no bundler, call loadConfig directly:

ts
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:

ts
// 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:

ts
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:

ts
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

MIT licensed.