import mqtt, { type MqttClient, type IClientOptions } from "mqtt"; import { AuthReason, Transport, TransportConfig, buildCleanConnectFlags, } from "./types.js"; /** 基于 MQTT.js 5 的真实传输。 */ export class MqttTransport implements Transport { private client?: MqttClient; private cfg?: TransportConfig; private cred = ""; private stopped = false; private upTopic = ""; private downTopic = ""; private attempt = 0; setCredential(passwordOrToken: string): void { this.cred = passwordOrToken; } async start(cfg: TransportConfig): Promise { this.cfg = cfg; this.stopped = false; this.upTopic = `nix/c/${cfg.endpointId}/up`; this.downTopic = `nix/c/${cfg.endpointId}/down`; await this.connectLoop(); } private async connectLoop(): Promise { const cfg = this.cfg!; while (!this.stopped) { const wait = cfg.backoff.delay(this.attempt); if (wait > 0) await sleep(wait); if (this.stopped) return; try { await this.oneConnect(); this.attempt = 0; return; } catch (e) { this.attempt++; cfg.backoff.markOffline(); cfg.onOffline(); if (this.stopped) return; // 认证失败等会设 stopped } } } private oneConnect(): Promise { const cfg = this.cfg!; const { cleanStart, sessionExpiry } = buildCleanConnectFlags(); cfg.onConnectPacket?.(cleanStart, sessionExpiry); const url = normalizeWSURL(cfg.url, cfg.allowTcp); const opts: IClientOptions = { clientId: cfg.endpointId, username: cfg.endpointId, password: this.cred, protocolVersion: 5, clean: cleanStart, connectTimeout: cfg.connectTimeoutMs, reconnectPeriod: 0, // 自行退避 properties: { sessionExpiryInterval: sessionExpiry, }, wsOptions: {}, }; return new Promise((resolve, reject) => { const client = mqtt.connect(url, opts); this.client = client; let settled = false; const fail = (err: Error) => { if (settled) return; settled = true; client.end(true); reject(err); }; client.on("connect", async () => { try { cfg.backoff.markOnline(); await client.subscribeAsync(this.downTopic, { qos: 1 }); await cfg.mqttReady(); if (!settled) { settled = true; resolve(); } // 断线后自行重连 client.on("close", () => { if (this.stopped) return; cfg.backoff.markOffline(); cfg.onOffline(); this.attempt++; void this.connectLoop(); }); } catch (e) { fail(e as Error); } }); client.on("message", (_topic, payload) => { cfg.onDown(payload); }); client.on("error", (err) => { // MQTT.js 用 error 报告部分失败 const msg = String(err?.message ?? err); if (/Not authorized|Bad user|banned|0x86|0x87|0x8A/i.test(msg)) { const reason: AuthReason = this.cred.startsWith("nst_") ? "session_invalid" : "bad_credentials"; this.stopped = true; cfg.onAuthFailed(reason); } fail(err); }); client.on("disconnect", (packet) => { const code = packet?.reasonCode; if (code === 0x8e) { this.stopped = true; cfg.onKicked(); } }); setTimeout(() => { if (!settled) fail(new Error("connect timeout")); }, cfg.connectTimeoutMs); }); } async publishUp(payload: Uint8Array | string): Promise { const c = this.client; if (!c || !c.connected) throw new Error("not connected"); const buf = typeof payload === "string" ? Buffer.from(payload) : Buffer.from(payload); await c.publishAsync(this.upTopic, buf, { qos: 1 }); } async stop(): Promise { this.stopped = true; const c = this.client; this.client = undefined; if (c) await new Promise((r) => c.end(true, {}, () => r())); } } function normalizeWSURL(raw: string, allowTcp: boolean): string { const u = new URL(raw); if (u.protocol === "http:") u.protocol = "ws:"; if (u.protocol === "https:") u.protocol = "wss:"; if (u.protocol === "ws:" || u.protocol === "wss:") { if (!u.pathname || u.pathname === "/") u.pathname = "/mqtt"; return u.toString(); } if (u.protocol === "mqtt:" || u.protocol === "mqtts:") { if (!allowTcp) throw new Error("裸 TCP 需显式 allowTcp"); return u.toString(); } throw new Error(`unsupported scheme ${u.protocol}`); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); }