Skip to content

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 + concurrency

You never write an index. The build assembles env.ts plus every slice into config, keyed by filename.

env.ts — the control panel

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(""),
})

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:

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

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

ts
declare module "@nwire/handler" {
  interface CtxConfig extends import("virtual:nwire-config").Config {}
}

A plugin reads the same object in setupctx.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.

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

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

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

See also

MIT licensed.