noise -> signal

000

press any key to skip

Room-based collaborative drawing app: Next.js canvas, Express API, WebSocket relay

Problem

People need to sketch together in real time inside shared rooms, with persisted drawing state so a room survives participants leaving and rejoining.

What it does

Users sign up/login (JWT cookie), create or join rooms by slug from a dashboard, draw shapes (rect, circle, line, arrow) on an HTML5 canvas, and broadcast finished shapes to everyone in the room over WebSockets; the full shape list is saved to Postgres on unload via a 'save' message so rooms can be reloaded later (apps/ws-backend/src/index.ts, apps/excalidraw-frontend/components/Canvas.tsx).

Architecture

[frontend] Next.js app (canvas, dashboard, auth pages) (client)
[http] Express HTTP backend (auth + room CRUD, :3001) (api)
[ws] ws WebSocket server (join/leave/chat/save, :8080) (worker)
[db] PostgreSQL via Prisma (User, Room with state + participants) (store)
[common] @repo/common shared package (Zod schemas, env config) (model)

frontend ──▶ http · axios withCredentials for signup/login/create-room/get-all-rooms/get-room
frontend ──▶ ws · WebSocket with ?token=<JWT> for join-room, chat (shapes), save
ws ──▶ db · Prisma: verify room/user, connect/disconnect participants, persist room.state
http ──▶ db · Prisma: create users (bcrypt), create rooms, read room state
http ──▶ frontend · JWT set as 'cookie' cookie on login
common ──▶ ws · messageSchema validation of WS frames
common ──▶ http · signup/login/room Zod schemas

Simplified — box-and-arrow sketch of the repo structure, not a system diagram.

Decisions

  1. 01

    Three-service monorepo (frontend / http / ws) with shared packages

    Isolates stateless REST traffic from long-lived WebSocket connections while sharing Zod schemas and Prisma client via @repo/common and @repo/db

    Evidence: turbo.json

  2. 02

    JWT-in-cookie auth reused for WebSocket handshake

    Same JWT_SECRET-signed token is verified in the ws connection query param and Express middleware, so one auth path covers both transports

    Evidence: apps/ws-backend/src/index.ts

  3. 03

    In-memory room membership in the ws server

    users[] array holds each socket's joined rooms so 'chat' broadcasts only to roommates without a DB round trip per draw

    Evidence: apps/ws-backend/src/index.ts

  4. 04

    Snapshot-based persistence, not event log

    Whole shape array is serialized to Room.state and saved on beforeunload / 'save' message, keeping the model simple but coarse-grained

    Evidence: apps/excalidraw-frontend/components/Canvas.tsx

  5. 05

    Full-shape redraw per frame with clearRect

    Rather than incremental diffs, the canvas re-renders shapes + currentShape + receivedShapes on every state change

    Evidence: apps/excalidraw-frontend/components/Canvas.tsx

  6. 06

    Shared Zod validation across transports

    HTTP bodies and WS messages are parsed with the same schemas (messageSchema requires roomId, guards malformed frames)

    Evidence: packages/common/src/zod.ts

The hard parts

  • Real-time sync: received shapes are appended to a separate receivedShapes array and only broadcast on mouseUp, so concurrent editors can drift/overwrite each other's stroke order (apps/excalidraw-frontend/hooks/useSocket.ts, components/Canvas.tsx)
  • Undo is per-client only (deletedShape stack) and never propagated to other users or persisted (components/Canvas.tsx)
  • Room state is a single JSON blob — last writer wins on 'save', no conflict resolution (apps/ws-backend/src/index.ts)

Known limitations

  • Hardcoded localhost URLs: http://localhost:3001 in dashboard/canvas pages and CORS origin http://localhost:3000 (app/dashboard/page.tsx, apps/http-backend/src/index.ts)
  • JWT_SECRET falls back to literal "123321" if env unset (packages/common/src/secret.ts)
  • CORS origin hardcoded; login cookie maxAge is 30*24*60*60 seconds not ms (loginController.ts)
  • No tests, no CI, no Dockerfile/compose, no .env.example, no LICENSE in the repo root
  • README hedges with 'Tailwind (if used)' / 'React Flow (if used)' — neither React Flow nor it describes actual routing; README is partially stale
  • Canvas page file app/canvas/[roomId]/page.tsx appears empty (0 lines read) — routing may be broken or a stray file
  • Select/drag only mutates local shape positions in some paths; dragging sync depends on the next chat broadcast

Command palette

Jump to a section, project or link