repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/canvas/src/store.ts
Rawimport { 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<string, unknown> }>;
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<string, unknown>;
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<string>,
): 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<string>();
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<string, unknown> {
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<T>(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<string, unknown> }>;
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<string>();
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<string>();
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;
}