Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/canvas/src/store.ts

Raw
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<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;
}