@elgato/streamdeck
Version:
The official Node.js SDK for creating Stream Deck plugins.
1,438 lines (1,410 loc) • 111 kB
JavaScript
/**!
* @author Elgato
* @module elgato/streamdeck
* @license MIT
* @copyright Copyright (c) Corsair Memory Inc.
*/
export { BarSubType, DeviceType } from '@elgato/schemas/streamdeck/plugins';
import WebSocket from 'ws';
import path, { join } from 'node:path';
import { cwd } from 'node:process';
import fs, { existsSync, readFileSync } from 'node:fs';
/**
* Languages supported by Stream Deck.
*/
const supportedLanguages = ["de", "en", "es", "fr", "ja", "ko", "zh_CN", "zh_TW"];
/**
* Defines the type of argument supplied by Stream Deck.
*/
var RegistrationParameter;
(function (RegistrationParameter) {
/**
* Identifies the argument that specifies the web socket port that Stream Deck is listening on.
*/
RegistrationParameter["Port"] = "-port";
/**
* Identifies the argument that supplies information about the Stream Deck and the plugin.
*/
RegistrationParameter["Info"] = "-info";
/**
* Identifies the argument that specifies the unique identifier that can be used when registering the plugin.
*/
RegistrationParameter["PluginUUID"] = "-pluginUUID";
/**
* Identifies the argument that specifies the event to be sent to Stream Deck as part of the registration procedure.
*/
RegistrationParameter["RegisterEvent"] = "-registerEvent";
})(RegistrationParameter || (RegistrationParameter = {}));
/**
* Defines the target of a request, i.e. whether the request should update the Stream Deck hardware, Stream Deck software (application), or both, when calling `setImage` and `setState`.
*/
var Target;
(function (Target) {
/**
* Hardware and software should be updated as part of the request.
*/
Target[Target["HardwareAndSoftware"] = 0] = "HardwareAndSoftware";
/**
* Hardware only should be updated as part of the request.
*/
Target[Target["Hardware"] = 1] = "Hardware";
/**
* Software only should be updated as part of the request.
*/
Target[Target["Software"] = 2] = "Software";
})(Target || (Target = {}));
/**
* Prevents the modification of existing property attributes and values on the value, and all of its child properties, and prevents the addition of new properties.
* @param value Value to freeze.
*/
function freeze(value) {
if (value !== undefined && value !== null && typeof value === "object" && !Object.isFrozen(value)) {
Object.freeze(value);
Object.values(value).forEach(freeze);
}
}
/**
* Gets the value at the specified {@link path}.
* @param path Path to the property to get.
* @param source Source object that is being read from.
* @returns Value of the property.
*/
function get(path, source) {
const props = path.split(".");
return props.reduce((obj, prop) => obj && obj[prop], source);
}
/**
* Internalization provider, responsible for managing localizations and translating resources.
*/
class I18nProvider {
language;
readTranslations;
/**
* Default language to be used when a resource does not exist for the desired language.
*/
static DEFAULT_LANGUAGE = "en";
/**
* Map of localized resources, indexed by their language.
*/
_translations = new Map();
/**
* Initializes a new instance of the {@link I18nProvider} class.
* @param language The default language to be used when retrieving translations for a given key.
* @param readTranslations Function responsible for loading translations.
*/
constructor(language, readTranslations) {
this.language = language;
this.readTranslations = readTranslations;
}
/**
* Translates the specified {@link key}, as defined within the resources for the {@link language}. When the key is not found, the default language is checked.
*
* Alias of `I18nProvider.translate(string, Language)`
* @param key Key of the translation.
* @param language Optional language to get the translation for; otherwise the default language.
* @returns The translation; otherwise the key.
*/
t(key, language = this.language) {
return this.translate(key, language);
}
/**
* Translates the specified {@link key}, as defined within the resources for the {@link language}. When the key is not found, the default language is checked.
* @param key Key of the translation.
* @param language Optional language to get the translation for; otherwise the default language.
* @returns The translation; otherwise the key.
*/
translate(key, language = this.language) {
// When the language and default are the same, only check the language.
if (language === I18nProvider.DEFAULT_LANGUAGE) {
return get(key, this.getTranslations(language))?.toString() || key;
}
// Otherwise check the language and default.
return (get(key, this.getTranslations(language))?.toString() ||
get(key, this.getTranslations(I18nProvider.DEFAULT_LANGUAGE))?.toString() ||
key);
}
/**
* Gets the translations for the specified language.
* @param language Language whose translations are being retrieved.
* @returns The translations, otherwise `null`.
*/
getTranslations(language) {
let translations = this._translations.get(language);
if (translations === undefined) {
translations = supportedLanguages.includes(language) ? this.readTranslations(language) : null;
freeze(translations);
this._translations.set(language, translations);
}
return translations;
}
}
/**
* Parses the localizations from the specified contents, or throws a `TypeError` when unsuccessful.
* @param contents Contents that represent the stringified JSON containing the localizations.
* @returns The localizations; otherwise a `TypeError`.
*/
function parseLocalizations(contents) {
const json = JSON.parse(contents);
if (json !== undefined && json !== null && typeof json === "object" && "Localization" in json) {
return json["Localization"];
}
throw new TypeError(`Translations must be a JSON object nested under a property named "Localization"`);
}
/**
* Levels of logging.
*/
var LogLevel;
(function (LogLevel) {
/**
* Error message used to indicate an error was thrown, or something critically went wrong.
*/
LogLevel[LogLevel["ERROR"] = 0] = "ERROR";
/**
* Warning message used to indicate something went wrong, but the application is able to recover.
*/
LogLevel[LogLevel["WARN"] = 1] = "WARN";
/**
* Information message for general usage.
*/
LogLevel[LogLevel["INFO"] = 2] = "INFO";
/**
* Debug message used to detail information useful for profiling the applications runtime.
*/
LogLevel[LogLevel["DEBUG"] = 3] = "DEBUG";
/**
* Trace message used to monitor low-level information such as method calls, performance tracking, etc.
*/
LogLevel[LogLevel["TRACE"] = 4] = "TRACE";
})(LogLevel || (LogLevel = {}));
/**
* Provides a {@link LogTarget} that logs to the console.
*/
class ConsoleTarget {
/**
* @inheritdoc
*/
write(entry) {
switch (entry.level) {
case LogLevel.ERROR:
console.error(...entry.data);
break;
case LogLevel.WARN:
console.warn(...entry.data);
break;
default:
console.log(...entry.data);
}
}
}
// Remove any dependencies on node.
const EOL = "\n";
/**
* Creates a new string log entry formatter.
* @param opts Options that defines the type for the formatter.
* @returns The string {@link LogEntryFormatter}.
*/
function stringFormatter(opts) {
{
return (entry) => {
const { data, level, scope } = entry;
let prefix = `${new Date().toISOString()} ${LogLevel[level].padEnd(5)} `;
if (scope) {
prefix += `${scope}: `;
}
return `${prefix}${reduce(data)}`;
};
}
}
/**
* Stringifies the provided data parameters that make up the log entry.
* @param data Data parameters.
* @returns The data represented as a single `string`.
*/
function reduce(data) {
let result = "";
let previousWasError = false;
for (const value of data) {
// When the value is an error, write the stack.
if (typeof value === "object" && value instanceof Error) {
result += `${EOL}${value.stack}`;
previousWasError = true;
continue;
}
// When the previous was an error, write a new line.
if (previousWasError) {
result += EOL;
previousWasError = false;
}
result += typeof value === "object" ? JSON.stringify(value) : value;
result += " ";
}
return result.trimEnd();
}
/**
* Logger capable of forwarding messages to a {@link LogTarget}.
*/
class Logger {
/**
* Backing field for the {@link Logger.level}.
*/
_level;
/**
* Options that define the loggers behavior.
*/
options;
/**
* Scope associated with this {@link Logger}.
*/
scope;
/**
* Initializes a new instance of the {@link Logger} class.
* @param opts Options that define the loggers behavior.
*/
constructor(opts) {
this.options = { minimumLevel: LogLevel.TRACE, ...opts };
this.scope = this.options.scope === undefined || this.options.scope.trim() === "" ? "" : this.options.scope;
if (typeof this.options.level !== "function") {
this.setLevel(this.options.level);
}
}
/**
* Gets the {@link LogLevel}.
* @returns The {@link LogLevel}.
*/
get level() {
if (this._level !== undefined) {
return this._level;
}
return typeof this.options.level === "function" ? this.options.level() : this.options.level;
}
/**
* Creates a scoped logger with the given {@link scope}; logs created by scoped-loggers include their scope to enable their source to be easily identified.
* @param scope Value that represents the scope of the new logger.
* @returns The scoped logger, or this instance when {@link scope} is not defined.
*/
createScope(scope) {
scope = scope.trim();
if (scope === "") {
return this;
}
return new Logger({
...this.options,
level: () => this.level,
scope: this.options.scope ? `${this.options.scope}->${scope}` : scope,
});
}
/**
* Writes the arguments as a debug log entry.
* @param data Message or data to log.
* @returns This instance for chaining.
*/
debug(...data) {
return this.write({ level: LogLevel.DEBUG, data, scope: this.scope });
}
/**
* Writes the arguments as error log entry.
* @param data Message or data to log.
* @returns This instance for chaining.
*/
error(...data) {
return this.write({ level: LogLevel.ERROR, data, scope: this.scope });
}
/**
* Writes the arguments as an info log entry.
* @param data Message or data to log.
* @returns This instance for chaining.
*/
info(...data) {
return this.write({ level: LogLevel.INFO, data, scope: this.scope });
}
/**
* Sets the log-level that determines which logs should be written. The specified level will be inherited by all scoped loggers unless they have log-level explicitly defined.
* @param level The log-level that determines which logs should be written; when `undefined`, the level will be inherited from the parent logger, or default to the environment level.
* @returns This instance for chaining.
*/
setLevel(level) {
if (level !== undefined && level > this.options.minimumLevel) {
this._level = LogLevel.INFO;
this.warn(`Log level cannot be set to ${LogLevel[level]} whilst not in debug mode.`);
}
else {
this._level = level;
}
return this;
}
/**
* Writes the arguments as a trace log entry.
* @param data Message or data to log.
* @returns This instance for chaining.
*/
trace(...data) {
return this.write({ level: LogLevel.TRACE, data, scope: this.scope });
}
/**
* Writes the arguments as a warning log entry.
* @param data Message or data to log.
* @returns This instance for chaining.
*/
warn(...data) {
return this.write({ level: LogLevel.WARN, data, scope: this.scope });
}
/**
* Writes the log entry.
* @param entry Log entry to write.
* @returns This instance for chaining.
*/
write(entry) {
if (entry.level <= this.level) {
this.options.targets.forEach((t) => t.write(entry));
}
return this;
}
}
// Polyfill, explicit resource management https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Symbol.dispose ??= Symbol("Symbol.dispose");
/**
* Creates a {@link IDisposable} that defers the disposing to the {@link dispose} function; disposing is guarded so that it may only occur once.
* @param dispose Function responsible for disposing.
* @returns Disposable whereby the disposing is delegated to the {@link dispose} function.
*/
function deferredDisposable(dispose) {
let isDisposed = false;
const guardedDispose = () => {
if (!isDisposed) {
dispose();
isDisposed = true;
}
};
return {
[Symbol.dispose]: guardedDispose,
dispose: guardedDispose,
};
}
/**
* An event emitter that enables the listening for, and emitting of, events.
*/
class EventEmitter {
/**
* Underlying collection of events and their listeners.
*/
events = new Map();
/**
* Adds the event {@link listener} for the event named {@link eventName}.
* @param eventName Name of the event.
* @param listener Event handler function.
* @returns This instance with the {@link listener} added.
*/
addListener(eventName, listener) {
return this.on(eventName, listener);
}
/**
* Adds the event {@link listener} for the event named {@link eventName}, and returns a disposable capable of removing the event listener.
* @param eventName Name of the event.
* @param listener Event handler function.
* @returns A disposable that removes the listener when disposed.
*/
disposableOn(eventName, listener) {
this.addListener(eventName, listener);
return deferredDisposable(() => this.removeListener(eventName, listener));
}
/**
* Emits the {@link eventName}, invoking all event listeners with the specified {@link args}.
* @param eventName Name of the event.
* @param args Arguments supplied to each event listener.
* @returns `true` when there was a listener associated with the event; otherwise `false`.
*/
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (listeners === undefined) {
return false;
}
for (let i = 0; i < listeners.length;) {
const { listener, once } = listeners[i];
if (once) {
listeners.splice(i, 1);
}
else {
i++;
}
listener(...args);
}
return true;
}
/**
* Gets the event names with event listeners.
* @returns Event names.
*/
eventNames() {
return Array.from(this.events.keys());
}
/**
* Gets the number of event listeners for the event named {@link eventName}. When a {@link listener} is defined, only matching event listeners are counted.
* @param eventName Name of the event.
* @param listener Optional event listener to count.
* @returns Number of event listeners.
*/
listenerCount(eventName, listener) {
const listeners = this.events.get(eventName);
if (listeners === undefined || listener == undefined) {
return listeners?.length || 0;
}
let count = 0;
listeners.forEach((ev) => {
if (ev.listener === listener) {
count++;
}
});
return count;
}
/**
* Gets the event listeners for the event named {@link eventName}.
* @param eventName Name of the event.
* @returns The event listeners.
*/
listeners(eventName) {
return Array.from(this.events.get(eventName) || []).map(({ listener }) => listener);
}
/**
* Removes the event {@link listener} for the event named {@link eventName}.
* @param eventName Name of the event.
* @param listener Event handler function.
* @returns This instance with the event {@link listener} removed.
*/
off(eventName, listener) {
const listeners = this.events.get(eventName) || [];
for (let i = listeners.length - 1; i >= 0; i--) {
if (listeners[i].listener === listener) {
listeners.splice(i, 1);
}
}
return this;
}
/**
* Adds the event {@link listener} for the event named {@link eventName}.
* @param eventName Name of the event.
* @param listener Event handler function.
* @returns This instance with the event {@link listener} added.
*/
on(eventName, listener) {
return this.add(eventName, (listeners) => listeners.push({ listener }));
}
/**
* Adds the **one-time** event {@link listener} for the event named {@link eventName}.
* @param eventName Name of the event.
* @param listener Event handler function.
* @returns This instance with the event {@link listener} added.
*/
once(eventName, listener) {
return this.add(eventName, (listeners) => listeners.push({ listener, once: true }));
}
/**
* Adds the event {@link listener} to the beginning of the listeners for the event named {@link eventName}.
* @param eventName Name of the event.
* @param listener Event handler function.
* @returns This instance with the event {@link listener} prepended.
*/
prependListener(eventName, listener) {
return this.add(eventName, (listeners) => listeners.splice(0, 0, { listener }));
}
/**
* Adds the **one-time** event {@link listener} to the beginning of the listeners for the event named {@link eventName}.
* @param eventName Name of the event.
* @param listener Event handler function.
* @returns This instance with the event {@link listener} prepended.
*/
prependOnceListener(eventName, listener) {
return this.add(eventName, (listeners) => listeners.splice(0, 0, { listener, once: true }));
}
/**
* Removes all event listeners for the event named {@link eventName}.
* @param eventName Name of the event.
* @returns This instance with the event listeners removed
*/
removeAllListeners(eventName) {
this.events.delete(eventName);
return this;
}
/**
* Removes the event {@link listener} for the event named {@link eventName}.
* @param eventName Name of the event.
* @param listener Event handler function.
* @returns This instance with the event {@link listener} removed.
*/
removeListener(eventName, listener) {
return this.off(eventName, listener);
}
/**
* Adds the event {@link listener} for the event named {@link eventName}.
* @param eventName Name of the event.
* @param fn Function responsible for adding the new event handler function.
* @returns This instance with event {@link listener} added.
*/
add(eventName, fn) {
let listeners = this.events.get(eventName);
if (listeners === undefined) {
listeners = [];
this.events.set(eventName, listeners);
}
fn(listeners);
return this;
}
}
/**
* Determines whether the specified {@link value} is a {@link RawMessageResponse}.
* @param value Value.
* @returns `true` when the value of a {@link RawMessageResponse}; otherwise `false`.
*/
function isRequest(value) {
return isMessage(value, "request") && has(value, "unidirectional", "boolean");
}
/**
* Determines whether the specified {@link value} is a {@link RawMessageResponse}.
* @param value Value.
* @returns `true` when the value of a {@link RawMessageResponse; otherwise `false`.
*/
function isResponse(value) {
return isMessage(value, "response") && has(value, "status", "number");
}
/**
* Determines whether the specified {@link value} is a message of type {@link type}.
* @param value Value.
* @param type Message type.
* @returns `true` when the value of a {@link Message} of type {@link type}; otherwise `false`.
*/
function isMessage(value, type) {
// The value should be an object.
if (value === undefined || value === null || typeof value !== "object") {
return false;
}
// The value should have a __type property of "response".
if (!("__type" in value) || value.__type !== type) {
return false;
}
// The value should should have at least an id, status, and path1.
return has(value, "id", "string") && has(value, "path", "string");
}
/**
* Determines whether the specified {@link key} exists in {@link obj}, and is typeof {@link type}.
* @param obj Object to check.
* @param key key to check for.
* @param type Expected type.
* @returns `true` when the {@link key} exists in the {@link obj}, and is typeof {@link type}.
*/
function has(obj, key, type) {
return key in obj && typeof obj[key] === type;
}
/**
* Message responder responsible for responding to a request.
*/
class MessageResponder {
request;
proxy;
/**
* Indicates whether a response has already been sent in relation to the response.
*/
_responded = false;
/**
* Initializes a new instance of the {@link MessageResponder} class.
* @param request The request the response is associated with.
* @param proxy Proxy responsible for forwarding the response to the client.
*/
constructor(request, proxy) {
this.request = request;
this.proxy = proxy;
}
/**
* Indicates whether a response can be sent.
* @returns `true` when a response has not yet been set.
*/
get canRespond() {
return !this._responded;
}
/**
* Sends a failure response with a status code of `500`.
* @param body Optional response body.
* @returns Promise fulfilled once the response has been sent.
*/
fail(body) {
return this.send(500, body);
}
/**
* Sends the {@link body} as a response with the {@link status}
* @param status Response status.
* @param body Optional response body.
* @returns Promise fulfilled once the response has been sent.
*/
async send(status, body) {
if (this.canRespond) {
await this.proxy({
__type: "response",
id: this.request.id,
path: this.request.path,
body,
status,
});
this._responded = true;
}
}
/**
* Sends a success response with a status code of `200`.
* @param body Optional response body.
* @returns Promise fulfilled once the response has been sent.
*/
success(body) {
return this.send(200, body);
}
}
/**
* Default request timeout.
*/
const DEFAULT_TIMEOUT = 5000;
const PUBLIC_PATH_PREFIX = "public:";
const INTERNAL_PATH_PREFIX = "internal:";
/**
* Message gateway responsible for sending, routing, and receiving requests and responses.
*/
class MessageGateway extends EventEmitter {
proxy;
actionProvider;
/**
* Requests with pending responses.
*/
requests = new Map();
/**
* Registered routes, and their respective handlers.
*/
routes = new EventEmitter();
/**
* Initializes a new instance of the {@link MessageGateway} class.
* @param proxy Proxy capable of sending messages to the plugin / property inspector.
* @param actionProvider Action provider responsible for retrieving actions associated with source messages.
*/
constructor(proxy, actionProvider) {
super();
this.proxy = proxy;
this.actionProvider = actionProvider;
}
/**
* Sends the {@link requestOrPath} to the server; the server should be listening on {@link MessageGateway.route}.
* @param requestOrPath The request, or the path of the request.
* @param bodyOrUndefined Request body, or moot when constructing the request with {@link MessageRequestOptions}.
* @returns The response.
*/
async fetch(requestOrPath, bodyOrUndefined) {
const id = crypto.randomUUID();
const { body, path, timeout = DEFAULT_TIMEOUT, unidirectional = false, } = typeof requestOrPath === "string" ? { body: bodyOrUndefined, path: requestOrPath } : requestOrPath;
// Initialize the response handler.
const response = new Promise((resolve) => {
this.requests.set(id, (res) => {
if (res.status !== 408) {
clearTimeout(timeoutMonitor);
}
resolve(res);
});
});
// Start the timeout, and send the request.
const timeoutMonitor = setTimeout(() => this.handleResponse({ __type: "response", id, path, status: 408 }), timeout);
const accepted = await this.proxy({
__type: "request",
body,
id,
path,
unidirectional,
});
// When the server did not accept the request, return a 406.
if (!accepted) {
this.handleResponse({ __type: "response", id, path, status: 406 });
}
return response;
}
/**
* Attempts to process the specified {@link message}.
* @param message Message to process.
* @returns `true` when the {@link message} was processed by this instance; otherwise `false`.
*/
async process(message) {
if (isRequest(message.payload)) {
// Server-side handling.
const action = this.actionProvider(message);
if (await this.handleRequest(action, message.payload)) {
return;
}
this.emit("unhandledRequest", message);
}
else if (isResponse(message.payload) && this.handleResponse(message.payload)) {
// Response handled successfully.
return;
}
this.emit("unhandledMessage", message);
}
/**
* Maps the specified {@link path} to the {@link handler}, allowing for requests from the client.
* @param path Path used to identify the route.
* @param handler Handler to be invoked when the request is received.
* @param options Optional routing configuration.
* @returns Disposable capable of removing the route handler.
*/
route(path, handler, options) {
options = { filter: () => true, ...options };
return this.routes.disposableOn(path, async (ev) => {
if (options?.filter && options.filter(ev.request.action)) {
await ev.routed();
try {
// Invoke the handler; when data was returned, propagate it as part of the response (if there wasn't already a response).
const result = await handler(ev.request, ev.responder);
if (result !== undefined) {
await ev.responder.send(200, result);
}
}
catch (err) {
// Respond with an error before throwing.
await ev.responder.send(500);
throw err;
}
}
});
}
/**
* Handles inbound requests.
* @param action Action associated with the request.
* @param source The request.
* @returns `true` when the request was handled; otherwise `false`.
*/
async handleRequest(action, source) {
const responder = new MessageResponder(source, this.proxy);
const request = {
action,
path: source.path,
unidirectional: source.unidirectional,
body: source.body,
};
// Get handlers of the path, and invoke them; filtering is applied by the handlers themselves
let routed = false;
const routes = this.routes.listeners(source.path);
for (const route of routes) {
await route({
request,
responder,
routed: async () => {
// Flags the path as handled, sending an immediate 202 if the request was unidirectional.
if (request.unidirectional) {
await responder.send(202);
}
routed = true;
},
});
}
// The request was successfully routed, so fallback to a 200.
if (routed) {
await responder.send(200);
return true;
}
// When there were no applicable routes, return not-handled.
await responder.send(501);
return false;
}
/**
* Handles inbound response.
* @param res The response.
* @returns `true` when the response was handled; otherwise `false`.
*/
handleResponse(res) {
const handler = this.requests.get(res.id);
this.requests.delete(res.id);
// Determine if there is a request pending a response.
if (handler) {
handler(new MessageResponse(res));
return true;
}
return false;
}
}
/**
* Message response, received from the server.
*/
class MessageResponse {
/**
* Body of the response.
*/
body;
/**
* Status of the response.
* - `200` the request was successful.
* - `202` the request was unidirectional, and does not have a response.
* - `406` the request could not be accepted by the server.
* - `408` the request timed-out.
* - `500` the request failed.
* - `501` the request is not implemented by the server, and could not be fulfilled.
*/
status;
/**
* Initializes a new instance of the {@link MessageResponse} class.
* @param res The status code, or the response.
*/
constructor(res) {
this.body = res.body;
this.status = res.status;
}
/**
* Indicates whether the request was successful.
* @returns `true` when the status indicates a success; otherwise `false`.
*/
get ok() {
return this.status >= 200 && this.status < 300;
}
}
const LOGGER_WRITE_PATH = `${INTERNAL_PATH_PREFIX}logger.write`;
/**
* Registers a route handler on the router, propagating any log entries to the specified logger for writing.
* @param router Router to receive inbound log entries on.
* @param logger Logger responsible for logging log entries.
*/
function registerCreateLogEntryRoute(router, logger) {
router.route(LOGGER_WRITE_PATH, (req, res) => {
if (req.body === undefined) {
return res.fail();
}
const { level, message, scope } = req.body;
if (level === undefined) {
return res.fail();
}
logger.write({ level, data: [message], scope });
return res.success();
});
}
/**
* Provides information for events received from Stream Deck.
*/
class Event {
/**
* Event that occurred.
*/
type;
/**
* Initializes a new instance of the {@link Event} class.
* @param source Source of the event, i.e. the original message from Stream Deck.
*/
constructor(source) {
this.type = source.event;
}
}
/**
* Provides information for an event relating to an action.
*/
class ActionWithoutPayloadEvent extends Event {
action;
/**
* Initializes a new instance of the {@link ActionWithoutPayloadEvent} class.
* @param action Action that raised the event.
* @param source Source of the event, i.e. the original message from Stream Deck.
*/
constructor(action, source) {
super(source);
this.action = action;
}
}
/**
* Provides information for an event relating to an action.
*/
class ActionEvent extends ActionWithoutPayloadEvent {
/**
* Provides additional information about the event that occurred, e.g. how many `ticks` the dial was rotated, the current `state` of the action, etc.
*/
payload;
/**
* Initializes a new instance of the {@link ActionEvent} class.
* @param action Action that raised the event.
* @param source Source of the event, i.e. the original message from Stream Deck.
*/
constructor(action, source) {
super(action, source);
this.payload = source.payload;
}
}
/**
* Provides event information for when the plugin received the global settings.
*/
class DidReceiveGlobalSettingsEvent extends Event {
/**
* Settings associated with the event.
*/
settings;
/**
* Initializes a new instance of the {@link DidReceiveGlobalSettingsEvent} class.
* @param source Source of the event, i.e. the original message from Stream Deck.
*/
constructor(source) {
super(source);
this.settings = source.payload.settings;
}
}
/**
* Provides a wrapper around a value that is lazily instantiated.
*/
class Lazy {
/**
* Private backing field for {@link Lazy.value}.
*/
#value = undefined;
/**
* Factory responsible for instantiating the value.
*/
#valueFactory;
/**
* Initializes a new instance of the {@link Lazy} class.
* @param valueFactory The factory responsible for instantiating the value.
*/
constructor(valueFactory) {
this.#valueFactory = valueFactory;
}
/**
* Gets the value.
* @returns The value.
*/
get value() {
if (this.#value === undefined) {
this.#value = this.#valueFactory();
}
return this.#value;
}
}
/**
* Wraps an underlying Promise{T}, exposing the resolve and reject delegates as methods, allowing for it to be awaited, resolved, or rejected externally.
*/
class PromiseCompletionSource {
/**
* The underlying promise that this instance is managing.
*/
_promise;
/**
* Delegate used to reject the promise.
*/
_reject;
/**
* Delegate used to resolve the promise.
*/
_resolve;
/**
* Wraps an underlying Promise{T}, exposing the resolve and reject delegates as methods, allowing for it to be awaited, resolved, or rejected externally.
*/
constructor() {
this._promise = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
}
/**
* Gets the underlying promise being managed by this instance.
* @returns The promise.
*/
get promise() {
return this._promise;
}
/**
* Rejects the promise, causing any awaited calls to throw.
* @param reason The reason for rejecting the promise.
*/
setException(reason) {
if (this._reject) {
this._reject(reason);
}
}
/**
* Sets the result of the underlying promise, allowing any awaited calls to continue invocation.
* @param value The value to resolve the promise with.
*/
setResult(value) {
if (this._resolve) {
this._resolve(value);
}
}
}
/**
* Provides information for a version, as parsed from a string denoted as a collection of numbers separated by a period, for example `1.45.2`, `4.0.2.13098`. Parsing is opinionated
* and strings should strictly conform to the format `{major}[.{minor}[.{patch}[.{build}]]]`; version numbers that form the version are optional, and when `undefined` will default to
* 0, for example the `minor`, `patch`, or `build` number may be omitted.
*
* NB: This implementation should be considered fit-for-purpose, and should be used sparing.
*/
class Version {
/**
* Build version number.
*/
build;
/**
* Major version number.
*/
major;
/**
* Minor version number.
*/
minor;
/**
* Patch version number.
*/
patch;
/**
* Initializes a new instance of the {@link Version} class.
* @param value Value to parse the version from.
*/
constructor(value) {
const result = value.match(/^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?$/);
if (result === null) {
throw new Error(`Invalid format; expected "{major}[.{minor}[.{patch}[.{build}]]]" but was "${value}"`);
}
[, this.major, this.minor, this.patch, this.build] = [...result.map((value) => parseInt(value) || 0)];
}
/**
* Compares this instance to the {@link other} {@link Version}.
* @param other The {@link Version} to compare to.
* @returns `-1` when this instance is less than the {@link other}, `1` when this instance is greater than {@link other}, otherwise `0`.
*/
compareTo(other) {
const segments = ({ major, minor, build, patch }) => [major, minor, build, patch];
const thisSegments = segments(this);
const otherSegments = segments(other);
for (let i = 0; i < 4; i++) {
if (thisSegments[i] < otherSegments[i]) {
return -1;
}
else if (thisSegments[i] > otherSegments[i]) {
return 1;
}
}
return 0;
}
/** @inheritdoc */
toString() {
return `${this.major}.${this.minor}`;
}
}
let __isDebugMode = undefined;
/**
* Determines whether the current plugin is running in a debug environment; this is determined by the command-line arguments supplied to the plugin by Stream. Specifically, the result
* is `true` when either `--inspect`, `--inspect-brk` or `--inspect-port` are present as part of the processes' arguments.
* @returns `true` when the plugin is running in debug mode; otherwise `false`.
*/
function isDebugMode() {
if (__isDebugMode === undefined) {
__isDebugMode = process.execArgv.some((arg) => {
const name = arg.split("=")[0];
return name === "--inspect" || name === "--inspect-brk" || name === "--inspect-port";
});
}
return __isDebugMode;
}
/**
* Gets the plugin's unique-identifier from the current working directory.
* @returns The plugin's unique-identifier.
*/
function getPluginUUID() {
const name = path.basename(process.cwd());
const suffixIndex = name.lastIndexOf(".sdPlugin");
return suffixIndex < 0 ? name : name.substring(0, suffixIndex);
}
/**
* Provides a {@link LogTarget} capable of logging to a local file system.
*/
class FileTarget {
options;
/**
* File path where logs will be written.
*/
filePath;
/**
* Current size of the logs that have been written to the {@link FileTarget.filePath}.
*/
size = 0;
/**
* Initializes a new instance of the {@link FileTarget} class.
* @param options Options that defines how logs should be written to the local file system.
*/
constructor(options) {
this.options = options;
this.filePath = this.getLogFilePath();
this.reIndex();
}
/**
* @inheritdoc
*/
write(entry) {
const fd = fs.openSync(this.filePath, "a");
try {
const msg = this.options.format(entry);
fs.writeSync(fd, msg + "\n");
this.size += msg.length;
}
finally {
fs.closeSync(fd);
}
if (this.size >= this.options.maxSize) {
this.reIndex();
this.size = 0;
}
}
/**
* Gets the file path to an indexed log file.
* @param index Optional index of the log file to be included as part of the file name.
* @returns File path that represents the indexed log file.
*/
getLogFilePath(index = 0) {
return path.join(this.options.dest, `${this.options.fileName}.${index}.log`);
}
/**
* Gets the log files associated with this file target, including past and present.
* @returns Log file entries.
*/
getLogFiles() {
const regex = /^\.(\d+)\.log$/;
return fs
.readdirSync(this.options.dest, { withFileTypes: true })
.reduce((prev, entry) => {
if (entry.isDirectory() || entry.name.indexOf(this.options.fileName) < 0) {
return prev;
}
const match = entry.name.substring(this.options.fileName.length).match(regex);
if (match?.length !== 2) {
return prev;
}
prev.push({
path: path.join(this.options.dest, entry.name),
index: parseInt(match[1]),
});
return prev;
}, [])
.sort(({ index: a }, { index: b }) => {
return a < b ? -1 : a > b ? 1 : 0;
});
}
/**
* Re-indexes the existing log files associated with this file target, removing old log files whose index exceeds the {@link FileTargetOptions.maxFileCount}, and renaming the
* remaining log files, leaving index "0" free for a new log file.
*/
reIndex() {
// When the destination directory is new, create it, and return.
if (!fs.existsSync(this.options.dest)) {
fs.mkdirSync(this.options.dest);
return;
}
const logFiles = this.getLogFiles();
for (let i = logFiles.length - 1; i >= 0; i--) {
const log = logFiles[i];
if (i >= this.options.maxFileCount - 1) {
fs.rmSync(log.path);
}
else {
fs.renameSync(log.path, this.getLogFilePath(i + 1));
}
}
}
}
// Log all entires to a log file.
const fileTarget = new FileTarget({
dest: path.join(cwd(), "logs"),
fileName: getPluginUUID(),
format: stringFormatter(),
maxFileCount: 10,
maxSize: 50 * 1024 * 1024,
});
// Construct the log targets.
const targets = [fileTarget];
if (isDebugMode()) {
targets.splice(0, 0, new ConsoleTarget());
}
/**
* Logger responsible for capturing log messages.
*/
const logger = new Logger({
level: isDebugMode() ? LogLevel.DEBUG : LogLevel.INFO,
minimumLevel: isDebugMode() ? LogLevel.TRACE : LogLevel.DEBUG,
targets,
});
process.once("uncaughtException", (err) => logger.error("Process encountered uncaught exception", err));
/**
* Provides a connection between the plugin and the Stream Deck allowing for messages to be sent and received.
*/
class Connection extends EventEmitter {
/**
* Private backing field for {@link Connection.registrationParameters}.
*/
_registrationParameters;
/**
* Private backing field for {@link Connection.version}.
*/
_version;
/**
* Used to ensure {@link Connection.connect} is invoked as a singleton; `false` when a connection is occurring or established.
*/
canConnect = true;
/**
* Underlying web socket connection.
*/
connection = new PromiseCompletionSource();
/**
* Logger scoped to the connection.
*/
logger = logger.createScope("Connection");
/**
* Underlying connection information provided to the plugin to establish a connection with Stream Deck.
* @returns The registration parameters.
*/
get registrationParameters() {
return (this._registrationParameters ??= this.getRegistrationParameters());
}
/**
* Version of Stream Deck this instance is connected to.
* @returns The version.
*/
get version() {
return (this._version ??= new Version(this.registrationParameters.info.application.version));
}
/**
* Establishes a connection with the Stream Deck, allowing for the plugin to send and receive messages.
* @returns A promise that is resolved when a connection has been established.
*/
async connect() {
// Ensure we only establish a single connection.
if (this.canConnect) {
this.canConnect = false;
const webSocket = new WebSocket(`ws://127.0.0.1:${this.registrationParameters.port}`);
webSocket.onmessage = (ev) => this.tryEmit(ev);
webSocket.onopen = () => {
webSocket.send(JSON.stringify({
event: this.registrationParameters.registerEvent,
uuid: this.registrationParameters.pluginUUID,
}));
// Web socket established a connection with the Stream Deck and the plugin was registered.
this.connection.setResult(webSocket);
this.emit("connected", this.registrationParameters.info);
};
}
await this.connection.promise;
}
/**
* Sends the commands to the Stream Deck, once the connection has been established and registered.
* @param command Command being sent.
* @returns `Promise` resolved when the command is sent to Stream Deck.
*/
async send(command) {
const connection = await this.connection.promise;
const message = JSON.stringify(command);
this.logger.trace(message);
connection.send(message);
}
/**
* Gets the registration parameters, provided by Stream Deck, that provide information to the plugin, including how to establish a connection.
* @returns Parsed registration parameters.
*/
getRegistrationParameters() {
const params = {
port: undefined,
info: undefined,
pluginUUID: undefined,
registerEvent: undefined,
};
const scopedLogger = logger.createScope("RegistrationParameters");
for (let i = 0; i < process.argv.length - 1; i++) {
const param = process.argv[i];
const value = process.argv[++i];
switch (param) {
case RegistrationParameter.Port:
scopedLogger.debug(`port=${value}`);
params.port = value;
break;
case RegistrationParameter.PluginUUID:
scopedLogger.debug(`pluginUUID=${value}`);
params.pluginUUID = value;
break;
case RegistrationParameter.RegisterEvent:
scopedLogger.debug(`registerEvent=${value}`);
params.registerEvent = value;
break;
case RegistrationParameter.Info:
scopedLogger.debug(`info=${value}`);
params.info = JSON.parse(value);
break;
default:
i--;
break;
}
}
const invalidArgs = [];
const validate = (name, value) => {
if (value === undefined) {
invalidArgs.push(name);
}
};
validate(RegistrationParameter.Port, params.port);
validate(RegistrationParameter.PluginUUID, params.pluginUUID);
validate(RegistrationParameter.RegisterEvent, params.registerEvent);
validate(RegistrationParameter.Info, params.info);
if (invalidArgs.length > 0) {
throw new Error(`Unable to establish a connection with Stream Deck, missing command line arguments: ${invalidArgs.join(", ")}`);
}
return params;
}
/**
* Attempts to emit the {@link ev} that was received from the {@link Connection.connection}.
* @param ev Event message data received from Stream Deck.
*/
tryEmit(ev) {
try {
const message = JSON.parse(ev.data.toString());
if (message.event) {
this.logger.trace(ev.data.toString());
this.emit(message.event, message);
}
else {
this.logger.warn(`Received unknown message: ${ev.data}`);
}
}
catch (err) {
this.logger.error(`Failed to parse message: ${ev.data}`, err);
}
}
}
const connection = new Connection();
let manifest$1;
let softwareMinimumVersion;
/**
* Gets the minimum version that this plugin required, as defined within the manifest.
* @returns Minimum required version.
*/
function getSoftwareMinimumVersion() {
return (softwareMinimumVersion ??= new Version(getManifest().Software.MinimumVersion));
}
/**
* Gets the manifest associated with the plugin.
* @returns The manifest.
*/
function getManifest() {
return (manifest$1 ??= readManifest());
}
/**
* Reads the manifest associated with the plugin from the `manifest.json` file.
* @returns The manifest.
*/
function readManifest() {
const path = join(process.cwd(), "manifest.json");
if (!existsSync(path)) {
throw new Error("Failed to read manifest.json as the file does not exist.");
}
return JSON.parse(readFileSync(path, {
encoding: "utf-8",
flag: "r",
}).toString());
}
/**
* Provides a read-only iterable collection of items that also acts as a partial polyfill for iterator helpers.
*/
class Enumerable {
/**
* Backing function responsible for providing the iterator of items.
*/
#items;
/**
* Backing function for {@link Enumerable.length}.
*/
#length;
/**