vanilla-party
Version:
An easy to use library for simple multi-user JavaScript apps.
227 lines (193 loc) • 6.79 kB
text/typescript
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()
}
}