import type { AbstractCrdtDocFactory, InferCrdtJson, InferDoc, InferDocLike, InferStorage, } from "@pluv/crdt"; import type { BroadcastProxy, CrdtDocLike, EventNotifierSubscriptionCallback, EventProxy, IOLike, Id, InferIOInput, InferIOOutput, MergeEvents, OtherSubscriptionCallback, OthersSubscriptionCallback, RoomEventListenerMap, RoomLike, StateNotifierSubjects, StorageProxy, StorageRootSubscriptionCallback, StorageSubscriptionCallback, SubscribeProxy, SubscriptionCallback, UpdateMyPresenceAction, UserInfo, WebSocketConnection, WebSocketState, } from "@pluv/types"; import { ConnectionState, StorageState } from "@pluv/types"; import type { CrdtManagerOptions } from "./CrdtManager"; import { CrdtManager } from "./CrdtManager"; import { CrdtNotifier } from "./CrdtNotifier"; import { EventNotifier } from "./EventNotifier"; import { PluvProcedure } from "./PluvProcedure"; import type { PluvRouterEventConfig } from "./PluvRouter"; import { PluvRouter } from "./PluvRouter"; import { StateNotifier } from "./StateNotifier"; import type { UsersManagerConfig } from "./UsersManager"; import { UsersManager } from "./UsersManager"; import { UsersNotifier } from "./UsersNotifier"; import { MAX_PRESENCE_SIZE_BYTES } from "./constants"; import type { EventResolver, EventResolverContext, InternalSubscriptions, PluvClientLimits, } from "./types"; export type MockedRoomEvents = Partial<{ [P in keyof InferIOInput]: (data: Id[P]>) => Partial>; }>; export type MockedRoomConfig< TIO extends IOLike, TPresence extends Record, TCrdt extends AbstractCrdtDocFactory, TEvents extends PluvRouterEventConfig>, > = { events?: MockedRoomEvents>; limits?: PluvClientLimits; router?: PluvRouter, TEvents>; } & Pick, "initialStorage"> & Omit, "limits">; export class MockedRoom< TIO extends IOLike, TPresence extends Record, TCrdt extends AbstractCrdtDocFactory, TEvents extends PluvRouterEventConfig>, > implements RoomLike, TPresence, InferStorage> { public readonly id: string; private readonly _crdtManager: CrdtManager; private readonly _crdtNotifier = new CrdtNotifier>(); private readonly _eventNotifier = new EventNotifier>(); private readonly _events?: MockedRoomEvents>; private readonly _limits: PluvClientLimits; private readonly _usersNotifier = new UsersNotifier(); private readonly _router: PluvRouter, TEvents>; private _state: WebSocketState = { authorization: { token: null, user: null, }, connection: { attempts: 0, id: null, state: ConnectionState.Untouched, }, storage: { state: StorageState.Unavailable, }, webSocket: null, }; private readonly _stateNotifier = new StateNotifier(); private readonly _subscriptions: InternalSubscriptions = { observeCrdt: null, }; private readonly _usersManager: UsersManager; constructor(room: string, options: MockedRoomConfig) { const { events, initialPresence, initialStorage, limits, presence, router } = options; this.id = room; this._events = events; this._limits = { presenceMaxSize: MAX_PRESENCE_SIZE_BYTES, ...limits, }; this._router = router ?? (new PluvRouter({}) as PluvRouter, TEvents>); this._usersManager = new UsersManager({ initialPresence, limits: this._limits, presence, }); this._crdtManager = new CrdtManager({ initialStorage, }); this._observeCrdt(); } public addEventListener( kind: TKind, handler: RoomEventListenerMap[TKind], ): () => void { return () => undefined; } public broadcast = new Proxy( async >>( event: TEvent, data: Id>[TEvent]>, ): Promise => { if (!this._state.webSocket) return; if (this._state.connection.state !== ConnectionState.Open) return; const type = event.toString(); const procedure = this._router._defs.events[type] as PluvProcedure< TIO, any, any, TPresence, TCrdt, "" > | null; if (!procedure?.config.broadcast) { this._simulateEvent(type as TEvent, data); return; } const myself = this._usersManager.myself; if (!myself) return; const parsed = procedure.config.input ? procedure.config.input.parse(data) : data; const context: EventResolverContext> = { doc: this._crdtManager.doc, others: this._usersManager.getOthers(), room: this.id, user: myself, }; const output = await ( procedure.config.broadcast as EventResolver< TIO, any, any, TPresence, InferStorage > )(parsed, context); Object.entries(output).forEach(([_type, _data]) => { this._simulateEvent(_type as TEvent, _data as any); }); }, { get(fn, prop) { return async ( data: Id>[any]>, ): Promise => { return await fn(prop, data); }; }, }, ) as BroadcastProxy; public canRedo = (): boolean => { return !!this._crdtManager.doc.canRedo(); }; public canUndo = (): boolean => { return !!this._crdtManager.doc.canUndo(); }; public getConnection = (): WebSocketConnection => { // Create a read-only clone of the connection state return Object.freeze(JSON.parse(JSON.stringify(this._state.connection))); }; public getDoc(): CrdtDocLike, InferStorage> { return this._crdtManager.doc; } public getMyPresence = (): TPresence => { return this._usersManager.myPresence; }; public getMyself = (): Id> | null => { return this._usersManager.myself; }; public getOther = (connectionId: string): Id> | null => { return this._usersManager.getOther(connectionId); }; public getOthers = (): readonly Id>[] => { return this._usersManager.getOthers(); }; public getStorage = >( type: TKey, ): InferStorage[TKey] | null => { const sharedType = this._crdtManager.get(type); if (typeof sharedType === "undefined") return null; return sharedType; }; public getStorageJson(): InferCrdtJson> | null; public getStorageJson>( type: TKey, ): InferCrdtJson[TKey]> | null; public getStorageJson>(type?: TKey) { if (this._state.connection.id === null) return null; if (typeof type === "undefined") return this._crdtManager.doc.toJson(); return this._crdtManager.doc.toJson(type); } public getStorageLoaded(): boolean { return true; } public redo = (): void => { this._crdtManager.doc.redo(); }; public storageRoot = ( fn: (value: { [P in keyof InferStorage]: InferCrdtJson[P]>; }) => void, ): (() => void) => { return this._crdtNotifier.subcribeRoot(fn); }; public subscribe = new Proxy( >( name: TSubject, callback: SubscriptionCallback, ): (() => void) => { return this._stateNotifier.subscribe(name, callback); }, { get: (fn, prop) => { if (prop === "connection") { return (callback: SubscriptionCallback) => { return fn("connection", callback); }; } if (prop === "myPresence") { return (callback: SubscriptionCallback) => { return fn("my-presence", callback); }; } if (prop === "myself") { return (callback: SubscriptionCallback) => { return fn("myself", callback); }; } if (prop === "others") { return (callback: OthersSubscriptionCallback) => { return this._usersNotifier.subscribeOthers(callback); }; } if (prop === "storageLoaded") { return (callback: SubscriptionCallback) => { return fn("storage-loaded", callback); }; } if (prop === "event") return this._event; if (prop === "other") return this._other; if (prop === "storage") return this.#_storage; }, }, ) as SubscribeProxy, TEvents>; public transact = (fn: (storage: InferStorage) => void, origin?: string): void => { const _origin = origin ?? this._state.connection.id; const crdtManager = this._crdtManager; /** * !HACK * @description Don't transact anything if there is no origin, because * that means the user isn't connected yet. This will mean events are * lost unfortunately. * @date September 23, 2023 */ if (typeof _origin !== "string") return; if (!crdtManager) return; crdtManager.doc.transact(() => { const storage = crdtManager.doc.get(); fn(storage); }, _origin); }; public undo = (): void => { this._crdtManager.doc.undo(); }; public updateMyPresence = (presence: UpdateMyPresenceAction): void => { const newPresence = typeof presence === "function" ? presence(this.getMyPresence()) : presence; this._usersManager.updateMyPresence(newPresence); const myPresence = this._usersManager.myPresence; const myself = this._usersManager.myself ?? null; this._stateNotifier.subjects["my-presence"].next(myPresence); if (!!myself) this._stateNotifier.subjects["myself"].next(myself); }; private _event = new Proxy( >>( event: TEvent, callback: EventNotifierSubscriptionCallback, any>, ): (() => void) => this._eventNotifier.subscribe(event, callback), { get(fn, prop) { return ( callback: EventNotifierSubscriptionCallback, any>, ): (() => void) => fn(prop as any, callback); }, }, ) as EventProxy; private _observeCrdt(): void { this._subscriptions.observeCrdt?.(); if (!this._crdtManager) return; const unsubscribe = this._crdtManager.doc.subscribe((event) => { const origin = event.origin ?? null; if (!this._crdtManager) return; if (origin === "$storageUpdated") return; const sharedTypes = this._crdtManager.doc.get(); const storageRoot = Object.keys(sharedTypes).reduce( (acc, prop) => { if (!this._crdtManager) return acc; const serialized = this._crdtManager.doc.toJson(prop); this._crdtNotifier.subject(prop).next(serialized); return { ...acc, [prop]: serialized }; }, {} as { [P in keyof InferStorage]: InferCrdtJson[P]>; }, ); this._crdtNotifier.rootSubject.next(storageRoot); }); this._subscriptions.observeCrdt = unsubscribe; } private _other = ( connectionId: string, callback: OtherSubscriptionCallback, ): (() => void) => { const clientId = this._usersManager.getClientId(connectionId); if (!clientId) return () => undefined; return this._usersNotifier.subscribeOther(clientId, callback); }; private _simulateEvent>>( event: TEvent, data: Id>[TEvent]>, ) { if (!this._events) return; const type = event.toString(); const resolver = this._events[type as TEvent]; if (!resolver) return; const result = resolver(data); Object.keys(result).forEach((type) => { const _type = type.toString() as keyof Partial>; const data = result[_type] as any; if (!data) return; this._eventNotifier.subject(_type).next(data); }); } #_storage = new Proxy( >( key: TKey, callback: StorageSubscriptionCallback, TKey>, ): (() => void) => this._crdtNotifier.subscribe(key, callback), { get: (fn, prop) => { type _Storage = InferStorage; if (!!prop) { return (callback: StorageRootSubscriptionCallback<_Storage>) => { return this._crdtNotifier.subcribeRoot(callback); }; } return (callback: StorageSubscriptionCallback<_Storage, keyof _Storage>) => { return fn(prop as keyof InferStorage, callback); }; }, }, ) as StorageProxy>; }