UNPKG

@nodecg/types

Version:

Dynamic broadcast graphics rendered in a browser

582 lines (576 loc) 20.9 kB
const require_typed_emitter = require('../../typed-emitter.js'); const require_api_base = require('../../api.base.js'); const require_logger_interface = require('../../logger-interface.js'); const require_replicants_shared = require('../../replicants.shared.js'); let klona_json = require("klona/json"); let __sentry_browser = require("@sentry/browser"); __sentry_browser = require_typed_emitter.__toESM(__sentry_browser); let util = require("util"); let fast_equals = require("fast-equals"); //#region src/client/api/config.ts const filteredConfig = globalThis.ncgConfig; //#endregion //#region src/client/api/logger/logger.client.ts const OrderedLogLevels = { verbose: 0, debug: 1, info: 2, warn: 3, error: 4, silent: 5 }; /** * A factory that configures and returns a Logger constructor. * * @returns A constructor used to create discrete logger instances. */ function loggerFactory(initialOpts = {}, sentry = void 0) { /** * Constructs a new Logger instance that prefixes all output with the given name. * @param name {String} - The label to prefix all output of this logger with. * @returns {Object} - A Logger instance. * @constructor */ return class Logger$1 { static _shouldLogReplicants = Boolean(initialOpts.console?.replicants); static _silent = !initialOpts.console?.enabled; static _level = initialOpts.console?.level ?? require_logger_interface.LogLevel.Info; constructor(name) { this.name = name; this.name = name; } trace(...args) { if (Logger$1._silent) return; if (OrderedLogLevels[Logger$1._level] > OrderedLogLevels.verbose) return; console.info(`[${this.name}]`, ...args); } debug(...args) { if (Logger$1._silent) return; if (OrderedLogLevels[Logger$1._level] > OrderedLogLevels.debug) return; console.info(`[${this.name}]`, ...args); } info(...args) { if (Logger$1._silent) return; if (OrderedLogLevels[Logger$1._level] > OrderedLogLevels.info) return; console.info(`[${this.name}]`, ...args); } warn(...args) { if (Logger$1._silent) return; if (OrderedLogLevels[Logger$1._level] > OrderedLogLevels.warn) return; console.warn(`[${this.name}]`, ...args); } error(...args) { if (Logger$1._silent) return; if (OrderedLogLevels[Logger$1._level] > OrderedLogLevels.error) return; console.error(`[${this.name}]`, ...args); if (sentry) { const formattedArgs = args.map((argument) => typeof argument === "object" ? (0, util.inspect)(argument, { depth: null, showProxy: true }) : argument); sentry.captureException(/* @__PURE__ */ new Error(`[${this.name}] ` + (0, util.format)(formattedArgs[0], ...formattedArgs.slice(1)))); } } replicants(...args) { if (Logger$1._silent) return; if (!Logger$1._shouldLogReplicants) return; console.info(`[${this.name}]`, ...args); } }; } //#endregion //#region src/client/api/logger/index.ts let Logger; if (filteredConfig.sentry.enabled) Logger = loggerFactory(filteredConfig.logging, __sentry_browser); else Logger = loggerFactory(filteredConfig.logging); function createLogger(name) { return new Logger(name); } //#endregion //#region src/client/api/replicant.ts const declaredReplicants = /* @__PURE__ */ new Map(); const REPLICANT_HANDLER = { get(target, prop) { if (prop === "value" && target.status !== "declared") target.log.warn("Attempted to get value before Replicant had finished declaring. This will always return undefined."); return target[prop]; }, set(target, prop, newValue) { if (prop !== "value" || require_replicants_shared.isIgnoringProxy(target)) { target[prop] = newValue; return true; } if (newValue === target[prop]) return true; target.validate(newValue); target.log.replicants("running setter with", newValue); target._addOperation({ path: "/", method: "overwrite", args: { newValue: (0, klona_json.klona)(newValue) } }); return true; } }; var ClientReplicant = class extends require_replicants_shared.AbstractReplicant { value = void 0; /** * When running in the browser, we have to wait until the socket joins the room * and the replicant is fully declared before running any additional commands. * After this time, commands do not need to be added to the queue and are simply executed immediately. */ _actionQueue = []; _socket; constructor(name, namespace, opts, socket = window.socket) { super(name, namespace, opts); this.log = createLogger(`Replicant/${namespace}.${name}`); const nsp = declaredReplicants.get(namespace); if (nsp) { const existing = nsp.get(name); if (existing) { existing.log.replicants("Existing replicant found, returning that instead of creating a new one."); return existing; } } else declaredReplicants.set(namespace, /* @__PURE__ */ new Map()); this._socket = socket; this._declare(); socket.on("replicant:operations", (data) => { this._handleOperations({ ...data, operations: data.operations }); }); socket.on("disconnect", () => { this._handleDisconnect(); }); socket.io.on("reconnect", () => { this._declare(); }); const thisProxy = new Proxy(this, REPLICANT_HANDLER); declaredReplicants.get(namespace).set(name, thisProxy); return thisProxy; } /** * A map of all Replicants declared in this context. Top-level keys are namespaces, * child keys are Replicant names. */ static get declaredReplicants() { const foo = {}; for (const [key, nsp] of declaredReplicants) foo[key] = Object.fromEntries(Object.entries(nsp)); return foo; } /** * Adds an operation to the operation queue, to be flushed at the end of the current tick. * @param path {string} - The object path to where this operation took place. * @param method {string} - The name of the operation. * @param args {array} - The arguments provided to this operation * @private */ _addOperation(operation) { this._operationQueue.push(operation); if (!this._pendingOperationFlush) { this._pendingOperationFlush = true; if (this.status === "declared") setTimeout(() => { this._flushOperations(); }, 0); else this._queueAction(this._flushOperations); } } /** * Emits all queued operations via Socket.IO & empties this._operationQueue. * @private */ _flushOperations() { this._pendingOperationFlush = false; if (this._operationQueue.length <= 0) return; this._socket.emit("replicant:proposeOperations", { name: this.name, namespace: this.namespace, operations: this._operationQueue, revision: this.revision, schemaSum: this.schemaSum, opts: this.opts }, (rejectReason, data) => { if (data?.schema) { this.schema = data.schema; this.schemaSum = data.schemaSum; } if (data && data.revision !== this.revision) { this.log.warn("Not at head revision (ours %s, theirs %s). Change aborted & head revision applied.", this.revision, data.revision); this._assignValue(data.value, data.revision); } if (rejectReason) if (this.listenerCount("operationsRejected") > 0) this.emit("operationsRejected", rejectReason); else this.log.error(rejectReason); }); this._operationQueue = []; } /** * Adds an "action" to the action queue. Actions are method calls on the Replicant object itself. * @param fn * @param args * @private */ _queueAction(fn, args) { this._actionQueue.push({ fn, args }); } /** * Emits "declareReplicant" via the socket. * @private */ _declare() { if (this.status === "declared" || this.status === "declaring") return; this.status = "declaring"; this._socket.emit("joinRoom", `replicant:${this.namespace}:${this.name}`, () => { this._socket.emit("replicant:declare", { name: this.name, namespace: this.namespace, opts: this.opts }, (rejectReason, data) => { if (rejectReason) { if (this.listenerCount("declarationRejected") > 0) { this.emit("declarationRejected", rejectReason); return; } throw new Error(rejectReason); } if (!data) { if (this.listenerCount("declarationRejected") > 0) { this.emit("declarationRejected", "data unexpectedly falsey"); return; } throw new Error("data unexpectedly falsey"); } this.log.replicants("declareReplicant callback (value: %s, revision: %s)", data.value, data.revision); this.status = "declared"; if (this.revision !== data.revision || !(0, fast_equals.deepEqual)(this.value, data.value)) this._assignValue(data.value, data.revision); if ("schema" in data) { this.schema = data.schema; this.schemaSum = data.schemaSum; this.validate = this._generateValidator(); } this.emit("declared", data); if (this.value === void 0 && this.revision === 0) this.emit("change", void 0, void 0, []); if (this._actionQueue.length > 0) { this._actionQueue.forEach((item) => { item.fn.apply(this, item.args ?? []); }); this._actionQueue = []; } }); }); } /** * Overwrites the value completely, and assigns a new one. * @param newValue {*} - The value to assign. * @param revision {number} - The new revision number. * @private */ _assignValue(newValue, revision) { const oldValue = (0, klona_json.klona)(this.value); const op = { path: "/", method: "overwrite", args: { newValue } }; this._applyOperation(op); if (typeof revision !== "undefined") this.revision = revision; this.emit("change", this.value, oldValue, [op]); } /** * Handles incoming operations performed on Array and Object Replicants. * Requests a fullUpdate if it determines that we're not at the latest revision of this Replicant. * @param data {object} - A record of operations to perform. * @private */ _handleOperations(data) { if (this.status !== "declared") return; const expectedRevision = this.revision + 1; if (data.name !== this.name || data.namespace !== this.namespace) return; if (data.revision !== expectedRevision) { this.log.warn("Not at head revision (ours: \"%s\", expected theirs to be \"%s\" but got \"%s\"), fetching latest...", this.revision, expectedRevision, data.revision); this._fullUpdate(data.revision); return; } this.log.replicants("received replicantOperations", data); const oldValue = (0, klona_json.klona)(this.value); data.operations.forEach((operation) => { this._applyOperation(operation); }); this.revision = data.revision; this.emit("change", this.value, oldValue, data.operations); } _handleDisconnect() { this.status = "undeclared"; this._operationQueue.length = 0; this._actionQueue.length = 0; } /** * Requests the latest value from the Replicator, discarding the local value. * @private */ _fullUpdate(revision) { window.NodeCG.readReplicant(this.name, this.namespace, (data) => { this.emit("fullUpdate", data); this._assignValue(data, revision); }); } }; //#endregion //#region src/client/api/api.client.ts const soundMetadata = /* @__PURE__ */ new WeakMap(); const apiContexts = /* @__PURE__ */ new Set(); /** * This is what enables intra-context messaging. * I.e., passing messages from one extension to another in the same Node.js context. */ function _forwardMessageToContext(messageName, bundleName, data) { setTimeout(() => { apiContexts.forEach((ctx) => { ctx._messageHandlers.forEach((handler) => { if (messageName === handler.messageName && bundleName === handler.bundleName) handler.func(data); }); }); }, 0); } var NodeCGAPIClient = class NodeCGAPIClient extends require_api_base.NodeCGAPIBase { static Replicant(name, namespace, opts = {}) { return new ClientReplicant(name, namespace, opts, globalThis.socket); } static sendMessageToBundle(messageName, bundleName, dataOrCb, cb) { let data = null; if (typeof dataOrCb === "function") cb = dataOrCb; else data = dataOrCb; _forwardMessageToContext(messageName, bundleName, data); if (typeof cb === "function") globalThis.socket.emit("message", { bundleName, messageName, content: data }, (err, response) => { if (response) cb(err, response); else cb(err); }); else return new Promise((resolve, reject) => { globalThis.socket.emit("message", { bundleName, messageName, content: data }, (err, response) => { if (err) reject(err); else resolve(response); }); }); } static readReplicant(name, namespace, cb) { globalThis.socket.emit("replicant:read", { name, namespace }, (error, value) => { if (error) console.error(error); else cb(value); }); } get Logger() { return Logger; } get log() { if (this._memoizedLogger) return this._memoizedLogger; this._memoizedLogger = new Logger(this.bundleName); return this._memoizedLogger; } /** * A filtered copy of the NodeCG server config with some sensitive keys removed. */ get config() { return Object.freeze(JSON.parse(JSON.stringify(filteredConfig))); } socket; soundsReady = false; _soundFiles; _bundleVolume; _masterVolume; _soundCues = []; _memoizedLogger; constructor(bundle, socket) { super(bundle); apiContexts.add(this); if (globalThis.document) document.addEventListener("DOMContentLoaded", () => { if (document.title === "") document.title = this.bundleName; }, false); this.socket = socket; this.socket.emit("joinRoom", bundle.name, () => {}); if (globalThis.window && bundle._hasSounds && window.createjs?.Sound) { const soundCuesRep = new ClientReplicant("soundCues", this.bundleName, {}, socket); this._soundFiles = new ClientReplicant("assets:sounds", this.bundleName, {}, socket); this._bundleVolume = new ClientReplicant(`volume:${this.bundleName}`, "_sounds", {}, socket); this._masterVolume = new ClientReplicant("volume:master", "_sounds", {}, socket); this._soundCues = []; const loadedSums = /* @__PURE__ */ new Set(); window.createjs.Sound.on("fileload", (e) => { if (this.soundsReady || !e.data.sum) return; loadedSums.add(e.data.sum); if (!this._soundCues.some((cue) => { if (cue.file) return !loadedSums.has(cue.file.sum); return false; }) && !this.soundsReady) { this.soundsReady = true; window.dispatchEvent(new CustomEvent("ncgSoundsReady")); } }); const handleAnyCuesRepChange = () => { this._soundCues = soundCuesRep.value ?? []; this._updateInstanceVolumes(); this._registerSounds(); }; soundCuesRep.on("change", handleAnyCuesRepChange); this._soundFiles.on("change", () => this._registerSounds.bind(this)); this._bundleVolume.on("change", () => this._updateInstanceVolumes.bind(this)); this._masterVolume.on("change", () => this._updateInstanceVolumes.bind(this)); } socket.on("message", (data) => { this.log.trace("Received message %s (sent to bundle %s) with data:", data.messageName, data.bundleName, data.content); this._messageHandlers.forEach((handler) => { if (data.messageName === handler.messageName && data.bundleName === handler.bundleName) handler.func(data.content); }); }); socket.io.on("error", (err) => { this.log.warn("Unhandled socket connection error:", err); }); socket.on("protocol_error", (err) => { if (err.type === "UnauthorizedError") if (globalThis.window) { const url = [ location.protocol, "//", location.host, location.pathname ].join(""); window.location.href = `/authError?code=${err.code}&message=${err.message}&viewUrl=${url}`; } else globalThis.close(); else this.log.error("Unhandled socket protocol error:", err); }); } /** * _Browser only, except workers_<br/> * Returns the specified dialog element. * @param {string} name - The desired dialog's name. * @param {string} [bundle=CURR_BNDL] - The bundle from which to select the dialog. * @returns {object} */ getDialog(name, bundle) { if (!globalThis.window) return; bundle = bundle ?? this.bundleName; const topDoc = window.top?.document; if (!topDoc) return; return topDoc.querySelector("ncg-dashboard")?.shadowRoot?.querySelector(`#dialogs #${bundle}_${name}`) ?? void 0; } /** * _Browser only, except workers_<br/> * Returns the specified dialog's iframe document. * @param {string} name - The desired dialog's name. * @param {string} [bundle=CURR_BNDL] - The bundle from which to select the dialog. * @returns {object} */ getDialogDocument(name, bundle) { if (!globalThis.window) return; bundle = bundle ?? this.bundleName; return this.getDialog(name, bundle)?.querySelector("iframe")?.contentWindow?.document; } /** * Returns the sound cue of the provided `cueName` in the current bundle. * Returns undefined if a cue by that name cannot be found in this bundle. */ findCue(cueName) { return this._soundCues.find((cue) => cue.name === cueName); } /** * Plays the sound cue of the provided `cueName` in the current bundle. * Does nothing if the cue doesn't exist or if the cue has no assigned file to play. * @param cueName {String} * @param [opts] {Object} * @param [opts.updateVolume=true] - Whether or not to let NodeCG automatically update this instance's volume * when the user changes it on the dashboard. * @returns {Object|undefined} - A SoundJS AbstractAudioInstance. */ playSound(cueName, { updateVolume = true } = { updateVolume: true }) { if (!globalThis.window) throw new Error("NodeCG Sound API methods are not available in workers"); if (!this._soundCues) throw new Error(`Bundle "${this.bundleName}" has no soundCues`); const cue = this.findCue(cueName); if (!cue) throw new Error(`Cue "${cueName}" does not exist in bundle "${this.bundleName}"`); if (!window.createjs?.Sound) throw new Error("NodeCG Sound API methods are not available when SoundJS isn't present"); if (!cue.file) throw new Error(`Cue $"{cueName}" does not have a file specified.`); const instance = window.createjs.Sound.play(cueName); this._setInstanceVolume(instance, cue); soundMetadata.set(instance, { cueName, updateVolume }); return instance; } /** * Stops all currently playing instances of the provided `cueName`. * @param cueName {String} */ stopSound(cueName) { if (!globalThis.window) throw new Error("NodeCG Sound API methods are not available in workers"); if (!this._soundCues) throw new Error(`Bundle "${this.bundleName}" has no soundCues`); if (!this._soundCues.find((cue) => cue.name === cueName)) throw new Error(`Cue "${cueName}" does not exist in bundle "${this.bundleName}"`); if (!window.createjs?.Sound) throw new Error("NodeCG Sound API methods are not available when SoundJS isn't present"); const instancesArr = window.createjs.Sound._instances; for (let i = instancesArr.length - 1; i >= 0; i--) { const instance = instancesArr[i]; if (soundMetadata.get(instance)?.cueName === cueName) instance.stop(); } } /** * Stops all currently playing sounds on the page. */ stopAllSounds() { if (!globalThis.window) throw new Error("NodeCG Sound API methods are not available in workers"); if (!window.createjs?.Sound) throw new Error("NodeCG Sound API methods are not available when SoundJS isn't present"); window.createjs.Sound.stop(); } sendMessageToBundle(messageName, bundleName, dataOrCb, cb) { return NodeCGAPIClient.sendMessageToBundle(messageName, bundleName, dataOrCb, cb); } sendMessage(messageName, dataOrCb, cb) { return this.sendMessageToBundle(messageName, this.bundleName, dataOrCb, cb); } readReplicant(name, nspOrCb, maybeCb) { let namespace = this.bundleName; let cb; if (typeof nspOrCb === "string") { namespace = nspOrCb; cb = maybeCb; } else cb = nspOrCb; NodeCGAPIClient.readReplicant(name, namespace, cb); } _replicantFactory = (name, namespace, opts) => new ClientReplicant(name, namespace, opts, this.socket); _registerSounds() { if (!globalThis.window) throw new Error("NodeCG Sound API methods are not available in workers"); this._soundCues.forEach((cue) => { if (!cue.file) return; window.createjs.Sound.registerSound(`${cue.file.url}?sum=${cue.file.sum}`, cue.name, { channels: typeof cue.channels === "undefined" ? 100 : cue.channels, sum: cue.file.sum }); }); } _setInstanceVolume(instance, cue) { if (this._masterVolume?.status !== "declared" || typeof this._masterVolume.value !== "number" || this._bundleVolume?.status !== "declared" || typeof this._bundleVolume.value !== "number") return; const volume = this._masterVolume.value / 100 * (this._bundleVolume.value / 100) * (cue.volume / 100); instance.volume = isFinite(volume) ? volume : 0; } _updateInstanceVolumes() { const instancesArr = window.createjs.Sound._instances; this._soundCues.forEach((cue) => { instancesArr.forEach((instance) => { const meta = soundMetadata.get(instance); if (meta && meta.cueName === cue.name && meta.updateVolume) this._setInstanceVolume(instance, cue); }); }); } }; globalThis.NodeCG = NodeCGAPIClient; //#endregion exports.NodeCGAPIClient = NodeCGAPIClient; //# sourceMappingURL=api.client.js.map