feat: 实现 Go 与 JS/TS SDK 连接收发与其余接口
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user