Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2194f423a1 | |||
| 5a6009c750 |
@@ -52,6 +52,7 @@
|
||||
"form-data": "^4.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"marked": "^17.0.1",
|
||||
"pako": "^2.0.3",
|
||||
"re2": "^1.21.0",
|
||||
"ws": "^8.19.0",
|
||||
"y-prosemirror": "1.3.7",
|
||||
|
||||
+628
-22
@@ -8,6 +8,10 @@ import {
|
||||
filterComment,
|
||||
filterSearchResult,
|
||||
} from "./lib/filters.js";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||
import * as Y from "yjs";
|
||||
import WebSocket from "ws";
|
||||
import { convertProseMirrorToMarkdown } from "./lib/markdown-converter.js";
|
||||
import {
|
||||
collectInternalFileNodes,
|
||||
@@ -20,10 +24,11 @@ import {
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
buildCollabWsUrl,
|
||||
assertYjsEncodable,
|
||||
applyDocToFragment,
|
||||
MutationResult,
|
||||
} from "./lib/collaboration.js";
|
||||
import { acquireCollabSession } from "./lib/collab-session.js";
|
||||
import { footnoteWarningsField } from "./lib/footnote-analyze.js";
|
||||
import { buildPageTree } from "./lib/tree.js";
|
||||
import {
|
||||
@@ -44,6 +49,15 @@ import {
|
||||
} from "./lib/node-ops.js";
|
||||
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||
import { withPageLock } from "./lib/page-lock.js";
|
||||
import {
|
||||
prepareModel,
|
||||
decodeDrawioSvg,
|
||||
buildDrawioSvg,
|
||||
mxHash,
|
||||
normalizeXml,
|
||||
countUserCells,
|
||||
} from "./lib/drawio-xml.js";
|
||||
import { renderDiagramShapes } from "./lib/drawio-preview.js";
|
||||
import {
|
||||
applyTextEdits,
|
||||
TextEdit,
|
||||
@@ -412,32 +426,177 @@ export class DocmostClient {
|
||||
* change report. The report is computed AFTER the atomic read->write and
|
||||
* never throws.
|
||||
*/
|
||||
private async mutateLiveContentUnlocked(
|
||||
private mutateLiveContentUnlocked(
|
||||
pageId: string,
|
||||
collabToken: string,
|
||||
transform: (liveDoc: any) => any | null,
|
||||
): Promise<MutationResult> {
|
||||
// Reuse a live CollabSession for the page (issue #400) instead of opening a
|
||||
// fresh provider per op. acquireCollabSession does NOT take the per-page
|
||||
// lock — the caller (replaceImage) already holds ONE withPageLock across its
|
||||
// scan -> upload -> write sequence, and the mutex is not reentrant, so
|
||||
// taking it here would deadlock. The synchronous read->write section and the
|
||||
// unsyncedChanges/connectionLost ack logic live in CollabSession.mutate,
|
||||
// preserved verbatim from the old inline machine (incl. the #152 structural
|
||||
// diff that keeps a live editor's cursor anchored).
|
||||
const session = await acquireCollabSession(pageId, collabToken, this.apiUrl, {
|
||||
// Only the actual 25s collab connect timeout emits this — the connect-vs-
|
||||
// unload signal; the other failure paths must NOT emit it.
|
||||
onConnectTimeout: () =>
|
||||
this.onMetricFn?.("collab_connect_timeouts_total", 1),
|
||||
const CONNECT_TIMEOUT_MS = 25000;
|
||||
const PERSIST_TIMEOUT_MS = 20000;
|
||||
const ydoc = new Y.Doc();
|
||||
const wsUrl = buildCollabWsUrl(this.apiUrl);
|
||||
|
||||
return new Promise<MutationResult>((resolve, reject) => {
|
||||
let provider: HocuspocusProvider | undefined;
|
||||
let applied = false; // onSynced may fire again on reconnect — apply once.
|
||||
let settled = false;
|
||||
let connectionLost = false;
|
||||
let connectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let unsyncedHandler: ((data: { number: number }) => void) | undefined;
|
||||
// The verifiable result resolved on every success/abort path. Set on abort
|
||||
// (no-op report) and after a real write (computed change report).
|
||||
let mutationResult: MutationResult;
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) clearTimeout(connectTimer);
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
if (provider) {
|
||||
if (unsyncedHandler) {
|
||||
try {
|
||||
provider.off("unsyncedChanges", unsyncedHandler);
|
||||
} catch (err) {}
|
||||
}
|
||||
try {
|
||||
provider.destroy();
|
||||
} catch (err) {}
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (err: Error | null, value?: MutationResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (err) reject(err);
|
||||
else resolve(value as MutationResult);
|
||||
};
|
||||
|
||||
connectTimer = setTimeout(() => {
|
||||
// Only the actual 25s collab connect timeout fires here — the agent's
|
||||
// collab connection to the server never became ready. This is the
|
||||
// connect-vs-unload signal; the other finish() paths must NOT emit it.
|
||||
this.onMetricFn?.("collab_connect_timeouts_total", 1);
|
||||
finish(new Error("Connection timeout to collaboration server"));
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
|
||||
const waitForPersistence = () => {
|
||||
if (settled) return;
|
||||
if (!provider) {
|
||||
finish(new Error("collab provider gone before persistence"));
|
||||
return;
|
||||
}
|
||||
if (provider.unsyncedChanges === 0) {
|
||||
finish(null, mutationResult);
|
||||
return;
|
||||
}
|
||||
persistTimer = setTimeout(() => {
|
||||
finish(
|
||||
new Error(
|
||||
"Timeout waiting for collaboration server to persist the update",
|
||||
),
|
||||
);
|
||||
}, PERSIST_TIMEOUT_MS);
|
||||
unsyncedHandler = (data: { number: number }) => {
|
||||
if (data.number === 0 && !connectionLost) {
|
||||
finish(null, mutationResult);
|
||||
}
|
||||
};
|
||||
provider.on("unsyncedChanges", unsyncedHandler);
|
||||
};
|
||||
|
||||
provider = new HocuspocusProvider({
|
||||
url: wsUrl,
|
||||
name: `page.${pageId}`,
|
||||
document: ydoc,
|
||||
token: collabToken,
|
||||
// @ts-ignore - Required for Node.js environment
|
||||
WebSocketPolyfill: WebSocket,
|
||||
onDisconnect: () => {
|
||||
connectionLost = true;
|
||||
finish(
|
||||
new Error(
|
||||
"Collaboration connection closed before the update was persisted/synced",
|
||||
),
|
||||
);
|
||||
},
|
||||
onClose: () => {
|
||||
connectionLost = true;
|
||||
finish(
|
||||
new Error(
|
||||
"Collaboration connection closed before the update was persisted/synced",
|
||||
),
|
||||
);
|
||||
},
|
||||
onSynced: () => {
|
||||
if (applied || settled) return;
|
||||
applied = true;
|
||||
|
||||
// CRITICAL: keep everything between reading and writing the live doc
|
||||
// synchronous (no await) so no remote update can interleave.
|
||||
let newDoc: any;
|
||||
let beforeDoc: any;
|
||||
try {
|
||||
let liveDoc = TiptapTransformer.fromYdoc(ydoc, "default");
|
||||
if (
|
||||
!liveDoc ||
|
||||
typeof liveDoc !== "object" ||
|
||||
!Array.isArray(liveDoc.content)
|
||||
) {
|
||||
liveDoc = { type: "doc", content: [] };
|
||||
}
|
||||
|
||||
// Snapshot the before-doc for the change report (safe deep clone).
|
||||
beforeDoc = JSON.parse(JSON.stringify(liveDoc));
|
||||
|
||||
newDoc = transform(liveDoc);
|
||||
|
||||
if (newDoc == null) {
|
||||
// Transform aborted — write nothing, return the live doc with a
|
||||
// no-op change report.
|
||||
mutationResult = {
|
||||
doc: liveDoc,
|
||||
verify: {
|
||||
changed: false,
|
||||
textInserted: 0,
|
||||
textDeleted: 0,
|
||||
blocksChanged: 0,
|
||||
marks: {},
|
||||
summary: "no changes (transform aborted)",
|
||||
},
|
||||
};
|
||||
finish(null, mutationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Structural diff into the live fragment (issue #152), mirroring
|
||||
// the main write path: preserves the Yjs ids of unchanged nodes so
|
||||
// an open editor's cursor is not yanked to the end of the document.
|
||||
// The previous destructive rewrite (delete-all + applyUpdate of a
|
||||
// fresh Y.Doc) discarded every node id, so replaceImage — the only
|
||||
// caller of this method — still reproduced the #152 cursor jump
|
||||
// (#164). applyDocToFragment runs its own atomic `transact`.
|
||||
applyDocToFragment(ydoc, newDoc);
|
||||
} catch (e) {
|
||||
finish(e instanceof Error ? e : new Error(String(e)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the verifiable change report AFTER the transact write: it
|
||||
// only needs the JSON before/after, so it cannot affect the atomic
|
||||
// read->write window, and summarizeChange never throws.
|
||||
mutationResult = {
|
||||
doc: newDoc,
|
||||
verify: summarizeChange(beforeDoc, newDoc),
|
||||
};
|
||||
waitForPersistence();
|
||||
},
|
||||
onAuthenticationFailed: () => {
|
||||
finish(
|
||||
new Error("Authentication failed for collaboration connection"),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
try {
|
||||
return await session.mutate(transform);
|
||||
} catch (e) {
|
||||
// Drop the session on any failure so the next call reconnects fresh.
|
||||
session.destroy("mutate failed");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3282,6 +3441,453 @@ export class DocmostClient {
|
||||
});
|
||||
}
|
||||
|
||||
// --- draw.io diagrams (issue #423) ---
|
||||
|
||||
/**
|
||||
* Upload a ready-made byte buffer as a page attachment via the same
|
||||
* multipart /files/upload endpoint uploadImage uses. Split out as its own
|
||||
* (overridable) seam so drawio_create/update can upload the generated
|
||||
* `.drawio.svg` without going through the URL-fetch path, and so tests can
|
||||
* stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403
|
||||
* re-auth handling (a FormData body is single-use, so it must be rebuilt per
|
||||
* attempt).
|
||||
*/
|
||||
protected async uploadAttachmentBuffer(
|
||||
pageId: string,
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
mime: string,
|
||||
): Promise<{ id: string; fileName: string; fileSize: number }> {
|
||||
await this.ensureAuthenticated();
|
||||
const buildForm = () => {
|
||||
const form = new FormData();
|
||||
form.append("pageId", pageId);
|
||||
form.append("file", buffer, { filename: fileName, contentType: mime });
|
||||
return form;
|
||||
};
|
||||
const uploadUrl = `${this.apiUrl}/files/upload`;
|
||||
let response;
|
||||
try {
|
||||
const form = buildForm();
|
||||
response = await axios.post(uploadUrl, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
await this.login();
|
||||
const form2 = buildForm();
|
||||
response = await axios.post(uploadUrl, form2, {
|
||||
headers: {
|
||||
...form2.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} else if (axios.isAxiosError(error)) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Attachment upload failed; response body:",
|
||||
JSON.stringify(error.response?.data),
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Attachment upload failed: ${error.response?.status} ${error.response?.statusText}`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const att = response.data?.data ?? response.data;
|
||||
if (!att?.id || !att?.fileName) {
|
||||
throw new Error(
|
||||
"Unexpected /files/upload response: " + JSON.stringify(response.data),
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: att.id,
|
||||
fileName: att.fileName,
|
||||
fileSize: att.fileSize ?? buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a stored `.drawio.svg` attachment as text. Overridable seam over
|
||||
* fetchInternalFile (the authed loopback fetch, which also rejects any
|
||||
* traversal/SSRF src) so drawio_get/update can read the current diagram and
|
||||
* tests can stub the bytes.
|
||||
*/
|
||||
protected async fetchAttachmentText(src: string): Promise<string> {
|
||||
const { buffer } = await this.fetchInternalFile(src);
|
||||
return buffer.toString("utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
|
||||
* node plus its ref. Throws a clear error if the ref does not resolve to a
|
||||
* drawio node.
|
||||
*/
|
||||
private async resolveDrawioNode(
|
||||
pageId: string,
|
||||
node: string,
|
||||
): Promise<{ node: any; ref: string }> {
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const hit = getNodeByRef(
|
||||
data.content ?? { type: "doc", content: [] },
|
||||
node,
|
||||
);
|
||||
if (!hit) {
|
||||
throw new Error(
|
||||
`drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#<index>" from get_outline)`,
|
||||
);
|
||||
}
|
||||
if (hit.type !== "drawio") {
|
||||
throw new Error(
|
||||
`drawio: node "${node}" on page ${pageId} is a ${hit.type}, not a drawio diagram`,
|
||||
);
|
||||
}
|
||||
return { node: hit.node, ref: node };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`.
|
||||
* Runs the decode chain (base64/entity content= → drawio file → nested XML or
|
||||
* pako-inflated compressed <diagram>). The returned `hash` is the
|
||||
* optimistic-lock key for drawio_update.
|
||||
*/
|
||||
async drawioGet(
|
||||
pageId: string,
|
||||
node: string,
|
||||
format: "xml" | "svg" = "xml",
|
||||
): Promise<{
|
||||
pageId: string;
|
||||
nodeId: string;
|
||||
format: "xml" | "svg";
|
||||
content: string;
|
||||
meta: {
|
||||
attachmentId: string | null;
|
||||
title: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
cellCount: number;
|
||||
hash: string;
|
||||
};
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
|
||||
const attrs = drawio.attrs || {};
|
||||
const src = attrs.src;
|
||||
if (!src) {
|
||||
throw new Error(
|
||||
`drawio: node "${node}" on page ${pageId} has no src to read`,
|
||||
);
|
||||
}
|
||||
const svg = await this.fetchAttachmentText(src);
|
||||
const modelXml = decodeDrawioSvg(svg);
|
||||
const meta = {
|
||||
attachmentId: attrs.attachmentId ?? null,
|
||||
title: attrs.title ?? null,
|
||||
width: attrs.width != null ? Number(attrs.width) : null,
|
||||
height: attrs.height != null ? Number(attrs.height) : null,
|
||||
cellCount: countUserCells(modelXml),
|
||||
hash: mxHash(modelXml),
|
||||
};
|
||||
return {
|
||||
pageId,
|
||||
nodeId: attrs.id ?? node,
|
||||
format,
|
||||
content: format === "svg" ? svg : normalizeXml(modelXml),
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a drawio diagram from mxGraph XML: lint → schematic SVG preview
|
||||
* (pure TS) → build the `.drawio.svg` (createDrawioSvg contract) → create the
|
||||
* attachment → insert a `drawio` node before/after an anchor or appended.
|
||||
* `xml` is a bare `<mxGraphModel>` or a list of `<mxCell>` (the server wraps
|
||||
* it and adds the id=0/id=1 sentinels).
|
||||
*/
|
||||
async drawioCreate(
|
||||
pageId: string,
|
||||
where: {
|
||||
position: "before" | "after" | "append";
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
xml: string,
|
||||
title?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
attachmentId: string;
|
||||
warnings: string[];
|
||||
verify?: any;
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
if (
|
||||
!where ||
|
||||
(where.position !== "before" &&
|
||||
where.position !== "after" &&
|
||||
where.position !== "append")
|
||||
) {
|
||||
throw new Error(
|
||||
'drawio_create: `where.position` must be one of "before", "after", "append"',
|
||||
);
|
||||
}
|
||||
if (where.position === "before" || where.position === "after") {
|
||||
const hasId =
|
||||
typeof where.anchorNodeId === "string" && where.anchorNodeId.length > 0;
|
||||
const hasText =
|
||||
typeof where.anchorText === "string" && where.anchorText.length > 0;
|
||||
if (hasId === hasText) {
|
||||
throw new Error(
|
||||
`drawio_create: position "${where.position}" requires exactly one of anchorNodeId or anchorText`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-write pipeline (throws a structured DrawioLintError on any violation).
|
||||
const prepared = prepareModel(xml);
|
||||
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||
const diagramTitle = title || "Page-1";
|
||||
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
|
||||
|
||||
const att = await this.uploadAttachmentBuffer(
|
||||
pageId,
|
||||
Buffer.from(svg, "utf-8"),
|
||||
"diagram.drawio.svg",
|
||||
"image/svg+xml",
|
||||
);
|
||||
|
||||
// NOTE: no `id` attribute is set here. The vendored `drawio` node schema
|
||||
// (diagramAttributes) declares no `id`, so any block id would be silently
|
||||
// dropped by PMNode.fromJSON on save and the returned handle would fail to
|
||||
// resolve. The addressable handle is the node's "#<index>" (like image/table
|
||||
// nodes), computed after the insert below.
|
||||
const drawioNode: any = {
|
||||
type: "drawio",
|
||||
attrs: {
|
||||
src: `/api/files/${att.id}/${att.fileName}`,
|
||||
attachmentId: att.id,
|
||||
width: prepared.bbox.width,
|
||||
height: prepared.bbox.height,
|
||||
align: "center",
|
||||
},
|
||||
};
|
||||
if (title) drawioNode.attrs.title = title;
|
||||
// Reuse the existing URL trust boundary (rejects unsafe src schemes).
|
||||
this.validateDocUrls(drawioNode);
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let inserted = false;
|
||||
let insertedIndex = -1;
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
inserted = false;
|
||||
insertedIndex = -1;
|
||||
const { doc: nd, inserted: ins } = insertNodeRelative(
|
||||
liveDoc,
|
||||
drawioNode,
|
||||
where,
|
||||
);
|
||||
inserted = ins;
|
||||
if (!inserted) return null; // anchor not found -> skip the write
|
||||
// Locate the freshly-inserted node to derive its "#<index>" handle. The
|
||||
// just-uploaded attachmentId is unique, so it identifies our node.
|
||||
if (Array.isArray(nd.content)) {
|
||||
insertedIndex = nd.content.findIndex(
|
||||
(b: any) =>
|
||||
b &&
|
||||
b.type === "drawio" &&
|
||||
b.attrs &&
|
||||
b.attrs.attachmentId === att.id,
|
||||
);
|
||||
}
|
||||
return nd;
|
||||
},
|
||||
);
|
||||
|
||||
if (!inserted) {
|
||||
const anchorDesc = where.anchorNodeId
|
||||
? `anchorNodeId "${where.anchorNodeId}"`
|
||||
: `anchorText "${where.anchorText}"`;
|
||||
throw new Error(
|
||||
`drawio_create: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (insertedIndex < 0) {
|
||||
// The node was inserted nested (e.g. inside a callout/table cell via an
|
||||
// anchor), where "#<index>" — which addresses only top-level blocks —
|
||||
// cannot reference it. drawio nodes carry no persisted id, so there is no
|
||||
// stable handle for a nested diagram.
|
||||
throw new Error(
|
||||
`drawio_create: the diagram was inserted on page ${pageId} but not as a ` +
|
||||
`top-level block, so it has no addressable "#<index>" handle. Anchor ` +
|
||||
`on a top-level block (or append) so the diagram can be re-read.`,
|
||||
);
|
||||
}
|
||||
|
||||
// The returned handle is POSITIONAL ("#<index>"): valid for the immediate
|
||||
// create -> get/update flow, but re-resolve via get_outline if the document
|
||||
// structure changes (blocks added/removed before it shift the index).
|
||||
const nodeId = `#${insertedIndex}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: prepared.warnings,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is
|
||||
* compared against the hash of the diagram's CURRENT XML (from drawio_get);
|
||||
* any mismatch means a human or another agent edited the diagram after the
|
||||
* read, so the write is refused with a conflict error. On success the new
|
||||
* `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is
|
||||
* avoided — some Docmost versions corrupt an attachment on overwrite, exactly
|
||||
* as replaceImage documents) and the node is repointed with new dimensions.
|
||||
*/
|
||||
async drawioUpdate(
|
||||
pageId: string,
|
||||
node: string,
|
||||
xml: string,
|
||||
baseHash: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
attachmentId: string;
|
||||
warnings: string[];
|
||||
verify?: any;
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
if (typeof baseHash !== "string" || baseHash.length === 0) {
|
||||
throw new Error(
|
||||
"drawio_update: baseHash is mandatory — read the diagram with drawio_get first and pass back its meta.hash",
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the node and read the CURRENT diagram to enforce the optimistic
|
||||
// lock before doing any write or upload.
|
||||
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
|
||||
const oldAttrs = drawio.attrs || {};
|
||||
const oldSrc = oldAttrs.src;
|
||||
// The returned handle is the caller-supplied reference. drawio nodes carry
|
||||
// no persisted id, so `ref` (an "#<index>" or a rare legacy attrs.id) is the
|
||||
// honest identifier to hand back.
|
||||
const nodeId = oldAttrs.id ?? ref;
|
||||
if (!oldSrc) {
|
||||
throw new Error(
|
||||
`drawio_update: node "${node}" on page ${pageId} has no src to compare against`,
|
||||
);
|
||||
}
|
||||
const currentSvg = await this.fetchAttachmentText(oldSrc);
|
||||
const currentHash = mxHash(decodeDrawioSvg(currentSvg));
|
||||
if (currentHash !== baseHash) {
|
||||
throw new Error(
|
||||
`drawio_update: conflict — the diagram changed since it was read ` +
|
||||
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawio_get and retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pipeline for the new content (throws a structured DrawioLintError).
|
||||
const prepared = prepareModel(xml);
|
||||
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||
const diagramTitle = oldAttrs.title || "Page-1";
|
||||
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
|
||||
|
||||
const att = await this.uploadAttachmentBuffer(
|
||||
pageId,
|
||||
Buffer.from(svg, "utf-8"),
|
||||
"diagram.drawio.svg",
|
||||
"image/svg+xml",
|
||||
);
|
||||
const newSrc = `/api/files/${att.id}/${att.fileName}`;
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let repointed = 0;
|
||||
const repoint = (n: any) => {
|
||||
n.attrs = {
|
||||
...n.attrs,
|
||||
src: newSrc,
|
||||
attachmentId: att.id,
|
||||
width: prepared.bbox.width,
|
||||
height: prepared.bbox.height,
|
||||
};
|
||||
repointed++;
|
||||
};
|
||||
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
repointed = 0;
|
||||
const doc =
|
||||
liveDoc && liveDoc.type === "doc"
|
||||
? liveDoc
|
||||
: { type: "doc", content: [] };
|
||||
if (!Array.isArray(doc.content)) doc.content = [];
|
||||
// Repoint ONLY the resolved node — never every node that happens to
|
||||
// share this attachmentId (a copied diagram is two nodes with one
|
||||
// attachmentId; keying on it would clobber both). Re-resolve the same
|
||||
// handle against the live doc and walk to its exact position.
|
||||
const hit = getNodeByRef(doc, ref);
|
||||
if (!hit || hit.type !== "drawio") return null; // vanished/changed -> skip
|
||||
let target: any = doc;
|
||||
for (const idx of hit.path) {
|
||||
if (!target || !Array.isArray(target.content)) {
|
||||
target = null;
|
||||
break;
|
||||
}
|
||||
target = target.content[idx];
|
||||
}
|
||||
if (!target || target.type !== "drawio") return null;
|
||||
repoint(target);
|
||||
if (repointed === 0) return null; // node vanished concurrently -> skip
|
||||
return doc;
|
||||
},
|
||||
);
|
||||
|
||||
if (repointed === 0) {
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: [
|
||||
...prepared.warnings,
|
||||
"target drawio node was removed concurrently; uploaded attachment is unreferenced",
|
||||
],
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: prepared.warnings,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Page history / diff / transform ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,11 +13,6 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||
export { DocmostClient } from "./client.js";
|
||||
export type { DocmostMcpConfig } from "./client.js";
|
||||
|
||||
// Teardown for the live per-page CollabSession cache (issue #400). An embedding
|
||||
// HTTP host (the gitmost NestJS server) should call this from its own shutdown
|
||||
// hook so no cached collab provider outlives the process.
|
||||
export { destroyAllSessions } from "./lib/collab-session.js";
|
||||
|
||||
// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
|
||||
// service can read it off the loaded module (it cannot import the ESM package's
|
||||
// internals directly; it goes through loadDocmostMcp()).
|
||||
@@ -52,7 +47,7 @@ const VERSION = packageJson.version;
|
||||
export const SERVER_INSTRUCTIONS =
|
||||
"Docmost editing guide — choose the tool by intent.\n" +
|
||||
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
|
||||
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||
@@ -465,6 +460,38 @@ registerShared(
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioGet,
|
||||
async ({ pageId, node, format }) => {
|
||||
const result = await docmostClient.drawioGet(pageId, node, format ?? "xml");
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioCreate,
|
||||
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => {
|
||||
const result = await docmostClient.drawioCreate(
|
||||
pageId,
|
||||
{ position, anchorNodeId, anchorText },
|
||||
xml,
|
||||
title,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_update — optimistic-locked full replacement of a diagram.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioUpdate,
|
||||
async ({ pageId, node, xml, baseHash }) => {
|
||||
const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: share_page
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's own `searchIndexing ?? true` default.
|
||||
|
||||
@@ -1,668 +0,0 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||
import * as Y from "yjs";
|
||||
import WebSocket from "ws";
|
||||
import {
|
||||
buildCollabWsUrl,
|
||||
applyDocToFragment,
|
||||
MutationResult,
|
||||
} from "./collaboration.js";
|
||||
import { summarizeChange } from "./diff.js";
|
||||
|
||||
/**
|
||||
* Live per-page collaboration session cache (issue #400).
|
||||
*
|
||||
* The one-shot write path (collaboration.mutatePageContent /
|
||||
* client.mutateLiveContentUnlocked) used to open a NEW HocuspocusProvider, run
|
||||
* the full connect -> auth -> onLoadDocument -> initial-sync handshake, apply a
|
||||
* single edit, wait for persistence, and then `provider.destroy()` — for EVERY
|
||||
* content mutation. Disconnecting after every edit means that once the pause
|
||||
* between calls exceeds the server's write debounce, the server does a full
|
||||
* store -> unload -> reload per cell, causing 25s connect timeouts and
|
||||
* event-loop lag under a burst of edits on one page.
|
||||
*
|
||||
* This module keeps ONE live provider + ydoc per (wsUrl, pageId, token) alive
|
||||
* across a SERIES of edits. While the provider stays connected the server never
|
||||
* enters store -> unload -> reload, its debounce coalesces N writes into 1-2
|
||||
* stores, and the repeated auth/load/initial-sync disappears.
|
||||
*
|
||||
* The synchronous read -> transform -> write section and the per-edit
|
||||
* persistence-ack logic are preserved VERBATIM from the one-shot machine — the
|
||||
* only change is that they run on a persistent provider instead of a throwaway
|
||||
* one. See CollabSession.mutate.
|
||||
*/
|
||||
|
||||
/** Time we wait for the initial handshake/sync before giving up. */
|
||||
const CONNECT_TIMEOUT_MS = 25000;
|
||||
/** Time we wait for the server to acknowledge our write before giving up. */
|
||||
const PERSIST_TIMEOUT_MS = 20000;
|
||||
|
||||
/**
|
||||
* Tunables, read fresh from the environment on every acquire so tests (and a
|
||||
* live rollback) can change them without reloading the module. Mirrors how
|
||||
* http.ts parses MCP_SESSION_IDLE_MS.
|
||||
* - MCP_COLLAB_SESSION_IDLE_MS: idle TTL, reset after every op. Default 60s.
|
||||
* 0 (or negative) DISABLES the cache — every op opens its own provider and
|
||||
* destroys it after the op, i.e. the exact legacy per-op-provider behavior
|
||||
* (the rollback path).
|
||||
* - MCP_COLLAB_SESSION_MAX_AGE_MS: hard lifetime checked at acquire; bounds
|
||||
* the permission-staleness window. Default 10 min.
|
||||
* - MCP_COLLAB_SESSION_MAX_ENTRIES: registry cap; the least-recently-used
|
||||
* session is destroy-evicted when the cap is reached. Default 32.
|
||||
*/
|
||||
interface SessionConfig {
|
||||
idleMs: number;
|
||||
maxAgeMs: number;
|
||||
maxEntries: number;
|
||||
}
|
||||
|
||||
function parseEnvInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = parseInt(value ?? "", 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function readConfig(): SessionConfig {
|
||||
// idleMs: allow 0 (disable). A malformed value falls back to the default.
|
||||
const idleRaw = parseInt(process.env.MCP_COLLAB_SESSION_IDLE_MS ?? "", 10);
|
||||
const idleMs = Number.isFinite(idleRaw) ? Math.max(0, idleRaw) : 60 * 1000;
|
||||
const maxAgeMs = Math.max(
|
||||
0,
|
||||
parseEnvInt(process.env.MCP_COLLAB_SESSION_MAX_AGE_MS, 10 * 60 * 1000),
|
||||
);
|
||||
const maxEntriesRaw = parseEnvInt(
|
||||
process.env.MCP_COLLAB_SESSION_MAX_ENTRIES,
|
||||
32,
|
||||
);
|
||||
const maxEntries = maxEntriesRaw > 0 ? maxEntriesRaw : 32;
|
||||
return { idleMs, maxAgeMs, maxEntries };
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of HocuspocusProvider this module depends on, so the provider can
|
||||
* be replaced with a fake in unit tests (there is no server in the test env).
|
||||
*/
|
||||
export interface CollabProviderLike {
|
||||
synced: boolean;
|
||||
unsyncedChanges: number;
|
||||
destroy(): void;
|
||||
on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
|
||||
off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
|
||||
}
|
||||
|
||||
/** The configuration object passed to the provider factory. */
|
||||
export interface CollabProviderConfig {
|
||||
url: string;
|
||||
name: string;
|
||||
document: Y.Doc;
|
||||
token: string;
|
||||
WebSocketPolyfill: unknown;
|
||||
onConnect: () => void;
|
||||
onSynced: () => void;
|
||||
onDisconnect: () => void;
|
||||
onClose: () => void;
|
||||
onAuthenticationFailed: () => void;
|
||||
}
|
||||
|
||||
export type CollabProviderFactory = (
|
||||
config: CollabProviderConfig,
|
||||
) => CollabProviderLike;
|
||||
|
||||
const defaultProviderFactory: CollabProviderFactory = (config) =>
|
||||
// @ts-ignore - WebSocketPolyfill is required for the Node.js environment.
|
||||
new HocuspocusProvider(config) as unknown as CollabProviderLike;
|
||||
|
||||
let providerFactory: CollabProviderFactory = defaultProviderFactory;
|
||||
|
||||
/**
|
||||
* TEST SEAM: swap the provider factory (pass null to restore the real one).
|
||||
* Not part of the public API — used only by the unit tests, which cannot reach
|
||||
* a real collaboration server.
|
||||
*/
|
||||
export function __setCollabProviderFactory(
|
||||
factory: CollabProviderFactory | null,
|
||||
): void {
|
||||
providerFactory = factory ?? defaultProviderFactory;
|
||||
}
|
||||
|
||||
/** Optional per-acquire hooks (metrics), passed through from the call site. */
|
||||
export interface AcquireOptions {
|
||||
/** Invoked when the initial connect handshake times out (CONNECT_TIMEOUT_MS). */
|
||||
onConnectTimeout?: () => void;
|
||||
}
|
||||
|
||||
type SessionState = "connecting" | "ready" | "dead";
|
||||
|
||||
/**
|
||||
* One live provider + ydoc for a single (wsUrl, pageId, token) triple.
|
||||
*
|
||||
* Lifecycle: connecting -> ready -> dead. A session becomes `dead` on the first
|
||||
* disconnect/close/auth-failure at ANY time, on an idle/eviction/max-age
|
||||
* teardown, or on an explicit destroy(); death is terminal and removes the
|
||||
* session from the registry so the next acquire opens a fresh one. We never use
|
||||
* the provider's auto-reconnect — destroying on the first disconnect closes the
|
||||
* "reconnect drove unsyncedChanges to 0 without retransmitting our write" class
|
||||
* of false success.
|
||||
*/
|
||||
export class CollabSession {
|
||||
readonly key: string;
|
||||
readonly pageId: string;
|
||||
readonly wsUrl: string;
|
||||
readonly token: string;
|
||||
readonly createdAt: number;
|
||||
state: SessionState = "connecting";
|
||||
/**
|
||||
* Set true on disconnect/close/auth-failure so a reconnect-driven
|
||||
* unsyncedChanges->0 cannot be mistaken for a successful persist of our
|
||||
* write (preserved verbatim from the one-shot machine).
|
||||
*/
|
||||
connectionLost = false;
|
||||
|
||||
provider: CollabProviderLike | undefined;
|
||||
private readonly ydoc: Y.Doc;
|
||||
private readonly cfg: SessionConfig;
|
||||
/**
|
||||
* Ephemeral sessions (cache disabled, MCP_COLLAB_SESSION_IDLE_MS<=0) are never
|
||||
* registered and self-destroy after their single op — the legacy
|
||||
* provider-per-op behavior.
|
||||
*/
|
||||
private readonly ephemeral: boolean;
|
||||
private readonly opts: AcquireOptions | undefined;
|
||||
|
||||
private dead = false;
|
||||
private connectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private openPromise: Promise<void> | undefined;
|
||||
private openResolve: (() => void) | undefined;
|
||||
private openReject: ((err: Error) => void) | undefined;
|
||||
private openSettled = false;
|
||||
/**
|
||||
* The rejector of the CURRENT in-flight mutate, if any. A disconnect/close/
|
||||
* auth-failure or timeout at ANY time rejects the in-flight op through this
|
||||
* with the SAME error text the one-shot machine emitted.
|
||||
*/
|
||||
private inflightReject: ((err: Error) => void) | undefined;
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
pageId: string,
|
||||
wsUrl: string,
|
||||
token: string,
|
||||
cfg: SessionConfig,
|
||||
ephemeral: boolean,
|
||||
opts: AcquireOptions | undefined,
|
||||
) {
|
||||
this.key = key;
|
||||
this.pageId = pageId;
|
||||
this.wsUrl = wsUrl;
|
||||
this.token = token;
|
||||
this.cfg = cfg;
|
||||
this.ephemeral = ephemeral;
|
||||
this.opts = opts;
|
||||
this.createdAt = Date.now();
|
||||
this.ydoc = new Y.Doc();
|
||||
}
|
||||
|
||||
/**
|
||||
* A cached session may be reused only when it is fully ready, still synced,
|
||||
* has not lost its connection, and has not exceeded its max age (invariant 5
|
||||
* "validate on reuse" + the max-age acquire check).
|
||||
*/
|
||||
isReusable(): boolean {
|
||||
return (
|
||||
!this.dead &&
|
||||
this.state === "ready" &&
|
||||
!this.connectionLost &&
|
||||
!!this.provider &&
|
||||
this.provider.synced === true &&
|
||||
Date.now() - this.createdAt < this.cfg.maxAgeMs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect and wait for the initial sync (onSynced) within CONNECT_TIMEOUT_MS.
|
||||
* Idempotent: repeated calls return the same in-flight/settled promise.
|
||||
*/
|
||||
open(): Promise<void> {
|
||||
if (this.openPromise) return this.openPromise;
|
||||
this.openPromise = new Promise<void>((resolve, reject) => {
|
||||
this.openResolve = resolve;
|
||||
this.openReject = reject;
|
||||
|
||||
this.connectTimer = setTimeout(() => {
|
||||
// The 25s connect timeout: the collab connection never became ready.
|
||||
this.opts?.onConnectTimeout?.();
|
||||
this.teardown(
|
||||
new Error("Connection timeout to collaboration server"),
|
||||
false,
|
||||
);
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
|
||||
if (process.env.DEBUG)
|
||||
console.error(`Connecting to WebSocket: ${this.wsUrl}`);
|
||||
|
||||
this.provider = providerFactory({
|
||||
url: this.wsUrl,
|
||||
name: `page.${this.pageId}`,
|
||||
document: this.ydoc,
|
||||
token: this.token,
|
||||
WebSocketPolyfill: WebSocket,
|
||||
onConnect: () => {
|
||||
if (process.env.DEBUG) console.error("WS Connect");
|
||||
},
|
||||
// An unexpected disconnect/close at ANY time (during the connect-wait,
|
||||
// between edits, or during a persistence wait) makes the session dead:
|
||||
// surface it now instead of hanging, reject any in-flight op with the
|
||||
// same error text as the one-shot machine, and remove ourselves from
|
||||
// the registry so the next acquire opens fresh. `teardown` is idempotent
|
||||
// so the onClose our own destroy() triggers is a harmless no-op.
|
||||
onDisconnect: () => {
|
||||
if (process.env.DEBUG) console.error("WS Disconnect");
|
||||
this.teardown(
|
||||
new Error(
|
||||
"Collaboration connection closed before the update was persisted/synced",
|
||||
),
|
||||
true,
|
||||
);
|
||||
},
|
||||
onClose: () => {
|
||||
if (process.env.DEBUG) console.error("WS Close");
|
||||
this.teardown(
|
||||
new Error(
|
||||
"Collaboration connection closed before the update was persisted/synced",
|
||||
),
|
||||
true,
|
||||
);
|
||||
},
|
||||
onSynced: () => {
|
||||
if (this.dead || this.openSettled) return;
|
||||
if (process.env.DEBUG) console.error("Connected and synced!");
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer);
|
||||
this.connectTimer = undefined;
|
||||
}
|
||||
this.state = "ready";
|
||||
this.openSettled = true;
|
||||
this.openResolve?.();
|
||||
},
|
||||
onAuthenticationFailed: () => {
|
||||
this.teardown(
|
||||
new Error("Authentication failed for collaboration connection"),
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
return this.openPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one atomic read -> transform -> write against the LIVE doc and wait for
|
||||
* the server to acknowledge the write.
|
||||
*
|
||||
* INVARIANT 1 (read->write atomicity): between `TiptapTransformer.fromYdoc`
|
||||
* and `applyDocToFragment` there is NO `await`. Yjs applies remote updates
|
||||
* only when the event loop yields, so this synchronous block sees a consistent
|
||||
* live doc and no concurrent human edit can interleave and be clobbered —
|
||||
* exactly as in the one-shot onSynced code, just on a persistent provider.
|
||||
*
|
||||
* INVARIANT 2 (per-edit ack): after the write, resolve immediately if
|
||||
* unsyncedChanges is already 0, else wait for the unsyncedChanges->0 event
|
||||
* (PERSIST_TIMEOUT_MS), guarded by connectionLost so a reconnect handshake
|
||||
* cannot report a false success.
|
||||
*
|
||||
* CONCURRENCY: not safe to invoke concurrently on ONE session — the caller
|
||||
* MUST serialize (hold the per-page lock), mirroring acquireCollabSession.
|
||||
* The in-flight op is tracked in a single `inflightReject` field, so an
|
||||
* overlapping second call would clobber the first's rejector and leave it
|
||||
* hanging on disconnect. A fail-fast guard below rejects the overlap instead.
|
||||
* Sequential (awaited) mutates are fine: localFinish clears inflightReject
|
||||
* before the promise settles, so the guard is clear by the time the next runs.
|
||||
*/
|
||||
mutate(
|
||||
transform: (liveDoc: any) => any | null,
|
||||
): Promise<MutationResult> {
|
||||
// Belt-and-suspenders (acquire already validated): refuse to write on a
|
||||
// session that is not in a live, synced, ready state.
|
||||
if (
|
||||
this.dead ||
|
||||
this.state !== "ready" ||
|
||||
this.connectionLost ||
|
||||
!this.provider ||
|
||||
this.provider.synced !== true
|
||||
) {
|
||||
return Promise.reject(
|
||||
new Error("Collaboration session is not in a ready state"),
|
||||
);
|
||||
}
|
||||
|
||||
// Fail-fast on concurrent use: a second overlapping mutate would overwrite
|
||||
// the first's inflightReject, so a disconnect would only reject the second
|
||||
// and hang the first until PERSIST_TIMEOUT_MS. Reject the overlap WITHOUT
|
||||
// touching the in-flight op's state (no localFinish/teardown here).
|
||||
if (this.inflightReject) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"mutate already in-flight; caller must serialize (hold the page lock)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<MutationResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let unsyncedHandler:
|
||||
| ((data: { number: number }) => void)
|
||||
| undefined;
|
||||
// The verifiable result resolved on every success/abort path. Set on
|
||||
// abort (no-op report) and after a real write (computed change report).
|
||||
let mutationResult: MutationResult;
|
||||
|
||||
const localFinish = (err: Error | null, value?: MutationResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
if (unsyncedHandler && this.provider) {
|
||||
try {
|
||||
this.provider.off("unsyncedChanges", unsyncedHandler);
|
||||
} catch (e) {}
|
||||
}
|
||||
this.inflightReject = undefined;
|
||||
if (err) reject(err);
|
||||
else resolve(value as MutationResult);
|
||||
// Post-settle lifecycle: an ephemeral (cache-disabled) session dies with
|
||||
// its single op; a cached session that is still alive re-arms its idle
|
||||
// TTL so the clock starts from the LAST op.
|
||||
if (this.ephemeral) {
|
||||
this.destroy("ephemeral op complete");
|
||||
} else if (!this.dead) {
|
||||
this.armIdle();
|
||||
}
|
||||
};
|
||||
|
||||
// Register so a disconnect/close/auth-failure/teardown rejects THIS op
|
||||
// with the connection-loss error text. localFinish's `settled` guard makes
|
||||
// a racing teardown + normal resolve safe (first one wins).
|
||||
this.inflightReject = (e: Error) => localFinish(e);
|
||||
|
||||
// Resolve once the server acknowledges our update: the provider increments
|
||||
// unsyncedChanges when the local update is sent and decrements it on the
|
||||
// server's SyncStatus(applied=true); reaching 0 means the authoritative
|
||||
// in-memory ydoc on the server now contains our write.
|
||||
const waitForPersistence = () => {
|
||||
if (settled) return;
|
||||
// A missing provider is a failure, not a success: without it the write
|
||||
// can never have been acknowledged.
|
||||
if (!this.provider) {
|
||||
localFinish(new Error("collab provider gone before persistence"));
|
||||
return;
|
||||
}
|
||||
if (this.provider.unsyncedChanges === 0) {
|
||||
localFinish(null, mutationResult);
|
||||
return;
|
||||
}
|
||||
persistTimer = setTimeout(() => {
|
||||
localFinish(
|
||||
new Error(
|
||||
"Timeout waiting for collaboration server to persist the update",
|
||||
),
|
||||
);
|
||||
}, PERSIST_TIMEOUT_MS);
|
||||
unsyncedHandler = (data: { number: number }) => {
|
||||
// Only treat unsyncedChanges->0 as success when the connection is
|
||||
// still up. A transient disconnect + reconnect handshake can drive the
|
||||
// counter back to 0 without our write being re-transmitted; in that
|
||||
// case let the disconnect/close error win instead.
|
||||
if (data.number === 0 && !this.connectionLost) {
|
||||
localFinish(null, mutationResult);
|
||||
}
|
||||
};
|
||||
this.provider.on("unsyncedChanges", unsyncedHandler);
|
||||
};
|
||||
|
||||
// CRITICAL: everything between reading the live doc and writing it back
|
||||
// must stay synchronous (no await). While the JS event loop is not
|
||||
// yielded, no incoming remote update can interleave, so any already-synced
|
||||
// concurrent edits are preserved in liveDoc.
|
||||
let newDoc: any;
|
||||
let beforeDoc: any;
|
||||
try {
|
||||
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
|
||||
if (
|
||||
!liveDoc ||
|
||||
typeof liveDoc !== "object" ||
|
||||
!Array.isArray(liveDoc.content)
|
||||
) {
|
||||
liveDoc = { type: "doc", content: [] };
|
||||
}
|
||||
|
||||
// Snapshot the before-doc for the change report. Docs are
|
||||
// JSON-serializable, so this is a safe deep clone.
|
||||
beforeDoc = JSON.parse(JSON.stringify(liveDoc));
|
||||
|
||||
newDoc = transform(liveDoc);
|
||||
|
||||
if (newDoc == null) {
|
||||
// Transform aborted — write nothing, return the live doc with a no-op
|
||||
// change report.
|
||||
mutationResult = {
|
||||
doc: liveDoc,
|
||||
verify: {
|
||||
changed: false,
|
||||
textInserted: 0,
|
||||
textDeleted: 0,
|
||||
blocksChanged: 0,
|
||||
marks: {},
|
||||
summary: "no changes (transform aborted)",
|
||||
},
|
||||
};
|
||||
localFinish(null, mutationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Structural diff into the live fragment (issue #152): preserves the Yjs
|
||||
// ids of unchanged nodes, so an open editor's cursor is not yanked to the
|
||||
// end of the document on every agent write.
|
||||
applyDocToFragment(this.ydoc, newDoc);
|
||||
} catch (e) {
|
||||
// Includes errors thrown by transform (e.g. "afterText not found",
|
||||
// "text not found"): propagate them verbatim to the caller.
|
||||
localFinish(e instanceof Error ? e : new Error(String(e)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the verifiable change report AFTER the transact write: it only
|
||||
// needs the JSON before/after, so it cannot affect the atomic read->write
|
||||
// window, and summarizeChange never throws.
|
||||
mutationResult = {
|
||||
doc: newDoc,
|
||||
verify: summarizeChange(beforeDoc, newDoc),
|
||||
};
|
||||
if (process.env.DEBUG)
|
||||
console.error("Content written, waiting for server to persist...");
|
||||
waitForPersistence();
|
||||
});
|
||||
}
|
||||
|
||||
/** (Re)arm the idle TTL so the clock starts from the most recent activity. */
|
||||
armIdle(): void {
|
||||
if (this.dead || this.ephemeral) return;
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
if (this.cfg.idleMs > 0) {
|
||||
this.idleTimer = setTimeout(() => {
|
||||
this.destroy("idle timeout");
|
||||
}, this.cfg.idleMs);
|
||||
// Never let the idle timer keep the process alive.
|
||||
(this.idleTimer as any).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent teardown: mark dead, clear timers, remove from the registry, fail
|
||||
* any pending open/in-flight op, and destroy the provider. `inflightError` is
|
||||
* the error a pending open or in-flight op is rejected with; `connectionLoss`
|
||||
* marks the session as connection-lost so the ack guard cannot report a false
|
||||
* success on a racing unsyncedChanges->0.
|
||||
*/
|
||||
private teardown(inflightError: Error | null, connectionLoss: boolean): void {
|
||||
if (this.dead) return;
|
||||
this.dead = true;
|
||||
this.state = "dead";
|
||||
if (connectionLoss) this.connectionLost = true;
|
||||
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer);
|
||||
this.connectTimer = undefined;
|
||||
}
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = undefined;
|
||||
}
|
||||
|
||||
// Remove ourselves from the registry (only if we are still the live entry —
|
||||
// a re-open under the same key must not be evicted by our teardown).
|
||||
if (sessions.get(this.key) === this) {
|
||||
sessions.delete(this.key);
|
||||
}
|
||||
|
||||
// Fail a pending open() and any in-flight mutate with the terminal error.
|
||||
if (!this.openSettled) {
|
||||
this.openSettled = true;
|
||||
this.openReject?.(
|
||||
inflightError ?? new Error("Collaboration session destroyed"),
|
||||
);
|
||||
}
|
||||
if (this.inflightReject) {
|
||||
const rej = this.inflightReject;
|
||||
this.inflightReject = undefined;
|
||||
rej(inflightError ?? new Error("Collaboration session destroyed"));
|
||||
}
|
||||
|
||||
if (this.provider) {
|
||||
try {
|
||||
this.provider.destroy();
|
||||
} catch (e) {}
|
||||
this.provider = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public idempotent teardown used by the acquire/eviction paths and by a
|
||||
* caller that wants the session dropped after a failed op ("next call
|
||||
* reconnects fresh").
|
||||
*/
|
||||
destroy(reason: string): void {
|
||||
if (this.dead) return;
|
||||
if (process.env.DEBUG)
|
||||
console.error(`Destroying collab session ${this.pageId}: ${reason}`);
|
||||
this.teardown(new Error(`Collaboration session destroyed: ${reason}`), false);
|
||||
}
|
||||
}
|
||||
|
||||
/** key = wsUrl + pageId + collabToken (identity isolation: invariant 4). */
|
||||
const sessions = new Map<string, CollabSession>();
|
||||
|
||||
function sessionKey(wsUrl: string, pageId: string, token: string): string {
|
||||
// The token is part of the key so sessions are NEVER shared between different
|
||||
// users' MCP sessions (HTTP mode), and a token rotation makes a new entry
|
||||
// while the old one idles out.
|
||||
return `${wsUrl} | ||||