import { Observable } from "@noya-app/observable"; import { ExtendedPatch } from "./extended"; import { ClientToServerMessage, MultiplayerStateManager, ServerToClientMessage, } from "./multiplayer"; export type ResourceEditServerMessage = | Extract, { type: "resourceEdit.object" }> | Extract, { type: "resourceEdit.acceptPatch" }> | Extract, { type: "resourceEdit.rejectPatch" }>; export type ResourceEditHandle = { resourceId: string; content$: Observable; isInitialized$: Observable; sendPatches: (patches: ExtendedPatch[], metadata?: { name?: string }) => void; close: () => void; }; export class ResourceEditSession { private multiplayer = new MultiplayerStateManager( "" ); private unsubscribeMultiplayerMessages: (() => void) | undefined; private isOpen = false; constructor( public options: { resourceId: string; sendMessage?: (message: ClientToServerMessage) => void; onClose?: () => void; } ) { this.ensureMessageSubscription(); } getHandle() { const handle: ResourceEditHandle = { resourceId: this.options.resourceId, content$: this.multiplayer.optimisticState$, isInitialized$: this.multiplayer.isInitialized$, sendPatches: (patches, metadata) => { this.sendPatches(patches, metadata); }, close: () => this.close(), }; return handle; } open() { this.ensureMessageSubscription(); if (!this.isOpen) { this.isOpen = true; this.multiplayer.connect(); return; } this.multiplayer.reinitialize(this.multiplayer.getConfirmedState()); } close() { if (!this.isOpen) { return; } this.isOpen = false; this.multiplayer.disconnect(); this.options.sendMessage?.({ type: "resourceEdit.close", resourceId: this.options.resourceId, }); this.unsubscribeMultiplayerMessages?.(); this.unsubscribeMultiplayerMessages = undefined; this.options.onClose?.(); } private sendPatches(patches: ExtendedPatch[], metadata?: { name?: string }) { this.multiplayer.applyPatch(metadata ?? {}, { patches }); } handleServerMessage(message: ResourceEditServerMessage) { const translated = this.translateIncomingMessage(message); if (!translated) return; this.multiplayer.incomingMessages.push(translated); this.multiplayer.processIncomingMessages(); } private flushOutgoingMessages() { while (this.multiplayer.outgoingMessages.length > 0) { const message = this.multiplayer.outgoingMessages.shift()!; const translated = this.translateOutgoingMessage(message); if (!translated) continue; this.options.sendMessage?.(translated); } } private translateOutgoingMessage( message: ClientToServerMessage ): ClientToServerMessage | undefined { switch (message.type) { case "init": { return { type: "resourceEdit.open", resourceId: this.options.resourceId, }; } case "patch": { return { type: "resourceEdit.patch", resourceId: this.options.resourceId, id: message.id, patches: message.patches, baseHash: message.baseHash, }; } default: { return undefined; } } } private translateIncomingMessage( message: ResourceEditServerMessage ): ServerToClientMessage | undefined { switch (message.type) { case "resourceEdit.object": { return { type: "object", object: message.content }; } case "resourceEdit.acceptPatch": { return { type: "acceptPatch", id: message.id, patches: message.patches, hash: message.hash, }; } case "resourceEdit.rejectPatch": { return { type: "rejectPatch", id: message.id, reason: message.reason, error: message.error, }; } default: return undefined; } } private ensureMessageSubscription() { if (this.unsubscribeMultiplayerMessages) return; this.unsubscribeMultiplayerMessages = this.multiplayer.messageEmitter.addListener(() => { this.flushOutgoingMessages(); }); } }