bridge: publish through a Publisher; devices no longer hold the broker client

src/transport.ts has the Publisher interface, MqttPublisher (the client the bridge has at the time) and
MemoryPublisher (tests without a broker); src/topics.ts names the four topics the library publishes to.
Device, AlexaStatusMessage, AlexaErrorResponse and sendSceneResponse shared three copies of the
publish-and-report code: they now call one send() that resolves the topic or "" and never rejects.
registerDevice() and addDevice() work before connect(); a send() without a connection resolves "" and the
"error" event says to call connect(). unregisterDevice() and clearDevices() take the publisher from the device.
Device.setMqttClient() is gone, the first constructor argument of Device is ignored, and the message classes
take a Publisher where they took the client. Tests: 108 -> 113.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 19:34:39 +00:00
parent 46fa06728c
commit 6789a1a077
39 changed files with 839 additions and 246 deletions

View file

@ -38,6 +38,7 @@ declare class Alex2MQTT extends EventEmitter {
private rootTopic;
private debugLogging;
private client;
private readonly publisher;
private devices;
private MqttHost;
private options;
@ -62,7 +63,8 @@ declare class Alex2MQTT extends EventEmitter {
*
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
* unless endpointHealth is false.
* unless endpointHealth is false. A device can be declared before connect(): what it sends before the bridge has
* a connection is not published, and the "error" event says so.
*/
addDevice(definition: EndpointDefinition): Device;
registerDevice(name: string, endpointId: string, displayCategory?: DisplayCategory | DisplayCategory[] | null): Device;
@ -71,6 +73,7 @@ declare class Alex2MQTT extends EventEmitter {
unregisterDevice(endpointId: string): boolean;
/** Forget every device. */
clearDevices(): void;
private release;
getDevices(): Device[];
getDevice(endpointId: string): Device | undefined;
getRootTopic(): string;

37
dist/esm/Alex2Node.js vendored
View file

@ -5,6 +5,8 @@ import { checkEndpoint } from "./device/validate.js";
import { EventEmitter } from "events";
import { DisplayCategory } from "./compat/enums.js";
import { DeclarationError } from "./registry/types.js";
import * as topics from "./topics.js";
import { MqttPublisher } from "./transport.js";
export const DEFAULT_HOST = "mqtt://Alex2MQTT.stormysdream.club:1883";
// What addDevice() copies from the definition to the device
const DESCRIBED = [
@ -33,6 +35,8 @@ class Alex2MQTT extends EventEmitter {
this.rootTopic = rootTopic;
this.debugLogging = debugLogging;
this.client = null;
// One for the life of the bridge: the devices keep it over disconnect() and connect()
this.publisher = new MqttPublisher(() => this.client);
this.devices = [];
// The lines of check() that were logged. Discovery comes every few minutes: a line is logged once.
this.logged = new Set();
@ -70,8 +74,6 @@ class Alex2MQTT extends EventEmitter {
...(this.options.mqtt || {}),
};
this.client = mqtt.connect(this.MqttHost, options);
for (const d of this.devices)
d.setMqttClient(this.client); // after a disconnect(): the devices publish through the new client
this.client.on("connect", () => {
this.connected = true;
this.log("Connected to MQTT broker");
@ -94,12 +96,12 @@ class Alex2MQTT extends EventEmitter {
this.log("Discovery request received, getting device json...");
const deviceArray = this.describeDevices();
this.lastDiscoveryAt = new Date().toISOString();
this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => {
if (err) {
this.fail(err);
void this.publisher.publish(topics.discoverReply(this.rootTopic), deviceArray).then((result) => {
if (!result.ok) {
this.fail(result.error);
return;
}
this.log(`Discovery payloads published to ${topic + "_r"}`, deviceArray);
this.log(`Discovery payloads published to ${result.topic}`, deviceArray);
this.emit("discover", deviceArray.length);
});
}
@ -173,15 +175,13 @@ class Alex2MQTT extends EventEmitter {
*
* Throws a DeclarationError when Alexa would reject the endpoint (an endpointId with a slash, a name with
* punctuation) or when the endpointId is registered already. Discovery lists Alexa.EndpointHealth for the device
* unless endpointHealth is false.
* unless endpointHealth is false. A device can be declared before connect(): what it sends before the bridge has
* a connection is not published, and the "error" event says so.
*/
addDevice(definition) {
if (!this.client) {
throw new Error("Must call connect before creating devices");
}
const { endpointId, name, categories, endpointHealth, ...described } = definition;
// Without categories the device would take LIGHT, the default of registerDevice: here it is a mistake
const device = new Device(this.client, this.rootTopic, name, endpointId, (categories ?? []));
const device = new Device(null, this.rootTopic, name, endpointId, (categories ?? []));
for (const [field, value] of Object.entries(described)) {
if (!DESCRIBED.includes(field))
throw new DeclarationError({ endpointId }, `${field} is not a field of an endpoint`);
@ -196,9 +196,6 @@ class Alex2MQTT extends EventEmitter {
return device;
}
registerDevice(name, endpointId, displayCategory) {
if (!this.client) {
throw new Error("Must call connect before creating devices");
}
const existing = this.devices.find((d) => d.endpointId === endpointId);
if (existing) { // 1.5.2: endpointIds are unique per root topic; 1.5.1 added a second device that never got a directive
const warning = `warning: registerDevice("${endpointId}") is already registered as "${existing.name}", returning that device`;
@ -210,7 +207,7 @@ class Alex2MQTT extends EventEmitter {
}
this.log(`Creating new device with endpoint: ${endpointId}`);
const normalizedCategory = Array.isArray(displayCategory) ? displayCategory : [displayCategory || DisplayCategory.LIGHT];
const device = new Device(this.client, this.rootTopic, name, endpointId, normalizedCategory);
const device = new Device(null, this.rootTopic, name, endpointId, normalizedCategory);
this.register(device, false);
return device;
}
@ -218,6 +215,7 @@ class Alex2MQTT extends EventEmitter {
device.alexaInterface = this.options.alexaInterface !== false;
device.endpointHealth = endpointHealth;
device.onPublishError = (err) => this.fail(err); // a failed publish is an "error" event (when listened to), never a rejected send()
device.publisher = this.publisher;
this.devices.push(device);
}
/** Forget a device (its listeners with it). Returns false when there was none. */
@ -225,16 +223,21 @@ class Alex2MQTT extends EventEmitter {
const i = this.devices.findIndex((d) => d.endpointId === endpointId);
if (i === -1)
return false;
this.devices[i].removeAllListeners();
this.release(this.devices[i]);
this.devices.splice(i, 1);
return true;
}
/** Forget every device. */
clearDevices() {
for (const d of this.devices)
d.removeAllListeners();
this.release(d);
this.devices = [];
}
// A device the bridge forgot hears no directive and publishes nothing
release(device) {
device.removeAllListeners();
device.publisher = null;
}
getDevices() {
return this.devices.slice();
}

View file

@ -1,17 +1,17 @@
import type { MqttClient } from "mqtt";
import type { ErrorResponseMessage } from "../messages/types.js";
import type { Publisher } from "../transport.js";
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
export declare class AlexaErrorResponse {
private readonly correlationToken;
private readonly rootTopic;
private readonly endpointId;
private readonly mqttClient;
private readonly publisher;
private error;
private namespace?;
private readonly messageId;
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
onPublishError?: (err: Error) => void;
constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient);
constructor(correlationToken: string, rootTopic: string, endpointId: string, publisher: Publisher | null);
/**
* The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header
* carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF;

View file

@ -1,13 +1,15 @@
import { randomUUID } from "crypto";
import { errorResponse } from "../messages/build.js";
import { AlexaErrors } from "../messages/errors.js";
import * as topics from "../topics.js";
import { send } from "../transport.js";
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
export class AlexaErrorResponse {
constructor(correlationToken, rootTopic, endpointId, mqttClient) {
constructor(correlationToken, rootTopic, endpointId, publisher) {
this.correlationToken = correlationToken;
this.rootTopic = rootTopic;
this.endpointId = endpointId;
this.mqttClient = mqttClient;
this.publisher = publisher;
// Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa
this.error = AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which");
// One id for the message, however often toJSON() is called
@ -36,15 +38,7 @@ export class AlexaErrorResponse {
}
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
send(sendAsync = false) {
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
return new Promise((resolve) => {
this.mqttClient.publish(topic, JSON.stringify(this.toJSON()), (err) => {
if (!err)
return resolve(topic);
if (this.onPublishError)
this.onPublishError(err); // -> the bridge's "error" event (when somebody listens)
resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup
});
});
const answer = sendAsync ? topics.deferred : topics.response;
return send(this.publisher, answer(this.rootTopic, this.endpointId), () => this.toJSON(), this.onPublishError);
}
}

View file

@ -1,5 +1,5 @@
import type { MqttClient } from "mqtt";
import type { ChangeCause, Property } from "../messages/types.js";
import type { Publisher } from "../transport.js";
import { TemperatureSensorScale } from "./enums.js";
import type { EndpointHealth, PowerController } from "./enums.js";
/**
@ -10,7 +10,7 @@ export declare class AlexaStatusMessage {
private readonly correlationToken;
private readonly rootTopic;
private readonly endpointId;
private readonly mqttClient;
private readonly publisher;
private readonly isResponse;
private readonly isDeferred;
private readonly changeCause;
@ -19,7 +19,7 @@ export declare class AlexaStatusMessage {
private estimatedDeferralInSeconds?;
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
onPublishError?: (err: Error) => void;
constructor(correlationToken: string, rootTopic: string, endpointId: string, mqttClient: MqttClient, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null);
constructor(correlationToken: string, rootTopic: string, endpointId: string, publisher: Publisher | null, isResponse?: boolean, isDeferred?: boolean, changeCause?: ChangeCause | null);
private addProperty;
/** ChangeReport: the add*Prop calls that follow describe what CHANGED (the default for a change report). */
changed(): this;

View file

@ -1,6 +1,8 @@
import { randomUUID } from "crypto";
import { changeReport, deferredResponse, response } from "../messages/build.js";
import { StateBuilder } from "../messages/StateBuilder.js";
import * as topics from "../topics.js";
import { send } from "../transport.js";
import { AlexaInterfaceType, TemperatureSensorScale } from "./enums.js";
// The 1.x helpers report every temperature in Celsius, whatever scale the device works in
function celsius(scale, value) {
@ -11,11 +13,11 @@ function celsius(scale, value) {
* device.getChangeReport(), the add...Prop() calls, send(). The values are not checked.
*/
export class AlexaStatusMessage {
constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false, isDeferred = false, changeCause = null) {
constructor(correlationToken, rootTopic, endpointId, publisher, isResponse = false, isDeferred = false, changeCause = null) {
this.correlationToken = correlationToken;
this.rootTopic = rootTopic;
this.endpointId = endpointId;
this.mqttClient = mqttClient;
this.publisher = publisher;
this.isResponse = isResponse;
this.isDeferred = isDeferred;
this.changeCause = changeCause;
@ -106,23 +108,8 @@ export class AlexaStatusMessage {
* to). Never rejects (1.5.1 did, so an un-caught send() could kill the host).
*/
send(sendAsync = false) {
const topic = this.changeCause
? `${this.rootTopic}/changeReport`
: `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
return new Promise((resolve) => {
const failed = (err) => {
if (this.onPublishError)
this.onPublishError(err); // -> the bridge's "error" event (when somebody listens)
resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup
};
let payload;
try {
payload = JSON.stringify(this.toJSON());
}
catch (err) {
return failed(err);
}
this.mqttClient.publish(topic, payload, (err) => (err ? failed(err) : resolve(topic)));
});
const answer = sendAsync ? topics.deferred : topics.response;
const topic = this.changeCause ? topics.changeReport(this.rootTopic) : answer(this.rootTopic, this.endpointId);
return send(this.publisher, topic, () => this.toJSON(), this.onPublishError);
}
}

View file

@ -1,5 +1,4 @@
import { EventEmitter } from "events";
import type { MqttClient } from "mqtt";
import { AlexaErrorResponse } from "../compat/AlexaErrorResponse.js";
import { AlexaInterface } from "../compat/AlexaInterface.js";
import { AlexaStatusMessage } from "../compat/AlexaStatusMessage.js";
@ -8,6 +7,7 @@ import type { AlexaInterfaceType } from "../compat/enums.js";
import type { ChangeCause } from "../messages/types.js";
import type { DisplayCategoryName } from "../registry/catalog.js";
import type { Directives, InterfaceDescriptor, Properties } from "../registry/types.js";
import type { Publisher } from "../transport.js";
import { Capability } from "./Capability.js";
import type { AnyCapability, CapabilityJson, Declaration } from "./Capability.js";
import type { EndpointFields } from "./validate.js";
@ -40,7 +40,6 @@ export interface EndpointJson extends EndpointFields {
}
type DeclarationArguments<O, I extends boolean> = {} extends Declaration<O, I> ? [options?: Declaration<O, I>] : [options: Declaration<O, I>];
declare class Device extends EventEmitter {
private mqttClient;
private rootTopic;
name: string;
endpointId: string;
@ -67,9 +66,14 @@ declare class Device extends EventEmitter {
private capabilities;
/** Where a failed publish from this device or a message it built is reported; Alex2MQTT sets it (1.5.2). */
onPublishError?: (err: Error) => void;
constructor(mqttClient: MqttClient, rootTopic: string, name: string, endpointId: string, displayCategory: Array<DisplayCategory> | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string);
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
setMqttClient(client: MqttClient): void;
/**
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
* is null every send() resolves "" and reports why.
*/
publisher: Publisher | null;
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
constructor(client: unknown, rootTopic: string, name: string, endpointId: string, displayCategory: Array<DisplayCategory> | null, description?: string, manufacturerName?: string, manufacturer?: string, model?: string);
getName(): string;
setName(name: string): void;
getEndpointId(): string;

View file

@ -8,12 +8,14 @@ import { Alexa } from "../registry/interfaces/Alexa.js";
import { EndpointHealth } from "../registry/interfaces/EndpointHealth.js";
import { SchemaError } from "../registry/schema.js";
import { DeclarationError } from "../registry/types.js";
import * as topics from "../topics.js";
import { send } from "../transport.js";
import { Capability, commonOptions } from "./Capability.js";
import { checkCapability, checkCapabilityCount, checkEndpoint } from "./validate.js";
class Device extends EventEmitter {
constructor(mqttClient, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
/** client is ignored: in 1.x it was the broker client, and a device could only be built after connect(). */
constructor(client, rootTopic, name, endpointId, displayCategory, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") {
super();
this.mqttClient = mqttClient;
this.rootTopic = rootTopic;
this.name = name;
this.endpointId = endpointId;
@ -37,10 +39,12 @@ class Device extends EventEmitter {
*/
this.endpointHealth = false;
this.capabilities = [];
}
/** Internal (1.5.2): Alex2MQTT.connect() re-binds every registered device to its new broker client after a disconnect(). */
setMqttClient(client) {
this.mqttClient = client;
/**
* What the device and the messages it builds publish through. The bridge sets it when the device is registered
* and clears it when the device is unregistered; a MemoryPublisher here tests a device without a broker. While it
* is null every send() resolves "" and reports why.
*/
this.publisher = null;
}
getName() {
return this.name;
@ -64,12 +68,12 @@ class Device extends EventEmitter {
return this.description;
}
getErrorMessage(correlationToken) {
const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.mqttClient);
const msg = new AlexaErrorResponse(correlationToken, this.rootTopic, this.endpointId, this.publisher);
msg.onPublishError = this.onPublishError;
return msg;
}
getStatusMessage(correlationToken, isResponse = false, isDeferred = false) {
const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse, isDeferred);
const msg = new AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.publisher, isResponse, isDeferred);
msg.onPublishError = this.onPublishError;
return msg;
}
@ -80,7 +84,7 @@ class Device extends EventEmitter {
* Without a changed property send() publishes nothing and resolves with "": Alex2MQTT would drop the report.
*/
getChangeReport(cause = "PHYSICAL_INTERACTION") {
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.mqttClient, false, false, cause);
const msg = new AlexaStatusMessage("", this.rootTopic, this.endpointId, this.publisher, false, false, cause);
msg.onPublishError = this.onPublishError;
return msg;
}
@ -89,17 +93,9 @@ class Device extends EventEmitter {
* (1.5.1). Resolves with the topic published to, or "" when the publish failed (never rejects, 1.5.2).
*/
sendSceneResponse(correlationToken, activated, cause = "VOICE_INTERACTION", sendAsync = false) {
const payload = sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`;
return new Promise((resolve) => {
this.mqttClient.publish(topic, JSON.stringify(payload), (err) => {
if (!err)
return resolve(topic);
if (this.onPublishError)
this.onPublishError(err); // -> the bridge's "error" event (when somebody listens)
resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup
});
});
const answer = sendAsync ? topics.deferred : topics.response;
const build = () => sceneEvent({ endpointId: this.endpointId, correlationToken, activated, cause });
return send(this.publisher, answer(this.rootTopic, this.endpointId), build, this.onPublishError);
}
/** The capabilities the device declared, in the order it declared them. */
getCapabilities() {

3
dist/esm/index.d.ts vendored
View file

@ -13,6 +13,9 @@ export type { ActionId, AssetId, DisplayCategoryName, StateId, UnitOfMeasure, Ac
export * as messages from "./messages/index.js";
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
export type { ChangeCause, ChangeReportMessage, DeferredResponseMessage, ErrorResponseMessage, Header, ProactiveEventMessage, Property, PropertyOptions, ResponseMessage, SceneEventMessage, } from "./messages/index.js";
export * as topics from "./topics.js";
export { MemoryPublisher } from "./transport.js";
export type { Publisher, PublishResult } from "./transport.js";
export { AlexaInterface } from "./compat/AlexaInterface.js";
export type { SupportedMode } from "./compat/AlexaInterface.js";
export { ActionMapping } from "./compat/ActionMapping.js";

3
dist/esm/index.js vendored
View file

@ -12,6 +12,9 @@ export { ASSETS as Assets, UNITS_OF_MEASURE as Units, ACTIONS as Actions, STATES
// The messages: what the bridge publishes, built from plain values
export * as messages from "./messages/index.js";
export { AlexaError, AlexaErrors, MessageError, StateBuilder, property } from "./messages/index.js";
// Publishing: the topics of the Alex2MQTT contract, and a publisher that needs no broker for tests
export * as topics from "./topics.js";
export { MemoryPublisher } from "./transport.js";
// 1.x
export { AlexaInterface } from "./compat/AlexaInterface.js";
export { ActionMapping } from "./compat/ActionMapping.js";

8
dist/esm/topics.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
/** Where the bridge answers a discovery request with its endpoints. */
export declare const discoverReply: (root: string) => string;
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
export declare const response: (root: string, endpointId: string) => string;
/** Where the answer goes once a DeferredResponse was sent for the directive. */
export declare const deferred: (root: string, endpointId: string) => string;
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
export declare const changeReport: (root: string) => string;

10
dist/esm/topics.js vendored Normal file
View file

@ -0,0 +1,10 @@
// The topics of the Alex2MQTT contract, named once. The backend and Alex2ESP use the same names, so none of them
// can change here alone.
/** Where the bridge answers a discovery request with its endpoints. */
export const discoverReply = (root) => `${root}/discover_r`;
/** Where the answer to a directive goes. "alexaResponce" is how the backend and Alex2ESP spell it. */
export const response = (root, endpointId) => `${root}/${endpointId}/alexaResponce`;
/** Where the answer goes once a DeferredResponse was sent for the directive. */
export const deferred = (root, endpointId) => `${root}/${endpointId}/deferredResponse`;
/** Where every ChangeReport of the root goes: the backend adds the user's token and posts it to Alexa. */
export const changeReport = (root) => `${root}/changeReport`;

48
dist/esm/transport.d.ts vendored Normal file
View file

@ -0,0 +1,48 @@
import type { MqttClient } from "mqtt";
/** What became of a publish. A failure is a value: publish() never rejects. */
export type PublishResult = {
ok: true;
topic: string;
} | {
ok: false;
topic: string;
error: Error;
};
/** Where the bridge, its devices and their messages publish. The message is sent as JSON. */
export interface Publisher {
publish(topic: string, message: object): Promise<PublishResult>;
}
/**
* Publishes to the broker through the client the bridge has at the time: a new one after disconnect() and connect(),
* none before connect() and after disconnect(). Without a client the publish is refused. With a client that lost
* its connection mqtt.js keeps the message and sends it after the reconnect, and the publish resolves then.
*/
export declare class MqttPublisher implements Publisher {
private readonly client;
constructor(client: () => MqttClient | null);
publish(topic: string, message: object): Promise<PublishResult>;
}
/**
* Keeps what it is asked to publish, for tests and dry runs without a broker:
*
* const sent = new MemoryPublisher();
* device.publisher = sent;
* await device.getStatusMessage(token, true).addPowerControllerProp(PowerController.ON).send();
* sent.published[0] // { topic: "<root>/<endpointId>/alexaResponce", message: { event, context } }
*/
export declare class MemoryPublisher implements Publisher {
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
readonly published: Array<{
topic: string;
message: any;
}>;
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
failWith: Error | null;
publish(topic: string, message: object): Promise<PublishResult>;
}
/**
* send() as 1.x promises it: resolves with the topic, or with "" when nothing was published, and never rejects
* (1.5.1 rejected, and a send() nobody caught killed the host on any broker hiccup). What went wrong goes to report:
* the message cannot be built, the device is on no bridge, or the publish failed.
*/
export declare function send(publisher: Publisher | null, topic: string, build: () => object, report?: (err: Error) => void): Promise<string>;

77
dist/esm/transport.js vendored Normal file
View file

@ -0,0 +1,77 @@
const asError = (err) => (err instanceof Error ? err : new Error(String(err)));
/**
* Publishes to the broker through the client the bridge has at the time: a new one after disconnect() and connect(),
* none before connect() and after disconnect(). Without a client the publish is refused. With a client that lost
* its connection mqtt.js keeps the message and sends it after the reconnect, and the publish resolves then.
*/
export class MqttPublisher {
constructor(client) {
this.client = client;
}
publish(topic, message) {
const client = this.client();
return new Promise((resolve) => {
const failed = (err) => resolve({ ok: false, topic, error: asError(err) });
if (!client) {
failed(new Error(`nothing was published to ${topic}: the bridge is not connected, call connect() first`));
return;
}
try {
client.publish(topic, JSON.stringify(message), (err) => (err ? failed(err) : resolve({ ok: true, topic })));
}
catch (err) {
failed(err);
}
});
}
}
/**
* Keeps what it is asked to publish, for tests and dry runs without a broker:
*
* const sent = new MemoryPublisher();
* device.publisher = sent;
* await device.getStatusMessage(token, true).addPowerControllerProp(PowerController.ON).send();
* sent.published[0] // { topic: "<root>/<endpointId>/alexaResponce", message: { event, context } }
*/
export class MemoryPublisher {
constructor() {
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
this.published = [];
/** Set it and every publish fails with this error, as a publish to a broker that is gone does. */
this.failWith = null;
}
publish(topic, message) {
if (this.failWith)
return Promise.resolve({ ok: false, topic, error: this.failWith });
try {
this.published.push({ topic, message: JSON.parse(JSON.stringify(message)) });
}
catch (err) {
return Promise.resolve({ ok: false, topic, error: asError(err) });
}
return Promise.resolve({ ok: true, topic });
}
}
/**
* send() as 1.x promises it: resolves with the topic, or with "" when nothing was published, and never rejects
* (1.5.1 rejected, and a send() nobody caught killed the host on any broker hiccup). What went wrong goes to report:
* the message cannot be built, the device is on no bridge, or the publish failed.
*/
export async function send(publisher, topic, build, report) {
let result;
try {
const message = build();
if (!publisher) {
throw new Error(`nothing was published to ${topic}: the device is on no bridge, register it with addDevice() or registerDevice()`);
}
result = await publisher.publish(topic, message);
}
catch (err) {
result = { ok: false, topic, error: asError(err) };
}
if (result.ok)
return topic;
if (report)
report(result.error);
return "";
}