Skip to content

ArjunDevs/SyncLabs

Repository files navigation

SyncLabs

SyncLabs

Realtime collaborative coding β€” every keystroke synced across every user.

Live demo β†’

Next.js React TypeScript Liveblocks Yjs Supabase Upstash Tailwind Auth.js License: MIT


Demo

Sign in with GitHub β†’ create a Lab β†’ invite teammates by email β†’ edit together in real time.

Features

  • Conflict-free realtime editing β€” every keystroke is reconciled through a Yjs CRDT bound to Monaco via MonacoBinding, so concurrent edits never clobber each other.
  • Multiplayer cursors & presence β€” remote selections and named carets render through Yjs awareness; user colours are deterministically hashed from the user id, so the same person stays the same colour across sessions.
  • Live file tree β€” a LiveList<LiveObject> directory backs the file explorer; create, rename, delete, and drag-to-reorder all sync as Liveblocks storage mutations.
  • Shared workspace storage β€” file contents live in a per-room Yjs document; nothing is stored on disk by the app.
  • Threaded comments β€” Liveblocks Comments UI with @-mention resolution against room members.
  • Four-role permission model β€” owner, admin, editor, viewer with server-enforced authorization on every change.
  • Project dashboard β€” create, browse, and delete Labs; upload an optional cover image (JPEG/PNG/WebP/GIF, ≀2 MB) to Supabase Storage.
  • GitHub OAuth β€” Auth.js v5 with the Supabase adapter; protected routes (/dashboard, /room) gated by an authorized callback wired into proxy.ts.
  • Rate-limited writes β€” Upstash sliding-window limits on room create (10/h), room delete (20/h), and permission changes (30/h) per user.
  • Hardened defaults β€” strict CSP (report-only), X-Frame-Options: DENY, locked-down Permissions-Policy, narrowed next/image remote patterns, and poweredByHeader: false.
  • Multi-language Monaco β€” extension β†’ language mapping for 25+ languages including TS, Python, Go, Rust, Java, C/C++, SQL, Dockerfile.

Tech Stack

Layer Technology
Framework Next.js 16.2.6 (App Router) β€” uses the new proxy.ts (formerly middleware)
Runtime React 19.2.4 + React DOM 19.2.4
Language TypeScript 5 (strict)
Realtime transport @liveblocks/client, @liveblocks/react, @liveblocks/react-ui, @liveblocks/node ^3.19.0
CRDT engine yjs, y-monaco, y-protocols, @liveblocks/yjs
Code editor @monaco-editor/react
Auth next-auth v5 beta + @auth/supabase-adapter (GitHub provider)
Database & storage @supabase/supabase-js (Postgres next_auth schema + room-images bucket)
Rate limiting @upstash/ratelimit + @upstash/redis (sliding window)
Styling Tailwind CSS 4 + tw-animate-css + next-themes (default dark)
UI primitives shadcn/ui, radix-ui, @base-ui/react, lucide-react, sonner
File tree react-arborist
Layout react-resizable-panels
Forms react-hook-form + zod + @hookform/resolvers
Animation gsap + @gsap/react (full plugin registration)
Lint ESLint 9 + eslint-config-next

Architecture

flowchart TB
    subgraph Browser["πŸ–₯️  Browser"]
        direction TB
        Monaco["Monaco Editor"]
        YDoc["Yjs Doc<br/>(+ awareness)"]
        UI["React UI<br/>/dashboard Β· /room"]
        Monaco <-->|"MonacoBinding"| YDoc
    end

    subgraph Edge["β–² Vercel Edge"]
        Proxy["proxy.ts<br/>Auth.js gate<br/>(/dashboard, /room/*)"]
    end

    subgraph Server["βš™οΈ  Next.js Route Handlers"]
        direction TB
        LBAuth["POST /api/liveblocks-auth"]
        Rooms["GET Β· POST Β· DELETE<br/>/api/rooms"]
        Perms["GET Β· POST<br/>/api/rooms/:id/permissions"]
        Health["GET /api/health"]
    end

    subgraph LB["☁️  Liveblocks Cloud"]
        LBRooms["Rooms Β· Storage Β· Presence Β· Yjs Β· Comments"]
    end

    subgraph Supabase["🟒 Supabase"]
        SBAuth[("next_auth schema<br/>users Β· accounts")]
        SBStorage[("Storage<br/>room-images bucket")]
    end

    subgraph Upstash["πŸ”΄ Upstash Redis"]
        RL["Ratelimit<br/>rl:room:create (10/h)<br/>rl:room:delete (20/h)<br/>rl:room:permission (30/h)"]
    end

    YDoc <-.->|"WSS Β· storage + awareness"| LBRooms
    UI -->|"fetch (JSON Β· FormData)"| Proxy
    Proxy -->|"session ok"| Server
    UI -->|"session cookie"| Proxy

    LBAuth -->|"identifyUser()"| LBRooms
    Rooms -->|"createRoom Β· getRooms Β· deleteRoom"| LBRooms
    Perms -->|"getRoom Β· updateRoom"| LBRooms

    LBAuth --> SBAuth
    Rooms --> SBStorage
    Perms --> SBAuth
    Rooms --> RL
    Perms --> RL

    classDef browser fill:#1e293b,stroke:#38bdf8,color:#e2e8f0
    classDef edge fill:#0f172a,stroke:#a855f7,color:#e2e8f0
    classDef server fill:#0f172a,stroke:#22c55e,color:#e2e8f0
    classDef lb fill:#1c0f0a,stroke:#f97316,color:#fed7aa
    classDef sb fill:#0a1f15,stroke:#3ecf8e,color:#bbf7d0
    classDef up fill:#1f0a0a,stroke:#ef4444,color:#fecaca

    class Monaco,YDoc,UI browser
    class Proxy edge
    class LBAuth,Rooms,Perms,Health server
    class LBRooms lb
    class SBAuth,SBStorage sb
    class RL up
Loading

Data ownership. Liveblocks is the only place collaborative state lives. Supabase holds users (managed by the Auth.js adapter, schema next_auth) and room cover images. Upstash holds nothing but rate-limit counters.

Auth flow. proxy.ts wraps lib/auth/index.ts's auth export and re-exports it as proxy. The matcher excludes api/auth, static assets, and image folders; everything else runs through the authorized callback, which gates /dashboard and /room behind a session.

Liveblocks identity. POST /api/liveblocks-auth calls liveblocks.identifyUser(...) with the user's email as userId, plus name, avatar, and a deterministic colour hashed from the email.


Permission Model

The model is implemented in lib/liveblocks/permissions.ts and enforced by the route handler at app/api/rooms/[roomId]/permissions/route.ts.

Roles are derived from two pieces of Liveblocks state per room:

  • room.metadata.owner (single email)
  • room.metadata.admins (string list of emails)
  • room.usersAccesses[email] (Liveblocks access scopes)
Role Liveblocks scopes Derivation rule
Owner ["room:write"] email === metadata.owner β€” set at room creation, never reassigned
Admin ["room:write"] listed in metadata.admins
Editor ["room:write"] has room:write and is neither owner nor admin
Viewer ["room:read", "room:presence:write"] does not have room:write

Authorization rules (authorisePermissionChange)

Caller Action Result
Not owner and not admin any change 403 Forbidden
Anyone change the owner's role 400 "Owner role cannot be changed"
Admin (not owner) grant admin 403 "Admins cannot grant admin role"
Owner any other change allowed
Admin promote/demote between editor/viewer, add new users allowed

Editor enforcement is mirrored on the client: currentUserPermission === "viewer" flips Monaco into readOnly + domReadOnly, and disables file-tree create/rename/delete buttons.

Room deletion (DELETE /api/rooms) is owner-only and best-effort cleans up the cover image from Supabase Storage.


Getting Started

Prerequisites

1. Clone & install

git clone https://github.com/your-username/synclabs.git
cd synclabs
npm install

2. Environment

Create .env.local in the project root:

# Supabase (server-only β€” service role key)
SUPABASE_URL=https://YOUR_PROJECT.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...

# Liveblocks (server-only)
LIVEBLOCKS_SECRET_KEY=sk_...

# Upstash Redis (server-only)
UPSTASH_REDIS_REST_URL=https://YOUR.upstash.io
UPSTASH_REDIS_REST_TOKEN=...

# Auth.js v5 (auto-detected by next-auth)
AUTH_SECRET=                # `openssl rand -base64 32`
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=

Only the first five are referenced explicitly via process.env. The AUTH_* keys are read by Auth.js v5 by convention.

3. Supabase setup

The Auth.js Supabase adapter writes to the next_auth schema. Install it once via the Supabase SQL editor following the adapter docs. The route handlers query next_auth.users directly to enrich room member lists.

Create a public Storage bucket named room-images (Storage β†’ New bucket β†’ public). The app uploads a UUID-named file per project image and stores the resulting public URL in room.metadata.image.

4. GitHub OAuth

  1. GitHub β†’ Settings β†’ Developer settings β†’ OAuth Apps β†’ New OAuth App
  2. Authorization callback URL: http://localhost:3000/api/auth/callback/github
  3. Paste the Client ID / Secret into AUTH_GITHUB_ID / AUTH_GITHUB_SECRET.

5. Run

npm run dev          # http://localhost:3000
npm run build        # production build
npm run start        # serve the production build
npm run lint         # eslint
npm run typecheck    # tsc --noEmit

Project Structure

synclabs/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ (shared)/
β”‚   β”‚   β”œβ”€β”€ layout.tsx                       # Navbar wrapper for authed pages
β”‚   β”‚   β”œβ”€β”€ dashboard/page.tsx               # Project grid, create/delete
β”‚   β”‚   └── room/[roomId]/page.tsx           # Liveblocks Room + IDE host
β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”œβ”€β”€ auth/[...nextauth]/route.ts      # Auth.js GET/POST handlers
β”‚   β”‚   β”œβ”€β”€ health/route.ts                  # { ok: true, ts } liveness
β”‚   β”‚   β”œβ”€β”€ liveblocks-auth/route.ts         # liveblocks.identifyUser()
β”‚   β”‚   └── rooms/
β”‚   β”‚       β”œβ”€β”€ route.ts                     # GET list / POST create / DELETE
β”‚   β”‚       └── [roomId]/permissions/route.ts# GET roster / POST upsert role
β”‚   β”œβ”€β”€ globals.css
β”‚   β”œβ”€β”€ layout.tsx                           # Root: ThemeProvider, GSAP, Toaster
β”‚   └── page.tsx                             # Marketing landing
β”‚
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ custom/
β”‚   β”‚   β”œβ”€β”€ auth-buttons/                    # GitHub sign-in / sign-out
β”‚   β”‚   β”œβ”€β”€ comments/                        # @liveblocks/react-ui Threads
β”‚   β”‚   β”œβ”€β”€ create-project-dialog/           # RHF + Zod, multipart upload
β”‚   β”‚   β”œβ”€β”€ cursors/                         # Yjs awareness β†’ CSS injection
β”‚   β”‚   β”œβ”€β”€ dashboard-link/
β”‚   β”‚   β”œβ”€β”€ editor/                          # Monaco + MonacoBinding(yText)
β”‚   β”‚   β”œβ”€β”€ file-explorer/                   # react-arborist over LiveList
β”‚   β”‚   β”œβ”€β”€ file-history/                    # Open-file tab bar
β”‚   β”‚   β”œβ”€β”€ icons/
β”‚   β”‚   β”œβ”€β”€ ide/                             # ide.tsx, ide-topbar, ide-sidebar, ide-workspace
β”‚   β”‚   β”œβ”€β”€ navbar/
β”‚   β”‚   β”œβ”€β”€ permissions-tab/                 # Share dialog (RHF + Zod)
β”‚   β”‚   β”œβ”€β”€ project-card/                    # Card + skeleton + delete confirm
β”‚   β”‚   β”œβ”€β”€ providers/                       # gsap, liveblocks, theme
β”‚   β”‚   β”œβ”€β”€ room/                            # <RoomProvider> with initial storage
β”‚   β”‚   └── user-permission-display/         # Role pill row
β”‚   β”œβ”€β”€ reactbits/dot-grid/                  # Animated background
β”‚   └── ui/                                  # shadcn/ui primitives
β”‚
β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ use-dimensions.ts                    # ResizeObserver hook
β”‚   β”œβ”€β”€ use-file-tabs.ts                     # Tab history (max 7)
β”‚   β”œβ”€β”€ use-permissions.ts                   # Roster CRUD + sonner toasts
β”‚   └── use-room-permission.ts               # Caller's role for the room
β”‚
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”œβ”€β”€ index.ts                         # NextAuth(GitHub + SupabaseAdapter)
β”‚   β”‚   └── actions.ts                       # "use server" login/logout
β”‚   β”œβ”€β”€ ide/
β”‚   β”‚   β”œβ”€β”€ constants.ts                     # extension β†’ Monaco language
β”‚   β”‚   └── icon-mappings.ts
β”‚   β”œβ”€β”€ liveblocks/
β”‚   β”‚   β”œβ”€β”€ index.ts                         # new Liveblocks({secret})
β”‚   β”‚   β”œβ”€β”€ actions.ts                       # resolveUsers, resolveMentions
β”‚   β”‚   β”œβ”€β”€ permissions.ts                   # role math + authorisation
β”‚   β”‚   └── utils.ts                         # LiveList tree traversal
β”‚   β”œβ”€β”€ supabase/
β”‚   β”‚   β”œβ”€β”€ index.ts                         # createClient(URL, SERVICE_ROLE)
β”‚   β”‚   └── room-image.ts                    # validate + upload + delete
β”‚   β”œβ”€β”€ upstash/
β”‚   β”‚   β”œβ”€β”€ index.ts                         # Redis singleton
β”‚   β”‚   └── rate-limit.tsx                   # 3 sliding-window limiters
β”‚   β”œβ”€β”€ types.ts                             # All shared TS types
β”‚   └── utils.ts                             # cn, getInitials, colorFromId, getExtension
β”‚
β”œβ”€β”€ liveblocks.config.ts                     # global Liveblocks type augmentation
β”œβ”€β”€ proxy.ts                                 # Next 16 proxy (replaces middleware)
β”œβ”€β”€ next.config.ts                           # CSP + image hosts + headers
β”œβ”€β”€ eslint.config.mjs
β”œβ”€β”€ tsconfig.json
└── components.json                          # shadcn/ui registry

Environment Variables

Every variable referenced via process.env in the codebase:

Variable Required Used in Purpose
SUPABASE_URL yes lib/supabase/index.ts, lib/auth/index.ts Supabase project URL
SUPABASE_SERVICE_ROLE_KEY yes lib/supabase/index.ts, lib/auth/index.ts Service role key β€” server-only
LIVEBLOCKS_SECRET_KEY yes lib/liveblocks/index.ts Liveblocks server SDK secret
UPSTASH_REDIS_REST_URL yes lib/upstash/index.ts Upstash Redis REST endpoint
UPSTASH_REDIS_REST_TOKEN yes lib/upstash/index.ts Upstash Redis REST token
AUTH_SECRET yes Auth.js (auto) Session/JWT signing secret
AUTH_GITHUB_ID yes Auth.js (auto) GitHub OAuth client id
AUTH_GITHUB_SECRET yes Auth.js (auto) GitHub OAuth client secret

Testing

There is no test suite in this repository (no __tests__/, no *.test.*, no test runner in package.json). The currently shipping verification surface is:

npm run lint         # ESLint 9 with eslint-config-next
npm run typecheck    # tsc --noEmit (strict)

The only runtime smoke endpoint is GET /api/health, which returns { ok: true, ts }.


Deployment

Deployed on Vercel at https://synclabs-seven.vercel.app/.

Steps:

  1. Import the repo in the Vercel dashboard.
  2. Add every variable from the Environment Variables table to the project's environment settings.
  3. Update the GitHub OAuth App's callback URL to https://<your-domain>/api/auth/callback/github.
  4. Set Supabase Storage room-images bucket to public, and ensure the next_auth schema is provisioned.
  5. Deploy. Liveblocks WSS endpoints are pre-allowlisted in the connect-src directive of next.config.ts.

License

MIT