rhythmjs

Search documentation

Search guides, the tutorial and every package.

On this page

Start from the template. src/app.service.ts and src/main.ts stay as they are.

Install#

Shell
bun add @rhythmjs/http

The S3 client is a provider#

Bun's S3Client works with S3 and compatible services such as R2 and MinIO. The defaults here match the MinIO service in the examples repo's compose.yaml.

TypeScript
// src/storage.ts
import { S3Client } from "bun";

export function createStorage() {
  return {
    s3: new S3Client({
      accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "minioadmin",
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "minioadmin",
      bucket: process.env.S3_BUCKET ?? "uploads",
      endpoint: process.env.S3_ENDPOINT ?? "http://localhost:9100",
    }),
  };
}

The controller handles the uploads#

multipart fills ctx.form and aborts mid-stream when a limit is crossed, so oversized uploads are never buffered. POST /avatar checks the type, names the file itself and writes it to disk with Bun.write; GET /avatar/:key serves it back, matching the key against a strict pattern so no path can leave the folder; POST /documents writes to S3 and returns a presigned URL.

TypeScript
// src/app.controller.ts
import { mkdir } from "node:fs/promises";
import { multipart, type MultipartContext } from "@rhythmjs/http/multipart";
import { RhythmRouter } from "@rhythmjs/router";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import type { S3Client } from "bun";
import type { appService } from "./app.service";

export type AppContext = RhythmHttpContext & {
  appService: typeof appService;
  s3: S3Client;
};

const uploadDir = () => process.env.UPLOAD_DIR ?? "uploads";
const ALLOWED_IMAGES = new Set(["image/png", "image/jpeg"]);
const limits = multipart({ maxBytes: 5 * 1024 * 1024, maxFileSize: 2 * 1024 * 1024, maxFiles: 1 });

export const appController = new RhythmRouter<AppContext>()
  .get("/", (ctx) => {
    ctx.response.headers.set("content-type", "text/plain");
    ctx.response.body = ctx.appService.getHello();
  })
  .post<MultipartContext>("/avatar", limits, async (ctx) => {
    const file = ctx.form.file("avatar");
    if (!file) {
      ctx.error(400, "avatar missing");
      return;
    }
    if (!ALLOWED_IMAGES.has(file.type)) {
      ctx.error(415, "avatar must be a PNG or JPEG");
      return;
    }

    const key = `${crypto.randomUUID()}.${file.type === "image/png" ? "png" : "jpg"}`;
    await mkdir(uploadDir(), { recursive: true });
    await Bun.write(`${uploadDir()}/${key}`, file);
    ctx.json({ key, size: file.size, url: `/avatar/${key}` }, 201);
  })
  .get("/avatar/:key", async (ctx) => {
    const file = Bun.file(`${uploadDir()}/${ctx.params.key}`);
    if (!/^[0-9a-f-]{36}\.(png|jpg)$/.test(ctx.params.key) || !(await file.exists())) {
      ctx.error(404, "Not found");
      return;
    }

    ctx.response.headers.set("content-type", ctx.params.key.endsWith(".png") ? "image/png" : "image/jpeg");
    ctx.response.body = file;
  })
  .post<MultipartContext>("/documents", limits, async (ctx) => {
    const file = ctx.form.file("document");
    if (!file) {
      ctx.error(400, "document missing");
      return;
    }

    const key = `documents/${crypto.randomUUID()}`;
    await ctx.s3.write(key, file, { type: file.type });
    ctx.json({ key, size: file.size, url: ctx.s3.presign(key, { expiresIn: 3600 }) }, 201);
  });

Provide the client in the module#

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 { createStorage } from "./storage";

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

Things to check#

  • file.type and the filename come from the browser: allow only the types you expect, and never use the client's filename as a path.
  • Add rate limiting and requireAuthentication unless uploads are public.

The full app, with tests, is examples/file-upload, built from the template.