UNPKG

@seriousme/opifex

Version:

MQTT client & server for Deno & NodeJS

250 lines (231 loc) 7.37 kB
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); } }