// This file contains code that is used in both server-side and client-side replicants.
import type { ErrorObject, ValidateFunction } from "ajv";
import { klona as clone } from "klona/json";
import objectPath from "object-path";
import { TypedEmitter } from "../shared/typed-emitter";
import type { LoggerInterface } from "../types/logger-interface";
import type { NodeCG } from "../types/nodecg";
import {
compileJsonSchema,
formatJsonSchemaErrors,
} from "./utils/compileJsonSchema";
import { stringifyError } from "./utils/errors";
import { isBrowser, isWorker } from "./utils/isBrowser";
export type ReplicantValue<
P extends NodeCG.Platform,
V,
O,
S extends boolean = false,
> = P extends "server"
? S extends true
? V
: O extends { defaultValue: infer D extends V }
? D
: V | undefined
: (O extends { defaultValue: infer D extends V } ? D : V) | undefined;
interface Events
{
change: (
newVal: ReplicantValue
,
oldVal: ReplicantValue
| undefined,
operations: NodeCG.Replicant.Operation>[],
) => void;
declared: (
data:
| { value: ReplicantValue; revision: number }
| {
value: ReplicantValue
;
revision: number;
schemaSum: string;
schema: Record;
},
) => void;
declarationRejected: (rejectReason: string) => void;
operations: (params: {
name: string;
namespace: string;
operations: NodeCG.Replicant.Operation>[];
revision: number;
}) => void;
operationsRejected: (rejectReason: string) => void;
fullUpdate: (data: ReplicantValue) => void;
}
/**
* If you're wondering why some things are prefixed with "_",
* but not marked as protected or private, this is because our Proxy
* trap handlers need to access these parts of the Replicant internals,
* but don't have access to private or protected members.
*
* So, we code this like its 2010 and just use "_" on some public members.
*/
export abstract class AbstractReplicant<
P extends NodeCG.Platform,
V,
O extends NodeCG.Replicant.Options = NodeCG.Replicant.Options,
S extends boolean = false,
> extends TypedEmitter> {
name: string;
namespace: string;
opts: O;
revision = 0;
log!: LoggerInterface; // Gets assigned by implementing classes
schema?: Record;
schemaSum?: string;
status: "undeclared" | "declared" | "declaring" = "undeclared";
validationErrors?: null | ErrorObject[] = [];
protected _value: ReplicantValue | undefined;
protected _oldValue: ReplicantValue
| undefined;
protected _operationQueue: NodeCG.Replicant.Operation<
ReplicantValue
>[] = [];
protected _pendingOperationFlush = false;
constructor(
name: string,
namespace: string,
opts: O = {} as Record,
) {
super();
if (!name || typeof name !== "string") {
throw new Error("Must supply a name when instantiating a Replicant");
}
if (!namespace || typeof namespace !== "string") {
throw new Error("Must supply a namespace when instantiating a Replicant");
}
if (typeof opts.persistent === "undefined") {
opts.persistent = true;
}
if (typeof opts.persistenceInterval === "undefined") {
opts.persistenceInterval = DEFAULT_PERSISTENCE_INTERVAL;
}
this.name = name;
this.namespace = namespace;
this.opts = opts;
// Prevents one-time change listeners from potentially being called twice.
// https://github.com/nodecg/nodecg/issues/296
const originalOnce = this.once.bind(this);
this.once = (event: string, listener: (...params: any[]) => void) => {
if (event === "change" && this.status === "declared") {
listener(this.value);
return;
}
return originalOnce(event as any, listener);
};
/**
* When a new "change" listener is added, chances are that the developer wants it to be initialized ASAP.
* However, if this replicant has already been declared previously in this context, their "change"
* handler will *not* get run until another change comes in, which may never happen for Replicants
* that change very infrequently.
* To resolve this, we immediately invoke all new "change" handlers if appropriate.
*/
this.on("newListener", (event, listener) => {
if (event === "change" && this.status === "declared") {
listener(this.value);
}
});
}
abstract get value(): ReplicantValue;
abstract set value(newValue: ReplicantValue
);
/**
* If the operation is an array mutator method, call it on the target array with the operation arguments.
* Else, handle it with objectPath.
*/
_applyOperation(operation: NodeCG.Replicant.Operation): boolean {
ignoreProxy(this);
let result;
const path = pathStrToPathArr(operation.path);
if (ARRAY_MUTATOR_METHODS.includes(operation.method)) {
if (typeof this.value !== "object" || this.value === null) {
throw new Error(
`expected replicant "${this.namespace}:${this.name}" to have a value with type "object", got "${typeof this
.value}" instead`,
);
}
const arr: unknown = objectPath.get(
this.value as unknown as Record,
path,
);
if (!Array.isArray(arr)) {
throw new Error(
`expected to find an array in replicant "${this.namespace}:${this.name}" at path "${operation.path}"`,
);
}
// eslint-disable-next-line prefer-spread
result = arr[operation.method as any].apply(
arr,
"args" in operation && "mutatorArgs" in operation.args
? operation.args.mutatorArgs
: [],
);
// Recursively check for any objects that may have been added by the above method
// and that need to be Proxied.
proxyRecursive(this as AbstractReplicant, arr, operation.path);
} else {
switch (operation.method) {
case "overwrite": {
const { newValue } = operation.args;
this[isBrowser() || isWorker() ? "value" : "_value"] = proxyRecursive(
this,
newValue as any,
operation.path,
);
result = true;
break;
}
case "add":
case "update": {
path.push(operation.args.prop);
let { newValue } = operation.args;
if (typeof newValue === "object") {
newValue = proxyRecursive(this, newValue, pathArrToPathStr(path));
}
result = objectPath.set(this.value as any, path, newValue);
break;
}
case "delete":
// Workaround for https://github.com/mariocasciaro/object-path/issues/69
if (path.length === 0 || objectPath.has(this.value as any, path)) {
const target = objectPath.get(this.value as any, path);
result = delete target[operation.args.prop];
}
break;
/* istanbul ignore next */
default:
/* istanbul ignore next */
throw new Error(`Unexpected operation method "${operation.method}"`);
}
}
resumeProxy(this as AbstractReplicant
);
return result;
}
/**
* Used to validate the new value of a replicant.
*
* This is a stub that will be replaced if a Schema is available.
*/
validate: Validator = (): boolean => true;
/**
* Adds an operation to the operation queue, to be flushed at the end of the current tick.
* @private
*/
abstract _addOperation(operation: NodeCG.Replicant.Operation): void;
/**
* Emits all queued operations via Socket.IO & empties this._operationQueue.
* @private
*/
abstract _flushOperations(): void;
/**
* Generates a JSON Schema validator function from the `schema` property of the provided replicant.
* @param replicant {object} - The Replicant to perform the operation on.
* @returns {function} - The generated validator function.
*/
protected _generateValidator(): Validator {
const { schema } = this;
if (!schema) {
throw new Error(
"can't generate a validator for a replicant which lacks a schema",
);
}
let validate: ValidateFunction;
try {
validate = compileJsonSchema(schema);
} catch (error: unknown) {
throw new Error(
`Error compiling JSON Schema for Replicant "${this.namespace}:${this.name}":\n\t${stringifyError(error)}`,
);
}
/**
* Validates a value against the current Replicant's schema.
* Throws when the value fails validation.
* @param [value=replicant.value] {*} - The value to validate. Defaults to the replicant's current value.
* @param [opts] {Object}
* @param [opts.throwOnInvalid = true] {Boolean} - Whether or not to immediately throw when the provided value fails validation against the schema.
*/
return function (
this: AbstractReplicant,
value: any = this.value,
{ throwOnInvalid = true }: ValidatorOptions = {},
) {
const valid = validate(value);
if (!valid) {
this.validationErrors = validate.errors;
if (throwOnInvalid) {
throw new Error(
`Invalid value rejected for replicant "${this.name}" in namespace "${
this.namespace
}":\n${formatJsonSchemaErrors(schema, validate.errors)}`,
);
}
}
return valid;
};
}
}
interface Metadata<
V,
O extends NodeCG.Replicant.Options = NodeCG.Replicant.Options,
> {
replicant: AbstractReplicant;
path: string;
proxy: unknown;
}
export interface ValidatorOptions {
throwOnInvalid?: boolean;
}
export type Validator = (newValue: any, opts?: ValidatorOptions) => boolean;
const proxyMetadataMap = new WeakMap();
const metadataMap = new WeakMap>();
const proxySet = new WeakSet();
const ignoringProxy = new WeakSet>();
export const ARRAY_MUTATOR_METHODS = [
"copyWithin",
"fill",
"pop",
"push",
"reverse",
"shift",
"sort",
"splice",
"unshift",
];
/**
* The default persistence interval, in milliseconds.
*/
const DEFAULT_PERSISTENCE_INTERVAL = 100;
export function ignoreProxy(
replicant: AbstractReplicant, any>,
): void {
ignoringProxy.add(replicant);
}
export function resumeProxy(
replicant: AbstractReplicant, any>,
): void {
ignoringProxy.delete(replicant);
}
export function isIgnoringProxy(
replicant: AbstractReplicant, any>,
): boolean {
return ignoringProxy.has(replicant);
}
const deleteTrap = function (target: T, prop: keyof T): boolean | void {
const metadata = metadataMap.get(target);
if (!metadata) {
throw new Error("arrived at delete trap without any metadata");
}
const { replicant } = metadata;
if (isIgnoringProxy(replicant)) {
return delete target[prop];
}
// If the target doesn't have this prop, return true.
if (!{}.hasOwnProperty.call(target, prop)) {
return true;
}
if (replicant.schema) {
const valueClone = clone(replicant.value);
const targetClone = objectPath.get(
valueClone,
pathStrToPathArr(metadata.path),
);
delete targetClone[prop];
replicant.validate(valueClone);
}
replicant._addOperation({
path: metadata.path,
method: "delete",
args: { prop },
});
if (!isBrowser() && !isWorker()) {
return delete target[prop];
}
};
const CHILD_ARRAY_HANDLER = {
get(target: T, prop: keyof T) {
const metadata = metadataMap.get(target);
if (!metadata) {
throw new Error("arrived at get trap without any metadata");
}
const { replicant } = metadata;
if (isIgnoringProxy(replicant)) {
return target[prop];
}
if (
{}.hasOwnProperty.call(Array.prototype, prop) &&
typeof Array.prototype[prop as any] === "function" &&
target[prop] === Array.prototype[prop as any] &&
ARRAY_MUTATOR_METHODS.includes(prop as any)
) {
return (...args: any[]) => {
if (replicant.schema) {
const valueClone = clone(replicant.value);
const targetClone = objectPath.get(
valueClone,
pathStrToPathArr(metadata.path),
);
// eslint-disable-next-line prefer-spread
targetClone[prop].apply(targetClone, args);
replicant.validate(valueClone);
}
if (isBrowser() || isWorker()) {
metadata.replicant._addOperation({
path: metadata.path,
method: prop as any,
args: {
mutatorArgs: Array.prototype.slice.call(args),
},
});
} else {
ignoreProxy(replicant);
metadata.replicant._addOperation({
path: metadata.path,
method: prop as any,
args: {
mutatorArgs: Array.prototype.slice.call(args),
},
});
const retValue = (target as any)[prop].apply(target, args);
resumeProxy(replicant);
// We have to re-proxy the target because the items could have been inserted.
proxyRecursive(replicant, target, metadata.path);
// TODO: This could leak a non-proxied object and cause bugs!
return retValue;
}
};
}
return target[prop];
},
set(target: T, prop: keyof T, newValue: any) {
if (target[prop] === newValue) {
return true;
}
const metadata = metadataMap.get(target);
if (!metadata) {
throw new Error("arrived at set trap without any metadata");
}
const { replicant } = metadata;
if (isIgnoringProxy(replicant)) {
target[prop] = newValue;
return true;
}
if (replicant.schema) {
const valueClone = clone(replicant.value);
const targetClone = objectPath.get(
valueClone,
pathStrToPathArr(metadata.path),
);
targetClone[prop] = newValue;
replicant.validate(valueClone);
}
// It is crucial that this happen *before* the assignment below.
if ({}.hasOwnProperty.call(target, prop)) {
replicant._addOperation({
path: metadata.path,
method: "update",
args: {
prop: prop as string,
newValue,
},
});
} else {
replicant._addOperation({
path: metadata.path,
method: "add",
args: {
prop: prop as string,
newValue,
},
});
}
// If this Replicant is running in the server context, immediately apply the value.
if (!isBrowser() && !isWorker()) {
target[prop] = proxyRecursive(
metadata.replicant,
newValue,
joinPathParts(metadata.path, prop as string),
);
}
return true;
},
deleteProperty: deleteTrap,
};
const CHILD_OBJECT_HANDLER = {
get(target: T, prop: keyof T) {
const value = target[prop];
const tag = Object.prototype.toString.call(value);
const shouldBindProperty =
prop !== "constructor" &&
(tag === "[object Function]" ||
tag === "[object AsyncFunction]" ||
tag === "[object GeneratorFunction]");
if (shouldBindProperty) {
return (value as any).bind(target);
}
return value;
},
set(target: T, prop: keyof T, newValue: any) {
if (target[prop] === newValue) {
return true;
}
const metadata = metadataMap.get(target);
if (!metadata) {
throw new Error("arrived at set trap without any metadata");
}
const { replicant } = metadata;
if (isIgnoringProxy(replicant)) {
target[prop] = newValue;
return true;
}
if (replicant.schema) {
const valueClone = clone(replicant.value);
const targetClone = objectPath.get(
valueClone,
pathStrToPathArr(metadata.path),
);
targetClone[prop] = newValue;
replicant.validate(valueClone);
}
// It is crucial that this happen *before* the assignment below.
if ({}.hasOwnProperty.call(target, prop)) {
replicant._addOperation({
path: metadata.path,
method: "update",
args: {
prop: prop as string,
newValue,
},
});
} else {
replicant._addOperation({
path: metadata.path,
method: "add",
args: {
prop: prop as string,
newValue,
},
});
}
// If this Replicant is running in the server context, immediately apply the value.
if (!isBrowser() && !isWorker()) {
target[prop] = proxyRecursive(
metadata.replicant,
newValue,
joinPathParts(metadata.path, prop as string),
);
}
return true;
},
deleteProperty: deleteTrap,
};
/**
* Recursively Proxies an Array or Object. Does nothing to primitive values.
* @param replicant {object} - The Replicant in which to do the work.
* @param value {*} - The value to recursively Proxy.
* @param path {string} - The objectPath to this value.
* @returns {*} - The recursively Proxied value (or just `value` unchanged, if `value` is a primitive)
* @private
*/
export function proxyRecursive(
replicant: AbstractReplicant, any>,
value: T,
path: string,
): T {
if (typeof value === "object" && value !== null) {
let p;
assertSingleOwner(replicant, value);
// If "value" is already a Proxy, don't re-proxy it.
if (proxySet.has(value as any)) {
p = value;
const metadata = proxyMetadataMap.get(value as any);
metadata.path = path; // Update the path, as it may have changed.
} else if (metadataMap.has(value)) {
const metadata = metadataMap.get(value);
if (!metadata) {
throw new Error("metadata unexpectedly not found");
}
p = metadata.proxy;
metadata.path = path; // Update the path, as it may have changed.
} else {
const handler = Array.isArray(value)
? CHILD_ARRAY_HANDLER
: CHILD_OBJECT_HANDLER;
p = new Proxy(value as any, handler as any);
proxySet.add(p);
const metadata = {
replicant,
path,
proxy: p,
};
metadataMap.set(value, metadata);
proxyMetadataMap.set(p, metadata);
}
for (const key in value) {
/* istanbul ignore if */
if (!{}.hasOwnProperty.call(value, key)) {
continue;
}
const escapedKey = key.replace(/\//g, "~1");
if (path) {
const joinedPath = joinPathParts(path, escapedKey);
value[key] = proxyRecursive(replicant, value[key], joinedPath);
} else {
value[key] = proxyRecursive(replicant, value[key], escapedKey);
}
}
return p;
}
return value;
}
function joinPathParts(part1: string, part2: string): string {
return part1.endsWith("/") ? `${part1}${part2}` : `${part1}/${part2}`;
}
/**
* Converts a string path (/a/b/c) to an array path ['a', 'b', 'c']
* @param path {String} - The path to convert.
* @returns {Array} - The converted path.
*/
function pathStrToPathArr(path: string): string[] {
const pathArr = path
.substr(1)
.split("/")
.map((part) =>
// De-tokenize '/' characters in path name
part.replace(/~1/g, "/"),
);
// For some reason, path arrays whose only item is an empty string cause errors.
// In this case, we replace the path with an empty array, which seems to be fine.
if (pathArr.length === 1 && pathArr[0] === "") {
return [];
}
return pathArr;
}
/**
* Converts an array path ['a', 'b', 'c'] to a string path /a/b/c)
* @param path {Array} - The path to convert.
* @returns {String} - The converted path.
*/
function pathArrToPathStr(path: string[]): string {
const strPath = path.join("/");
if (!strPath.startsWith("/")) {
return `/${strPath}`;
}
return strPath;
}
/**
* Throws an exception if an object belongs to more than one Replicant.
* @param replicant {object} - The Replicant that this value should belong to.
* @param value {*} - The value to check ownership of.
*/
function assertSingleOwner(
replicant: AbstractReplicant,
value: any,
): void {
let metadata: Metadata;
if (proxySet.has(value)) {
metadata = proxyMetadataMap.get(value);
} else if (metadataMap.has(value)) {
metadata = metadataMap.get(value)!;
} else {
// If there's no metadata for this value, then it doesn't belong to any Replicants yet,
// and we're okay to continue.
return;
}
if (metadata.replicant !== replicant) {
throw new Error(
`This object belongs to another Replicant, ${metadata.replicant.namespace}::${metadata.replicant.name}.` +
`\nA given object cannot belong to multiple Replicants. Object value:\n${JSON.stringify(value, null, 2)}`,
);
}
}