Skip to content

Your first app

Start by scaffolding — not from an empty file. create-nwire gives you a running service with a real bounded context, batteries, typed config, and a production Dockerfile, so your first move is to read working code, not assemble it.

1. Scaffold + run

bash
pnpm create nwire my-app --template service
cd my-app
pnpm install
pnpm dev

pnpm dev boots the app under HTTP with watch mode and Studio at http://localhost:3000.

bash
curl -X POST http://localhost:3000/api/todos \
  -H "content-type: application/json" -H "x-user-id: dina" \
  -d '{"text":"water the plants"}'
# 201 {"id":"…","userId":"dina","text":"water the plants","status":"open",…}

curl http://localhost:3000/api/todos -H "x-user-id: dina"
# {"items":[{…}],"total":1}

A handler ran, an event published, a projection updated, and Studio drew it.

2. What you got

my-app/
  app/
    main.ts          the entry — binds a port, serves traffic
    app.ts           the app as a pure value — structure, no side effects
    todos/           the worked bounded context, as use-case folders
      create-todo.action.ts      one file, one use case
      list-todos.query.ts
      todo.events.ts             the facts this BC publishes
      on-todo-completed.workflow.ts
      routes/                    the HTTP surface, explicit + ordered
    wires/http.ts    the route table — read it to see every endpoint
  config/            the control panel — every knob, typed + documented
  plugins/           app-specific glue (the auth bridge)
  Dockerfile         multi-stage, non-root, with a readiness healthcheck

The todos context is the one worked example — you replace it with yours.

3. The pieces

app.ts is structure. It names the app, lists its plugins, and lets the framework discover its primitives. It reads no environment and binds no port, so a test imports it directly.

ts
// app/app.ts
import { registry } from "virtual:nwire-registry"; // generated from app/

export const app = createApp({
  appName: "my-app",
  logger: new ConsoleLogger({ service: "my-app" }),
  registry,                               // every define* under app/, grouped by kind
  plugins: [
    ...forgePlugins(),                     // wires what the registry registered
    todoStorePlugin(),
    mailPlugin(), storagePlugin(), cachePlugin(),   // batteries, config-driven
  ],
});

You write define* files under app/; discovery registers them and forgePlugins() reads the runtime by kind. You never hand-list primitives.

A use case is one file. It's callable from a route, another action, a workflow, or a test — never tied to HTTP:

ts
// app/todos/create-todo.action.ts
export const createTodo = defineAction({
  name: "todos.create-todo",
  description: "Add a todo to the caller's list.",
  input: z.object({ userId: z.string(), text: z.string().min(1).max(500) }),
  emits: [TodoCreated],
  handler: async ({ input, resolve, emit }) => {
    const store = resolve<TodoStore>("todos");
    const row = store.add({ userId: input.userId, text: input.text });
    await emit(TodoCreated, {
      id: row.id, userId: row.userId, text: row.text, addedAt: row.addedAt,
    });
    return row;
  },
});

Routes stay explicit. app/wires/http.ts lists them in order — an endpoint table you read, never inferred.

config/ is the control panel. config/env.ts is the one place the environment is read; each config/<concern>.ts shapes it into a documented slice. main.ts supplies it at boot (.mount(app, config)); a handler reads ctx.config. See Configuration.

Batteries default to zero-infra. mailPlugin(), storagePlugin(), and cachePlugin() run in-process and read their config.<key> slice. Point one at a real backend by editing config and wiring its adapter — the use cases don't change.

4. Grow it

  • Another use case — add a file under app/todos/ (or a new BC folder); export the define* and it's registered.
  • Real backends — flip a driver in config/, install the adapter, wire it in app.ts.
  • Ship itpnpm build bundles app/main.ts to dist/my-app/main.js; pnpm start runs it on plain Node. See Going to production.

Under the hood: the minimal shape

You don't need the scaffold to run Nwire. The smallest program is a handler wired to a transport:

ts
import { createApp } from "@nwire/app";
import { endpoint } from "@nwire/endpoint";
import { get } from "@nwire/wires/http";
import { httpKoa } from "@nwire/koa";
import { z } from "zod";

const app = createApp({ appName: "hello" });
app.wire(
  get("/hello/:name", { params: z.object({ name: z.string() }) }),
  async (input) => ({ message: `hello ${input.name}` }),
);

await endpoint("hello", { port: 3000 }).use(httpKoa()).mount(app).run();

That's a typed route with graceful shutdown and health probes already wired. The scaffold is this shape grown up — structure, config, and batteries factored into the folders above.

Where to next

MIT licensed.