import { randomUUID } from "node:crypto"; import { documentFingerprint } from "./document.ts"; export { documentFingerprint } from "./document.ts"; export type CanvasElement = { id: string; type: string; isDeleted?: boolean; x?: number; y?: number; width?: number; height?: number; angle?: number; text?: string; [key: string]: unknown; }; export type CanvasDraw = { id: string; title: string; baseRevision: number; additions: CanvasElement[]; updates: Array<{ id: string; changes: Record }>; deleteIds: string[]; mermaid?: { definition: string; x: number; y: number }; preparedAdditions: CanvasElement[] | null; clientId: string | null; }; export type CanvasState = { revision: number; elements: CanvasElement[]; pendingDraw: CanvasDraw | null; focusIds?: string[]; notice?: { id: string; text: string }; }; export type CanvasViewport = { data: string; mimeType: "image/png" | "image/jpeg"; imageWidth: number; imageHeight: number; width: number; height: number; scrollX: number; scrollY: number; zoom: number; }; export type CanvasAction = | { kind: "scene"; revision: number; elements: unknown; viewport?: CanvasViewport; } | { kind: "prepare"; requestId: string; clientId: string; additions: unknown } | { kind: "commit"; requestId: string; clientId: string; revision: number; elements: unknown; } | { kind: "cancel"; requestId: string; clientId: string }; const TYPES = new Set([ "rectangle", "ellipse", "diamond", "text", "arrow", "line", "freedraw", "frame", ]); const STYLE_KEYS = new Set([ "strokeColor", "backgroundColor", "fillStyle", "strokeWidth", "strokeStyle", "roughness", "opacity", ]); const MAX_JSON = 1024 * 1024; export const MAX_ELEMENTS = 1000; export const MAX_STATE_BYTES: number = MAX_JSON; type RecordValue = Record; export function emptyState(): CanvasState { return { revision: 0, elements: [], pendingDraw: null, }; } export function isRecord(value: unknown): value is RecordValue { return value !== null && typeof value === "object" && !Array.isArray(value); } function boundedJson(value: unknown, limit = MAX_JSON): void { const raw = JSON.stringify(value); if (!raw || Buffer.byteLength(raw) > limit) throw Object.assign(new Error("Canvas payload exceeds size limit."), { status: 413, }); const visit = (item: unknown, depth: number): void => { if (depth > 16) throw new Error("Canvas data is nested too deeply."); if (Array.isArray(item)) { if (item.length > 4000) throw new Error("Canvas array is too large."); for (const child of item) visit(child, depth + 1); } else if (isRecord(item)) { for (const [key, child] of Object.entries(item)) { if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("Unsafe canvas property."); visit(child, depth + 1); } } else if (typeof item === "number" && !Number.isFinite(item)) throw new Error("Canvas numbers must be finite."); }; visit(value, 0); } function refs(element: CanvasElement): string[] { const result: string[] = []; for (const key of ["containerId", "frameId"]) { const value = element[key]; if (value !== undefined && value !== null) { if (typeof value !== "string" || !value) throw new Error("Malformed canvas container reference."); result.push(value); } } for (const key of ["startBinding", "endBinding"]) { const value = element[key]; if (value !== undefined && value !== null) { if ( !isRecord(value) || typeof value["elementId"] !== "string" || !value["elementId"] ) throw new Error("Malformed canvas arrow binding."); result.push(value["elementId"]); } } const bound = element["boundElements"]; if (bound !== undefined && bound !== null) { if (!Array.isArray(bound) || bound.length > 100) throw new Error("Malformed canvas bound element."); for (const value of bound) { if (!isRecord(value) || typeof value["id"] !== "string" || !value["id"]) throw new Error("Malformed canvas bound element."); result.push(value["id"]); } } return result; } function validateElements( value: unknown, allowedRefs?: Set, ): CanvasElement[] { boundedJson(value, 512 * 1024); if (!Array.isArray(value) || value.length > MAX_ELEMENTS) throw new Error("Canvas supports at most 1000 elements."); const elements = structuredClone(value) as CanvasElement[]; const ids = new Set(); for (const element of elements) { if ( !isRecord(element) || typeof element.id !== "string" || !element.id || element.id.length > 120 || typeof element.type !== "string" || !TYPES.has(element.type) ) throw new Error("Invalid canvas element ID or type."); if (ids.has(element.id)) throw new Error("Duplicate canvas element ID."); ids.add(element.id); for (const key of ["x", "y", "width", "height", "angle"]) if ( element[key] !== undefined && (typeof element[key] !== "number" || !Number.isFinite(element[key]) || Math.abs(element[key] as number) > 1e7) ) throw new Error("Invalid canvas geometry."); if ( element.text !== undefined && (typeof element.text !== "string" || element.text.length > 16000) ) throw new Error("Invalid canvas text."); } const live = new Set( elements .filter((element) => !element.isDeleted) .map((element) => element.id), ); for (const element of elements) if (!element.isDeleted) for (const id of refs(element)) if (!live.has(id) && !allowedRefs?.has(id)) throw new Error("Dangling canvas element reference."); return elements; } export function acceptElements(value: unknown): CanvasElement[] { return validateElements(value); } function id(value: unknown, what: string): string { if (typeof value !== "string" || !value || value.length > 120) throw new Error(`Invalid ${what} ID.`); return value; } function validStyle(changes: unknown): changes is Record { if ( !isRecord(changes) || !Object.keys(changes).length || Object.keys(changes).some((key) => !STYLE_KEYS.has(key)) ) return false; for (const [key, value] of Object.entries(changes)) { if ( (key === "strokeColor" || key === "backgroundColor") && (typeof value !== "string" || value.length > 32 || !/^(?:#[0-9a-f]{3,8}|transparent)$/iu.test(value)) ) return false; if ( key === "fillStyle" && !["hachure", "cross-hatch", "solid"].includes(value as string) ) return false; if ( key === "strokeStyle" && !["solid", "dashed", "dotted"].includes(value as string) ) return false; if ( key === "strokeWidth" && (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 10) ) return false; if ( key === "roughness" && (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 10) ) return false; if ( key === "opacity" && (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 100) ) return false; } return true; } function freeze(value: T): T { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } return value; } function inbound(elements: CanvasElement[], target: string): boolean { return elements.some( (element) => !element.isDeleted && refs(element).includes(target), ); } export function createDraw( state: CanvasState, input: { baseRevision: number; title: string; additions?: unknown[]; updates?: Array<{ id: string; changes: Record }>; deleteIds?: string[]; mermaid?: { definition: string; x: number; y: number }; }, ): CanvasState { if (state.pendingDraw || input.baseRevision !== state.revision) throw new Error("Drawing changed; read state before drawing."); if (!input.title.trim() || input.title.length > 500) throw new Error("Invalid drawing title."); const additions = validateElements( (input.additions ?? []).map((value) => isRecord(value) ? { ...value, id: typeof value["id"] === "string" ? value["id"] : `canvas-${randomUUID()}`, } : value, ), ); const updates = input.updates ?? []; const deleteIds = input.deleteIds ?? []; if ( !additions.length && !updates.length && !deleteIds.length && !input.mermaid ) throw new Error("Drawing contains no changes."); if (additions.length + updates.length + deleteIds.length > 100) throw new Error("Drawing exceeds 100 operations."); const all = new Set(state.elements.map((element) => element.id)); const live = new Set( state.elements .filter((element) => !element.isDeleted) .map((element) => element.id), ); for (const addition of additions) { if ( all.has(addition.id) || addition.isDeleted || typeof addition.x !== "number" || typeof addition.y !== "number" ) throw new Error("New shapes need unique IDs and x/y coordinates."); all.add(addition.id); } const touched = new Set(); for (const target of [...updates.map((update) => update.id), ...deleteIds]) { if (touched.has(target) || !live.has(target)) throw new Error("Drawing target is missing or repeated."); touched.add(target); } for (const update of updates) if (!validStyle(update.changes)) throw new Error("Only bounded style updates supported."); for (const target of deleteIds) { const element = state.elements.find((item) => item.id === target); if (inbound(state.elements, target) || (element && refs(element).length)) throw new Error("Deleting referenced shapes is not supported."); } if (input.mermaid) { const { definition, x, y } = input.mermaid; if ( !definition.trim() || definition.length > 12000 || definition.split("\n").length > 400 || /%%\{|^\s*---|\b(?:click|href|link|image)\b|<\/?[a-z]|https?:\/\//imu.test( definition, ) || !Number.isFinite(x) || !Number.isFinite(y) || Math.abs(x) > 1e7 || Math.abs(y) > 1e7 ) throw new Error("Mermaid drawing is invalid or unsafe."); if (additions.length || updates.length || deleteIds.length) throw new Error("Use a separate draw request for Mermaid."); } const draw: CanvasDraw = { id: randomUUID(), title: input.title, baseRevision: state.revision, additions, updates: structuredClone(updates), deleteIds: [...deleteIds], ...(input.mermaid ? { mermaid: structuredClone(input.mermaid) } : {}), preparedAdditions: null, clientId: null, }; const next = { ...state, pendingDraw: draw }; boundedJson(next); return next; } function sameDocument(left: CanvasElement, right: CanvasElement): boolean { const canonical = (element: CanvasElement): string => JSON.stringify(element, (key, value: unknown) => { if (["version", "versionNonce", "updated", "index"].includes(key)) return undefined; if (value !== null && typeof value === "object" && !Array.isArray(value)) return Object.fromEntries( Object.entries(value).sort(([a], [b]) => a.localeCompare(b)), ); return value; }); return canonical(left) === canonical(right); } function allowedLabel( addition: CanvasElement, requested: CanvasElement, ): boolean { if ( addition.type !== "text" || addition["containerId"] !== requested.id || typeof addition.text !== "string" ) return false; return ( isRecord(requested["label"]) && typeof requested["label"]["text"] === "string" && (addition["originalText"] ?? addition.text) === requested["label"]["text"] ); } export function prepareDraw( state: CanvasState, requestId: string, clientId: string, additionsValue: unknown, ): CanvasState { const draw = state.pendingDraw; if ( !draw || draw.id !== requestId || draw.baseRevision !== state.revision || draw.clientId !== null ) throw new Error("Stale drawing request."); id(requestId, "request"); id(clientId, "client"); const existing = new Set(state.elements.map((element) => element.id)); const additions = validateElements( additionsValue, new Set( state.elements .filter((element) => !element.isDeleted) .map((element) => element.id), ), ); if (additions.length > 300) throw new Error("Drawing contains too many generated elements."); for (const addition of additions) if (existing.has(addition.id) || addition.isDeleted) throw new Error("New shapes must be live and cannot reuse canvas IDs."); if (draw.mermaid && !additions.length) throw new Error("Mermaid produced no native elements."); if (draw.mermaid) { for (const addition of additions) if ( !new RegExp(`^${escapeRegExp(requestId)}-mermaid-[0-9]+$`, "u").test( addition.id, ) ) throw new Error("Invalid Mermaid element ID."); } else { const requested = new Map( draw.additions.map((addition) => [addition.id, addition]), ); const labels = new Set(); for (const addition of additions) { const source = requested.get(addition.id); if ( source && Object.entries(source).every( ([key, value]) => key === "label" || JSON.stringify(value) === JSON.stringify(addition[key]), ) ) continue; const owner = typeof addition["containerId"] === "string" ? requested.get(addition["containerId"]) : undefined; if (owner && !labels.has(owner.id) && allowedLabel(addition, owner)) { labels.add(owner.id); continue; } throw new Error("Prepared addition does not match requested drawing."); } if ( additions.filter((addition) => requested.has(addition.id)).length !== requested.size ) throw new Error("Prepared drawing is missing a requested addition."); } const next = { ...state, pendingDraw: { ...draw, clientId, preparedAdditions: freeze(additions), }, }; boundedJson(next); return next; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } export function cancelDraw( state: CanvasState, requestId: string, clientId: string, ): CanvasState { const draw = state.pendingDraw; if ( !draw || draw.id !== requestId || (draw.clientId !== null && draw.clientId !== clientId) ) throw new Error("Stale drawing request."); return { ...state, pendingDraw: null }; } export function commitDraw( state: CanvasState, requestId: string, clientId: string, revision: number, elementsValue: unknown, ): CanvasState { const draw = state.pendingDraw; if ( !draw || draw.id !== requestId || draw.clientId !== clientId || draw.preparedAdditions === null || revision !== state.revision || draw.baseRevision !== revision ) throw new Error("Stale drawing request."); const elements = validateElements(elementsValue); const finalById = new Map(elements.map((element) => [element.id, element])); const original = new Map( state.elements.map((element) => [element.id, element]), ); const prepared = new Map( draw.preparedAdditions.map((element) => [element.id, element]), ); for (const element of elements) if (!original.has(element.id) && !prepared.has(element.id)) throw new Error("Drawing contains an unrequested addition."); for (const [elementId, addition] of prepared) { const actual = finalById.get(elementId); if (!actual || !sameDocument(addition, actual)) throw new Error("Drawing additions changed after preparation."); } const expectedOrder = [ ...state.elements.map((element) => element.id), ...draw.preparedAdditions.map((element) => element.id), ]; if ( elements.length !== expectedOrder.length || elements.some((element, index) => element.id !== expectedOrder[index]) ) throw new Error("Drawing changed canvas element order."); for (const [elementId, before] of original) { const after = finalById.get(elementId); if (!after) throw new Error("Drawing removed an original element."); if (draw.deleteIds.includes(elementId)) { if ( !after.isDeleted || !sameDocument({ ...before, isDeleted: true }, after) ) throw new Error("Drawing deletion does not match request."); continue; } const update = draw.updates.find((item) => item.id === elementId); const expected = update ? { ...before, ...update.changes } : before; if (!sameDocument(expected, after)) throw new Error("Drawing changed an unrelated shape or property."); } const next = { ...state, elements, revision: state.revision + 1, pendingDraw: null, }; boundedJson(next); return next; } export function changeScene( state: CanvasState, revision: number, value: unknown, ): CanvasState { if (revision !== state.revision) throw new Error("Drawing revision changed; reconnect or retry."); const elements = acceptElements(value); if (documentFingerprint(elements) === documentFingerprint(state.elements)) return state; const next = { ...state, elements, revision: state.revision + 1 }; boundedJson(next); return next; } export function restoreState(entries: unknown[]): CanvasState { let state = emptyState(); for (const entry of entries) try { if ( !isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== "canvas" || !isRecord(entry["data"]) || entry["data"]["kind"] !== "snapshot" || !isRecord(entry["data"]["state"]) ) continue; const saved = entry["data"]["state"]; if ( !Number.isSafeInteger(saved["revision"]) || (saved["revision"] as number) < 0 ) continue; const elements = acceptElements(saved["elements"]); state = { ...emptyState(), revision: saved["revision"] as number, elements, }; boundedJson(state); } catch { /* Ignore malformed historic entries. */ } return state; }