Files
YwxAppThink/addon/mqttbroker/client/MqttClient.js
T

169 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);