Server / The relay on Cloudflare
The relay on Cloudflare
Each note is one Durable Object: a single-threaded room that holds the Yjs document in memory, relays updates between sockets, and saves the state as one SQLite row. The relay is Yjs 14 too (@y/y, not yrs), so the server reads exactly what the clients write. Its document comes from createYDoc() in @lexical/y/headless: garbage collected, with Yjs's format cleanup turned off, as @lexical/y requires of any server that stores state. With cleanup on, the room would delete format items the clients keep, such as those around text someone deleted, and send the deletions out as an edit of its own.
Routing
The Worker checks the note id (22 base64url characters, 128 random bits: the link is the key), then hands the WebSocket upgrade to the note's object. Everything else is static assets, and unknown paths fall back to the app (src/worker/index.ts).
The room
Sockets are accepted with the Hibernation API: an idle note is evicted from memory while its sockets stay open, and the next message runs the constructor again, which reloads the document from SQLite. On connect the room sends sync step 1, everyone's awareness and the note's size.
async fetch(request: Request): Promise<Response> {
const id = request.headers.get(NOTE_ID_HEADER);
if (id === null || !isNoteId(id)) {
return new Response('Not a note id.', { status: 400 });
}
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected a WebSocket upgrade.', { status: 426 });
}
this.noteId = id;
this.touch();
const { 0: client, 1: server } = new WebSocketPair();
this.ctx.acceptWebSocket(server);
try {
server.serializeAttachment({ clocks: {} } satisfies SocketAttachment);
const step1 = encoding.createEncoder();
encoding.writeVarUint(step1, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(step1, this.doc);
server.send(encoding.toUint8Array(step1));
const states = this.awareness.snapshot();
if (states.length > 0) {
server.send(encodeAwarenessMessage(states));
}
server.send(encodeNoteStatus(this.size + this.pending, 0));
} catch (error) {
// The socket is already accepted: forget it rather than leave it open
// with nobody on the other end.
this.logError('fetch', error);
this.forget(server, 'fetch', CLOSE_INTERNAL_ERROR, 'Internal error');
return new Response('Could not open the note.', { status: 500 });
}
return new Response(null, { status: 101, webSocket: client });
}The protocol
The y-websocket protocol, plus one message of jot's own (protocol.ts). Every frame starts with a varuint message type.
| Type | Message | Direction | Body |
|---|---|---|---|
0 | sync | both | y-protocols sync: step 1 (a state vector), step 2, update |
1 | awareness | both | y-protocols awareness update |
3 | query awareness | to server | none; answered with every state |
64 | note status | to client | stored bytes, flags (1: update refused) |
Every applied update goes to every socket, the sender's included: @y/websocket counts its own updates as confirmed when they come back. A malformed frame closes only its own socket, with code 1007.
Storage
A save writes the whole state two seconds after the first unsaved change, and at once when the last socket leaves. Size and limits covers the cap, compaction and expiry. The whole room is src/worker/room.ts.