This project is a real-time collaborative text editor built from scratch using Bun, TypeScript, and WebSockets. It serves as a practical demonstration of Operational Transformation (OT) for achieving conflict-free, multi-user text synchronization.
The server maintains a version history for each document, inspired by Git's commit model, to manage different states and enable robust transformations.
- Real-time, Multi-User Editing: Changes from one user are instantly broadcast to all other connected clients.
- Operational Transformation (OT): A custom OT implementation resolves concurrent edits without data loss.
- Optimistic UI Updates: The editor updates immediately on user input, with client-side logic to rebase local changes against incoming server updates.
- Git-like Document Versioning: The server stores document states as a chain of hashed commits, providing a clear history.
- Live Presence: Shows a live count of users currently editing a document.
- Modern Tech Stack: Built with Bun's fast, all-in-one toolkit.
- Runtime: Bun
- Language: TypeScript
- Backend: Bun's native HTTP & WebSocket server
- Frontend: Plain HTML, TypeScript, and Tailwind CSS (via CDN)
- Synchronization: WebSockets with a custom Operational Transformation protocol.
- You must have Bun installed on your machine.
Clone the repository and install the dependencies:
git clone github.com/bethel-nz/ink
cd ink
bun installThe application consists of a backend sync server and a frontend client. You need to run them in two separate terminal sessions.
1. Start the Sync Server
This server handles WebSocket connections, applies operational transforms, and persists document versions.
bun run server/index.tsThe server will start on http://localhost:3000.
2. Start the Frontend Client
This command uses Bun's built-in development server to serve the client-side application.
bun run client/index.htmlThe client will be available at http://localhost:3001 (or the next available port shown in your terminal).
3. Collaborate!
Open the client URL in multiple browser tabs or windows to simulate different users. Type in one editor, and you will see the changes reflected in the others in real-time.
This system works by transmitting compact "operations" describing user edits, rather than sending the full document text on every change. Here's a step-by-step breakdown of the data flow and logic.
When a user makes an edit, we first need to determine exactly what changed. This is done with a character-by-character diffing algorithm.
- Core Logic: The
difffunction inserver/versioning.tsuses a classic dynamic programming approach to find the Longest Common Subsequence (LCS) between the original text and the new text. - Output: The result is an array of
DiffResultobjects, which is a flat list describing every character as eitheradded,removed, orunchanged. For example, changing "cat" to "cart" yields:This character-level granularity is essential for the OT algorithm to work correctly on any kind of text change, including multi-line edits which are simply treated as a continuous stream of characters (including[ { type: 'unchanged', char: 'c' }, { type: 'unchanged', char: 'a' }, { type: 'insert', char: 'r' }, // The change { type: 'unchanged', char: 't' } ]\n).
The raw diff is not what we send over the network. We convert it into a more compact and intelligent format called an Operation.
- The
OperationInterface: This is the universal language for changes in our system. An operation is an object like{ type, position, text?, length? }.type: Can beinsert,delete, orretain.position: The character index in the document where the operation should apply.text: The characters to insert (forinsertops).length: The number of characters to delete or to skip over (fordeleteandretainops).
diffToOperations: This function incommon/ot.tsconsumes theDiffResultarray and produces a new, optimized array ofOperationobjects. It tracks a cursor (position) through the original document:- An
unchangeddiff becomes aretainoperation, advancing the cursor. - A
removeddiff becomes adeleteoperation, advancing the cursor. - An
addeddiff becomes aninsertoperation. The cursor does not advance, as an insert happens at a position without consuming any characters from the original string.
- An
The client provides a smooth user experience by updating the UI instantly, without waiting for the server. It manages this through a careful state-management system.
- Core State Variables:
synchronizedContent: The last text version confirmed ("acknowledged") by the server.latestHash: The server's commit hash forsynchronizedContent. This is the client's "parent commit".inFlightOps: Operations the client has sent to the server but for which it has not yet received anack.pendingOps: Changes the user makes while other operations are in-flight. These are queued up.
- The Flow of a Local Edit:
- A user types. A 500ms debounce timer starts.
- After the pause, the client calculates a "base text" for the diff. This base is what the user is currently seeing:
synchronizedContentwith bothinFlightOpsandpendingOpsapplied to it. - It then diffs this
base textagainst the editor's current value (editor.value) and creates a new set of operations. - These operations are passed to
sendOperations. If there are noinFlightOps, they are sent to the server immediately and become the newinFlightOps. If operations are already in-flight, these new ones are added to thependingOpsqueue.
- Handling Incoming Changes: When the client receives an
updatefrom the server (another user's changes), it must "rebase" its local, un-acknowledged changes.- It applies the incoming server operations to its
synchronizedContent. - It calls
transformOperationson itsinFlightOpsagainst the server ops. - It calls
transformOperationson itspendingOpsagainst the server ops. - Finally, it re-renders the editor's text by applying the new
synchronizedContent, the transformedinFlightOps, and the transformedpendingOps. This preserves local edits while integrating remote ones.
- It applies the incoming server operations to its
The server is the single source of truth and is responsible for ordering all operations and resolving conflicts. It uses a Git-like versioning model from server/versioning.ts.
- The Commit Chain: Every accepted change creates a new "commit" with a unique hash, forming a linear history.
HEADalways points to the latest version. - Handling a Sync Request: When the server receives operations from a client, it looks at the
base_hashincluded in the request.- Scenario A: Fast-Forward (No Conflict): If the client's
base_hashis the same as the server'sHEADhash, the client was perfectly up-to-date. The server can safely apply the client's operations, create a new commit, and broadcast those same operations to all other clients. - Scenario B: Concurrent Edit (Conflict Resolution): If
base_hashis older thanHEAD, another user's changes were committed first. This is where OT's magic happens.- The server uses the client's
base_hashto get the historical document state that both the client and server changes are based on (the "common ancestor"). - It reconstructs the text the client intended to create.
- It now has three versions: the common ancestor, the server's current version, and the client's intended version.
- It calls
operationalTransform. This function generates the operations to go from the ancestor to the server version (serverOps) and from the ancestor to the client version (clientOps). - It transforms
clientOpsagainstserverOps. This modifies the client's operations so they can be applied cleanly on top of the server's version. - The merged result is applied to the server's current content.
- This merged result is committed.
- Crucially, the server calculates a new set of operations representing the diff between its state before the merge and the new merged state. It is this new set of operations that gets broadcast to all other clients, ensuring everyone converges to the same state.
- The server uses the client's
- Scenario A: Fast-Forward (No Conflict): If the client's
This shows the simple case where a client sends changes based on the latest version. (Note: You will need to add image.png to the project root for this to display).
This diagram illustrates the more complex scenario where two users edit the document at the same time. The visual flow for this can be found in the Excalidraw file below.
You can view the diagram by opening this file in Excalidraw:
The architecture of this project is based on two core concepts: the data model of distributed version control systems and the concurrency control algorithms for real-time collaborative editing.
-
Git Internals - Git Objects: An authoritative explanation of Git's underlying data structures (blobs, trees, and commits), which heavily inspired the versioning engine for this project.
-
Operational Transformation (Wikipedia): A foundational overview of the theory behind OT, the algorithm that allows this application to merge concurrent edits without conflicts.
