rspace-online/lib/community-sync.ts

822 lines
23 KiB
TypeScript

import * as Automerge from "@automerge/automerge";
import type { FolkShape } from "./folk-shape";
import type { OfflineStore } from "./offline-store";
// Shape data stored in Automerge document
export interface ShapeData {
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
content?: string;
// Arrow-specific
sourceId?: string;
targetId?: string;
color?: string;
strokeWidth?: number;
// Wrapper-specific
title?: string;
icon?: string;
primaryColor?: string;
isMinimized?: boolean;
isPinned?: boolean;
tags?: string[];
// Allow arbitrary shape-specific properties from toJSON()
[key: string]: unknown;
}
// Automerge document structure
export interface CommunityDoc {
meta: {
name: string;
slug: string;
createdAt: string;
};
shapes: {
[id: string]: ShapeData;
};
}
type SyncState = Automerge.SyncState;
/**
* CommunitySync - Bridges FolkJS shapes with Automerge CRDT sync
*
* Handles:
* - Local shape changes → Automerge document → WebSocket broadcast
* - Remote Automerge sync messages → Local document → DOM updates
*/
export class CommunitySync extends EventTarget {
#doc: Automerge.Doc<CommunityDoc>;
#syncState: SyncState;
#ws: WebSocket | null = null;
#communitySlug: string;
#shapes: Map<string, FolkShape> = new Map();
#pendingChanges: boolean = false;
#reconnectAttempts = 0;
#maxReconnectAttempts = 5;
#reconnectDelay = 1000;
#offlineStore: OfflineStore | null = null;
#saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
#wsUrl: string | null = null;
constructor(communitySlug: string, offlineStore?: OfflineStore) {
super();
this.#communitySlug = communitySlug;
// Initialize empty Automerge document
this.#doc = Automerge.init<CommunityDoc>();
this.#doc = Automerge.change(this.#doc, "Initialize community", (doc) => {
doc.meta = {
name: communitySlug,
slug: communitySlug,
createdAt: new Date().toISOString(),
};
doc.shapes = {};
});
this.#syncState = Automerge.initSyncState();
if (offlineStore) {
this.#offlineStore = offlineStore;
}
}
/**
* Load document and sync state from offline cache.
* Call BEFORE connect() to show cached content immediately.
* @returns true if cached data was loaded.
*/
async initFromCache(): Promise<boolean> {
if (!this.#offlineStore) return false;
try {
const docBinary = await this.#offlineStore.loadDoc(this.#communitySlug);
if (!docBinary) return false;
this.#doc = Automerge.load<CommunityDoc>(docBinary);
// Try to restore sync state for incremental reconnection
const syncStateBinary = await this.#offlineStore.loadSyncState(this.#communitySlug);
if (syncStateBinary) {
this.#syncState = Automerge.decodeSyncState(syncStateBinary);
}
// Apply cached doc to DOM
this.#applyDocToDOM();
this.dispatchEvent(new CustomEvent("offline-loaded", {
detail: { slug: this.#communitySlug }
}));
return true;
} catch (e) {
console.error("[CommunitySync] Failed to load from cache:", e);
return false;
}
}
get doc(): Automerge.Doc<CommunityDoc> {
return this.#doc;
}
get shapes(): Map<string, FolkShape> {
return this.#shapes;
}
/**
* Connect to WebSocket server for real-time sync
*/
connect(wsUrl: string): void {
this.#wsUrl = wsUrl;
if (this.#ws?.readyState === WebSocket.OPEN) {
return;
}
this.#ws = new WebSocket(wsUrl);
this.#ws.binaryType = "arraybuffer";
this.#ws.onopen = () => {
console.log(`[CommunitySync] Connected to ${this.#communitySlug}`);
this.#reconnectAttempts = 0;
// Request initial sync
this.#requestSync();
this.dispatchEvent(new CustomEvent("connected"));
if (this.#offlineStore) {
this.#offlineStore.markSynced(this.#communitySlug);
}
};
this.#ws.onmessage = (event) => {
this.#handleMessage(event.data);
};
this.#ws.onclose = () => {
console.log(`[CommunitySync] Disconnected from ${this.#communitySlug}`);
this.dispatchEvent(new CustomEvent("disconnected"));
// Attempt reconnect
this.#attemptReconnect(wsUrl);
};
this.#ws.onerror = (error) => {
console.error("[CommunitySync] WebSocket error:", error);
this.dispatchEvent(new CustomEvent("error", { detail: error }));
};
}
#attemptReconnect(wsUrl: string): void {
// When offline store is available, keep retrying forever (user has local persistence)
// Without offline store, give up after maxReconnectAttempts
if (!this.#offlineStore && this.#reconnectAttempts >= this.#maxReconnectAttempts) {
console.error("[CommunitySync] Max reconnect attempts reached");
return;
}
this.#reconnectAttempts++;
const maxDelay = this.#offlineStore ? 30000 : 16000;
const delay = Math.min(
this.#reconnectDelay * Math.pow(2, this.#reconnectAttempts - 1),
maxDelay
);
console.log(`[CommunitySync] Reconnecting in ${delay}ms (attempt ${this.#reconnectAttempts})`);
setTimeout(() => {
this.connect(wsUrl);
}, delay);
}
/**
* Request sync from server (sends our sync state)
*/
#requestSync(): void {
const [nextSyncState, syncMessage] = Automerge.generateSyncMessage(
this.#doc,
this.#syncState
);
this.#syncState = nextSyncState;
this.#persistSyncState();
if (syncMessage) {
this.#send({
type: "sync",
data: Array.from(syncMessage),
});
}
}
/**
* Handle incoming WebSocket messages
*/
#handleMessage(data: ArrayBuffer | string): void {
try {
// Handle binary Automerge sync messages
if (data instanceof ArrayBuffer) {
const message = new Uint8Array(data);
this.#applySyncMessage(message);
return;
}
// Handle JSON messages
const msg = JSON.parse(data as string);
switch (msg.type) {
case "sync":
// Server sending sync message as JSON array
if (Array.isArray(msg.data)) {
const syncMessage = new Uint8Array(msg.data);
this.#applySyncMessage(syncMessage);
}
break;
case "full-sync":
// Server sending full document (for initial load)
if (msg.doc) {
const binary = new Uint8Array(msg.doc);
this.#doc = Automerge.load<CommunityDoc>(binary);
this.#syncState = Automerge.initSyncState();
this.#applyDocToDOM();
this.#scheduleSave();
}
break;
case "presence":
// Handle presence updates (cursors, selections)
this.dispatchEvent(new CustomEvent("presence", { detail: msg }));
break;
}
} catch (e) {
console.error("[CommunitySync] Failed to handle message:", e);
}
}
/**
* Apply incoming Automerge sync message
*/
#applySyncMessage(message: Uint8Array): void {
const result = Automerge.receiveSyncMessage(
this.#doc,
this.#syncState,
message
);
this.#doc = result[0];
this.#syncState = result[1];
// Persist after receiving remote changes
this.#scheduleSave();
this.#persistSyncState();
// Apply changes to DOM if we received new patches
const patch = result[2] as { patches: Automerge.Patch[] } | null;
if (patch && patch.patches && patch.patches.length > 0) {
this.#applyPatchesToDOM(patch.patches);
}
// Generate response if needed
const [nextSyncState, responseMessage] = Automerge.generateSyncMessage(
this.#doc,
this.#syncState
);
this.#syncState = nextSyncState;
this.#persistSyncState();
if (responseMessage) {
this.#send({
type: "sync",
data: Array.from(responseMessage),
});
}
}
/**
* Send message over WebSocket
*/
#send(message: object): void {
if (this.#ws?.readyState === WebSocket.OPEN) {
this.#ws.send(JSON.stringify(message));
}
}
/**
* Send presence update (cursor position, selection)
*/
sendPresence(data: { cursor?: { x: number; y: number }; selection?: string; username?: string; color?: string }): void {
this.#send({
type: "presence",
...data,
});
}
/**
* Send a keep-alive ping to prevent WebSocket idle timeout
*/
ping(): void {
this.#send({ type: "ping" });
}
/**
* Register a shape element for syncing
*/
registerShape(shape: FolkShape): void {
this.#shapes.set(shape.id, shape);
// Listen for transform events
shape.addEventListener("folk-transform", ((e: CustomEvent) => {
this.#handleShapeChange(shape);
}) as EventListener);
// Listen for content changes (for markdown shapes)
shape.addEventListener("content-change", ((e: CustomEvent) => {
this.#handleShapeChange(shape);
}) as EventListener);
// Add to document if not exists
if (!this.#doc.shapes[shape.id]) {
this.#updateShapeInDoc(shape);
}
}
/**
* Unregister a shape
*/
unregisterShape(shapeId: string): void {
this.#shapes.delete(shapeId);
}
/**
* Handle local shape change - update Automerge doc and sync
*/
#handleShapeChange(shape: FolkShape): void {
this.#updateShapeInDoc(shape);
this.#syncToServer();
// Broadcast to parent frame (for rtrips.online integration)
const shapeData = this.#shapeToData(shape);
this.#postMessageToParent("shape-updated", shapeData);
}
/**
* Update shape data in Automerge document
*/
#updateShapeInDoc(shape: FolkShape): void {
const shapeData = this.#shapeToData(shape);
this.#doc = Automerge.change(this.#doc, `Update shape ${shape.id}`, (doc) => {
if (!doc.shapes) doc.shapes = {};
doc.shapes[shape.id] = shapeData;
});
this.#scheduleSave();
}
/**
* Convert FolkShape to serializable data using the shape's own toJSON() method.
* This ensures all shape-specific properties are captured for every shape type.
*/
#shapeToData(shape: FolkShape): ShapeData {
const json = (shape as any).toJSON?.() ?? {};
const data: ShapeData = {
type: (json.type as string) || shape.tagName.toLowerCase(),
id: (json.id as string) || shape.id,
x: (json.x as number) ?? shape.x,
y: (json.y as number) ?? shape.y,
width: (json.width as number) ?? shape.width,
height: (json.height as number) ?? shape.height,
rotation: (json.rotation as number) ?? shape.rotation,
};
// Merge all extra properties from toJSON
for (const [key, value] of Object.entries(json)) {
if (!(key in data)) {
data[key] = value;
}
}
return data;
}
/**
* Sync local changes to server
*/
#syncToServer(): void {
const [nextSyncState, syncMessage] = Automerge.generateSyncMessage(
this.#doc,
this.#syncState
);
this.#syncState = nextSyncState;
this.#persistSyncState();
if (syncMessage) {
this.#send({
type: "sync",
data: Array.from(syncMessage),
});
}
}
/**
* Delete a shape from the document (hard delete — use forgetShape instead)
*/
deleteShape(shapeId: string): void {
this.forgetShape(shapeId);
}
/**
* FUN: Update — explicitly update specific fields of a shape.
* Use this for programmatic updates (API calls, module callbacks).
* Shape transform/content changes are auto-captured via registerShape().
*/
updateShape(shapeId: string, fields: Record<string, unknown>): void {
const existing = this.#doc.shapes?.[shapeId];
if (!existing) return;
this.#doc = Automerge.change(this.#doc, `Update shape ${shapeId}`, (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
for (const [key, value] of Object.entries(fields)) {
(doc.shapes[shapeId] as Record<string, unknown>)[key] = value;
}
}
});
// Sync the updated shape to DOM and server
const shape = this.#shapes.get(shapeId);
if (shape) {
this.#updateShapeElement(shape, this.#doc.shapes[shapeId]);
}
this.#scheduleSave();
this.#syncToServer();
}
/**
* Forget a shape — soft-delete. Shape stays in the doc but is hidden.
*/
forgetShape(shapeId: string): void {
this.#doc = Automerge.change(this.#doc, `Forget shape ${shapeId}`, (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
(doc.shapes[shapeId] as Record<string, unknown>).forgotten = true;
(doc.shapes[shapeId] as Record<string, unknown>).forgottenAt = Date.now();
}
});
// Remove from visible DOM
this.#removeShapeFromDOM(shapeId);
this.#scheduleSave();
this.#syncToServer();
}
/**
* Remember a forgotten shape — restore it to the canvas.
*/
rememberShape(shapeId: string): void {
const shapeData = this.#doc.shapes?.[shapeId];
if (!shapeData) return;
this.#doc = Automerge.change(this.#doc, `Remember shape ${shapeId}`, (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
(doc.shapes[shapeId] as Record<string, unknown>).forgotten = false;
(doc.shapes[shapeId] as Record<string, unknown>).forgottenAt = 0;
(doc.shapes[shapeId] as Record<string, unknown>).forgottenBy = '';
}
});
// Re-add to DOM
this.#applyShapeToDOM(this.#doc.shapes[shapeId]);
this.#scheduleSave();
this.#syncToServer();
}
/**
* Get all forgotten shapes (for the memory layer UI).
*/
getForgottenShapes(): ShapeData[] {
const shapes = this.#doc.shapes || {};
return Object.values(shapes).filter(s => s.forgotten);
}
/**
* Apply full document to DOM (for initial load).
* Skips forgotten shapes — they live in the doc but are hidden from view.
*/
#applyDocToDOM(): void {
const shapes = this.#doc.shapes || {};
for (const [id, shapeData] of Object.entries(shapes)) {
if (shapeData.forgotten) continue; // FUN: forgotten shapes stay in doc, hidden from canvas
this.#applyShapeToDOM(shapeData);
}
this.dispatchEvent(new CustomEvent("synced", { detail: { shapes } }));
}
/**
* Apply Automerge patches to DOM.
* Handles forgotten state: when a shape becomes forgotten, remove it from
* the visible canvas; when remembered, re-add it.
*/
#applyPatchesToDOM(patches: Automerge.Patch[]): void {
for (const patch of patches) {
const path = patch.path;
// Handle shape updates: ["shapes", shapeId, ...]
if (path[0] === "shapes" && typeof path[1] === "string") {
const shapeId = path[1];
const shapeData = this.#doc.shapes?.[shapeId];
if (patch.action === "del" && path.length === 2) {
// Shape hard-deleted
this.#removeShapeFromDOM(shapeId);
} else if (shapeData) {
// FUN: if shape was just forgotten, remove from DOM
if (shapeData.forgotten) {
this.#removeShapeFromDOM(shapeId);
this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } }));
} else {
// Shape created, updated, or remembered — render it
this.#applyShapeToDOM(shapeData);
this.#postMessageToParent("shape-updated", shapeData);
}
}
}
}
}
/**
* Apply shape data to DOM element
*/
#applyShapeToDOM(shapeData: ShapeData): void {
let shape = this.#shapes.get(shapeData.id);
if (!shape) {
// FUN: New — instantiate shape element
shape = this.#newShapeElement(shapeData);
if (shape) {
this.#shapes.set(shapeData.id, shape);
this.dispatchEvent(new CustomEvent("shape-new", { detail: { shape, data: shapeData } }));
}
return;
}
// Update existing shape (avoid triggering our own change events)
this.#updateShapeElement(shape, shapeData);
}
/**
* FUN: New — emit event for the canvas to instantiate a new shape from data
*/
#newShapeElement(data: ShapeData): FolkShape | undefined {
this.dispatchEvent(new CustomEvent("new-shape", { detail: data }));
return undefined;
}
/**
* Update shape element without triggering change events
*/
#updateShapeElement(shape: FolkShape, data: ShapeData): void {
// Temporarily remove event listeners to avoid feedback loop
const isOurChange =
shape.x === data.x &&
shape.y === data.y &&
shape.width === data.width &&
shape.height === data.height &&
shape.rotation === data.rotation;
if (isOurChange && !("content" in data)) {
return; // No change needed
}
// Update position and size
if (shape.x !== data.x) shape.x = data.x;
if (shape.y !== data.y) shape.y = data.y;
if (shape.width !== data.width) shape.width = data.width;
if (shape.height !== data.height) shape.height = data.height;
if (shape.rotation !== data.rotation) shape.rotation = data.rotation;
// Update content for markdown shapes
if ("content" in data && "content" in shape) {
const shapeWithContent = shape as any;
if (shapeWithContent.content !== data.content) {
shapeWithContent.content = data.content;
}
}
// Update arrow-specific properties
if (data.type === "folk-arrow") {
const arrow = shape as any;
if (data.sourceId !== undefined && arrow.sourceId !== data.sourceId) {
arrow.sourceId = data.sourceId;
}
if (data.targetId !== undefined && arrow.targetId !== data.targetId) {
arrow.targetId = data.targetId;
}
if (data.color !== undefined && arrow.color !== data.color) {
arrow.color = data.color;
}
if (data.strokeWidth !== undefined && arrow.strokeWidth !== data.strokeWidth) {
arrow.strokeWidth = data.strokeWidth;
}
}
// Update wrapper-specific properties
if (data.type === "folk-wrapper") {
const wrapper = shape as any;
if (data.title !== undefined && wrapper.title !== data.title) {
wrapper.title = data.title;
}
if (data.icon !== undefined && wrapper.icon !== data.icon) {
wrapper.icon = data.icon;
}
if (data.primaryColor !== undefined && wrapper.primaryColor !== data.primaryColor) {
wrapper.primaryColor = data.primaryColor;
}
if (data.isMinimized !== undefined && wrapper.isMinimized !== data.isMinimized) {
wrapper.isMinimized = data.isMinimized;
}
if (data.isPinned !== undefined && wrapper.isPinned !== data.isPinned) {
wrapper.isPinned = data.isPinned;
}
if (data.tags !== undefined) {
wrapper.tags = data.tags;
}
}
// Update token mint properties
if (data.type === "folk-token-mint") {
const mint = shape as any;
if (data.tokenName !== undefined && mint.tokenName !== data.tokenName) mint.tokenName = data.tokenName;
if (data.tokenSymbol !== undefined && mint.tokenSymbol !== data.tokenSymbol) mint.tokenSymbol = data.tokenSymbol;
if (data.description !== undefined && mint.description !== data.description) mint.description = data.description;
if (data.totalSupply !== undefined && mint.totalSupply !== data.totalSupply) mint.totalSupply = data.totalSupply;
if (data.issuedSupply !== undefined && mint.issuedSupply !== data.issuedSupply) mint.issuedSupply = data.issuedSupply;
if (data.tokenColor !== undefined && mint.tokenColor !== data.tokenColor) mint.tokenColor = data.tokenColor;
if (data.tokenIcon !== undefined && mint.tokenIcon !== data.tokenIcon) mint.tokenIcon = data.tokenIcon;
}
// Update token ledger properties
if (data.type === "folk-token-ledger") {
const ledger = shape as any;
if (data.mintId !== undefined && ledger.mintId !== data.mintId) ledger.mintId = data.mintId;
if (data.entries !== undefined) ledger.entries = data.entries;
}
// Update choice-vote properties
if (data.type === "folk-choice-vote") {
const vote = shape as any;
if (data.title !== undefined && vote.title !== data.title) vote.title = data.title;
if (data.options !== undefined) vote.options = data.options;
if (data.mode !== undefined && vote.mode !== data.mode) vote.mode = data.mode;
if (data.budget !== undefined && vote.budget !== data.budget) vote.budget = data.budget;
if (data.votes !== undefined) vote.votes = data.votes;
}
// Update choice-rank properties
if (data.type === "folk-choice-rank") {
const rank = shape as any;
if (data.title !== undefined && rank.title !== data.title) rank.title = data.title;
if (data.options !== undefined) rank.options = data.options;
if (data.rankings !== undefined) rank.rankings = data.rankings;
}
// Update choice-spider properties
if (data.type === "folk-choice-spider") {
const spider = shape as any;
if (data.title !== undefined && spider.title !== data.title) spider.title = data.title;
if (data.options !== undefined) spider.options = data.options;
if (data.criteria !== undefined) spider.criteria = data.criteria;
if (data.scores !== undefined) spider.scores = data.scores;
}
// Update social-post properties
if (data.type === "folk-social-post") {
const post = shape as any;
if (data.platform !== undefined && post.platform !== data.platform) post.platform = data.platform;
if (data.postType !== undefined && post.postType !== data.postType) post.postType = data.postType;
if (data.mediaUrl !== undefined && post.mediaUrl !== data.mediaUrl) post.mediaUrl = data.mediaUrl;
if (data.mediaType !== undefined && post.mediaType !== data.mediaType) post.mediaType = data.mediaType;
if (data.scheduledAt !== undefined && post.scheduledAt !== data.scheduledAt) post.scheduledAt = data.scheduledAt;
if (data.status !== undefined && post.status !== data.status) post.status = data.status;
if (data.hashtags !== undefined) post.hashtags = data.hashtags;
if (data.stepNumber !== undefined && post.stepNumber !== data.stepNumber) post.stepNumber = data.stepNumber;
}
}
/**
* FUN: Forget — remove shape from visible DOM (shape remains in Automerge doc)
*/
#removeShapeFromDOM(shapeId: string): void {
const shape = this.#shapes.get(shapeId);
if (shape) {
this.#shapes.delete(shapeId);
this.dispatchEvent(new CustomEvent("shape-removed", { detail: { shapeId, shape } }));
}
}
/**
* Save current state immediately. Call from beforeunload handler.
*/
saveBeforeUnload(): void {
if (!this.#offlineStore) return;
if (this.#saveDebounceTimer) {
clearTimeout(this.#saveDebounceTimer);
this.#saveDebounceTimer = null;
}
try {
this.#offlineStore.saveDocImmediate(
this.#communitySlug,
Automerge.save(this.#doc)
);
} catch (e) {
console.warn("[CommunitySync] Failed to save before unload:", e);
}
}
#scheduleSave(): void {
if (!this.#offlineStore) return;
if (this.#saveDebounceTimer) {
clearTimeout(this.#saveDebounceTimer);
}
this.#saveDebounceTimer = setTimeout(() => {
this.#saveDebounceTimer = null;
this.#offlineStore!.saveDoc(
this.#communitySlug,
Automerge.save(this.#doc)
);
}, 2000);
}
#persistSyncState(): void {
if (!this.#offlineStore) return;
try {
const encoded = Automerge.encodeSyncState(this.#syncState);
this.#offlineStore.saveSyncState(this.#communitySlug, encoded);
} catch (e) {
console.warn("[CommunitySync] Failed to save sync state:", e);
}
}
/**
* Disconnect from server
*/
disconnect(): void {
if (this.#ws) {
this.#ws.close();
this.#ws = null;
}
}
/**
* Get document as binary for storage
*/
getDocumentBinary(): Uint8Array {
return Automerge.save(this.#doc);
}
/**
* Load document from binary
*/
loadDocumentBinary(binary: Uint8Array): void {
this.#doc = Automerge.load<CommunityDoc>(binary);
this.#syncState = Automerge.initSyncState();
this.#applyDocToDOM();
}
/**
* Broadcast shape updates to parent frame (for iframe embedding in rtrips.online)
*/
#postMessageToParent(type: string, data: ShapeData): void {
if (typeof window === "undefined" || window.parent === window) return;
try {
window.parent.postMessage(
{
source: "rspace-canvas",
type,
communitySlug: this.#communitySlug,
shapeId: data.id,
data,
},
"*"
);
} catch {
// postMessage may fail in certain security contexts
}
}
}