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.

Install#

Nothing: RedisClient is part of Bun.

The client is created at startup#

TypeScript
// src/redis.ts
import { RedisClient } from "bun";

export function createRedis(): RedisClient {
  return new RedisClient(process.env.REDIS_URL ?? "redis://localhost:6379");
}

main.ts creates it once, assigns it to the app's startup context, and closes it on shutdown. Rhythm does not close it for you.

A service holds the data#

A plain in-memory service stands in for a slow database. loads counts how often it was really read.

TypeScript
// src/products/products.service.ts
export interface Product {
  id: string;
  name: string;
  price: number;
}

export const productsService = {
  loads: 0,
  products: new Map<string, Product>([
    ["1", { id: "1", name: "Keyboard", price: 49 }],
    ["2", { id: "2", name: "Mouse", price: 25 }],
  ]),

  async get(id: string): Promise<Product | undefined> {
    this.loads++;
    await Bun.sleep(50);
    return this.products.get(id);
  },

  update(id: string, input: { name?: string; price?: number }): Product | undefined {
    const product = this.products.get(id);
    if (!product) return undefined;
    const next = { ...product, ...input };
    this.products.set(id, next);
    return next;
  },
};

The controller caches#

This is the products controller, a RhythmRouter with a /products prefix. GET /:id is cache-aside: look in Redis, load on a miss, store with an expiry. PATCH /:id deletes the key, so the next read refreshes the cache.

TypeScript
// src/products/products.controller.ts
import { RhythmRouter } from "@rhythmjs/router";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import type { RedisClient } from "bun";
import type { productsService } from "./products.service";

export type ProductsContext = RhythmHttpContext & {
  redis: RedisClient;
  productsService: typeof productsService;
};

const TTL_SECONDS = 60;
const cacheKey = (id: string) => `product:${id}`;

export const productsController = new RhythmRouter<ProductsContext>({ prefix: "/products" })
  .get("/:id", async (ctx) => {
    const key = cacheKey(ctx.params.id);

    const hit = await ctx.redis.get(key);
    if (hit !== null) {
      ctx.response.headers.set("x-cache", "hit");
      ctx.json(JSON.parse(hit));
      return;
    }

    const product = await ctx.productsService.get(ctx.params.id);
    if (!product) {
      ctx.error(404, "Product not found");
      return;
    }

    await ctx.redis.set(key, JSON.stringify(product));
    await ctx.redis.expire(key, TTL_SECONDS);
    ctx.response.headers.set("x-cache", "miss");
    ctx.json(product);
  })
  .patch("/:id", async (ctx) => {
    const { name, price } = (await ctx.request.json()) as { name?: string; price?: number };
    if (price !== undefined && (typeof price !== "number" || price < 0)) {
      ctx.error(400, "price must be a non-negative number");
      return;
    }

    const product = ctx.productsService.update(ctx.params.id, { name, price });
    if (!product) {
      ctx.error(404, "Product not found");
      return;
    }

    await ctx.redis.del(cacheKey(ctx.params.id));
    ctx.json(product);
  });

The products module owns the feature#

The module declares the redis it needs as input, carries the service on its own context, and mounts the controller.

TypeScript
// src/products/products.module.ts
import type { RedisClient } from "bun";
import { Rhythm } from "@rhythmjs/rhythm";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import { productsController } from "./products.controller";
import { productsService } from "./products.service";

export const productsModule = new Rhythm<
  RhythmHttpContext & { redis: RedisClient },
  { productsService: typeof productsService }
>({
  name: "products",
  type: "module",
}).use(productsController.middleware());

productsModule.context.productsService = productsService;

Register it in the app#

The app declares the startup shape { appService, redis }. register(productsModule) is type-checked against it: the parent must be able to supply redis.

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

export const appModule = new Rhythm<RhythmHttpContext, { appService: typeof appService; redis: RedisClient }>({
  name: "app",
  type: "module",
})
  .register(productsModule)
  .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" });
  });

appModule.context.appService = appService;

main.ts assigns the client and closes it on SIGINT:

TypeScript
// src/main.ts
import { toFetchHandler } from "@rhythmjs/router/fetch";
import { appModule } from "./app.module";
import { createRedis } from "./redis";

const port = Number(process.env.PORT ?? 3011);
const redis = createRedis();
appModule.context.redis = redis;

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

process.on("SIGINT", async () => {
  await server.stop();
  redis.close();
  process.exit(0);
});

Things to check#

  • Treat a cache failure as a miss: catch errors around get and fall through to the source.
  • One client per process; never one per request.

The full app, with tests, is examples/redis, built from the template.