@seriousme/opifex
Version:
MQTT client & server for Deno & NodeJS
250 lines (231 loc) • 7.37 kB
text/typescript
import {
type ConnectPacket,
Deferred,
logger,
MemoryStore,
PacketType,
type PublishPacket,
type SockConn,
type SubscribePacket,
type TAuthenticationResult,
} from "./deps.ts";
import { Context } from "./context.ts";
/**
* Generates a random client ID with the given prefix
* @param prefix - The prefix to use for the client ID
* @returns A string containing the prefix followed by a random number
*/
function generateClientId(prefix: string): string {
return `${prefix}-${Math.random().toString().slice(-10)}`;
}
type ConnectOptions = Omit<
ConnectPacket,
"type" | "protocolName" | "protocolLevel"
>;
/** ConnectParameters define how to connect */
export type ConnectParameters = {
url?: URL;
caCerts?: string[];
cert?: string;
key?: string;
numberOfRetries?: number;
options?: ConnectOptions;
};
/** PublishParameters define how a message should be published */
export type PublishParameters = Omit<PublishPacket, "type" | "id">;
/** SubscribeParameters define how to subscribe to a topic */
export type SubscribeParameters = Omit<SubscribePacket, "type" | "id">;
/**
* Implements exponential backoff sleep with optional randomization
* based on https://dthain.blogspot.com/2009/02/exponential-backoff-in-distributed.html
* @param random - Whether to add randomization to the delay
* @param attempt - The attempt number (used to calculate delay)
* @returns Promise that resolves after the calculated delay
*/
function backOffSleep(random: boolean, attempt: number): Promise<void> {
const factor = 1.5;
const min = 1000;
const max = 5000;
const randomness = 1 + (random ? Math.random() : 0);
const delay = Math.floor(
Math.min(randomness * min * (factor ** attempt), max),
);
logger.debug({ delay });
return new Promise((resolve) => setTimeout(resolve, delay));
}
/** the default MQTT URL to connect to */
export const DEFAULT_URL = "mqtt://localhost:1883/";
const DEFAULT_KEEPALIVE = 60; // 60 seconds
const DEFAULT_RETRIES = 3; // on first connect
const CLIENTID_PREFIX = "opifex"; // on first connect
/**
* The Client class provides an MQTT Client that can be used to connect to
* a MQTT broker and publish/subscribe messages.
*
* The Client class is not meant to be used directly, but
* instead should be subclassed and the subclass should
* override the createConn() method to provide a
* connection type that is supported by the subclass.
*/
export class Client {
protected clientIdPrefix = CLIENTID_PREFIX;
protected numberOfRetries = DEFAULT_RETRIES;
protected url: URL = new URL(DEFAULT_URL);
protected keepAlive = DEFAULT_KEEPALIVE;
protected autoReconnect = true;
private caCerts?: string[];
private cert?: string;
private key?: string;
private clientId: string;
private ctx = new Context(new MemoryStore());
private connectPacket?: ConnectPacket;
/**
* Creates a new MQTT client instance
*/
constructor() {
this.clientId = generateClientId(this.clientIdPrefix);
this.numberOfRetries = DEFAULT_RETRIES;
}
/**
* Creates a new connection to the MQTT broker
* @param protocol - The protocol to use (mqtt, mqtts, etc)
* @param _hostname - The hostname to connect to
* @param _port - The port to connect to
* @param _caCerts - Optional CA certificates
* @param _cert - Optional client certificate
* @param _key - Optional client key
* @returns Promise resolving to a SockConn connection
*/
protected createConn(
protocol: string,
_hostname: string,
_port?: number,
_caCerts?: string[],
_cert?: string,
_key?: string,
): Promise<SockConn> {
// if you need to support alternative connection types just
// overload this method in your subclass
throw `Unsupported protocol: ${protocol}`;
}
/**
* Handles the connection process including retries and reconnection
* @returns Promise that resolves when connection is established or fails
*/
private async doConnect(): Promise<void> {
if (!this.connectPacket) {
return;
}
let isReconnect = false;
let attempt = 1;
let lastMessage = new Error();
let tryConnect = true;
while (tryConnect) {
logger.debug(`${isReconnect ? "re" : ""}connecting`);
try {
const conn = await this.createConn(
this.url.protocol,
this.url.hostname,
Number(this.url.port) ?? undefined,
this.caCerts,
this.cert,
this.key,
);
// if we get this far we have a connection
tryConnect =
(await this.ctx.handleConnection(conn, this.connectPacket)) &&
this.autoReconnect;
logger.debug({ tryConnect });
isReconnect = true;
this.connectPacket.clean = false;
this.ctx.close();
} catch (err) {
if (err instanceof Error) {
lastMessage = err;
}
logger.debug(lastMessage);
if (!isReconnect && attempt > this.numberOfRetries) {
tryConnect = false;
} else {
await backOffSleep(true, attempt++);
}
}
}
if (isReconnect === false) {
this.ctx.unresolvedConnect?.reject(lastMessage);
}
}
/**
* Connects to the MQTT broker
* @param params - Connection parameters
* @returns Promise resolving to authentication result
*/
connect(params: ConnectParameters = {}): Promise<TAuthenticationResult> {
this.url = params?.url || this.url;
this.numberOfRetries = params.numberOfRetries || this.numberOfRetries;
this.caCerts = params?.caCerts;
this.cert = params?.cert;
this.key = params?.key;
const options = Object.assign(
{
keepAlive: this.keepAlive,
clientId: this.clientId,
},
params?.options,
);
this.connectPacket = {
type: PacketType.connect,
...options,
};
const deferred = new Deferred<TAuthenticationResult>();
this.ctx.unresolvedConnect = deferred;
this.doConnect();
return deferred.promise;
}
/**
* Disconnects from the MQTT broker
* @returns Promise that resolves when disconnected
*/
async disconnect(): Promise<void> {
await this.ctx.disconnect();
}
/**
* Publishes a message to the MQTT broker
* @param params - Publish parameters including topic and payload
* @returns Promise that resolves when published
*/
async publish(params: PublishParameters): Promise<void> {
const packet: PublishPacket = {
type: PacketType.publish,
...params,
};
await this.ctx.publish(packet);
}
/**
* Subscribes to topics on the MQTT broker
* @param params - Subscribe parameters including topics
* @returns Promise that resolves when subscribed
*/
async subscribe(params: SubscribeParameters): Promise<void> {
const packet: SubscribePacket = {
type: PacketType.subscribe,
id: 0, //placeholder
...params,
};
await this.ctx.subscribe(packet);
}
/**
* Gets an async iterator for received messages
* @returns AsyncGenerator yielding received publish packets
*/
async *messages(): AsyncGenerator<PublishPacket, void, unknown> {
yield* this.ctx.incoming;
}
/**
* Closes the message stream
* @param reason - Optional reason for closing
*/
closeMessages(reason?: string) {
this.ctx.incoming.close(reason);
}
}