Configuration
Every app has a config/ folder. One file declares the environment; one file per concern turns it into typed settings. The result is a single frozen object your handlers and plugins read, and Studio renders live. Read the concept for the model; this page is the day-to-day.
The folder
config/
env.ts # the one zod schema — the only file that reads process.env
app.ts # process identity + endpoint budgets
http.ts # transport knobs (prefix, cors, openapi, docs, inspect)
mail.ts # mailer driver + settings
cache.ts # cache driver + ttl
storage.ts # object storage driver + settings
database.ts # connection
auth.ts # identity bridge
logging.ts # log driver + level
queue.ts # queue driver + concurrencyYou never write an index. The build assembles env.ts plus every slice into config, keyed by filename.
env.ts — the control panel
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(""),
})
export default defineEnv(EnvSchema)
declare module "@nwire/config" {
interface Env extends z.infer<typeof EnvSchema> {}
}Scan this file to see every knob the app exposes. A bad or missing variable fails at boot with one message listing all of them.
A slice
A slice is a function of the validated env. The documented-block style reads like Laravel's config files, so the file itself is the reference:
// config/mail.ts
import { defineConfig, secret } from "@nwire/config"
export default defineConfig((env) => ({
/*
|----------------------------------------------------------------------------
| Default Mail Driver
|----------------------------------------------------------------------------
|
| "memory" records sends in process — zero infra, ideal for dev and tests.
| "smtp" delivers for real; statically import the adapter and wire it.
|
*/
driver: env.MAIL_DRIVER,
from: '"Acme" <no-reply@acme.io>',
smtp: {
host: env.SMTP_HOST,
pass: secret(env.SMTP_PASS), // masked in Studio
},
}))Reading config
A handler reads ctx.config:
const create = defineAction({
name: "todos.create",
input: z.object({ text: z.string() }),
handler: async (ctx) => {
const limit = ctx.config.http.bodyLimit
// …
},
})Type it across the app by augmenting CtxConfig once:
declare module "@nwire/handler" {
interface CtxConfig extends import("virtual:nwire-config").Config {}
}A plugin reads the same object in setup — ctx.config.mail, ctx.config.cache, and so on.
Adding a slice
Drop a file in config/. config/billing.ts becomes config.billing. No registration step.
// config/billing.ts
import { defineConfig } from "@nwire/config"
export default defineConfig((env) => ({
currency: env.CURRENCY,
trialDays: env.TRIAL_DAYS,
}))Add the variables to env.ts and they flow through.
Surfacing a battery's settings
The batteries — mail, cache, storage — read their own slice by convention. Call the plugin with no arguments and it self-configures from config.<name>:
import { mailPlugin } from "@nwire/mail"
createApp({
appName: "app",
registry,
plugins: [mailPlugin()], // reads config.mail
})To switch to a real adapter, import it statically and pass it:
import { mailPlugin } from "@nwire/mail"
import { smtpMailer } from "@nwire/mail-nodemailer"
createApp({
appName: "app",
registry,
plugins: [mailPlugin({ mailer: smtpMailer(config.mail.smtp) })],
})The adapter is a declared dependency you can see in the file. There is no driver string that pulls a package in behind your back.
Secrets
Keep secrets in env, never in committed config. Wrap them with secret() in the slice so Studio masks them. A plugin reads a secret through ctx.config, so no handler or plugin ever reaches for process.env.