Start / Quick start
Quick start
Give the editor a Yjs document through YCollabExtension, connect the document to a provider, and initialize the binding after the provider's first sync. That's the whole integration.
import { buildEditorFromExtensions, getExtensionDependencyFromEditor } from '@lexical/extension';
import { RichTextExtension } from '@lexical/rich-text';
import { createYDoc, YCollabExtension } from '@lexical/y';
import { WebsocketProvider } from '@y/websocket';
import { configExtension, defineExtension } from 'lexical';
import * as decoding from 'lib0/decoding';
import { isNoteId } from '../../shared/ids';
import { MESSAGE_NOTE_STATUS } from '../../shared/protocol';
export function openNote(element: HTMLElement, id: string) {
// jot's relay only opens 22-character note ids; anything else is a 400.
if (!isNoteId(id)) throw new Error('Not a jot note id');
const doc = createYDoc();
const editor = buildEditorFromExtensions(
defineExtension({
name: 'my-app/note',
dependencies: [
RichTextExtension,
configExtension(YCollabExtension, { doc, schemaVersion: 1 }),
],
}),
);
const { binding } = getExtensionDependencyFromEditor(editor, YCollabExtension).output;
editor.setRootElement(element);
const provider = new WebsocketProvider('wss://jot.rovira.sh/api/n', `${id}/ws`, doc);
// The relay speaks y-websocket plus one message of its own: the note's size.
provider.messageHandlers[MESSAGE_NOTE_STATUS] = (_encoder, decoder) => {
decoding.readVarUint(decoder); // stored bytes
decoding.readVarUint(decoder); // flags: 1 when an update was refused
};
provider.on('sync', (synced: boolean) => {
if (synced) binding.initialize(); // a no-op after the first time
});
return { doc, editor, provider };
}Any Yjs 14 provider works with the binding. This one talks to jot's relay, which speaks the y-websocket protocol plus one message of its own (type 64, the note's stored size and whether an update was refused). A stock @y/websocket provider doesn't know that type: it misreads the rest of the frame and throws, so the snippet reads it. The room must be a jot note id, 22 base64url characters: the relay answers anything else with a 400, and the provider would retry forever.
The document comes from createYDoc(), which the binding requires. schemaVersion is your app's version of what it stores: each client records it with its first write, and a client with a lower one opens the note read only instead of editing over content it can't keep. Raise it in the release that starts writing something older releases can't keep.
In jot
src/client/editor/session.ts does the same with jot's full set of extensions (lists, checklists, links, code blocks, tables, markdown shortcuts) from extensions.ts. The provider runs without a BroadcastChannel, so tabs sync through the server, which enforces the size cap, and it adopts an Awareness the session owns. A new note is shared from the start: it opens at its own /n/<id>, connects, and initializes after that first sync like any other.
The extension's output also has a status signal: 'pending' until the first sync, then 'active', or 'readonly' when the binding can't write the note safely: a newer schema version or layout, content with no layout record, node types the editor doesn't register, a type recorded with a storage kind this editor can't use, or an older layout's root that needs converting. jot then says "Read only" and keeps the note readable.
Install
pnpm add \
https://pkg.pr.new/meta/lexical/lexical@0823b74ef4b5e034b412dcb4ac622b936ab72c3d \
https://pkg.pr.new/meta/lexical/@lexical/extension@0823b74ef4b5e034b412dcb4ac622b936ab72c3d \
https://pkg.pr.new/meta/lexical/@lexical/rich-text@0823b74ef4b5e034b412dcb4ac622b936ab72c3d \
https://pkg.pr.new/meta/lexical/@lexical/y@0823b74ef4b5e034b412dcb4ac622b936ab72c3d \
@y/y@14.0.0-rc.29 \
@y/websocket@4.0.0-rc.2@lexical/y isn't on npm yet, so jot installs every Lexical package from the same pkg.pr.new build of its pull request; pnpm bump-lexical <pr-or-sha> moves the pin. @y/y is a peer dependency pinned to the release candidate the binding is tested with. Its type declarations don't type-check on their own, so set skipLibCheck: true.
Next, see how the note is stored, or read the binding's source.