Alex2Node/src/transport.ts
David 6789a1a077 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>
2026-09-28 19:34:39 +00:00

89 lines
3.5 KiB
TypeScript

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>;
}
const asError = (err: unknown): Error => (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 implements Publisher {
constructor(private readonly client: () => MqttClient | null) {}
publish(topic: string, message: object): Promise<PublishResult> {
const client = this.client();
return new Promise((resolve) => {
const failed = (err: unknown): void => 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 implements Publisher {
/** Oldest first. The message is what a subscriber gets after JSON.parse. */
public 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. */
public failWith: Error | null = null;
publish(topic: string, message: object): Promise<PublishResult> {
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: Publisher | null,
topic: string,
build: () => object,
report?: (err: Error) => void
): Promise<string> {
let result: PublishResult;
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 "";
}