p5.party
Version:
Pre-release! An easy to use library for simple multi-user sketches with p5.js.
238 lines (197 loc) • 6.74 kB
text/typescript
import onChange from "on-change";
import { DeepstreamClient } from "@deepstream/client";
import { CONNECTION_STATE } from "@deepstream/client/dist/src/constants";
import { Record as DSRecord } from "@deepstream/client/dist/src/record/record";
import * as log from "./log";
import { SubscriptionCallback } from "./validate";
import { UserData, JSONValue, JSONObject, RecordData } from "./validate";
import { isJSONValue, isJSONObject, isEmpty } from "./validate";
import { patchInPlace } from "./patch";
type SharedObject = JSONObject;
const sharedRecordLookup = new WeakMap<SharedObject, Record>();
export class Record {
readonly #ds: DeepstreamClient;
readonly #name: string;
#dsRecord: DSRecord | null;
#shared: SharedObject;
#watchedShared: SharedObject;
#whenLoaded: Promise<void> | null;
constructor(ds: DeepstreamClient, name: string) {
this.#ds = ds;
this.#name = name;
this.#dsRecord = null;
this.#shared = {};
this.#watchedShared = onChange(
this.#shared,
this.#onClientChangeData.bind(this),
{
onValidate: this.#onClientValidateData.bind(this),
}
);
this.#whenLoaded = null;
sharedRecordLookup.set(this.#shared, this);
}
async load(initObject?: UserData, overwrite = false): Promise<void> {
if (this.#whenLoaded) {
log.warn("Record.load() called twice!", this.#name);
return this.#whenLoaded;
}
if (this.#ds.getConnectionState() !== CONNECTION_STATE.OPEN) {
// "OPEN"
log.error("Record.load() called before room is connected.", this.#name);
return;
}
const innerLoad = async () => {
this.#dsRecord = this.#ds.record.getRecord(this.#name);
this.#dsRecord.subscribe(this.#onServerChangeData.bind(this), true);
await this.#dsRecord.whenReady();
if (!initObject) return;
await this.initData(initObject, overwrite);
};
this.#whenLoaded = innerLoad();
return this.#whenLoaded;
}
get whenLoaded(): Promise<void> {
if (this.#whenLoaded === null) {
log.error("Record.whenLoaded called before load().", this.#name);
return Promise.reject(
new Error("Record.whenLoaded called before load().")
);
}
return this.#whenLoaded;
}
/**
* sets initial data on the record only if the record is empty
*
* @param data initial data to set on the record
*/
async initData(data: UserData, overwrite = false): Promise<void> {
if (!this.#dsRecord?.isReady) {
log.error("Record.initData() called before record ready.", this.#name);
return;
}
// if (!data) return;
if (!overwrite && !isEmpty(this.#dsRecord.get())) return; // don't overwrite existing data
if (!isJSONObject(data, "init-data")) return; // don't try to write bad data
// todo: allow but warn non-owner writes
await this.#dsRecord.setWithAck(data);
}
setData(data: UserData): void {
if (!this.#dsRecord?.isReady) {
// prettier-ignore
log.error(
`Record.setData() called before record ready. ${
this.#name
}\n Ignored: ${JSON.stringify(data)}`
);
return;
}
if (!isJSONObject(data, "set-data")) return; // don't try to write bad data
// todo: allow but warn non-owner writes
this.#dsRecord.set(data);
}
watchShared(callback: SubscriptionCallback, triggerNow?: boolean): void;
watchShared(
path: string,
callback: SubscriptionCallback,
triggerNow?: boolean
): void;
watchShared(
path: string | SubscriptionCallback,
cb?: SubscriptionCallback | boolean,
triggerNow?: boolean
): void {
if (!this.#dsRecord?.isReady) {
log.warn(`watchShared() called on '${this.#name}' before ready.`);
return;
}
// @ts-expect-error subscribe overload signatures DO match watchShared
this.#dsRecord.subscribe(path, cb, triggerNow);
}
get shared(): JSONObject {
return this.#watchedShared;
}
get name(): string {
return this.#name;
}
async delete() {
if (!this.#dsRecord?.isReady) {
log.error(`delete() called on ${this.#name} before ready.`);
return;
}
return new Promise((resolve) => {
this.#dsRecord?.once("delete", resolve);
void this.#dsRecord?.delete();
});
}
async _set(path: string, value: JSONValue) {
// value `as RecordData` because all JSONValues ARE supported
await this.#dsRecord?.setWithAck(path, value as RecordData);
}
_get(key?: string): JSONValue {
return this.#dsRecord?.get(key) as JSONValue;
}
#onClientValidateData(
path: string,
newValue: UserData,
oldValue: UserData
): boolean {
return isJSONValue(newValue, `${this.#name}/${path}`);
}
#onClientChangeData(
path: string,
newValue: UserData,
oldValue: UserData
): void {
if (!this.#dsRecord?.isReady) {
// prettier-ignore
log.warn(
`Shared object written to before ready. ${
this.#name
}\n Ignored: ${path} = ${JSON.stringify(newValue)}`
);
return;
}
//! experimental
// function get(obj: JSONValue, path: string) {
// return path.split(".").reduce((current, key) => {
// return current && current[key];
// }, obj);
// }
// function propertyIsEnumerable(obj: JSONValue, path: string) {
// const keys = path.split(".");
// const lastKey = keys.pop();
// const parent = keys.reduce((current, key) => {
// return current && current[key];
// }, obj);
// // return parent && parent.hasOwnProperty(lastKey);
// return (
// parent && Object.prototype.propertyIsEnumerable.call(parent, lastKey)
// );
// }
// if (!propertyIsEnumerable(this.#shared, path)) {
// return; // don't sync non-enumerable properties
// }
//! /experimental
// todo: warn and allow non-owner writes
// `as JSONValue` because newValue validated in onClientValidateData
void this._set(path, newValue as JSONValue);
}
#onServerChangeData(data: JSONObject): void {
/* istanbul ignore next */ // the server should never be sending this
if (!isJSONObject(data, "server-data")) {
log.error(`Incoming server data not valid.`);
}
// don't replace #shared itself as #watchedShared has a reference to it
// instead patch it to match the incoming data
patchInPlace(this.#shared, data, "shared");
}
static recordForShared(watchedShared: JSONObject): Record | undefined {
const shared = onChange.target(watchedShared);
if (!sharedRecordLookup.has(shared)) {
log.error(`No record found for shared object.`);
return undefined;
}
return sharedRecordLookup.get(shared);
}
}