/** * MqttClient —— 浏览器/小程序 MQTT over WebSocket 重连封装 * * 稳定性策略(对应路线 B2「即时通信稳定性」): * 1. 指数退避重连:基数 1s,上限 30s,成功一次后重置。 * 2. 会话恢复:clean=false 时重连自动补投离线消息(由 Broker 保证)。 * 3. 遗嘱(LWT):连接异常断开时 Broker 代发 will,上层可据此标记「离线」。 * 4. 心跳兜底:onclose 即触发重连,不依赖 broker 的 keepalive 超时。 * * 用法: * const c = new MqttClient('wss://your.host/mqtt', { * clientId: 'web-' + uid, username, password, clean: false, * will: { topic: 'presence/' + uid, payload: 'offline', qos: 1, retain: true } * }); * c.on('connect', () => c.subscribe('topic/a')); * c.on('message', (t, p) => console.log(t, p)); */ (function (global) { 'use strict'; const BACKOFF_BASE = 1000; // 1s const BACKOFF_MAX = 30000; // 30s const BACKOFF_FACTOR = 1.8; function MqttClient(url, opts) { this.url = url; this.opts = opts || {}; this.clientId = this.opts.clientId || ('mqttjs_' + Math.random().toString(16).slice(2, 10)); this._listeners = {}; this._ws = null; this._connected = false; this._closedByUser = false; this._backoff = BACKOFF_BASE; this._retryTimer = null; this._subscriptions = {}; // topic -> qos,重连后自动重订 } MqttClient.prototype.on = function (evt, fn) { (this._listeners[evt] = this._listeners[evt] || []).push(fn); return this; }; MqttClient.prototype._emit = function (evt, a, b) { (this._listeners[evt] || []).forEach((fn) => fn(a, b)); }; MqttClient.prototype.connect = function () { this._closedByUser = false; this._open(); return this; }; MqttClient.prototype._open = function () { let ws; try { ws = new WebSocket(this.url, ['mqtt']); } catch (e) { return this._scheduleReconnect(); } this._ws = ws; ws.binaryType = 'arraybuffer'; ws.onopen = () => { // 发送 MQTT CONNECT(极简 CONNECT 报文,支持 will) const buf = this._buildConnect(); ws.send(buf); }; ws.onmessage = (ev) => { const pkt = this._parseConnAck(ev.data); if (pkt && pkt.sessionPresent) { this._emit('resume'); // 会话恢复,Broker 会在后台补投离线消息 } this._connected = true; this._backoff = BACKOFF_BASE; this._emit('connect'); Object.keys(this._subscriptions).forEach((t) => this.subscribe(t, this._subscriptions[t])); }; ws.onclose = () => { if (this._connected) this._emit('disconnect'); this._connected = false; if (!this._closedByUser) this._scheduleReconnect(); }; ws.onerror = () => { try { ws.close(); } catch (e) {} }; }; MqttClient.prototype._scheduleReconnect = function () { if (this._closedByUser) return; this._emit('reconnecting', this._backoff); clearTimeout(this._retryTimer); this._retryTimer = setTimeout(() => { this._backoff = Math.min(BACKOFF_MAX, Math.floor(this._backoff * BACKOFF_FACTOR)); this._open(); }, this._backoff); }; MqttClient.prototype.subscribe = function (topic, qos) { this._subscriptions[topic] = qos || 0; if (!this._connected || !this._ws) return; this._ws.send(this._buildSubscribe(topic, qos || 0)); }; MqttClient.prototype.publish = function (topic, payload, qos, retain) { if (!this._connected || !this._ws) return false; this._ws.send(this._buildPublish(topic, payload, qos || 0, retain ? 1 : 0)); return true; }; MqttClient.prototype.end = function () { this._closedByUser = true; clearTimeout(this._retryTimer); if (this._ws) { try { this._ws.close(); } catch (e) {} } }; /* ---------- 以下为最小 MQTT 报文构造(仅覆盖 CONNECT/SUBSCRIBE/PUBLISH) ---------- */ MqttClient.prototype._encStr = function (s) { const b = new TextEncoder().encode(s); return new Uint8Array([b.length >> 8, b.length & 0xff, ...b]); }; MqttClient.prototype._buildConnect = function () { const o = this.opts; const cid = this._encStr(this.clientId); let payload = cid; if (o.username) payload = this._join(payload, this._encStr(o.username), this._encStr(o.password || '')); if (o.will) { payload = this._join(payload, this._encStr(o.will.topic), this._encStr(o.will.payload || '')); } let varHdr = new Uint8Array([4, 0x02 /*clean*/, 0x3c /*keepalive 60*/]); if (o.clean === false) varHdr[1] = 0; let flags = 0; if (o.username) flags |= 0x80; if (o.password) flags |= 0x40; if (o.will) flags |= 0x04 | ((o.will.qos || 0) << 3) | ((o.will.retain ? 1 : 0) << 5); varHdr[1] |= flags; const body = this._join(varHdr, payload); return this._wrap(1, body); }; MqttClient.prototype._buildSubscribe = function (topic, qos) { const pid = new Uint8Array([0x00, 0x01]); const t = this._join(this._encStr(topic), new Uint8Array([qos])); return this._wrap(8, this._join(pid, t)); }; MqttClient.prototype._buildPublish = function (topic, payload, qos, retain) { const t = this._encStr(topic); const p = typeof payload === 'string' ? new TextEncoder().encode(payload) : new Uint8Array(payload); return this._wrap(3 | (qos << 1) | (retain << 0), this._join(t, p)); }; MqttClient.prototype._wrap = function (cmd, body) { const len = body.length; const rem = [len & 0x7f]; if (len > 127) rem.unshift((len >> 7) & 0x7f | 0x80); return new Uint8Array([cmd << 4, ...rem, ...body]); }; MqttClient.prototype._join = function (...arrs) { let n = 0; arrs.forEach((a) => n += a.length); const out = new Uint8Array(n); let p = 0; arrs.forEach((a) => { out.set(a, p); p += a.length; }); return out; }; MqttClient.prototype._parseConnAck = function (data) { try { const buf = new Uint8Array(data); return { sessionPresent: (buf[2] & 0x01) === 1, code: buf[3] }; } catch (e) { return null; } }; global.MqttClient = MqttClient; if (typeof module !== 'undefined') module.exports = MqttClient; })(typeof window !== 'undefined' ? window : this);