169 lines
4.7 KiB
TypeScript
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));
|
|
}
|