Server / Server-side editing
Server-side editing
@lexical/y/headless reads and writes a note without a browser: seed a new note from a template, append a line from a webhook, or export Markdown. Each helper opens the state in a headless editor bound to a Y.Doc of its own, with the same extensions as your clients, and hands back plain bytes.
import { ListExtension } from '@lexical/list';
import { $convertToMarkdownString, TRANSFORMERS } from '@lexical/markdown';
import { $createHeadingNode, RichTextExtension } from '@lexical/rich-text';
import {
createYWriter,
editYState,
type HeadlessYConfig,
readYState,
seedYState,
} from '@lexical/y/headless';
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical';
// Use your clients' extensions, all of them. With fewer (these two alone), editYState
// refuses, read only, any note holding a node it doesn't register: a link, a table…
const config: HeadlessYConfig = {
extensions: [RichTextExtension, ListExtension],
schemaVersion: 1, // your clients' schemaVersion
};
/** A new note's state: content-hashed, so seeding it twice merges into one copy. */
export function seedNote(title: string): Uint8Array {
return seedYState(config, () => {
const heading = $createHeadingNode('h1').append($createTextNode(title));
$getRoot().append(heading);
});
}
// Who the server writes as: one writer per agent or integration, created once per
// process. It keeps one client id per note, so a note edited a thousand times carries
// one client id for it, not a thousand. Pass `writer: null` for a one-off anonymous id.
const writer = createYWriter('jot AI');
/**
* Append a line to a stored note. `update` is what to apply and broadcast;
* `clientID` is who wrote it, to credit to the writer in your attribution.
*/
export function appendLine(state: Uint8Array, text: string) {
const append = () => {
$getRoot().append($createParagraphNode().append($createTextNode(text)));
};
const { update, state: next, clientID } = editYState(state, config, append, { writer });
return { update, state: next, clientID };
}
/** Read a note as Markdown. Nothing is written. */
export function toMarkdown(state: Uint8Array): string {
return readYState(state, config, () => $convertToMarkdownString(TRANSFORMERS));
}The three helpers
seedYState(config, $build): a new document's state. Seeding identical content twice gives the same bytes, and the two seeds merge into one copy.editYState(state, config, $edit, { writer }): runs one editor update and returns the update it wrote (nullwhen nothing changed), the new state, the client id it wrote under, and any warnings.writeris required: acreateYWriter('jot AI')made once per process continues one client id per note, andnullwrites under a new anonymous id.readYState(state, config, $read): returns whatever$readreturns. Nothing is written.
Refusals
editYState writes only to the layout root unless allowedRoots says otherwise. It throws a YHeadlessError with a code: 'not-initialized' for a note that was never seeded, 'read-only' for a newer schema version or layout, or node types the editor doesn't register, and 'foreign-roots' for an update that writes anywhere else. If updates pass through yrs older than 0.28, set clientIDRange: 'uint32'.
In a relay like jot's
jot's relay doesn't run Lexical: it relays and stores Yjs updates and nothing else. To edit from the server, a room would pass Y.encodeStateAsUpdate(this.doc) to editYState with its writer and apply the update it returns to its document. The room's update handler then broadcasts it to every socket, like a client's. See headless.ts at the Lexical commit jot pins for the full API.