Skip to content

Repository files navigation

CollabCode 🚀

A real-time collaborative workspace where two people code or write together — live CRDT-synced editing with named cursors, in-browser code execution across 9 languages, and a diff-based persistence pipeline backed by PostgreSQL and S3.

✨ Features

  • Conflict-free real-time editing — both users type simultaneously; edits merge via CRDTs (Yjs), never overwrite
  • Live presence — every collaborator gets a stable personal color and a name label pinned to their cursor, in both editors
  • Two room types — code rooms (CodeMirror 6 + Judge0 execution) and doc rooms (TipTap rich-text with a Google-Docs-style toolbar)
  • Instant code execution — 9 languages via the Judge0 API with an async submit–poll pipeline
  • Diff-based persistence — saves ship a minimal text splice + an incremental CRDT delta, not the whole file
  • File upload & export — upload local files into rooms; download code with the right extension, docs as .doc/.html/.txt
  • Google-Docs-style dashboard — searchable room grid with live code-preview thumbnails
  • Secure by default — JWT in httpOnly cookies for REST and the socket handshake, DB-verified room membership before any relay

🛠️ Tech Stack

LayerTechnology
FrontendNext.js 15, React 19, TypeScript, Tailwind v4, shadcn/ui
EditorsCodeMirror 6 (y-codemirror.next), TipTap (extension-collaboration + collaboration-caret)
Real-timeSocket.IO, Yjs (CRDT) + y-protocols awareness
BackendNode.js, Express 5, TypeScript (module-first architecture)
DatabasePostgreSQL via Prisma 7 (@prisma/adapter-pg, Rust-engine-free)
Object storageAWS S3 (content + serialized CRDT state), MinIO-compatible for local dev
ExecutionJudge0 (RapidAPI), server-side key custody
Data fetchingTanStack Query, Axios

🏗️ High-Level Design

flowchart TB
subgraph Client["Client — Next.js 15"]
Landing["Landing / Auth pages"]
Dash["Dashboard<br/>(room grid, search, upload)"]
CodeEd["Code editor<br/>CodeMirror 6 + yCollab"]
DocEd["Doc editor<br/>TipTap + Collaboration"]
Session["useCollabSession<br/>Y.Doc + Awareness"]
Sync["useContentSync<br/>splice diff + SHA-256 + Yjs delta"]
CodeEd --> Session
DocEd --> Session
CodeEd --> Sync
DocEd --> Sync
end
subgraph NextAPI["Next.js API routes"]
Judge["/api/submit · /api/result/:token<br/>(JUDGE0_API_KEY stays server-side)"]
end
subgraph Backend["Backend — Express 5 + Socket.IO (module-first)"]
Auth["auth module<br/>JWT httpOnly cookie<br/>REST + socket handshake"]
Rooms["room module<br/>routes → controller → service"]
Gateway["room.gateway<br/>membership-checked binary relay"]
Store["storage module<br/>content-store: write-back cache<br/>debounced S3 flush"]
Rooms --> Store
end
PG[("PostgreSQL<br/>users · rooms · membership<br/>contentVersion · preview")]
S3[("S3<br/>rooms/{id}/content.txt<br/>rooms/{id}/state.yjs")]
J0["Judge0 API"]
Client -- "REST (axios, credentials)" --> Backend
Session <-- "YJS_UPDATE / YJS_AWARENESS<br/>(binary, size-capped)" --> Gateway
CodeEd -- "run code" --> NextAPI
NextAPI --> J0
Auth --> PG
Rooms --> PG
Gateway -- "membership check" --> PG
Store -- "debounced PUT / lazy GET" --> S3
Loading

Separation of concerns: Postgres owns metadata and coordination (identity, membership, the content version counter). S3 owns bulk bytes (document text, CRDT state). The Socket.IO gateway owns ephemeral relay and holds zero document state. Judge0 calls never touch the backend — they run through Next.js route handlers so the API key never reaches the browser.

🔄 System Design Deep-Dives

1. Real-time collaboration (CRDT)

Document sync is conflict-free by construction: both editors bind to a shared Y.Doc, and Yjs (YATA algorithm) guarantees every replica converges to the identical state regardless of message order — Strong Eventual Consistency.

sequenceDiagram
participant A as User A (editor)
participant GW as Gateway (Socket.IO)
participant B as User B (editor)
Note over A,B: both authenticated at the socket handshake (JWT cookie)
A->>GW: ROOM_JOIN {room}
GW->>GW: DB membership check (admin / joinedUser?)
GW-->>B: USER_JOINED
B->>GW: YJS_UPDATE (full state push)
GW-->>A: YJS_UPDATE — late joiner catches up
loop every local edit
A->>GW: YJS_UPDATE (binary delta)
GW->>GW: socket.rooms.has(room)? size ≤ cap?
GW-->>B: YJS_UPDATE
B->>B: Y.applyUpdate(doc, delta, "remote")
end
A-)GW: YJS_AWARENESS (cursor pos, name, color)
GW--)B: YJS_AWARENESS → named colored caret
Loading

Key properties:

  • The server is a dumb relay — it never materializes documents, so scaling it means scaling message fan-out, not document computation
  • Echo loops are prevented by origin tags: remote updates are applied with origin="remote", and the local update listener skips that origin
  • Awareness (cursors, names, colors) is ephemeral — a separate protocol that auto-expires, never persisted
  • Per-user undoY.UndoManager tracks origins, so Ctrl+Z undoes only your own edits

2. Persistence: diff sync → Postgres + S3

S3 objects are immutable, so "sync diffs to S3" really means: diffs cut client→server bandwidth; the server materializes state in a write-back cache and flushes full objects to S3 on a debounce.

sequenceDiagram
participant C as Client (useContentSync)
participant API as room service
participant CS as content-store (write-back cache)
participant PG as PostgreSQL
participant S3 as S3
Note over C: debounce 1s after last keystroke
C->>C: splice = diff(lastAckedText, text)<br/>hash = SHA-256(text)<br/>delta = Y.encodeStateAsUpdate(doc, lastAckedSV)
C->>API: PATCH /room/:id/update<br/>{baseVersion, splice, contentHash, yjsDelta}
API->>CS: applySave()
CS->>CS: baseVersion match? → apply splice<br/>SHA-256(result) == contentHash?<br/>yjs = Y.mergeUpdates([current, delta])
CS->>PG: contentVersion++, preview, hash (synchronous)
API-->>C: 200 {version} → client re-anchors
CS--)S3: debounced flush (2s quiet / 10s max)<br/>content.txt + state.yjs
Note over C,CS: conflict path
C->>API: splice against stale baseVersion
API-->>C: 409 Conflict
C->>API: resend FULL text (last-write-wins, safe:<br/>text is a projection of the converged CRDT)
Loading

Design decisions worth knowing:

  • The correctness triangle: splice (minimal payload) + baseVersion (optimistic ordering) + SHA-256 (integrity). A wrong diff can be rejected, never silently persisted
  • Y.mergeUpdates is a pure function over binary updates — the server maintains canonical CRDT state without instantiating a Y.Doc, so it doesn't care whether the room is code or rich text
  • Postgres is the version authority (updated synchronously, one cheap row); S3 takes the heavy bytes on a debounce because it bills per PUT and hates chatty writes
  • Durability window = the debounce interval, bounded by a 10s max-delay and a flush on SIGINT/SIGTERM
  • preview column (first 500 chars, denormalized) lets the dashboard render N thumbnails with zero S3 reads
  • Known limit: the cache is in-process (single-writer). Horizontal scaling requires Redis or sticky room routing — a deliberate, documented trade-off

3. Authentication & security

flowchart LR
Login["POST /auth/login"] --> Verify["bcrypt.compare<br/>unified 401 (anti-enumeration)"]
Verify --> Cookie["Set-Cookie: token<br/>httpOnly · SameSite=Strict · Secure(prod)"]
Cookie --> REST["REST: validateToken middleware"]
Cookie --> WS["Socket: authenticateSocket at handshake<br/>(rejected before any handler runs)"]
WS --> Member["ROOM_JOIN → DB membership check"]
Member --> Relay["relays only within joined rooms<br/>+ payload size caps"]
Loading
  • One cookie authenticates both transports — no token in localStorage (XSS-safe), SameSite=Strict (CSRF)
  • Registration relies on the DB unique constraint (P2002 → 409), not check-then-insert
  • The 2-user room seat is claimed with a conditional update (WHERE joinedUserId IS NULL) so concurrent joins can't both win
  • Judge0 key, DB, and S3 credentials never leave the server

4. Code execution pipeline

Editor → POST /api/submit (Next.js route) → Judge0 → token
→ GET /api/result/:token (poll until status > 2) → stdout/stderr/compile output

Async submit–poll rather than blocking: Judge0 queues submissions, and polling from the client keeps serverless route handlers short-lived.

5. Data model

modelUser {idString@id@default(uuid())usernameStringemailString@uniquepasswordString// bcrypt, hashed in the service layerownedRoomsRoom[]@relation("RoomAdmin")joinedRoomsRoom[]@relation("RoomMember")}modelRoom {idString@id@default(uuid())nameStringtypeRoomType@default(CODE)// CODE | DOClanguageInt?// Judge0 id, code rooms onlyadminIdString// owner (cascade delete)joinedUserIdString?// the one seat (2-user rooms)contentVersionInt@default(0)// optimistic lock for diff synccontentHashString?// SHA-256 integritypreviewString?@db.VarChar(500)// dashboard thumbnails, no S3 read}

Content itself lives in S3: rooms/{id}/content.txt (derived text/HTML) and rooms/{id}/state.yjs (the CRDT source of truth for late-joiner hydration).

🚀 Getting Started

Prerequisites

Setup

git clone https://github.com/ritik6559/CollabCode.git
cd CollabCode
# installcd frontend && npm install &&cd ../backend && npm install
# configure
cp .env.example .env # backend — fill DATABASE_URL, JWT_SECRET, S3 vars# frontend/.env.local — JUDGE0_API_KEY, NEXT_PUBLIC_API_URL, NEXT_PUBLIC_BACKEND_URL# database
npx prisma migrate dev --name init
# run (two terminals)
npm run dev # backend :8000cd ../frontend && npm run dev # frontend :3000

For local S3, start MinIO and set S3_ENDPOINT=http://localhost:9000 in backend/.env.

📁 Project Structure

frontend/
├── app/ # Next.js App Router (landing, auth, dashboard, editors)
├── features/ # feature modules: auth, dashboard, editor, doc, landing
├── hooks/ # useCollabSession (CRDT), useCodeExecution
└── lib/ # diff protocol, collab colors, download, api helpers
backend/
├── prisma/ # schema + migrations
└── src/
├── modules/
│ ├── auth/ # JWT (REST + socket), user service
│ ├── room/ # routes/controller/service + socket gateway
│ └── storage/ # S3 client, write-back content store, text diff
├── common/ # ApiError, asyncHandler, errorHandler, validators
└── db/ # Prisma client (driver adapter)

⚖️ Trade-offs & Scaling Path

Current designWhyAt scale
In-process content cacheSimple, zero infraRedis or sticky room routing
Socket.IO single instance2-user rooms, low fan-outRedis adapter + sticky sessions
Debounced S3 flushS3 bills per PUTSame, plus S3 versioning for history
Full snapshot on S3Immutable objectsAppend-only Yjs update log + compaction
2 users per roomProduct choice, not CRDT limitYjs scales to N; lift the seat check

Built with ❤️ by Ritik

About

A real-time collaborative coding platform that allows developers to create rooms and code together seamlessly. Built with modern web technologies for optimal performance and user experience.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages