Integrations
Redis
Bun ships a Redis client: build it once at startup, keep the feature in one module, and cache with an expiry.
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#
// 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.
// 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.
// 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.
// 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.
// 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:
// 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
getand 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.