Meridian is a TypeScript end-to-end collaborative browser IDE for engineering teams. Engineers open a workspace in their browser, write and edit code through a Monaco-powered editor, and see each other's changes in real time — backed by Yjs CRDT convergence, Socket.IO sessions, Redis cross-instance fan-out, and PostgreSQL persistence.
- Browser IDE workspace — file explorer, editor tabs, activity bar, status bar, and a collapsible collaboration side panel
- Monaco editor — VS Code's editor engine with syntax highlighting, bracket pair colorization, and multi-language support
- Project / document tree — hierarchical file and folder structure per workspace, fetched from the backend on load
- File operations — create/rename/delete files and folders, open a local file from disk, and import/export a ZIP project — all synced to the backend; see Workspace ZIP export
- Editor tabs — multi-file editing with dirty-state tracking and Cmd+S / Ctrl+S save to backend
- Command palette — press Cmd+K / Ctrl+K to fuzzily search files and run real, permission-aware workspace commands — see Command palette
- Integrated terminal — opt-in (
ENABLE_TERMINAL=true) PTY-backed terminal that materializes the workspace's files into a sandbox so you can run them (incl. "Run Active File"), with live sync as you edit — see Integrated terminal - Version history & restore — every meaningful save snapshots the file; preview any past version, diff it against the current file in a Monaco side-by-side editor, and restore it (editors/owners) — see Version history & restore
- Live collaboration — Socket.IO document rooms with Yjs CRDT merge; concurrent edits from multiple clients converge deterministically
- Presence & chat — real cursors/selections via the Yjs awareness protocol and a per-workspace live chat over Socket.IO
- Share & invite — backend-backed workspace invites: generate a shareable link or email an invite, accept it after sign-in to join with the assigned role
- Authentication — sign up, log in, log out, and a secure forgot/reset-password flow (argon2id hashing, JWT httpOnly cookies, per-session revocation)
- Settings — update your display name, switch theme (persisted), and trigger a password-reset email
- Notifications — an in-app feed of real session events (file saved, invite created); never fabricated
- Yjs CRDT sync — binary update protocol; the server maintains an authoritative Y.Doc per open document and performs sync step 1/2 handshakes with every joining client
- Redis cross-instance fan-out — Yjs updates and awareness states are published to Redis and relayed to clients on other server instances
- PostgreSQL persistence — users, workspaces, documents, invites, and Yjs update logs persisted durably via Prisma
- Snapshot compaction — every N Yjs updates the in-memory Y.Doc state is saved as a Snapshot row and preceding update rows are deleted in a single transaction, bounding storage growth
- Frontend resilience — when the backend is unavailable the frontend falls back to a clearly-labelled local demo workspace; no crash, no empty screen, and demo data never appears as if it were real
Meridian/
├── client/ # React + TypeScript + Vite frontend
├── server/ # NestJS + TypeScript backend
└── docs/ # Architecture documentation
| Library / Tool | Role |
|---|---|
| React 18 | UI component framework |
| TypeScript | Static typing end-to-end |
| Vite | Build tool and dev server |
| Tailwind CSS | Utility-first styling system |
| Zustand | Global workspace state management |
| Monaco Editor | VS Code editor engine (@monaco-editor/react) |
| React Router | Client-side routing |
| Yjs + y-monaco | CRDT collaborative editing bound to Monaco |
| socket.io-client | WebSocket transport |
| Library / Tool | Role |
|---|---|
| NestJS | Server framework — modules, DI, decorators |
| TypeScript | Static typing end-to-end |
| PostgreSQL | Primary durable datastore |
| Prisma | ORM, type-safe queries, schema migrations |
| Socket.IO | WebSocket transport for realtime events |
| Yjs + y-protocols | Authoritative CRDT document state on the server |
| Redis (ioredis) | Cross-instance pub/sub fan-out |
JWT (@nestjs/jwt) |
Stateless auth tokens |
| argon2id | Password hashing (argon2id variant) |
Pino (nestjs-pino) |
Structured JSON logging |
| Swagger | Auto-generated OpenAPI documentation |
cd client
npm install
npm run dev # Dev server → http://localhost:5173
npm run build # Production buildcd server
npm install
cp .env.example .env # Set JWT_SECRET; review other values
npm run infra:up # Start PostgreSQL + Redis via Docker Compose
npm run db:migrate # Apply Prisma schema migrations
npm run db:seed # Seed demo workspace and user
npm run start:dev # Dev server with file watch → http://localhost:3000
npm test # Run unit test suite
npm run build # Compile TypeScript| URL | Purpose |
|---|---|
http://localhost:5173 |
Frontend workspace |
http://localhost:3000/health |
Backend liveness probe |
http://localhost:3000/ready |
Backend readiness probe (Postgres + Redis) |
http://localhost:3000/docs |
Swagger / OpenAPI explorer |
http://localhost:5555 |
Prisma Studio (npm run db:studio in server/) |
Invites are real and backed by the database (Invite model):
POST /workspaces/:workspaceId/invites— create an invite (members only). Returns a shareableinviteUrlcontaining a high-entropy, unguessable token. An optionalemailtriggers an invite email.GET /invites/:token— public invite details (workspace name, role, inviter, expiry) used to render the/invite/:tokenpage before sign-in.POST /invites/:token/accept— accept as the authenticated user; adds aWorkspaceMemberwith the invite's role.
Design notes:
- Tokens are random 24-byte
base64urlstrings stored uniquely; they are unguessable, so they are not additionally hashed at rest. - Invites expire after 7 days (
410 Goneafter that). - Invites are safely reusable: accepting is idempotent, so a single link can onboard a whole team. The first acceptance stamps
acceptedAt. - The
/invite/:tokenpage shows a valid/expired/invalid state, and when unauthenticated it sends the user to sign in with a?redirect=back to the invite so acceptance completes in one flow.
Press Cmd+K (macOS) / Ctrl+K (Windows/Linux) anywhere in the workspace to open the command palette. It is also reachable from Go → Command Palette in the header.
Behavior:
- Opens with Cmd+K / Ctrl+K (handled globally, so it works even when the editor or terminal is focused), closes with Esc.
- The search input autofocuses; ↑/↓ move through results, Enter runs the highlighted one, and clicking runs it.
- Results are grouped into Files and Commands. Typing filters both (files by name/path, commands by name/keywords); an empty query lists every available command. The empty state reads "No matching files or commands."
File search — searches the current workspace file tree by name and full path (case-insensitive, prefix/name matches ranked first). Selecting a file opens it in the editor. With no workspace loaded, no file results appear.
Commands — every entry maps to the same real action used elsewhere in the UI (no duplicated or placeholder logic): New File, New Folder, Save Active File, Run Active File, Open Version History, Toggle Terminal, Toggle Theme, Toggle Explorer, Toggle Collaboration Panel, Share Workspace, Open Settings, and Sign Out.
Permission-aware — commands reflect your role and workspace state rather than failing after the fact:
| Command | Availability |
|---|---|
| New File / New Folder / Save Active File | Disabled for viewers ("Requires editor access"); Save also needs an open file and a backend |
| Open Version History | Available to everyone, but needs an open, saved file ("Open a file first" / "Save the file first") |
| Toggle Terminal | Disabled with a reason when there is no workspace, the user is a viewer ("Requires editor access"), or the terminal is disabled on the server |
| Run Active File | Editor/owner only; disabled with a reason when there is no open file, the file type is not executable, or the terminal is disabled — see Integrated terminal |
| Share Workspace | Shown only to owners |
| Toggle Theme / Explorer / Collaboration, Settings, Sign Out | Available to everyone |
Disabled commands show a short reason and cannot be executed (they are aria-disabled and skipped by keyboard navigation). Nothing in the palette is fake or a dead control — every visible entry either works or is honestly disabled for your role/state.
Accessibility: the palette is a role="dialog" with aria-modal, the input is a labelled combobox driving an aria-activedescendant, and results are a listbox of options.
The inverse of ZIP import: download the current workspace as a .zip via File → Export Workspace as ZIP or the command palette ("Export Workspace as ZIP"). The browser downloads <workspace-name>.zip (filename sanitized from the workspace name).
- Endpoint —
GET /workspaces/:workspaceId/exportreturnsContent-Type: application/zipwithContent-Disposition: attachment; filename="<safe-name>.zip". - Permissions — any workspace member can export, including viewers (it is read-only). Non-members get a
404(the workspace's existence isn't leaked). No role gating beyond membership. - Source of truth — the archive is built from the database: every folder/file document with its latest saved content and preserved structure. Document paths are normalized to safe POSIX relative paths; absolute paths,
..traversal, and control characters are rejected (such documents are skipped). - Included — your workspace's files and folders (including empty folders), with the latest saved content.
- Excluded — terminal sandbox internals (they are never DB-backed, so they can't appear), and build artifacts such as
.meridian-build/. No server files, env, or secrets are ever included.
Limitations
- Export reflects the latest saved DB content, not unsaved editor changes — save (Cmd/Ctrl+S) before exporting to include in-progress edits.
- The ZIP is assembled in memory (via
jszip) before being sent. This is fine at Meridian's current scale (per-file content is small and capped); a very large workspace would warrant a streaming archiver.
Meridian has a real, interactive PTY-backed terminal (xterm.js on the client, node-pty on the server) that operates on the current workspace's files.
Enabling it — the terminal is off by default. Set ENABLE_TERMINAL=true on the server to enable it. When disabled, terminal:start/terminal:run-file return a clear "Terminal feature is disabled on this server" message and the UI shows an honest disabled state — nothing is faked.
Workspace sandbox model — when the terminal starts, the workspace's DB-backed documents are materialized into a per-user, per-workspace sandbox directory (under the OS temp dir), preserving folder structure, and the shell's working directory is that sandbox root. A welcome banner names the sandbox path, and pwd/ls reflect the editor's files.
- The database is the source of truth. The sandbox is a disposable runtime projection of the workspace used only for terminal execution.
- While the terminal is open, editor operations are synced into the sandbox best-effort: save rewrites the file, create writes the file/folder, rename moves it, delete removes it, and version restore rewrites the restored content. A failed sync warns in the terminal ("Could not sync workspace file to terminal sandbox") and via a sync-status badge — it never corrupts the database.
- Sync is one-way (DB → sandbox). Files you create inside the terminal are not imported back into the workspace.
Run Active File — from the Command Palette (Cmd/Ctrl+K) or File → Run Active File. It saves the file if dirty, ensures the sandbox is synced, opens the terminal, and runs the file in it so the command and its real output appear naturally. Supported types:
| Extension | Command |
|---|---|
.py |
python3 <file> |
.js |
node <file> |
.ts |
npx tsx <file> (honest error if tsx isn't installed) |
.sh |
bash <file> |
Other types (.json, .md, .txt, images/binaries) are not executable and the action is disabled with the reason "This file type is not executable". Run is editor/owner only (viewers see "Requires editor access"); it is also disabled with a reason when no file is open or the terminal is disabled.
Security — secrets (DATABASE_URL, JWT_SECRET, …) are never put in the shell environment; HOME points at the sandbox; all materialization/sync paths are validated to stay inside the sandbox root (absolute paths, .. traversal, control characters, and symlinked-ancestor escapes are rejected); run-file validates auth, workspace membership, editor/owner role, that the document belongs to the workspace, and a safe (single-quoted) path. Start/input/run are all gated by ENABLE_TERMINAL and by role.
Known limitations
- The sandbox is a projection of DB-backed workspace files; it is not the database.
- Runtime availability (
python3,node,tsx,bash) depends on what is installed on the server machine — a missing runtime surfaces the shell's real error, not a fake message. - This is not container/Docker isolation. The shell runs as the server's OS user with that user's filesystem permissions; sandboxing is limited to working directory,
HOME, environment scrubbing, and sandbox-confined file sync. Do not run the server as root, and run it as an unprivileged user in untrusted multi-tenant settings. - The database remains the source of truth; the sandbox can be deleted at any time and is rebuilt on the next terminal start.
Every file keeps a real, server-backed history of saved versions (DocumentVersion model). There are no local-only or fabricated entries — every item in the list is a row in the database.
When versions are created
- A version is recorded only when a save meaningfully changes the content — i.e. a
PATCH /documents/:idwhosecontentdiffers from what is currently persisted. Identical saves and metadata-only updates (rename/move) never create a version, so the history has no duplicates. versionNumberincrements per document; the first meaningful save becomes version 1.- The document update and the version insert happen in a single transaction, so they either both commit or both roll back.
Endpoints
GET /documents/:documentId/versions— lightweight list (id, number, timestamp, author, message, content length), newest first.GET /documents/:documentId/versions/:versionId— a single version with its full content.POST /documents/:documentId/versions/:versionId/restore— restore the document to that version's content.
Restore behavior
- Rewrites the document content and records a new version capturing the restored content with the message
Restored from version X(so a restore is itself an undoable point in history). - The new content is broadcast to everyone currently editing the file, and connected clients update live.
Permissions
- Viewers can list, preview, and diff versions, but cannot restore — the restore control is replaced with "Viewer access cannot restore versions."
- Editors and owners can restore.
- Non-members receive
404for every version endpoint (ids are not enumerable across workspaces), consistent with the rest of the document API.
UI
- Open via File → Version History (enabled only for a file that exists on the backend). The dialog shows a loading state, an empty state when no versions exist, and the version list with number, timestamp, and author.
- Select a version to preview it read-only, or toggle Compare with current to see a Monaco side-by-side diff (left: the selected version, right: the current file).
- Restoring asks for confirmation, calls the backend, shows a "Restored version X" notification, marks the tab clean, and refreshes the list.
Collaboration / Yjs note
Live editing is driven by a Yjs CRDT, while versions store plain text. Restore reconciles both:
- If the document is open (a live
Y.Docexists), the server replaces the canonical Y.Text inside a Yjs transaction and broadcasts the resulting incremental update — connected editors converge cleanly with no reload, no rebind, and no divergent CRDT items. The CRDT history is then collapsed into a single snapshot of the restored state. - If the document is not open, the Yjs history is dropped so the next collaborative open re-seeds the
Y.Docfrom the restored content column. - A
document:restoredevent is emitted so clients reconcile their save/dirty indicators.
Like the rest of the realtime persistence layer, this assumes a single server instance for the in-memory sequence counter; multi-instance restore would additionally publish the update and event over Redis (see docs/architecture.md).
POST /auth/forgot-passwordalways returns the same generic message and never reveals whether an account exists.POST /auth/reset-passwordconsumes a single-use token (30-minute TTL).- Email delivery uses
MailService. IfRESEND_API_KEYis set, reset and invite emails are sent via Resend. In development without a provider, the action URL is logged to the console so you can test locally. In production without a provider, the send fails internally (logged/monitored) rather than pretending an email was sent.
Set these in server/.env (see .env.example):
| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
REDIS_URL |
Redis connection string |
JWT_SECRET |
Secret for signing session JWTs (required) |
CLIENT_ORIGIN |
Frontend origin, used to build invite/reset URLs and for CORS |
RESEND_API_KEY |
Optional — enables real email delivery via Resend |
MAIL_FROM |
From-address for outgoing email |
E2E_TEST |
When true, raises rate limits and enables test-only helper endpoints (see below) |
The client reads VITE_API_URL (defaults to http://localhost:3000).
Meridian has four test layers:
| Layer | Where | Runner | Needs a backend? |
|---|---|---|---|
| Server unit | server/src/**/*.spec.ts |
Jest (Prisma mocked) | No |
| Server HTTP integration | server/test/**/*.e2e-spec.ts |
Jest + supertest | Yes — real Postgres + Redis |
| Client unit | client/src/**/*.test.ts |
Vitest | No |
| End-to-end | client/e2e/**/*.spec.ts |
Playwright | Yes — full stack |
# Server unit tests (fast, no database)
cd server && npm test
# Server HTTP integration tests — exercise the real request pipeline
# (ValidationPipe, JwtAuthGuard, throttler, exception filter, real Prisma)
# via supertest. Run against a migrated database (Postgres + Redis up):
cd server && npm run test:integration
# Client unit tests (pure logic) + type-check + build
cd client && npm test && npx tsc -b && npm run build
# End-to-end (Playwright)
cd client && npx playwright install chromium # first run only
# Start the backend with test helpers + relaxed limits:
cd server && E2E_TEST=true npm run start:dev
# Then run the suite (Playwright starts the Vite dev server itself):
cd client && MERIDIAN_BACKEND_URL=http://localhost:3000 npm run test:e2eThe HTTP integration tests boot the real AppModule (no listening port — supertest drives app.getHttpServer()), create throwaway users under an int-* email prefix, and clean only those rows up afterward, so they are safe to run against a shared dev database. E2E tests that require a backend skip automatically when none is reachable; the remaining demo/offline tests run against the frontend alone.
.github/workflows/ci.yml runs on every push to main and on pull requests:
- server —
prisma generate→npm run build→npm test(unit tests mock Prisma, so no database is needed). - server-integration — spins up Postgres + Redis,
prisma migrate deploy, thennpm run test:integration(supertest against the booted app;E2E_TESTdeliberately unset so the real throttler runs). - client —
tsc -b(typecheck) →npm test(Vitest unit tests) →npm run build. - e2e — spins up Postgres + Redis services, applies migrations, starts the backend with
E2E_TEST=true ENABLE_TERMINAL=true, then runs the full Playwright suite (Playwright launches the Vite dev server itself). The Playwright report is uploaded as an artifact on failure. - lint —
npm run lint(ESLint); blocking.
This flag is only for automated tests and changes nothing in normal dev/prod:
- Rate limiters are raised so Playwright never trips
429s. - WebSocket message limits are raised so rapid Yjs updates aren't dropped.
- Test-only endpoints become reachable (they return
404otherwise):GET /auth/e2e/password-reset-token?email=— returns a raw reset token without sending email.POST /e2e/cleanup— deletes throwaway accounts (default email prefixe2e-) and their owned workspaces, keeping the test DB tidy across runs.
These were deliberately left out (and are therefore hidden from the UI rather than faked):
- Git integration — there is no branch selector or source-control panel; Meridian persists documents, not Git history.
- AI assistant — no model is wired up, so the AI sidebar was removed.
- GitHub OAuth — sign-in is email/password only.
- Changing your login email — the settings panel shows email read-only.
See docs/architecture.md for the full diagram set: system context, container layout, backend components, realtime editing sequence, cross-instance Redis scaling, Yjs persistence and recovery, and the complete data model.