rhythmjs

Search documentation

Search guides, the tutorial and every package.

On this page

A real controller#

A controller is a RhythmRouter with a prefix and a chain of routes. Start the notes feature with an in-memory list — the database arrives in step 8:

TypeScript
// src/notes/notes.controller.ts
import { RhythmRouter } from "@rhythmjs/router";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";

interface Note {
  id: string;
  title: string;
  content: string;
}

const notes = new Map<string, Note>();

export const notesController = new RhythmRouter<RhythmHttpContext>({ prefix: "/api/notes" })
  .get("/", (ctx) => {
    ctx.json([...notes.values()]);
  })
  .get("/:id", (ctx) => {
    const note = notes.get(ctx.params.id);
    if (!note) return ctx.error(404, "Note not found");
    ctx.json(note);
  })
  .post("/", async (ctx) => {
    const input = (await ctx.request.json()) as { title: string; content?: string };
    const note: Note = { id: crypto.randomUUID(), title: input.title, content: input.content ?? "" };
    notes.set(note.id, note);
    ctx.json(note, 201);
  })
  .delete("/:id", (ctx) => {
    if (!notes.delete(ctx.params.id)) return ctx.error(404, "Note not found");
    ctx.response.status = 204;
  });

Mount it in the module before the 404 tail: .use(notesController.middleware()). Routes register under the prefix (/api/notes/:id), and a static segment always beats a param segment when both match.

The context helpers#

Every handler receives a RhythmHttpContext: the Web-standard ctx.request, a mutable ctx.response (status, headers, body), and five helpers — json(data, status?), text, html, error(status, message?), and redirect(url, status?). Route matches add ctx.params, typed as Readonly<Record<string, string>>.

Query strings are the plain Web platform — no magic parsing:

TypeScript
.get("/", (ctx) => {
  const q = new URL(ctx.request.url).searchParams.get("q") ?? "";
  ctx.json([...notes.values()].filter((n) => n.title.includes(q)));
})

Middleware on a controller#

Controllers accept use() like the kernel does, and middleware interleaves with routes in registration order — the onion. A timing middleware wraps everything registered after it:

TypeScript
export const notesController = new RhythmRouter<RhythmHttpContext>({ prefix: "/api/notes" })
  .use(async (ctx, next) => {
    const start = performance.now();
    await next(); // run the matched route (and later middleware)
    ctx.response.headers.set("x-elapsed", `${Math.round(performance.now() - start)}ms`);
  })
  .get("/", (ctx) => { /* ... */ });

A middleware that returns without calling next() short-circuits: nothing downstream runs. That is the entire control-flow story — no interceptors, pipes, or guards as separate concepts; each is just middleware placed at the right point in the chain.

Many controllers, one app#

An app is a stack of controllers mounted in order. Unmatched requests fall through each router to the next middleware, which is why the 404 tail comes last:

TypeScript
export const appModule = new Rhythm<RhythmHttpContext>({ name: "app", type: "module" })
  .use(healthController.middleware())   // /health/*
  .use(notesController.middleware())    // /api/notes/*
  .use(usersController.middleware())    // /api/users/*
  .use((ctx) => { /* 404 tail */ });

Route patterns come from rou3: :param, :param?, *, and ** all work in any segment. See the router docs for matching details and static file serving.