This site is driven by two keys, like the machine it came from. Reading: j and k scroll, d and u move half a page, gg goes to the top, G to the bottom, H and L switch windows, ? opens the help. Space is the Neovim leader and handles content: Space then h home, r research, p projects, g gear, a about, / tags, or Space then a digit to jump to that window. Home is the tmux prefix and handles windows: Home then c opens a terminal, Home then & closes a window, Home then space goes to the next one. While focus is on the keyboard, h j k l move between keys and Enter opens one.

ResearchR AboutA Gear — G for gear — keyboard, terminal, editor, homelab.G
    ProjectsP Tags — / as in vim: search./
    ×
    Menu

    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 authMiddleware and rolesMiddleware kept separate so “who are you” and “what may you do” don’t get tangled in one place.
    • CroncronController exposes the endpoint that runs the expiring-material check, guarded by cronAuth (its own token, not a user JWT). The scheduler lives outside; the service stays one binary.
    • Emailnodemailer behind emailService, used by notificationService to send the list of material about to lapse.
    • UploadsfileRoutes writes into UPLOAD_DIR (default ./uploads); the directory is created at boot if missing.
    • XSSelysia-xss is applied globally; the material form genuinely accepts free text.
    • Seedingseeder.ts for sample data, a separate seeder-prod.ts for the initial data that is actually used. Two scripts beat one script with a --production flag that will eventually be run without the flag.
    id en
    rss gh in