import { WebSocket } from "ws";
export type CanvasCommand =
| { type: "create"; shape: ShapeData }
| { type: "update"; id: string; props: Partial<ShapeData> }
| { type: "delete"; ids: string[] }
| { type: "connect"; from: string; to: string; label?: string }
| { type: "snapshot" }
| { type: "clear" }
| { type: "zoom_to_fit" }
| { type: "export"; format: "png" | "svg" | "json" };
export interface ShapeData {
id?: string;
type: "geo" | "text" | "note" | "arrow" | "frame";
x: number;
y: number;
width?: number;
height?: number;
text?: string;
geo?: string;
color?: string;
fill?: string;
name?: string;
}
export interface CanvasState {
shapes: ShapeData[];
bounds: { x: number; y: number; width: number; height: number };
}
type MessageHandler = (data: unknown) => void;
export class CanvasBridge {
private ws: WebSocket | null = null;
private url: string;
private handlers = new Map<string, MessageHandler>();
private requestId = 0;
private connected = false;
constructor(url: string = "ws://localhost:4000") {
this.url = url;
}
async connect(): Promise<void> {
if (this.connected) return;
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.url);
const connectionTimeout = setTimeout(() => {
reject(new Error(`Connection timeout: Could not connect to widget at ${this.url}. Make sure the widget is running (cd widget && bun run dev)`));
}, 5000);
this.ws.on("open", () => {
clearTimeout(connectionTimeout);
this.connected = true;
resolve();
});
this.ws.on("message", (data) => {
try {
const msg = JSON.parse(data.toString());
const handler = this.handlers.get(msg.requestId);
if (handler) {
if (msg.error) {
handler({ error: msg.error });
} else {
handler(msg);
}
this.handlers.delete(msg.requestId);
}
} catch {
// ignore parse errors
}
});
this.ws.on("close", () => {
this.connected = false;
});
this.ws.on("error", (err) => {
clearTimeout(connectionTimeout);
this.connected = false;
const errorMessage = err.message.includes("ECONNREFUSED")
? `Cannot connect to widget at ${this.url}. Make sure the widget is running (cd widget && bun run dev)`
: `WebSocket error: ${err.message}`;
reject(new Error(errorMessage));
});
});
}
async send<T>(command: CanvasCommand): Promise<T> {
if (!this.connected || !this.ws) {
await this.connect();
}
const requestId = String(++this.requestId);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.handlers.delete(requestId);
reject(new Error(`Request timeout: Widget did not respond within 10s for command '${command.type}'`));
}, 10000);
this.handlers.set(requestId, (data) => {
clearTimeout(timeout);
const response = data as T & { error?: string };
if (response.error) {
reject(new Error(response.error));
} else {
resolve(response);
}
});
this.ws!.send(JSON.stringify({ ...command, requestId }));
});
}
async createShape(shape: ShapeData): Promise<{ id: string }> {
return this.send({ type: "create", shape });
}
async updateShape(id: string, props: Partial<ShapeData>): Promise<void> {
return this.send({ type: "update", id, props });
}
async deleteShapes(ids: string[]): Promise<void> {
return this.send({ type: "delete", ids });
}
async connectShapes(from: string, to: string, label?: string): Promise<{ id: string }> {
return this.send({ type: "connect", from, to, label });
}
async getSnapshot(): Promise<CanvasState> {
return this.send({ type: "snapshot" });
}
async clear(): Promise<void> {
return this.send({ type: "clear" });
}
async zoomToFit(): Promise<void> {
return this.send({ type: "zoom_to_fit" });
}
async createFrame(
x: number,
y: number,
width: number,
height: number,
name?: string
): Promise<{ id: string }> {
return this.send({
type: "create",
shape: { type: "frame", x, y, width, height, name },
});
}
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
this.connected = false;
}
}
}