169 lines
6.6 KiB
JavaScript
169 lines
6.6 KiB
JavaScript
/**
|
||
* 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);
|