rhythmjs

Search documentation

Search guides, the tutorial and every package.

On this page

Start from the template. src/app.service.ts and src/app.controller.ts stay as they are; everything below is added.

Install#

Shell
bun add ai @ai-sdk/openai @rhythmjs/config zod

Config picks the model#

A config namespace holds the model id, so swapping models is an environment change: AI_MODEL=gpt-4o. An empty value stops the app from booting.

TypeScript
// src/config/ai.config.ts
import { registerAs } from "@rhythmjs/config";
import { z } from "zod";

export const aiConfig = registerAs("ai", z.object({ model: z.string().min(1).default("gpt-4o-mini") }), () => ({
  model: process.env.AI_MODEL,
}));

export const load = [aiConfig] as const;

A service creates the model#

modelService is a plain service. The module provides it, and tests stub modelService.create with the SDK's mock model.

TypeScript
// src/chat/models.ts
import { openai } from "@ai-sdk/openai";
import type { LanguageModel } from "ai";

export interface ChatModel {
  model: LanguageModel;
  modelInfo: { id: string };
}

export const modelService = {
  create(id: string): ChatModel {
    return { model: openai(id), modelInfo: { id } };
  },
};

The controller streams the reply#

This is the chat controller, a RhythmRouter with a /api prefix. A handler fills ctx.response, and its body takes a byte stream, so result.textStream piped through a TextEncoderStream is the whole response, with no headers to set. abortSignal stops the model call when the client disconnects. Clients read the stream with TextStreamChatTransport; for tool calls or reasoning in the chat UI, use the SDK's UI message stream instead, which needs its own headers.

TypeScript
// src/chat/chat.controller.ts
import { RhythmRouter } from "@rhythmjs/router";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import { convertToModelMessages, generateText, streamText, type UIMessage } from "ai";
import type { ChatModel } from "./models";

export type ChatContext = RhythmHttpContext & ChatModel;

export const chatController = new RhythmRouter<ChatContext>({ prefix: "/api" })
  .get("/model", (ctx) => {
    ctx.json(ctx.modelInfo);
  })
  .post("/chat", async (ctx) => {
    const { messages } = (await ctx.request.json()) as { messages?: UIMessage[] };
    if (!Array.isArray(messages) || messages.length === 0) {
      ctx.error(400, "messages are required");
      return;
    }

    const result = streamText({
      model: ctx.model,
      system: "You are a concise assistant.",
      messages: await convertToModelMessages(messages),
      abortSignal: ctx.request.signal,
    });

    ctx.response.body = result.textStream.pipeThrough(new TextEncoderStream());
  })
  .post("/summarize", async (ctx) => {
    const { text } = (await ctx.request.json()) as { text?: string };
    if (!text) {
      ctx.error(400, "text is required");
      return;
    }

    const result = await generateText({
      model: ctx.model,
      prompt: `Summarize in one sentence: ${text}`,
      abortSignal: ctx.request.signal,
    });

    ctx.json({ summary: result.text });
  });

The chat module owns the feature#

The module registers the config module, derives the model from the configured id with the service, and mounts the controller.

TypeScript
// src/chat/chat.module.ts
import { configModule, type ConfigContext } from "@rhythmjs/config";
import { derive, Rhythm } from "@rhythmjs/rhythm";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import { load } from "../config/ai.config";
import { chatController } from "./chat.controller";
import { modelService } from "./models";

type ConfiguredContext = RhythmHttpContext & ConfigContext<typeof load> & { modelService: typeof modelService };

export const chatModule = new Rhythm<RhythmHttpContext>({ name: "chat", type: "module" })
  .provide(() => ({ modelService }))
  .register(configModule.forRoot(...load), ({ configService }) => ({ configService }))
  .use(derive((ctx: ConfiguredContext) => ctx.modelService.create(ctx.configService.get("ai.model"))))
  .use(chatController.middleware());

Register it in the app#

The app module only registers the chat module, before the template's own controller. main.ts calls appModule.setup() first, so a bad config fails at startup and not at the first request.

TypeScript
// src/app.module.ts
import { Rhythm } from "@rhythmjs/rhythm";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import { appController } from "./app.controller";
import { appService } from "./app.service";
import { chatModule } from "./chat/chat.module";

export const appModule = new Rhythm<RhythmHttpContext>({ name: "app", type: "module" })
  .provide(() => ({ appService }))
  .register(chatModule)
  .use(appController.middleware())
  .use((ctx) => {
    ctx.response.status = 404;
    ctx.response.headers.set("content-type", "application/json");
    ctx.response.body = JSON.stringify({ success: false, status: 404, message: "Not Found" });
  });
TypeScript
// src/main.ts
import { toFetchHandler } from "@rhythmjs/router/fetch";
import { appModule } from "./app.module";

await appModule.setup();

const port = Number(process.env.PORT ?? 3008);

const server = Bun.serve({ port, fetch: toFetchHandler(appModule) });
console.log(`listening on ${server.url}`);

Things to check#

The full app, with tests, is examples/ai-sdk, built from the template.