Skip to main content

Architecture

Shape

Two deployable applications over one PostgreSQL database:

┌──────────────┐
│ Browser │
└──────┬───────┘
HTTP │ WebSocket + media
┌─────────────────┼──────────────────┬──────────┐
▼ │ ▼ ▼
┌───────────────┐ │ ┌─────────────┐ ┌────────┐
│ apps/web │ │ │ web-socket │ │LiveKit │
│ Next.js 14 │◄────────┴───────►│ ws + git │ │ SFU │
│ UI + API │ git service │ │ └────────┘
└───────┬───────┘ (HTTP) └──────┬──────┘
│ │
│ ┌──────────────┐ │
└────────►│ PostgreSQL │◄───────┘
└──────────────┘

┌───────┴───────┐
│ S3 / R2 / LS │
└───────────────┘
ComponentResponsibility
apps/webUI, REST API, auth, persistence
apps/web-socketRealtime hub and git microservice
PostgreSQLAll durable state
S3-compatibleAvatars, editor drafts
LiveKitCall media

Both apps share apps/web/prisma/schema.prisma.

Monorepo

npm workspaces with Turborepo:

ko-lab/
├── apps/
│ ├── web/ # Next.js — UI + API + schema
│ └── web-socket/ # Node — WS + git
├── packages/
│ ├── ui/ # mostly unused
│ ├── db/ # EMPTY — no schema here
│ ├── eslint-config/
│ └── typescript-config/
└── turbo.json

:::warning Two misleading directories packages/db is empty despite its name — the schema is in apps/web/prisma/. And packages/ui is largely unused; components live in apps/web/app/components/. Both mislead newcomers reading the tree. :::

Request paths

Standard request — browser → Next.js route handler → Prisma → PostgreSQL. Nothing unusual.

Realtime — the client holds a WebSocket to apps/web-socket. Chat and board operations go over it for immediacy while also being persisted over HTTP. The socket carries no media and no file contents.

Gitapps/web never shells out to git. It calls the git microservice over HTTP with a bearer token, and that service operates on clones under workspaces/. This keeps disk-bound work out of the Next.js process, which may be serverless.

Calls — signalling over WebSocket, media directly between browser and LiveKit. apps/web mints short-lived tokens and reconciles state from LiveKit webhooks.

Trust boundaries

BoundaryMechanism
Browser → apps/webNextAuth session
Browser → apps/web-socketHMAC token, 300s TTL, origin allowlist
apps/web → git serviceGIT_SERVICE_SECRET bearer token
apps/web → GitHubOAuth token, AES-256-CBC at rest
Browser → LiveKitLiveKit access token, 10m TTL

Deliberate choices

No CRDT. Board collaboration is last-write-wins. Simple, adequate for graph boards, and would not survive collaborative text editing.

Git in a separate process. Cloning needs a writable disk and is slow — both a poor fit for serverless.

Dual-path messages. Chat persists over HTTP and broadcasts over WebSocket. Simple, at the cost of a failure mode where one path succeeds and the other does not.

Structural constraints

Single WebSocket instance. All connection state is in-process memory. ioredis and kafkajs are installed and redisClient.ts exists, but nothing imports them — the scale-out path was started and abandoned. Running two instances splits users into groups that cannot see each other.

The git service is stateful. Clones on local disk mean it cannot be freely rescheduled or horizontally scaled.

One database. No read replicas or sharding.

For what this means in practice, see Known limitations.