Marcom Services: The Bun + Elysia API Behind the Marcom Dashboard
The Marcom dashboard's backend — Elysia on the Bun runtime, MySQL, JWT, Socket.IO on a separate port, and an expiry cron triggered over HTTP.
This is the backend the Marcom dashboard runs against. One Bun process serving a REST API and, on a different port, a Socket.IO server.
Why Bun + Elysia
What I wanted at the time: TypeScript with no build step, fast startup in a small container, and a router that doesn’t need a dozen supporting packages. Elysia on Bun gives all three — bun run --watch src/index.ts in development, bun src/index.ts in production, no tsc in between.
The composition is flat, one controller per entity:
const app = new Elysia()
.use(corsMiddleware)
.use(errorHandler)
.use(authController)
.use(brandController)
.use(clusterController)
.use(fiturController)
.use(jenisController)
.use(materiController)
.use(usersController)
.use(fileRoutes)
.use(cronController);
Below the controllers a services/ layer holds the SQL and models/ holds the shapes. Queries are written directly against mysql2 — no ORM. For a schema that is five core tables with four foreign keys, an ORM only adds a layer you have to memorise.
Two ports, one process
The HTTP API listens on PORT (default 5001); Socket.IO gets its own createServer() on SOCKET_IO (default 5002). Splitting the ports makes the proxy in front of it far easier to configure: one of them needs a WebSocket upgrade, the other doesn’t.
The rate limiter that had to be switched off
There is a deliberately commented block in src/index.ts:
// Global rate limit: temporarily disabled to fix body consumption issue
// .use(rateLimit({ duration: 60_000, max: 1000, ... }))
The rate-limit middleware reads the request body for its own purposes, and the handler behind it then receives a body that has already been consumed — POST requests failed with a misleading validation message. It took a long time to accept that the validation wasn’t the thing that was broken. The block is still commented rather than deleted, so the reason doesn’t get lost.
The rest of it
- Auth — JWT, with
authMiddlewareandrolesMiddlewarekept separate so “who are you” and “what may you do” don’t get tangled in one place. - Cron —
cronControllerexposes the endpoint that runs the expiring-material check, guarded bycronAuth(its own token, not a user JWT). The scheduler lives outside; the service stays one binary. - Email —
nodemailerbehindemailService, used bynotificationServiceto send the list of material about to lapse. - Uploads —
fileRouteswrites intoUPLOAD_DIR(default./uploads); the directory is created at boot if missing. - XSS —
elysia-xssis applied globally; the material form genuinely accepts free text. - Seeding —
seeder.tsfor sample data, a separateseeder-prod.tsfor the initial data that is actually used. Two scripts beat one script with a--productionflag that will eventually be run without the flag.