UNPKG

vanilla-party

Version:

An easy to use library for simple multi-user JavaScript apps.

227 lines (193 loc) 6.79 kB
import { DeepstreamClient } from "@deepstream/client" import { CONNECTION_STATE } from "@deepstream/client/dist/src/constants" import * as log from "./log" import { SubscriptionCallback } from "./validate" import { UserData, JSONValue, JSONObject } from "./validate" import { isJSONValue, isEmpty } from "./validate" import { Record } from "./Record" export class Room { readonly #ds: DeepstreamClient readonly #appName: string readonly #roomName: string readonly #roomId: string // e.g. "app-room" readonly #guestName: string // e.g. "app-room/client-uid" readonly #guestRecords: Map<string, Record> readonly #guestShareds: JSONObject[] // all guest shareds in this room readonly #publishedGuestShareds: JSONObject[] // just the non-empty ones readonly #myGuestRecord: Record // record for this guest's shared object readonly #whenConnected: Promise<void> #guestNames: string[] // names of guests in the room #hostName: string | undefined // name of designated host constructor(host: string, appName: string, roomName: string) { this.#ds = new DeepstreamClient(host) this.#appName = appName this.#roomName = roomName this.#roomId = `${appName}-${roomName}` this.#guestName = `${this.#roomId}/${this.#ds.getUid()}` this.#guestRecords = new Map() this.#guestShareds = [] this.#publishedGuestShareds = [] this.#myGuestRecord = new Record(this.#ds, this.#guestName) this.#guestNames = [] this.#hostName = undefined this.#whenConnected = this.#connect() } #connect(): Promise<void> { const innerConnect = async () => { // subscribe to deepstream errors this.#ds.on("error", (error: string, event: string, topic: string) => { log.error("ds error", error, event, topic) this.emit("party:ds-error", { error, event, topic }) }) // subscribe to ANY/ALL clients connect/disconnect on SERVER // (not limited to app / room) this.#ds.presence.subscribe(this.#onPresence.bind(this)) // log in to deepstream server await this.#ds.login({ username: this.#guestName }) // load my guest record this.#myGuestRecord.load() // get room list and determine host await this.#updateGuestNames() // get the guest records and update guestShareds this.#updateGuestShareds() } return innerConnect() } isConnected(): boolean { return this.#ds.getConnectionState() === CONNECTION_STATE.OPEN } get whenConnected(): Promise<void> { return this.#whenConnected } disconnect() { if (!this.isConnected()) return this.#ds.close() } info() { return { appName: this.#appName, roomName: this.#roomName, guestNames: this.#guestNames, guestName: this.#guestName, isConnected: this.isConnected(), isHost: this.isHost(), } } getRecord(name: string): Record { const r = new Record(this.#ds, `${this.#roomId}/${name}`) return r } subscribe(event: string, cb: SubscriptionCallback): void { this.#ds.event.subscribe(`${this.#roomId}/${event}`, cb) } unsubscribe(event: string, cb?: SubscriptionCallback): void { // cb `as SubscriptionCallback` because cb CAN BE undefined this.#ds.event.unsubscribe( `${this.#roomId}/${event}`, cb as SubscriptionCallback ) } emit(event: string, data?: UserData): void { if (data === undefined || isJSONValue(data, "emit-data")) { // data `as JSONValue` because data CAN BE undefined this.#ds.event.emit(`${this.#roomId}/${event}`, data as JSONValue) } } // at any given time any occupied room has exactly one host get hostName(): string | undefined { return this.#hostName } // sorted list of connected guests in this room // semi-private for test suite get _guestNames(): string[] { // return copy so that it can't be modified return [...this.#guestNames] } get myGuestRecord(): Record { return this.#myGuestRecord } get guestShareds(): JSONObject[] { // return read only proxy of array // caller can still modify properties of the items return new Proxy(this.#publishedGuestShareds, { set() { log.error("The guestShared array is read-only.") // eat the value return true }, }) } isHost(): boolean { return this.#hostName === this.#guestName } #updateHost() { this.#hostName = this.#guestNames[0] } async #updateGuestNames(): Promise<void> { const everyone = await this.#ds.presence.getAll() this.#guestNames = everyone .filter((guestName: string) => guestName.startsWith(`${this.#roomId}/`)) .concat(this.#guestName) .sort() this.#updateHost() } #onPresence(username: string, isLoggedIn: boolean): void { if (!username.startsWith(`${this.#roomId}/`)) return // not in this room if (isLoggedIn) { // add to #guestNames this.#guestNames.push(username) this.#guestNames.sort() } else { // remove from #guestNames this.#guestNames = this.#guestNames.filter( (guestName) => guestName !== username ) } this.#updateHost() // update guest shareds this.#updateGuestShareds() } #updateGuestShareds() { const updatePublishedGuestShareds = () => { this.#publishedGuestShareds.length = 0 const nonemptyShareds = this.#guestShareds.filter((s) => !isEmpty(s)) this.#publishedGuestShareds.push(...nonemptyShareds) } // add any missing records to guestRecords, start loading them for (const name of this.#guestNames) { if (this.#guestRecords.has(name)) continue if (name === this.#guestName) { // add my guest record this.#guestRecords.set(name, this.#myGuestRecord) // republish when record changes this.#myGuestRecord.whenLoaded.then(() => { this.#myGuestRecord.watchShared( updatePublishedGuestShareds.bind(this), true ) }) } else { // create record for remote guest and add it const r = new Record(this.#ds, name) this.#guestRecords.set(name, r) // republish when record changes r.load().then(() => { r.watchShared(updatePublishedGuestShareds.bind(this), true) }) } } // update shareds in guestShareds this.#guestShareds.length = 0 // empty it for (const name of this.#guestNames) { if (name === this.#guestName) { // add `my` guest record this.#guestShareds.push(this.#myGuestRecord.shared) } else { // add remote guest record const r = this.#guestRecords.get(name) if (r) this.#guestShareds.push(r.shared) } } updatePublishedGuestShareds() } }