@elgato/streamdeck
Version:
The official Node.js SDK for creating Stream Deck plugins.
1,424 lines (1,398 loc) • 56.6 kB
JavaScript
/**!
* @author Elgato
* @module elgato/streamdeck
* @license MIT
* @copyright Copyright (c) Corsair Memory Inc.
*/
export { DeviceType } from '@elgato/schemas/streamdeck/plugins';
// 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;
}
}
/**
* 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);
}
}
}
/**
* Connection used by the UI to communicate with the plugin, and Stream Deck.
*/
class Connection extends EventEmitter {
/**
* Determines whether the connection can connect;
*/
canConnect = true;
/**
* Underlying web socket connection.
*/
connection = new PromiseCompletionSource();
/**
* Underlying connection information provided to the plugin to establish a connection with Stream Deck.
*/
info = new PromiseCompletionSource();
/**
* Initializes a new instance of the {@link Connection} class.
*/
constructor() {
super();
window.connectElgatoStreamDeckSocket = (port, uuid, event, info, actionInfo) => {
return this.connect(port, uuid, event, JSON.parse(info), JSON.parse(actionInfo));
};
}
/**
* Gets the connection's information.
* @returns The information used to establish the connection.
*/
async getInfo() {
return this.info.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);
connection.send(message);
}
/**
* Establishes a connection with Stream Deck, allowing for the UI to send and receive messages.
* @param port Port to be used when connecting to Stream Deck.
* @param uuid Identifies the UI; this must be provided when establishing the connection with Stream Deck.
* @param event Name of the event that identifies the registration procedure; this must be provided when establishing the connection with Stream Deck.
* @param info Information about the Stream Deck application, the plugin, the user's operating system, user's Stream Deck devices, etc.
* @param actionInfo Information for the action associated with the UI.
* @returns A promise that is resolved when a connection has been established.
*/
async connect(port, uuid, event, info, actionInfo) {
if (this.canConnect) {
this.canConnect = false;
this.emit("connecting", info, actionInfo);
const webSocket = new WebSocket(`ws://127.0.0.1:${port}`);
webSocket.onmessage = (ev) => this.tryEmit(ev);
webSocket.onopen = () => {
webSocket.send(JSON.stringify({ event, uuid }));
this.connection.setResult(webSocket);
// As the emitter does not awaiter listeners, we are safe from dead-locking against the listener calling `getInfo()`.
this.emit("connected", info, actionInfo);
this.info.setResult({ uuid, info, actionInfo });
};
}
await this.connection.promise;
}
/**
* 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) {
const message = JSON.parse(ev.data);
if (message.event) {
this.emit(message.event, message);
}
}
}
const connection = new Connection();
/**
* 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 ({ data }) => `${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;
}
}
/**
* 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`;
/**
* Creates a log target that that sends the log entry to the router.
* @param router Router to which log entries should be sent to.
* @returns The log target, attached to the router.
*/
function createRoutedLogTarget(router) {
const format = stringFormatter();
return {
write: (entry) => {
router.fetch({
body: {
level: entry.level,
message: format(entry),
scope: entry.scope,
},
path: LOGGER_WRITE_PATH,
unidirectional: true,
});
},
};
}
/**
* Gets the global settings associated with the plugin. Use in conjunction with {@link setGlobalSettings}.
* @template T The type of global settings associated with the plugin.
* @returns Promise containing the plugin's global settings.
*/
async function getGlobalSettings() {
const { uuid } = await connection.getInfo();
return new Promise((resolve) => {
connection.once("didReceiveGlobalSettings", (ev) => resolve(ev.payload.settings));
connection.send({
event: "getGlobalSettings",
context: uuid,
});
});
}
/**
* Gets the settings for the action associated with the UI.
* @template T The type of settings associated with the action.
* @returns Promise containing the action instance's settings.
*/
async function getSettings() {
const { uuid, actionInfo: { action }, } = await connection.getInfo();
return new Promise((resolve) => {
connection.once("didReceiveSettings", (ev) => resolve(ev.payload.settings));
connection.send({
event: "getSettings",
action,
context: uuid,
});
});
}
/**
* Occurs when the global settings are requested, or when the the global settings were updated by the plugin.
* @template T The type of settings associated with the action.
* @param listener Function to be invoked when the event occurs.
* @returns A disposable that, when disposed, removes the listener.
*/
function onDidReceiveGlobalSettings(listener) {
return connection.disposableOn("didReceiveGlobalSettings", (ev) => listener({
settings: ev.payload.settings,
type: ev.event,
}));
}
/**
* Occurs when the settings associated with an action instance are requested, or when the the settings were updated by the plugin.
* @template T The type of settings associated with the action.
* @param listener Function to be invoked when the event occurs.
* @returns A disposable that, when disposed, removes the listener.
*/
function onDidReceiveSettings(listener) {
return connection.disposableOn("didReceiveSettings", (ev) => listener({
action: {
id: ev.context,
manifestId: ev.action,
getSettings,
setSettings,
},
payload: ev.payload,
type: ev.event,
}));
}
/**
* Sets the global {@link settings} associated the plugin. **Note**, these settings are only available to this plugin, and should be used to persist information securely. Use in
* conjunction with {@link getGlobalSettings}.
* @param settings Settings to save.
* @returns `Promise` resolved when the global `settings` are sent to Stream Deck.
* @example
* streamDeck.settings.setGlobalSettings({
* apiKey,
* connectedDate: new Date()
* })
*/
async function setGlobalSettings(settings) {
const { uuid } = await connection.getInfo();
return connection.send({
event: "setGlobalSettings",
context: uuid,
payload: settings,
});
}
/**
* Sets the settings for the action associated with the UI.
* @param settings Settings to persist.
* @returns `Promise` resolved when the {@link settings} are sent to Stream Deck.
*/
async function setSettings(settings) {
const { uuid, actionInfo: { action }, } = await connection.getInfo();
return connection.send({
event: "setSettings",
action,
context: uuid,
payload: settings,
});
}
var settings = /*#__PURE__*/Object.freeze({
__proto__: null,
getGlobalSettings: getGlobalSettings,
getSettings: getSettings,
onDidReceiveGlobalSettings: onDidReceiveGlobalSettings,
onDidReceiveSettings: onDidReceiveSettings,
setGlobalSettings: setGlobalSettings,
setSettings: setSettings
});
/**
* Router responsible for communicating with the plugin.
*/
const router = new MessageGateway(async (payload) => {
await sendPayload(payload);
return true;
}, ({ context: id, action: manifestId }) => ({ id, manifestId, getSettings, setSettings }));
connection.on("sendToPropertyInspector", (ev) => router.process(ev));
/**
* Controller responsible for interacting with the plugin associated with the property inspector.
*/
class PluginController {
/**
* Sends a fetch request to the plugin; the plugin can listen for requests by registering routes.
* @template T The type of the response body.
* @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) {
if (typeof requestOrPath === "string") {
return router.fetch(`${PUBLIC_PATH_PREFIX}${requestOrPath}`, bodyOrUndefined);
}
else {
return router.fetch({
...requestOrPath,
path: `${PUBLIC_PATH_PREFIX}${requestOrPath.path}`,
});
}
}
/**
* Occurs when a message was sent to the property inspector _from_ the plugin. The property inspector can also send messages _to_ the plugin using {@link PluginController.sendToPlugin}.
* @template TPayload The type of the payload received from the property inspector.
* @template TSettings The type of settings associated with the action.
* @param listener Function to be invoked when the event occurs.
* @returns A disposable that, when disposed, removes the listener.
*/
onSendToPropertyInspector(listener) {
return router.disposableOn("unhandledMessage", (ev) => {
listener({
action: {
id: ev.context,
manifestId: ev.action,
getSettings,
setSettings,
},
payload: ev.payload,
type: "sendToPropertyInspector",
});
});
}
/**
* Registers the function as a route, exposing it to the plugin via `streamDeck.ui.current.fetch(path)`.
* @template TBody The type of the request body.
* @template TSettings The type of the action's settings.
* @param path Path that identifies the route.
* @param handler Handler to be invoked when a matching request is received.
* @param options Optional routing configuration.
* @returns Disposable capable of removing the route handler.
* @example
* streamDeck.plugin.registerRoute("/set-text", async (req, res) => {
* // Set the value of the text field in the property inspector.
* document.querySelector("#text-field").value = req.body.value;
* });
*/
registerRoute(path, handler, options) {
return router.route(`${PUBLIC_PATH_PREFIX}${path}`, handler, options);
}
/**
* Sends a payload to the plugin.
* @param payload Payload to send.
* @returns Promise completed when the message was sent.
*/
async sendToPlugin(payload) {
return sendPayload(payload);
}
}
/**
* Sends a payload to the plugin.
* @param payload Payload to send.
* @returns Promise completed when the message was sent.
*/
async function sendPayload(payload) {
const { uuid, actionInfo: { action }, } = await connection.getInfo();
return connection.send({
event: "sendToPlugin",
action,
context: uuid,
payload,
});
}
const plugin = new PluginController();
/**
* Logger responsible for capturing log messages.
*/
const logger = new Logger({
level: LogLevel.DEBUG,
targets: [new ConsoleTarget(), createRoutedLogTarget(router)],
});
const __cwd = cwd();
/**
* Internalization provider, responsible for managing localizations and translating resources.
*/
const i18n = new I18nProvider((window.navigator.language ? window.navigator.language.split("-")[0] : "en"), xmlHttpRequestLocaleProviderSync);
/**
* Loads a locale from the file system using `fetch`.
* @param language Language to load.
* @returns Contents of the locale.
*/
function xmlHttpRequestLocaleProviderSync(language) {
const filePath = `${__cwd}${language}.json`;
try {
const req = new XMLHttpRequest();
req.open("GET", filePath, false);
req.send();
return parseLocalizations(req.response);
}
catch (err) {
if (err instanceof DOMException && err.name === "NOT_FOUND_ERR") {
// Browser consoles will inherently log an error if a resource cannot be found; we should provide
// a more forgiving warning alongside the error, without cluttering the main log file.
console.warn(`Missing localization file: ${language}.json`);
}
else {
logger.error(`Failed to load translations from ${filePath}`, err);
}
return null;
}
}
/**
* Gets the current working directory.
* @returns The directory.
*/
function cwd() {
let path = "";
const segments = window.location.href.split("/");
for (let i = 0; i < segments.length - 1; i++) {
path += `${segments[i]}/`;
if (segments[i].endsWith(".sdPlugin")) {
break;
}
}
return path;
}
/**
* Opens the specified `url` in the user's default browser.
* @param url URL to open.
* @returns `Promise` resolved when the request to open the `url` has been sent to Stream Deck.
*/
function openUrl(url) {
return connection.send({
event: "openUrl",
payload: {
url,
},
});
}
var system = /*#__PURE__*/Object.freeze({
__proto__: null,
openUrl: openUrl
});
/**
* 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;
/**
* Captured iterator from the underlying iterable; used to fulfil {@link IterableIterator} methods.
*/
#iterator;
/**
* Initializes a new instance of the {@link Enumerable} class.
* @param source Source that contains the items.
* @returns The enumerable.
*/
constructor(source) {
if (source instanceof Enumerable) {
// Enumerable
this.#items = source.#items;
this.#length = source.#length;
}
else if (Array.isArray(source)) {
// Array
this.#items = () => source.values();
this.#length = () => source.length;
}
else if (source instanceof Map || source instanceof Set) {
// Map or Set
this.#items = () => source.values();
this.#length = () => source.size;
}
else {
// IterableIterator delegate
this.#items = source;
this.#length = () => {
let i = 0;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const _ of this) {
i++;
}
return i;
};
}
}
/**
* Gets the number of items in the enumerable.
* @returns The number of items.
*/
get length() {
return this.#length();
}
/**
* Gets the iterator for the enumerable.
* @yields The items.
*/
*[Symbol.iterator]() {
for (const item of this.#items()) {
yield item;
}
}
/**
* Transforms each item within this iterator to an indexed pair, with each pair represented as an array.
* @returns An iterator of indexed pairs.
*/
asIndexedPairs() {
return new Enumerable(function* () {
let i = 0;
for (const item of this) {
yield [i++, item];
}
}.bind(this));
}
/**
* Returns an iterator with the first items dropped, up to the specified limit.
* @param limit The number of elements to drop from the start of the iteration.
* @returns An iterator of items after the limit.
*/
drop(limit) {
if (isNaN(limit) || limit < 0) {
throw new RangeError("limit must be 0, or a positive number");
}
return new Enumerable(function* () {
let i = 0;
for (const item of this) {
if (i++ >= limit) {
yield item;
}
}
}.bind(this));
}
/**
* Determines whether all items satisfy the specified predicate.
* @param predicate Function that determines whether each item fulfils the predicate.
* @returns `true` when all items satisfy the predicate; otherwise `false`.
*/
every(predicate) {
for (const item of this) {
if (!predicate(item)) {
return false;
}
}
return true;
}
/**
* Returns an iterator of items that meet the specified predicate..
* @param predicate Function that determines which items to filter.
* @returns An iterator of filtered items.
*/
filter(predicate) {
return new Enumerable(function* () {
for (const item of this) {
if (predicate(item)) {
yield item;
}
}
}.bind(this));
}
/**
* Finds the first item that satisfies the specified predicate.
* @param predicate Predicate to match items against.
* @returns The first item that satisfied the predicate; otherwise `undefined`.
*/
find(predicate) {
for (const item of this) {
if (predicate(item)) {
return item;
}
}
}
/**
* Finds the last item that satisfies the specified predicate.