@nodecg/types
Version:
Dynamic broadcast graphics rendered in a browser
582 lines (576 loc) • 20.9 kB
JavaScript
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