https://github.com/mortenson/lexical-collab-stream
Sync changes between Lexical editors without Yjs, using WebSockets or WebRTC
https://github.com/mortenson/lexical-collab-stream
collaboration collaborative-editing crdt lexical lexical-editor redis webrtc
Last synced: 2 months ago
JSON representation
Sync changes between Lexical editors without Yjs, using WebSockets or WebRTC
- Host: GitHub
- URL: https://github.com/mortenson/lexical-collab-stream
- Owner: mortenson
- License: mit
- Created: 2025-07-05T17:32:08.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2025-07-25T22:23:50.000Z (2 months ago)
- Last Synced: 2025-07-25T23:52:34.823Z (2 months ago)
- Topics: collaboration, collaborative-editing, crdt, lexical, lexical-editor, redis, webrtc
- Language: TypeScript
- Homepage: https://mortenson.coffee/lexical-collab-stream?trystero
- Size: 280 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 2
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Lexical Collab Stream
A "taken too far" proof of concept for how you can achieve collaborative text
editing in Lexical by streaming mutations to peers.## Installation and use
First, install the package:
`npm install --save @mortenson/lexical-collab-stream@latest`
Then in your frontend framework of choice, create a `CollabInstance` and pass
it your Lexical editor:```js
const collab = new CollabInstance(
userId, // Some identifier for the user
editor, // Some instance of the editor
new CollabWebSocket("wss://example.com"),
// or
new CollabTrystero({ appId: "appId", password: "secret" }, "roomId"),
);
collab.start();
```For ease of use, a React plugin has been included which makes it easier to use
collab stream. Here's an example of adding it to a `LexicalComposer` instance,
and displaying cursors using a provided minimal ugly component:```jsx
const [cursors, setCursors] = useState();
const [desynced, setDesynced] = useState(false);
return (
setDesynced(true)}
cursorListener={(cursors) => setCursors(new Map(cursors))}
network={{
type: "websocket",
url: "wss://example.com",
}}
/>
{/* or */}
...
{desynced &&You were offline for too long, oh no!}
{cursors &&
Array.from(cursors.entries()).map(([userId, cursor]) => {
return (
);
})}
)
```If you're using websockets, copy [examples/server/server.ts](examples/server/server.ts)
and modify it as needed for your application. If people are interested I can
add in some amount of default authentication there and make it a more of a
"batteries included" example._Note: re-implementing the server in another language is surprisingly simple,
as it mostly exists as a broker for a Redis stream and doesn't require Lexical_If you're using WebRTC / [Trystero](https://github.com/dmotz/trystero/issues),
no server is required as public signaling servers are used. That said, the
network implementation has less guarantees than websockets and may need some
love when it comes to offline editing.## Implementation details
After reading the article "[Collaborative Text Editing without CRDTs or OT](https://mattweidner.com/2025/05/21/text-without-crdts.html)",
I thought that it's be fun to try to build a collaborative editor without Yjs.Here's how it works:
1. Ensure that every node has a (unique) UUID by watching for create mutations.
2. A mapping is (poorly?) maintained between UUIDs and NodeKeys
3. A custom Node Transform is used to (try to) split TextNodes by word (more
nodes == better sync, probably)
4. Clients connect to a websocket server and receive the current EditorState
and the stream ID associated with that document.
5. A mutation listener sends websocket messages that contain a serialized node
and information required to upsert/destroy it. On the server, these messages
are added to a Redis stream and later streamed back to peers.
6. A websocket listener receives messages from other clients and upserts nodes
from JSON, or destroys them. Node insertion is always relative to a sibling or
parent.### Attempt to diagram
```mermaid
flowchart RL
Redis@{ shape: cyl, label: "Redis Stream" }
Client -- "insertAfter" --> EditorState
EditorState -- "onMutation" --> Client
Client -- "sendMessage" --> Server
Server -- "onMessage" --> Client
Server -- "XADD" --> Redis
Redis -- "XREAD" --> Server
```## Running locally
For the websocket server, you'll need Redis running locally on port 6379.
1. Build the library: `npm i && npm run build`
2. In one tab, run the client:
`cd examples/client && npm i && npm run dev`
3. In another tab:
`cd examples/server && npm i && npm run server` (`npm run server-wipe-db`
will wipe Redis if needed).If you want to try out Trystero/WebRTC instead of running a websocket server,
add the `?trystero` query param to the page.## Not implemented yet
- Accurate server reconciliation (there's no guarantee all clients have the
same EditorState, we could have an explicit reconciliation cycle or something
like rollback+reapply per the blog linked above)## Not planning to implement
- Authentication
- Redis performance magic (seems like you could tell that two websockets are on
the same ID and share streams but unsure if that matters)## Why
Probably 60% for fun, 40% because of some things I dislike about Yjs:
- Yjs being a black box feels weird, if collaboration is important I'd like to
be in control of it
- Persistence is tricky, in general it's easier to just store the binary
document forever
- Making the server authoritative is also tricky
- Introspecting/modifying the Ydoc in non-JS languages is hard
- The WebRTC integration is broken between browsers (this doesn't solve that,
but if I have to figure out horizontal scaling and websockets anyway may as
well go DIY)
- Due to WebRTC being broken, the distributed promises kind of fall apart## Other thoughts
- It'd be nice if we synced transforms, not mutations, but the word splitting
might make this less necessary.
- Lexical doesn't really expose the purpose of clone, so it's hard to tell when
new UUIDs need to be generated (ex: text split/paste vs. just typing)## Credit
`examples/client` is cloned from `@lexical/react-rich-example`, the original
LICENSE file is included even though some (minor) modifications have been made.