@colyseus/schema
Version:
Binary state serializer with delta encoding for games
438 lines (378 loc) • 18.2 kB
text/typescript
import { Metadata } from "../../Metadata";
import { Collection, NonFunctionNonPrimitivePropNames, NonFunctionPropNames } from "../../types/HelperTypes";
import { Ref } from "../../encoder/ChangeTree";
import { Decoder } from "../Decoder";
import { DataChange } from "../DecodeOperation";
import { OPERATION } from "../../encoding/spec";
import { Schema } from "../../Schema";
import type { DefinitionType } from "../../annotations";
import type { CollectionSchema } from "../../types/custom/CollectionSchema";
//
// Discussion: https://github.com/colyseus/schema/issues/155
//
// Main points:
// - Decouple structures from their callbacks.
// - Registering deep callbacks can be confusing.
// - Avoid closures by allowing to pass a context. (https://github.com/colyseus/schema/issues/155#issuecomment-1804694081)
//
/**
* TODO: define a schema interface, which even having duplicate definitions, it could be used to get the callback proxy.
*
* ```ts
* export type SchemaCallbackProxy<RoomState> = (<T extends Schema>(instance: T) => CallbackProxy<T>);
* ```
*/
export type SchemaCallbackProxy<RoomState> = (<T>(instance: T) => CallbackProxy<T>);
export type GetCallbackProxy = SchemaCallbackProxy<any>; // workaround for compatibility for < colyseus.js0.16.6. Remove me on next major release.
export type CallbackProxy<T> = unknown extends T // is "any"?
? SchemaCallback<T> & CollectionCallback<any, any>
: T extends Collection<infer K, infer V, infer _>
? CollectionCallback<K, V>
: SchemaCallback<T>;
export type SchemaCallback<T> = {
/**
* Trigger callback when value of a property changes.
*
* @param prop name of the property
* @param callback callback to be triggered on property change
* @param immediate trigger immediatelly if property has been already set.
* @return callback to detach the listener
*/
listen<K extends NonFunctionPropNames<T>>(
prop: K,
callback: (value: T[K], previousValue: T[K]) => void,
immediate?: boolean,
): () => void;
/**
* Trigger callback whenever any property changed within this instance.
*
* @param prop name of the property
* @param callback callback to be triggered on property change
* @param immediate trigger immediatelly if property has been already set.
* @return callback to detach the listener
*/
onChange(callback: () => void): () => void;
/**
* Bind properties to another object. Changes on the properties will be reflected on the target object.
*
* @param targetObject object to bind properties to
* @param properties list of properties to bind. If not provided, all properties will be bound.
*/
bindTo(targetObject: any, properties?: Array<NonFunctionPropNames<T>>): void;
} & {
[K in NonFunctionNonPrimitivePropNames<T>]: CallbackProxy<T[K]>;
}
export type CollectionCallback<K, V> = {
/**
* Trigger callback when an item has been added to the collection.
*
* @param callback
* @param immediate
* @return callback to detach the onAdd listener
*/
onAdd(callback: (item: V, index: K) => void, immediate?: boolean): () => void;
/**
* Trigger callback when an item has been removed to the collection.
*
* @param callback
* @return callback to detach the onRemove listener
*/
onRemove(callback: (item: V, index: K) => void): () => void;
/**
* Trigger callback when the value on a key has changed.
*
* THIS METHOD IS NOT RECURSIVE!
* If you want to listen to changes on individual items, you need to attach callbacks to the them directly inside the `onAdd` callback.
*
* @param callback
* @return callback to detach the onChange listener
*/
onChange(callback: (item: V, index: K) => void): () => void;
};
type OnInstanceAvailableCallback = (callback: (ref: Ref, existing: boolean) => void) => void;
type CallContext = {
instance?: any,
parentInstance?: any,
onInstanceAvailable?: OnInstanceAvailableCallback,
}
export function getDecoderStateCallbacks<T extends Schema>(decoder: Decoder<T>): SchemaCallbackProxy<T> {
const $root = decoder.root;
const callbacks = $root.callbacks;
const onAddCalls: WeakMap<Function, boolean> = new WeakMap();
let currentOnAddCallback: Function | undefined;
decoder.triggerChanges = function (allChanges: DataChange[]) {
const uniqueRefIds = new Set<number>();
for (let i = 0, l = allChanges.length; i < l; i++) {
const change = allChanges[i];
const refId = change.refId;
const ref = change.ref;
const $callbacks = callbacks[refId];
if (!$callbacks) { continue; }
//
// trigger onRemove on child structure.
//
if (
(change.op & OPERATION.DELETE) === OPERATION.DELETE &&
change.previousValue instanceof Schema
) {
const deleteCallbacks = callbacks[$root.refIds.get(change.previousValue)]?.[OPERATION.DELETE];
for (let i = deleteCallbacks?.length - 1; i >= 0; i--) {
deleteCallbacks[i]();
}
}
if (ref instanceof Schema) {
//
// Handle schema instance
//
if (!uniqueRefIds.has(refId)) {
// trigger onChange
const replaceCallbacks = $callbacks?.[OPERATION.REPLACE];
for (let i = replaceCallbacks?.length - 1; i >= 0; i--) {
replaceCallbacks[i]();
// try {
// } catch (e) {
// console.error(e);
// }
}
}
if ($callbacks.hasOwnProperty(change.field)) {
const fieldCallbacks = $callbacks[change.field];
for (let i = fieldCallbacks?.length - 1; i >= 0; i--) {
fieldCallbacks[i](change.value, change.previousValue);
// try {
// } catch (e) {
// console.error(e);
// }
}
}
} else {
//
// Handle collection of items
//
if ((change.op & OPERATION.DELETE) === OPERATION.DELETE) {
//
// FIXME: `previousValue` should always be available.
//
if (change.previousValue !== undefined) {
// triger onRemove
const deleteCallbacks = $callbacks[OPERATION.DELETE];
for (let i = deleteCallbacks?.length - 1; i >= 0; i--) {
deleteCallbacks[i](change.previousValue, change.dynamicIndex ?? change.field);
}
}
// Handle DELETE_AND_ADD operations
if ((change.op & OPERATION.ADD) === OPERATION.ADD) {
const addCallbacks = $callbacks[OPERATION.ADD];
for (let i = addCallbacks?.length - 1; i >= 0; i--) {
addCallbacks[i](change.value, change.dynamicIndex ?? change.field);
}
}
} else if (
(change.op & OPERATION.ADD) === OPERATION.ADD &&
change.previousValue !== change.value
) {
// triger onAdd
const addCallbacks = $callbacks[OPERATION.ADD];
for (let i = addCallbacks?.length - 1; i >= 0; i--) {
addCallbacks[i](change.value, change.dynamicIndex ?? change.field);
}
}
// trigger onChange
if (
change.value !== change.previousValue &&
// FIXME: see "should not encode item if added and removed at the same patch" test case.
// some "ADD" + "DELETE" operations on same patch are being encoded as "DELETE"
(change.value !== undefined || change.previousValue !== undefined)
) {
const replaceCallbacks = $callbacks[OPERATION.REPLACE];
for (let i = replaceCallbacks?.length - 1; i >= 0; i--) {
replaceCallbacks[i](change.value, change.dynamicIndex ?? change.field);
}
}
}
uniqueRefIds.add(refId);
}
};
function getProxy(
metadataOrType: Metadata | DefinitionType,
context: CallContext
) {
let metadata: Metadata = context.instance?.constructor[Symbol.metadata] || metadataOrType;
let isCollection = (
(context.instance && typeof (context.instance['forEach']) === "function") ||
(metadataOrType && typeof ((metadataOrType as typeof Schema)[Symbol.metadata]) === "undefined")
);
if (metadata && !isCollection) {
const onAddListen = function (
ref: Ref,
prop: string,
callback: (value: any, previousValue: any) => void, immediate: boolean
) {
// immediate trigger
if (
immediate &&
context.instance[prop] !== undefined &&
!onAddCalls.has(currentOnAddCallback) // Workaround for https://github.com/colyseus/schema/issues/147
) {
callback(context.instance[prop], undefined);
}
return $root.addCallback($root.refIds.get(ref), prop, callback);
}
/**
* Schema instances
*/
return new Proxy({
listen: function listen(prop: string, callback: (value: any, previousValue: any) => void, immediate: boolean = true) {
if (context.instance) {
return onAddListen(context.instance, prop, callback, immediate);
} else {
// collection instance not received yet
let detachCallback = () => {};
context.onInstanceAvailable((ref: Ref, existing: boolean) => {
detachCallback = onAddListen(ref, prop, callback, immediate && existing && !onAddCalls.has(currentOnAddCallback))
});
return () => detachCallback();
}
},
onChange: function onChange(callback: () => void) {
return $root.addCallback(
$root.refIds.get(context.instance),
OPERATION.REPLACE,
callback
);
},
//
// TODO: refactor `bindTo()` implementation.
// There is room for improvement.
//
bindTo: function bindTo(targetObject: any, properties?: string[]) {
if (!properties) {
properties = Object.keys(metadata).map((index) => metadata[index as any as number].name);
}
return $root.addCallback(
$root.refIds.get(context.instance),
OPERATION.REPLACE,
() => {
properties.forEach((prop) =>
targetObject[prop] = context.instance[prop])
}
);
}
}, {
get(target, prop: string) {
const metadataField = metadata[metadata[prop]];
if (metadataField) {
const instance = context.instance?.[prop];
const onInstanceAvailable: OnInstanceAvailableCallback = (
(callback: (ref: Ref, existing: boolean) => void) => {
const unbind = $(context.instance).listen(prop, (value, _) => {
callback(value, false);
// FIXME: by "unbinding" the callback here,
// it will not support when the server
// re-instantiates the instance.
//
unbind?.();
}, false);
// has existing value
if ($root.refIds.get(instance) !== undefined) {
callback(instance, true);
}
}
);
return getProxy(metadataField.type, {
// make sure refId is available, otherwise need to wait for the instance to be available.
instance: ($root.refIds.get(instance) && instance),
parentInstance: context.instance,
onInstanceAvailable,
});
} else {
// accessing the function
return target[prop as keyof typeof target];
}
},
has(target, prop: string) { return metadata[prop] !== undefined; },
set(_, _1, _2) { throw new Error("not allowed"); },
deleteProperty(_, _1) { throw new Error("not allowed"); },
});
} else {
/**
* Collection instances
*/
const onAdd = function (ref: Ref, callback: (value: any, key: any) => void, immediate: boolean) {
// Trigger callback on existing items
if (immediate) {
(ref as CollectionSchema).forEach((v, k) => callback(v, k));
}
return $root.addCallback($root.refIds.get(ref), OPERATION.ADD, (value: any, key: any) => {
onAddCalls.set(callback, true);
currentOnAddCallback = callback;
callback(value, key);
onAddCalls.delete(callback)
currentOnAddCallback = undefined;
});
};
const onRemove = function (ref: Ref, callback: (value: any, key: any) => void) {
return $root.addCallback($root.refIds.get(ref), OPERATION.DELETE, callback);
};
const onChange = function (ref: Ref, callback: (value: any, key: any) => void) {
return $root.addCallback($root.refIds.get(ref), OPERATION.REPLACE, callback);
};
return new Proxy({
onAdd: function(callback: (value: any, key: any) => void, immediate: boolean = true) {
//
// https://github.com/colyseus/schema/issues/147
// If parent instance has "onAdd" registered, avoid triggering immediate callback.
//
if (context.instance) {
return onAdd(context.instance, callback, immediate && !onAddCalls.has(currentOnAddCallback));
} else if (context.onInstanceAvailable) {
// collection instance not received yet
let detachCallback = () => {};
context.onInstanceAvailable((ref: Ref, existing: boolean) => {
detachCallback = onAdd(ref, callback, immediate && existing && !onAddCalls.has(currentOnAddCallback));
});
return () => detachCallback();
}
},
onRemove: function(callback: (value: any, key: any) => void) {
if (context.instance) {
return onRemove(context.instance, callback);
} else if (context.onInstanceAvailable) {
// collection instance not received yet
let detachCallback = () => {};
context.onInstanceAvailable((ref: Ref) => {
detachCallback = onRemove(ref, callback)
});
return () => detachCallback();
}
},
onChange: function(callback: (value: any, key: any) => void) {
if (context.instance) {
return onChange(context.instance, callback);
} else if (context.onInstanceAvailable) {
// collection instance not received yet
let detachCallback = () => {};
context.onInstanceAvailable((ref: Ref) => {
detachCallback = onChange(ref, callback)
});
return () => detachCallback();
}
},
}, {
get(target, prop: string) {
if (!target[prop as keyof typeof target]) {
throw new Error(`Can't access '${prop}' through callback proxy. access the instance directly.`);
}
return target[prop as keyof typeof target];
},
has(target, prop) { return target[prop as keyof typeof target] !== undefined; },
set(_, _1, _2) { throw new Error("not allowed"); },
deleteProperty(_, _1) { throw new Error("not allowed"); },
});
}
}
function $<T>(instance: T): CallbackProxy<T> {
return getProxy(undefined, { instance }) as unknown as CallbackProxy<T>;
}
return $;
}