import WebSocket from 'ws';
import crypto from 'crypto';
import type { PaperWebSocketMessage, PaperNode, PaperDocumentState, PendingRequest } from './types.js';
import { ConnectionState } from './types.js';
export class PaperWebSocketClient {
private ws: WebSocket | null = null;
private documentId: string;
private cookies: string;
private debug: boolean;
private state: 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' = 'disconnected';
private pendingRequests: Map<string, PendingRequest> = new Map();
private documentState: PaperDocumentState | null = null;
private nodeRelationships: Map<string, string> = new Map();
private rootNodeId: string | null = null;
private pages: Array<{ id: string; label: string; rootNodeId: string }> = [];
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 1000; // Start with 1 second
private maxReconnectDelay = 30000; // Max 30 seconds
private reconnectTimer: NodeJS.Timeout | null = null;
private requestTimeout = 30000; // 30 seconds default timeout
constructor(documentId: string, cookies: string, debug = false) {
this.documentId = documentId;
this.cookies = cookies;
this.debug = debug;
}
private generateNodeId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = crypto.randomBytes(8).toString('hex').toUpperCase();
return '01' + timestamp + random.substring(0, 16);
}
getDocumentId(): string {
return this.documentId;
}
getRootNodeId(): string | null {
return this.rootNodeId;
}
getPages(): Array<{ id: string; label: string; rootNodeId: string }> {
return this.pages;
}
getPageRootNodeId(pageIndex: number): string | null {
if (pageIndex < 1 || pageIndex > this.pages.length) {
return null;
}
return this.pages[pageIndex - 1].rootNodeId;
}
private log(message: string, data?: any) {
if (this.debug) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] ${message}`);
if (data !== undefined) {
console.error(JSON.stringify(data, null, 2));
}
}
}
private generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.state === 'connected' || this.state === 'connecting') {
resolve();
return;
}
this.state = 'connecting';
const url = `wss://sync.paper.design/sync/${this.documentId}`;
this.log('Connecting to WebSocket', { url });
try {
this.ws = new WebSocket(url, {
headers: {
'Cookie': this.cookies,
},
});
this.ws.on('open', () => {
console.error('[Paper] β
WebSocket connected successfully');
this.state = ConnectionState.Connected;
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
resolve();
});
this.ws.on('message', (data: WebSocket.Data) => {
try {
const message = JSON.parse(data.toString()) as PaperWebSocketMessage;
this.handleMessage(message);
} catch (error) {
this.log('Failed to parse WebSocket message', { error, data: data.toString() });
}
});
this.ws.on('error', (error) => {
this.log('WebSocket error', { error });
if (this.state === ConnectionState.Connecting) {
this.state = ConnectionState.Disconnected;
reject(new Error(`WebSocket connection failed: ${error.message || String(error)}`));
}
});
this.ws.on('close', (code, reason) => {
this.log('WebSocket closed', { code, reason: reason.toString() });
this.state = 'disconnected';
this.ws = null;
// Don't reconnect on authentication errors
if (code === 1008 || code === 4003) {
this.log('Authentication failed - not reconnecting');
return;
}
this.handleReconnect();
});
} catch (error) {
this.state = 'disconnected';
reject(error);
}
});
}
private handleMessage(message: PaperWebSocketMessage) {
// Handle response to pending request
if (message.id && this.pendingRequests.has(message.id)) {
const pending = this.pendingRequests.get(message.id)!;
clearTimeout(pending.timeout);
this.pendingRequests.delete(message.id);
pending.resolve(message);
return;
}
// Handle document state - parse from any message that contains node data
this.parseDocumentState(message);
}
private parseDocumentState(message: PaperWebSocketMessage) {
// Initialize document state if needed
if (!this.documentState) {
this.documentState = {
nodes: new Map<string, PaperNode>(),
documentId: this.documentId,
};
}
let nodesAdded = 0;
let nodesUpdated = 0;
// Extract all page IDs from pages on initial load ($t: 4)
if (message['$t'] === 4 && message.file?.pages && message.file.pages.length > 0) {
this.pages = message.file.pages.map((page: { id: string; label: string }) => ({
id: page.id,
label: page.label,
rootNodeId: 'root_node_' + page.id
}));
this.rootNodeId = this.pages[0].rootNodeId;
this.log('Found pages', { pages: this.pages, rootNodeId: this.rootNodeId });
console.error(`[Paper] π Found ${this.pages.length} page(s): ${this.pages.map(p => p.label).join(', ')}`);
}
// Handle nodeRelationships
if (message.file?.nodeRelationships) {
for (const [nodeId, relationship] of Object.entries(message.file.nodeRelationships)) {
this.nodeRelationships.set(nodeId, relationship as string);
}
}
// Handle nodes from file object (initial state, $t: 4)
if (message.file?.nodes && typeof message.file.nodes === 'object') {
const nodeEntries = Object.entries(message.file.nodes);
nodeEntries.forEach(([nodeId, nodeData]: [string, any]) => {
if (nodeId && typeof nodeId === 'string' && nodeData && typeof nodeData === 'object') {
const existed = this.documentState!.nodes.has(nodeId);
this.documentState!.nodes.set(nodeId, nodeData as PaperNode);
if (existed) {
nodesUpdated++;
} else {
nodesAdded++;
}
}
});
if (nodeEntries.length > 0 && nodesAdded > 0) {
console.error(`[Paper] π Parsed ${nodesAdded} nodes from file.nodes`);
}
}
// Handle nodes object at top level (for other message types)
if (message.nodes && typeof message.nodes === 'object' && !message.file) {
const nodeEntries = Object.entries(message.nodes);
nodeEntries.forEach(([nodeId, nodeData]: [string, any]) => {
if (nodeId && typeof nodeId === 'string' && nodeData && typeof nodeData === 'object') {
const existed = this.documentState!.nodes.has(nodeId);
this.documentState!.nodes.set(nodeId, nodeData as PaperNode);
if (existed) {
nodesUpdated++;
} else {
nodesAdded++;
}
}
});
if (nodeEntries.length > 0 && nodesAdded > 0) {
console.error(`[Paper] π Parsed ${nodesAdded} nodes from 'nodes' object`);
}
}
// Handle $t: 6 edit messages
if (message['$t'] === 6 && message.edit) {
const { path, type, value } = message.edit;
if (path && path.startsWith('nodes/')) {
const parts = path.split('/');
const nodeId = parts[1];
if (type === 'add' && value && typeof value === 'object') {
this.documentState!.nodes.set(nodeId, value as PaperNode);
nodesAdded++;
} else if (type === 'update' && parts.length >= 3) {
const node = this.documentState!.nodes.get(nodeId);
if (node) {
const field = parts[2];
(node as any)[field] = value;
nodesUpdated++;
}
} else if (type === 'delete') {
this.documentState!.nodes.delete(nodeId);
}
} else if (path && path.startsWith('nodeRelationships/')) {
const nodeId = path.split('/')[1];
if (type === 'add' || type === 'update') {
this.nodeRelationships.set(nodeId, value as string);
} else if (type === 'delete') {
this.nodeRelationships.delete(nodeId);
}
}
}
// Handle images object - contains full image node objects
if (message.file?.images && typeof message.file.images === 'object' && this.documentState) {
const imageEntries = Object.entries(message.file.images);
imageEntries.forEach(([id, image]: [string, any]) => {
if (image && typeof image === 'object' && image.id) {
const existed = this.documentState!.nodes.has(image.id);
this.documentState!.nodes.set(image.id, image as PaperNode);
if (existed) {
nodesUpdated++;
} else {
nodesAdded++;
}
}
});
if (imageEntries.length > 0 && nodesAdded > 0) {
console.error(`[Paper] π Parsed ${nodesAdded} images from 'images' object`);
}
}
// Update document ID if provided
if (message.documentId && this.documentState) {
this.documentState.documentId = message.documentId;
}
// Log only significant updates (initial load or large batches)
if (nodesAdded > 0 || nodesUpdated > 0) {
const totalNodes = this.documentState.nodes.size;
// Only log if we added a substantial number (initial load) or in debug mode
if (nodesAdded > 50 || this.debug) {
console.error(`[Paper] π Document state: +${nodesAdded} nodes, ~${nodesUpdated} updated (total: ${totalNodes})`);
}
}
}
// Send an edit message using the $t: 6 protocol
sendEdit(path: string, type: 'add' | 'update' | 'delete', value?: any): void {
if (this.state !== ConnectionState.Connected || !this.ws) {
throw new Error('WebSocket is not connected');
}
const message = {
'$t': 6,
fileId: this.documentId,
edit: {
path,
type,
value
}
};
this.log('β EDIT', message);
this.ws.send(JSON.stringify(message));
}
// Create a new node on the canvas
// pageIndex is 1-based (1 = first page, 2 = second page, etc.)
createNode(nodeData: Partial<PaperNode>, pageIndex: number = 1): string {
const targetRootNodeId = this.getPageRootNodeId(pageIndex);
if (!targetRootNodeId) {
if (this.pages.length === 0) {
throw new Error('Document not fully loaded. No pages found yet.');
}
throw new Error(`Page ${pageIndex} not found. Available pages: 1-${this.pages.length}`);
}
const nodeId = this.generateNodeId();
// First add the nodeRelationship to the target page
this.sendEdit(
`nodeRelationships/${nodeId}`,
'add',
`${targetRootNodeId}:~~~`
);
// Then add the node
const fullNode: PaperNode = {
id: nodeId,
label: nodeData.label || 'Rectangle',
x: nodeData.x || 0,
y: nodeData.y || 0,
component: nodeData.component || 'Rectangle',
styles: nodeData.styles || {
width: '100px',
height: '100px'
},
'~': false,
styleMeta: nodeData.styleMeta || {
fill: [
{
type: 'solid',
color: {
value: {
mode: 'oklch',
l: 0.7,
c: 0,
h: 0,
alpha: 1
},
mode: 'hex',
gamut: 'rgb'
},
isVisible: true
}
]
},
isVisible: true,
...nodeData
};
this.sendEdit(`nodes/${nodeId}`, 'add', fullNode);
// Update local state
this.documentState?.nodes.set(nodeId, fullNode);
this.nodeRelationships.set(nodeId, `${targetRootNodeId}:~~~`);
console.error(`[Paper] β
Created node ${nodeId} on page ${pageIndex} (${this.pages[pageIndex - 1]?.label || 'unknown'})`);
return nodeId;
}
// Update an existing node
updateNodeProperty(nodeId: string, property: string, value: any): void {
this.sendEdit(`nodes/${nodeId}/${property}`, 'update', value);
// Update local state
const node = this.documentState?.nodes.get(nodeId);
if (node) {
(node as any)[property] = value;
}
}
// Delete a node
deleteNodeById(nodeId: string): void {
// Delete node relationship first
this.sendEdit(`nodeRelationships/${nodeId}`, 'delete');
// Then delete the node
this.sendEdit(`nodes/${nodeId}`, 'delete');
// Update local state
this.documentState?.nodes.delete(nodeId);
this.nodeRelationships.delete(nodeId);
}
async sendRequest(message: Partial<PaperWebSocketMessage>): Promise<PaperWebSocketMessage> {
if (this.state !== ConnectionState.Connected || !this.ws) {
throw new Error('WebSocket is not connected. Current state: ' + this.state);
}
const requestId = this.generateRequestId();
const fullMessage: PaperWebSocketMessage = {
...message,
id: requestId,
};
if (this.debug) {
this.log('β OUTGOING', fullMessage);
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error(`Request timeout after ${this.requestTimeout}ms: ${requestId}`));
}, this.requestTimeout);
this.pendingRequests.set(requestId, {
id: requestId,
resolve,
reject,
timeout,
});
try {
const messageStr = JSON.stringify(fullMessage);
this.ws!.send(messageStr, (error) => {
if (error) {
this.pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(new Error(`Failed to send WebSocket message: ${error.message || String(error)}`));
}
});
} catch (error) {
this.pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(new Error(`Failed to serialize WebSocket message: ${error instanceof Error ? error.message : String(error)}`));
}
});
}
private handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.log('Max reconnection attempts reached');
return;
}
if (this.reconnectTimer) {
return; // Already scheduled
}
this.state = 'reconnecting';
this.reconnectAttempts++;
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
this.log(`Scheduling reconnection attempt ${this.reconnectAttempts} in ${delay}ms`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect().catch((error) => {
this.log('Reconnection failed', { error });
});
}, delay);
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
// Reject all pending requests
for (const [id, pending] of this.pendingRequests.entries()) {
clearTimeout(pending.timeout);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
this.state = 'disconnected';
}
getDocumentState(): PaperDocumentState | null {
return this.documentState;
}
getState(): 'disconnected' | 'connecting' | 'connected' | 'reconnecting' {
return this.state;
}
isConnected(): boolean {
return this.state === ConnectionState.Connected && this.ws !== null;
}
}