UNPKG

@nodecg/types

Version:

Dynamic broadcast graphics rendered in a browser

512 lines 20 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ARRAY_MUTATOR_METHODS = exports.AbstractReplicant = void 0; exports.ignoreProxy = ignoreProxy; exports.resumeProxy = resumeProxy; exports.isIgnoringProxy = isIgnoringProxy; exports.proxyRecursive = proxyRecursive; const json_1 = require("klona/json"); const object_path_1 = __importDefault(require("object-path")); const typed_emitter_1 = require("../shared/typed-emitter"); const compileJsonSchema_1 = require("./utils/compileJsonSchema"); const errors_1 = require("./utils/errors"); const isBrowser_1 = require("./utils/isBrowser"); /** * 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. */ class AbstractReplicant extends typed_emitter_1.TypedEmitter { name; namespace; opts; revision = 0; log; // Gets assigned by implementing classes schema; schemaSum; status = "undeclared"; validationErrors = []; _value; _oldValue; _operationQueue = []; _pendingOperationFlush = false; constructor(name, namespace, opts = {}) { 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, listener) => { if (event === "change" && this.status === "declared") { listener(this.value); return; } return originalOnce(event, 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); } }); } /** * 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) { ignoreProxy(this); let result; const path = pathStrToPathArr(operation.path); if (exports.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 = object_path_1.default.get(this.value, 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].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, arr, operation.path); } else { switch (operation.method) { case "overwrite": { const { newValue } = operation.args; this[(0, isBrowser_1.isBrowser)() || (0, isBrowser_1.isWorker)() ? "value" : "_value"] = proxyRecursive(this, newValue, 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 = object_path_1.default.set(this.value, path, newValue); break; } case "delete": // Workaround for https://github.com/mariocasciaro/object-path/issues/69 if (path.length === 0 || object_path_1.default.has(this.value, path)) { const target = object_path_1.default.get(this.value, 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); 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 = () => true; /** * 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. */ _generateValidator() { const { schema } = this; if (!schema) { throw new Error("can't generate a validator for a replicant which lacks a schema"); } let validate; try { validate = (0, compileJsonSchema_1.compileJsonSchema)(schema); } catch (error) { throw new Error(`Error compiling JSON Schema for Replicant "${this.namespace}:${this.name}":\n\t${(0, errors_1.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 (value = this.value, { throwOnInvalid = true } = {}) { 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${(0, compileJsonSchema_1.formatJsonSchemaErrors)(schema, validate.errors)}`); } } return valid; }; } } exports.AbstractReplicant = AbstractReplicant; const proxyMetadataMap = new WeakMap(); const metadataMap = new WeakMap(); const proxySet = new WeakSet(); const ignoringProxy = new WeakSet(); exports.ARRAY_MUTATOR_METHODS = [ "copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift", ]; /** * The default persistence interval, in milliseconds. */ const DEFAULT_PERSISTENCE_INTERVAL = 100; function ignoreProxy(replicant) { ignoringProxy.add(replicant); } function resumeProxy(replicant) { ignoringProxy.delete(replicant); } function isIgnoringProxy(replicant) { return ignoringProxy.has(replicant); } const deleteTrap = function (target, prop) { 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 = (0, json_1.klona)(replicant.value); const targetClone = object_path_1.default.get(valueClone, pathStrToPathArr(metadata.path)); delete targetClone[prop]; replicant.validate(valueClone); } replicant._addOperation({ path: metadata.path, method: "delete", args: { prop }, }); if (!(0, isBrowser_1.isBrowser)() && !(0, isBrowser_1.isWorker)()) { return delete target[prop]; } }; const CHILD_ARRAY_HANDLER = { get(target, prop) { 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] === "function" && target[prop] === Array.prototype[prop] && exports.ARRAY_MUTATOR_METHODS.includes(prop)) { return (...args) => { if (replicant.schema) { const valueClone = (0, json_1.klona)(replicant.value); const targetClone = object_path_1.default.get(valueClone, pathStrToPathArr(metadata.path)); // eslint-disable-next-line prefer-spread targetClone[prop].apply(targetClone, args); replicant.validate(valueClone); } if ((0, isBrowser_1.isBrowser)() || (0, isBrowser_1.isWorker)()) { metadata.replicant._addOperation({ path: metadata.path, method: prop, args: { mutatorArgs: Array.prototype.slice.call(args), }, }); } else { ignoreProxy(replicant); metadata.replicant._addOperation({ path: metadata.path, method: prop, args: { mutatorArgs: Array.prototype.slice.call(args), }, }); const retValue = target[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, prop, newValue) { 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 = (0, json_1.klona)(replicant.value); const targetClone = object_path_1.default.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, newValue, }, }); } else { replicant._addOperation({ path: metadata.path, method: "add", args: { prop: prop, newValue, }, }); } // If this Replicant is running in the server context, immediately apply the value. if (!(0, isBrowser_1.isBrowser)() && !(0, isBrowser_1.isWorker)()) { target[prop] = proxyRecursive(metadata.replicant, newValue, joinPathParts(metadata.path, prop)); } return true; }, deleteProperty: deleteTrap, }; const CHILD_OBJECT_HANDLER = { get(target, prop) { 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.bind(target); } return value; }, set(target, prop, newValue) { 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 = (0, json_1.klona)(replicant.value); const targetClone = object_path_1.default.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, newValue, }, }); } else { replicant._addOperation({ path: metadata.path, method: "add", args: { prop: prop, newValue, }, }); } // If this Replicant is running in the server context, immediately apply the value. if (!(0, isBrowser_1.isBrowser)() && !(0, isBrowser_1.isWorker)()) { target[prop] = proxyRecursive(metadata.replicant, newValue, joinPathParts(metadata.path, prop)); } 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 */ function proxyRecursive(replicant, value, path) { 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)) { p = value; const metadata = proxyMetadataMap.get(value); 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, handler); 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, part2) { 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) { 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) { 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, value) { let 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)}`); } } //# sourceMappingURL=replicants.shared.js.map