Skip to content
Developer Tools2026

Chorus

A shared code editor where simultaneous edits merge without conflict, then run in a sandbox.

chorus-ide.netlify.app
Screenshot of Chorus: Developer Tools, built with React, Express, MongoDB, Yjs.

The problem

Two people typing into one file is a merge problem that never stops. Locking the file or keeping the last write throws away somebody’s work, and a scheme that merges correctly in a demo can still diverge after a reconnect, a restart or a slow database, which is exactly when nobody is watching. The file also has to be runnable by anyone in the room without a visitor’s infinite loop taking the server down, and an assistant asked about it has to see the version on screen, not the one last saved.

Decisions

01

Edits merge through a CRDT and are stored as binary state

Every client binds the editor to a shared Yjs document, so two people typing at the same offset both keep their characters and every copy converges on the same text. The server persists the document as binary Yjs state rather than text, because text is lossy for a CRDT: rehydrating from it discards the client ids and clocks that make a later merge resolvable. The text is materialised beside it for everything that is not the live editor, and Run reads the open room first, so it never executes a copy older than what is on screen.

02

One server, one auth path, and the socket checks its own origin

Express and the editor’s WebSocket share one HTTP server, and the handshake reuses the token verification and access gate every REST route uses, so a project cannot be private to the API and writable over the socket. WebSocket handshakes are not covered by CORS: the browser sends them cross-origin without a preflight, so the upgrade handler checks the origin itself. Without that, the REST allowlist means nothing.

03

The editor is the performance budget

Monaco’s default import pulls in about a hundred language grammars and a 5.9MB TypeScript worker, which would be fetched the moment anyone opened a JavaScript file, to type-check code that is about to run in a container anyway. Chorus imports the editor core, a grammar for each file type it offers and one generic worker, all behind the lazy workspace route. The public pages’ critical path is 105KB gz, and the build fails if an editor chunk ever reaches the entry.

04

The model catalogue is treated as a guess

Google retires and renames Gemini models on its own schedule, so no name is pinned: the API reads the catalogue, ranks it, and lets experience overrule the ranking. The model that last answered moves to the front. One that hangs is cut off at ten seconds and sent to the back for ten minutes, after a newly listed model began accepting requests and saying nothing for thirty seconds; that change took the assistant from one answer in four back to ten in ten.

The hard part

Seeding a document from text is not idempotent

A CRDT identifies every character by a client id and a clock, and a fresh Yjs document picks a random client id. So seeding the same file from the same text in two different rooms produces two sets of characters Yjs has no reason to believe are the same, and merging them is a union: the file appears twice. It takes only a file with no saved state yet, a room torn down before its first write (a restart will do it) and a client that reconnects still holding the first seeding. It happened to a seeded notes file, where 289 characters became 578 with the heading twice.

Deriving the seeding client id from the file id makes every seeding of a given file byte-identical, so a second one merges into the first and changes nothing. Repairing the damage taught the other half: halving the text did not fix the file, because the binary state is the authoritative copy and the next person to open it rewrote the text from it. A standing check now hydrates every file exactly as a room does, and reports a document that contains its own text twice or disagrees with its saved text.

const seedClientId = (fileId) =>
  (crypto.createHash('sha256').update(`chorus/seed/${fileId}`).digest().readUInt32BE(0) || 1) >>> 0;

const seed = (doc, fileId, content) => {
  const template = new Y.Doc();
  template.clientID = seedClientId(fileId);
  template.getText(TEXT_KEY).insert(0, content);
  Y.applyUpdate(doc, Y.encodeStateAsUpdate(template));
  template.destroy();
};
backend/realtime/persistence.js. Two seedings of one file are now the same characters, so merging them is a no-op.