Files
NixMsg/sdk/js/src/mqtt.ts
T

169 lines
4.7 KiB
TypeScript

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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
this.stopped = true;
const c = this.client;
this.client = undefined;
if (c) await new Promise<void>((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<void> {
return new Promise((r) => setTimeout(r, ms));
}