@nodecg/types
Version:
Dynamic broadcast graphics rendered in a browser
369 lines (338 loc) • 12.5 kB
text/typescript
import express from "express";
import isError from "is-error";
import { serializeError } from "serialize-error";
import type { DeepReadonly } from "ts-essentials";
import { NodeCGAPIBase } from "../shared/api.base";
import type { NodeCG } from "../types/nodecg";
import type { RootNS } from "../types/socket-protocol";
import { config } from "./config";
import { Logger } from "./logger";
import type { Replicator } from "./replicant/replicator";
import type { ServerReplicant } from "./replicant/server-replicant";
import type { ExtensionEventMap } from "./server/extensions";
import { authCheck } from "./util/authcheck";
export function serverApiFactory(
io: RootNS,
replicator: Replicator,
extensions: Record<string, unknown>,
mount: NodeCG.Middleware,
) {
const apiContexts = new Set<
NodeCGAPIBase<"server", Record<string, any>, ExtensionEventMap>
>();
/**
* 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: string,
bundleName: string,
data: unknown,
): void {
process.nextTick(() => {
apiContexts.forEach((ctx) => {
ctx._messageHandlers.forEach((handler) => {
if (
messageName === handler.messageName &&
bundleName === handler.bundleName
) {
handler.func(data);
}
});
});
});
}
return class NodeCGAPIServer<
C extends Record<string, any> = NodeCG.Bundle.UnknownConfig,
> extends NodeCGAPIBase<"server", C, ExtensionEventMap> {
static sendMessageToBundle(
messageName: string,
bundleName: string,
data?: unknown,
): void {
_forwardMessageToContext(messageName, bundleName, data);
io.emit("message", {
bundleName,
messageName,
content: data,
});
}
static readReplicant<T = unknown>(
name: string,
namespace: string,
): T | undefined {
if (!name || typeof name !== "string") {
throw new Error("Must supply a name when reading a Replicant");
}
if (!namespace || typeof namespace !== "string") {
throw new Error("Must supply a namespace when reading a Replicant");
}
const replicant = replicator.declare(name, namespace);
return replicant.value as T | undefined;
}
static Replicant<
V,
O extends NodeCG.Replicant.Options<V> = NodeCG.Replicant.Options<V>,
>(name: string, namespace: string, opts: O): ServerReplicant<V, O> {
if (!name || typeof name !== "string") {
throw new Error("Must supply a name when reading a Replicant");
}
if (!namespace || typeof namespace !== "string") {
throw new Error("Must supply a namespace when reading a Replicant");
}
return replicator.declare(name, namespace, opts);
}
readonly Logger = Logger;
readonly log = new Logger(this.bundleName);
/**
* The full NodeCG server config, including potentially sensitive keys.
*/
readonly config: DeepReadonly<typeof config> = JSON.parse(
JSON.stringify(config),
);
/**
* _Extension only_<br/>
* Creates a new express router.
* See the [express docs](http://expressjs.com/en/api.html#express.router) for usage.
* @function
*/
readonly Router = express.Router;
util = {
/**
* _Extension only_<br/>
* Checks if a session is authorized. Intended to be used in express routes.
* @param {object} req - A HTTP request.
* @param {object} res - A HTTP response.
* @param {function} next - The next middleware in the control flow.
*/
authCheck: authCheck,
};
/**
* _Extension only_<br/>
* Object containing references to all other loaded extensions. To access another bundle's extension,
* it _must_ be declared as a `bundleDependency` in your bundle's [`package.json`]{@tutorial manifest}.
* @name NodeCG#extensions
*
* @example
* // bundles/my-bundle/package.json
* {
* "name": "my-bundle"
* ...
* "bundleDependencies": {
* "other-bundle": "^1.0.0"
* }
* }
*
* // bundles/my-bundle/extension.js
* module.exports = function (nodecg) {
* const otherBundle = nodecg.extensions['other-bundle'];
* // Now I can use `otherBundle`!
* }
*/
readonly extensions = extensions;
/**
* _Extension only_<br/>
* Mounts Express middleware to the main server Express app.
* Middleware mounted using this method comes _after_ all the middlware that NodeCG
* uses internally.
* See the [Express docs](http://expressjs.com/en/api.html#app.use) for usage.
* @function
*/
mount = mount;
constructor(bundle: NodeCG.Bundle) {
super(bundle);
apiContexts.add(this);
io.on("connection", (socket) => {
socket.on("message", (data, ack) => {
const wrappedAck = _wrapAcknowledgement(ack);
this._messageHandlers.forEach((handler) => {
if (
data.messageName === handler.messageName &&
data.bundleName === handler.bundleName
) {
handler.func(data.content, wrappedAck);
}
});
});
});
}
/**
* _Extension only_<br/>
* Gets the server Socket.IO context.
* @function
*/
readonly getSocketIOServer = (): RootNS => io;
/**
* Sends a message to a specific bundle. Also available as a static method.
* See {@link NodeCG#sendMessage} for usage details.
* @param {string} messageName - The name of the message.
* @param {string} bundleName - The name of the target bundle.
* @param {mixed} [data] - The data to send.
* @param {function} [cb] - _Browser only_ The error-first callback to handle the server's
* [acknowledgement](http://socket.io/docs/#sending-and-getting-data-%28acknowledgements%29) message, if any.
* @return {Promise|undefined} - _Browser only_ A Promise that is rejected if the first argument provided to the
* acknowledgement is an `Error`, otherwise it is resolved with the remaining arguments provided to the acknowledgement.
* But, if a callback was provided, this return value will be `undefined`, and there will be no Promise.
*/
sendMessageToBundle(
messageName: string,
bundleName: string,
data?: unknown,
): void {
this.log.trace(
"Sending message %s to bundle %s with data:",
messageName,
bundleName,
data,
);
return NodeCGAPIServer.sendMessageToBundle.apply(
NodeCGAPIBase,
arguments as any, // eslint-disable-line prefer-rest-params
);
}
/**
* Sends a message with optional data within the current bundle.
* Messages can be sent from client to server, server to client, or client to client.
*
* Messages are namespaced by bundle. To send a message in another bundle's namespace,
* use {@link NodeCG#sendMessageToBundle}.
*
* When a `sendMessage` is used from a client context (i.e., graphic or dashboard panel),
* it returns a `Promise` called an "acknowledgement". Your server-side code (i.e., extension)
* can invoke this acknowledgement with whatever data (or error) it wants. Errors sent to acknowledgements
* from the server will be properly serialized and intact when received on the client.
*
* Alternatively, if you do not wish to use a `Promise`, you can provide a standard error-first
* callback as the last argument to `sendMessage`.
*
* If your server-side code has multiple listenFor handlers for your message,
* you must first check if the acknowledgement has already been handled before
* attempting to call it. You may so do by checking the `.handled` boolean
* property of the `ack` function passed to your listenFor handler.
*
* See [Socket.IO's docs](http://socket.io/docs/#sending-and-getting-data-%28acknowledgements%29)
* for more information on how acknowledgements work under the hood.
*
* @param {string} messageName - The name of the message.
* @param {mixed} [data] - The data to send.
* @param {function} [cb] - _Browser only_ The error-first callback to handle the server's
* [acknowledgement](http://socket.io/docs/#sending-and-getting-data-%28acknowledgements%29) message, if any.
* @return {Promise} - _Browser only_ A Promise that is rejected if the first argument provided to the
* acknowledgement is an `Error`, otherwise it is resolved with the remaining arguments provided to the acknowledgement.
*
* @example <caption>Sending a normal message:</caption>
* nodecg.sendMessage('printMessage', 'dope.');
*
* @example <caption>Sending a message and replying with an acknowledgement:</caption>
* // bundles/my-bundle/extension.js
* module.exports = function (nodecg) {
* nodecg.listenFor('multiplyByTwo', (value, ack) => {
* if (value === 4) {
* ack(new Error('I don\'t like multiplying the number 4!');
* return;
* }
*
* // acknowledgements should always be error-first callbacks.
* // If you do not wish to send an error, send "null"
* if (ack && !ack.handled) {
* ack(null, value * 2);
* }
* });
* }
*
* // bundles/my-bundle/graphics/script.js
* // Both of these examples are functionally identical.
*
* // Promise acknowledgement
* nodecg.sendMessage('multiplyByTwo', 2)
* .then(result => {
* console.log(result); // Will eventually print '4'
* .catch(error => {
* console.error(error);
* });
*
* // Error-first callback acknowledgement
* nodecg.sendMessage('multiplyByTwo', 2, (error, result) => {
* if (error) {
* console.error(error);
* return;
* }
*
* console.log(result); // Will eventually print '4'
* });
*/
sendMessage(messageName: string, data?: unknown): void {
this.sendMessageToBundle(messageName, this.bundleName, data);
}
/**
* Reads the value of a replicant once, and doesn't create a subscription to it. Also available as a static method.
* @param {string} name - The name of the replicant.
* @param {string} [bundle=CURR_BNDL] - The bundle namespace to in which to look for this replicant.
* @param {function} cb - _Browser only_ The callback that handles the server's response which contains the value.
* @example <caption>From an extension:</caption>
* // Extensions have immediate access to the database of Replicants.
* // For this reason, they can use readReplicant synchronously, without a callback.
* module.exports = function (nodecg) {
* var myVal = nodecg.readReplicant('myVar', 'some-bundle');
* }
* @example <caption>From a graphic or dashboard panel:</caption>
* // Graphics and dashboard panels must query the server to retrieve the value,
* // and therefore must provide a callback.
* nodecg.readReplicant('myRep', 'some-bundle', value => {
* // I can use 'value' now!
* console.log('myRep has the value '+ value +'!');
* });
*/
readReplicant<T = unknown>(
name: string,
param2?: string | NodeCG.Bundle,
): T | undefined {
let { bundleName } = this;
if (typeof param2 === "string") {
bundleName = param2;
} else if (typeof param2 === "object" && bundleName in param2) {
bundleName = param2.name;
}
return (this.constructor as any).readReplicant(name, bundleName);
}
_replicantFactory = <
V,
O extends NodeCG.Replicant.Options<V> = NodeCG.Replicant.Options<V>,
>(
name: string,
namespace: string,
opts: O,
): ServerReplicant<V, O> => replicator.declare<V, O>(name, namespace, opts);
};
}
/**
* By default, Errors get serialized to empty objects when run through JSON.stringify.
* This function wraps an "acknowledgement" callback and checks if the first argument
* is an Error. If it is, that Error is serialized _before_ being sent off to Socket.IO
* for serialization to be sent across the wire.
* @param ack {Function}
* @private
* @ignore
* @returns {Function}
*/
function _wrapAcknowledgement(
ack: (err?: any, response?: unknown) => void,
): NodeCG.Acknowledgement {
let handled = false;
const wrappedAck = function (firstArg: any, ...restArgs: any[]): void {
if (handled) {
throw new Error("Acknowledgement already handled");
}
handled = true;
if (isError(firstArg)) {
firstArg = serializeError(firstArg);
}
ack(firstArg, ...restArgs);
};
Object.defineProperty(wrappedAck, "handled", {
get() {
return handled;
},
});
return wrappedAck as NodeCG.Acknowledgement;
}