feat: 实现 Go 与 JS/TS SDK 连接收发与其余接口

This commit is contained in:
Nixevol
2026-09-30 07:03:33 +08:00
parent 1cff321f2a
commit 9650cbff76
32 changed files with 7145 additions and 1 deletions
+780
View File
@@ -0,0 +1,780 @@
import { v7 as uuidv7 } from "uuid";
import {
APIError,
AuthReason,
Body,
ClientOptions,
ConnectionEvent,
ConnectionState,
Credential,
GroupEvent,
GroupMemberIn,
HandshakeLimits,
Message,
PresenceEvent,
RecallResult,
Receipt,
RegisterOptions,
RegisterResult,
ReconnectBackoff,
RevokedEvent,
SendOptions,
SendResult,
Target,
Transport,
marshalJSON,
registerURLFromConnect,
} from "./types.js";
import { MqttTransport } from "./mqtt.js";
type RespFrame = {
ok: boolean;
data?: unknown;
error?: { code: string; message: string };
};
type SendItem = {
frame: Record<string, unknown>;
payload: string;
id: string;
result: { resolve: (v: SendResult) => void; reject: (e: unknown) => void };
inflight: boolean;
};
type DedupState = "delivered" | "acked";
export class Client {
private opts: Required<
Pick<
ClientOptions,
| "manualAck"
| "allowTcp"
| "connectTimeoutMs"
| "clientLabel"
| "sendQueueSize"
| "maxInflight"
| "dedupCapacity"
>
> &
ClientOptions = {
manualAck: false,
allowTcp: false,
connectTimeoutMs: 30_000,
clientLabel: "js-sdk/0.1",
sendQueueSize: 1000,
maxInflight: 100,
dedupCapacity: 10000,
};
private transport?: Transport;
private backoff = new ReconnectBackoff();
private endpointId = "";
private ridSeq = 0;
private pending = new Map<string, { resolve: (r: RespFrame) => void; reject: (e: unknown) => void }>();
private sendQ: SendItem[] = [];
private inflight = 0;
private handshook = false;
private stopReconnect = false;
private closed = false;
private state: ConnectionState = "offline";
private limits: HandshakeLimits = {
server_time_ms: 0,
server_version: "",
max_body_bytes: 262144,
max_meta_bytes: 4096,
max_frame_bytes: 786432,
max_ttl_seconds: 2592000,
max_schedule_seconds: 31536000,
ack_timeout_seconds: 300,
};
private clockSkew = 0;
private dedup = new Map<string, DedupState>();
private dedupOrd: string[] = [];
private receiptSeen = new Set<string>();
private cbChain: Promise<void> = Promise.resolve();
private onSession?: (token: string) => void;
private onMessage?: (msg: Message) => void | Promise<void>;
private onReceipt?: (r: Receipt) => void;
private onRevoked?: (e: RevokedEvent) => void;
private onPresence?: (e: PresenceEvent) => void;
private onGroupEvent?: (e: GroupEvent) => void;
private onConnection?: (e: ConnectionEvent) => void;
onSessionHandler(h: (token: string) => void): void {
this.onSession = h;
}
onMessageHandler(h: (msg: Message) => void | Promise<void>): void {
this.onMessage = h;
}
onReceiptHandler(h: (r: Receipt) => void): void {
this.onReceipt = h;
}
onRevokedHandler(h: (e: RevokedEvent) => void): void {
this.onRevoked = h;
}
onPresenceHandler(h: (e: PresenceEvent) => void): void {
this.onPresence = h;
}
onGroupEventHandler(h: (e: GroupEvent) => void): void {
this.onGroupEvent = h;
}
onConnectionHandler(h: (e: ConnectionEvent) => void): void {
this.onConnection = h;
}
/** 别名,贴近文档 onSession(handler) */
onSessionCb(h: (token: string) => void): void {
this.onSessionHandler(h);
}
clockSkewMs(): number {
return this.clockSkew;
}
getLimits(): HandshakeLimits {
return { ...this.limits };
}
async connect(
url: string,
endpointId: string,
credential: Credential,
options: ClientOptions = {},
): Promise<void> {
if (this.closed) throw new APIError("closed", "已关闭");
if (this.transport) throw new APIError("bad_request", "已在连接中");
this.opts = {
...this.opts,
...options,
connectTimeoutMs: options.connectTimeoutMs ?? 30_000,
clientLabel: options.clientLabel ?? "js-sdk/0.1",
sendQueueSize: options.sendQueueSize ?? 1000,
maxInflight: options.maxInflight ?? 100,
dedupCapacity: options.dedupCapacity ?? 10000,
manualAck: options.manualAck ?? false,
allowTcp: options.allowTcp ?? false,
};
this.endpointId = endpointId;
this.stopReconnect = false;
this.handshook = false;
this.backoff = new ReconnectBackoff();
const pass = credential.sessionToken ?? credential.password ?? "";
const tr = options.transport ?? new MqttTransport();
this.transport = tr;
tr.setCredential(pass);
this.setState("connecting");
await tr.start({
url,
endpointId,
connectTimeoutMs: this.opts.connectTimeoutMs!,
allowTcp: !!this.opts.allowTcp,
backoff: this.backoff,
onDown: (p) => this.handleDown(p),
onOffline: () => {
this.handshook = false;
if (!this.stopReconnect && !this.closed) this.setState("reconnecting");
},
onAuthFailed: (r) => this.failAuth(r),
onKicked: () => this.failKicked(),
mqttReady: () => this.doHello(),
});
const deadline = Date.now() + this.opts.connectTimeoutMs!;
while (Date.now() < deadline) {
if (this.handshook) return;
if (this.stopReconnect || this.state === "auth_failed" || this.state === "kicked") {
throw new APIError("auth_failed", this.state);
}
await sleep(20);
}
await this.close();
throw new APIError("not_connected", "连接超时");
}
private failAuth(reason: AuthReason): void {
this.stopReconnect = true;
this.handshook = false;
this.setState("auth_failed", reason);
this.failQueued(new APIError(reason, "认证失败,停止重连"));
void this.transport?.stop();
}
private failKicked(): void {
this.stopReconnect = true;
this.handshook = false;
this.setState("kicked", "0x8E");
this.failQueued(new APIError("kicked", "被顶号,停止重连"));
void this.transport?.stop();
}
private setState(state: ConnectionState, reason?: string): void {
this.state = state;
this.enqueueCb(() => this.onConnection?.({ state, reason }));
}
private enqueueCb(fn: () => void | Promise<void>): void {
this.cbChain = this.cbChain.then(async () => {
try {
await fn();
} catch {
/* 回调错误不打断串行链 */
}
});
}
private nextRid(): string {
this.ridSeq += 1;
return String(this.ridSeq);
}
private async doHello(): Promise<void> {
const sentAt = Date.now();
const req: Record<string, unknown> = {
v: 1,
type: "hello",
rid: this.nextRid(),
client: this.opts.clientLabel,
};
if (this.opts.maxReceiveBytes && this.opts.maxReceiveBytes > 0) {
req.max_receive_bytes = this.opts.maxReceiveBytes;
}
const data = (await this.request(req, true)) as Record<string, unknown>;
const recvAt = Date.now();
const serverTime = Number(data.server_time_ms ?? 0);
this.clockSkew = serverTime - Math.floor((sentAt + recvAt) / 2);
this.limits = {
server_time_ms: serverTime,
server_version: String(data.server_version ?? ""),
max_body_bytes: Number(data.max_body_bytes ?? 262144),
max_meta_bytes: Number(data.max_meta_bytes ?? 4096),
max_frame_bytes: Number(data.max_frame_bytes ?? 786432),
max_ttl_seconds: Number(data.max_ttl_seconds ?? 2592000),
max_schedule_seconds: Number(data.max_schedule_seconds ?? 31536000),
ack_timeout_seconds: Number(data.ack_timeout_seconds ?? 300),
};
this.handshook = true;
this.setState("online");
const token = data.session_token ? String(data.session_token) : "";
if (token) {
this.transport?.setCredential(token);
this.enqueueCb(() => this.onSession?.(token));
}
void this.drainSendQueue();
}
private handleDown(payload: Uint8Array): void {
let head: { type?: string; rid?: string };
try {
head = JSON.parse(new TextDecoder().decode(payload));
} catch {
return;
}
const text = new TextDecoder().decode(payload);
switch (head.type) {
case "resp": {
const rf = JSON.parse(text) as RespFrame & { rid: string };
const p = this.pending.get(rf.rid ?? head.rid!);
if (p) {
this.pending.delete(rf.rid ?? head.rid!);
p.resolve(rf);
}
break;
}
case "msg":
void this.handleMsg(JSON.parse(text));
break;
case "receipt":
void this.handleReceipt(JSON.parse(text));
break;
case "revoked":
this.handleRevoked(JSON.parse(text));
break;
case "presence": {
const p = JSON.parse(text) as PresenceEvent;
this.enqueueCb(() => this.onPresence?.(p));
break;
}
case "group_event": {
const g = JSON.parse(text) as GroupEvent;
this.enqueueCb(() => this.onGroupEvent?.(g));
break;
}
case "fatal": {
const f = JSON.parse(text) as { reason?: string };
this.stopReconnect = true;
this.setState("auth_failed", f.reason);
this.failQueued(new APIError("fatal", f.reason ?? ""));
void this.transport?.stop();
break;
}
}
}
private async handleMsg(m: {
id: string;
from: string;
to: Target;
body: Body;
meta?: Record<string, unknown>;
send_at_ms: number;
}): Promise<void> {
const key = `${m.from}\0${m.id}`;
const ent = this.dedup.get(key);
if (ent === "acked") {
await this.sendAckFrame(m.from, m.id);
return;
}
if (ent === "delivered") return;
this.rememberDedup(key, "delivered");
const msg: Message = {
id: m.id,
from: m.from,
to: m.to,
body: m.body,
meta: m.meta,
send_at_ms: m.send_at_ms,
};
let cbErr: unknown;
await new Promise<void>((resolve) => {
this.enqueueCb(async () => {
try {
await this.onMessage?.(msg);
} catch (e) {
cbErr = e;
}
resolve();
});
});
if (this.opts.manualAck) return;
if (cbErr) {
this.dedup.delete(key);
return;
}
await this.sendAckFrame(m.from, m.id);
this.dedup.set(key, "acked");
}
private rememberDedup(key: string, st: DedupState): void {
if (!this.dedup.has(key)) {
this.dedupOrd.push(key);
while (this.dedupOrd.length > this.opts.dedupCapacity!) {
const old = this.dedupOrd.shift()!;
this.dedup.delete(old);
}
}
this.dedup.set(key, st);
}
async ack(msg: Message): Promise<void> {
await this.sendAckFrame(msg.from, msg.id);
this.rememberDedup(`${msg.from}\0${msg.id}`, "acked");
}
private async sendAckFrame(from: string, id: string): Promise<void> {
const data = await this.request(
{ v: 1, type: "ack", rid: this.nextRid(), from, id },
true,
);
if (data && typeof data === "object" && "result" in (data as object)) {
const result = String((data as { result: string }).result);
if (result && result !== "accepted") {
this.enqueueCb(() => this.onRevoked?.({ id, from, reason: result }));
}
}
}
private async handleReceipt(r: Receipt & { receipt_id: string }): Promise<void> {
if (this.receiptSeen.has(r.receipt_id)) {
await this.sendReceiptAck(r.receipt_id);
return;
}
this.receiptSeen.add(r.receipt_id);
this.enqueueCb(() => this.onReceipt?.(r));
await this.sendReceiptAck(r.receipt_id);
}
private async sendReceiptAck(receiptId: string): Promise<void> {
await this.request({ v: 1, type: "receipt_ack", rid: this.nextRid(), receipt_id: receiptId }, true);
}
private handleRevoked(r: RevokedEvent): void {
const key = `${r.from}\0${r.id}`;
const ent = this.dedup.get(key);
if (!ent || ent === "acked") return;
this.dedup.delete(key);
this.enqueueCb(() => this.onRevoked?.(r));
}
private request(frame: Record<string, unknown>, allowUnready: boolean): Promise<unknown> {
if (this.closed) return Promise.reject(new APIError("closed"));
if (!allowUnready && !this.handshook) return Promise.reject(new APIError("not_connected", "未握手"));
const tr = this.transport;
if (!tr) return Promise.reject(new APIError("not_connected"));
const rid = String(frame.rid ?? this.nextRid());
frame.rid = rid;
const payload = marshalJSON(frame);
return new Promise((resolve, reject) => {
this.pending.set(rid, {
resolve: (rf) => {
if (!rf.ok) {
reject(new APIError(rf.error?.code ?? "bad_request", rf.error?.message ?? ""));
return;
}
resolve(rf.data);
},
reject,
});
void tr.publishUp(payload).catch((e) => {
this.pending.delete(rid);
reject(e);
});
});
}
async send(to: Target, body: Body, opt: SendOptions = {}): Promise<SendResult> {
const enc = body.enc || "utf8";
const b: Body = {
enc,
data: body.data,
content_type:
opt.contentType ||
body.content_type ||
(enc === "base64" ? "application/octet-stream" : "text/plain; charset=utf-8"),
};
const n = bodyDecodedLen(b);
const maxBody = this.limits.max_body_bytes || 262144;
if (n > maxBody) throw new APIError("body_too_large", "正文超限");
if (opt.sendAt && opt.delayMs != null) throw new APIError("bad_request", "sendAt 与 delay 互斥");
const id = opt.id || uuidv7();
const frame: Record<string, unknown> = {
v: 1,
type: "send",
rid: this.nextRid(),
id,
to,
body: b,
};
if (opt.meta) frame.meta = opt.meta;
if (opt.talkPassword) frame.talk_password = opt.talkPassword;
if (opt.receipt != null) frame.receipt = opt.receipt;
if (opt.keep) {
const off: Record<string, unknown> = { keep: true };
if (opt.ttl != null) off.ttl_seconds = opt.ttl;
frame.offline = off;
}
if (opt.sendAt) {
frame.send_at_ms = opt.sendAt.getTime() + this.clockSkew;
} else if (opt.delayMs != null) {
frame.delay_ms = opt.delayMs;
}
const payload = marshalJSON(frame);
const maxFrame = this.limits.max_frame_bytes || 786432;
if (this.handshook && payload.length > maxFrame) {
throw new APIError("frame_too_large", "整帧超限");
}
if (this.closed || this.stopReconnect) throw new APIError("closed");
if (this.sendQ.length >= this.opts.sendQueueSize!) {
throw new APIError("queue_full", "发送队列已满");
}
return new Promise<SendResult>((resolve, reject) => {
this.sendQ.push({ frame, payload, id, result: { resolve, reject }, inflight: false });
void this.drainSendQueue();
});
}
private async drainSendQueue(): Promise<void> {
while (true) {
if (!this.handshook || !this.transport) return;
const next = this.sendQ.find((x) => !x.inflight);
if (!next || this.inflight >= this.opts.maxInflight!) return;
next.inflight = true;
this.inflight++;
void this.dispatchSend(next);
}
}
private async dispatchSend(item: SendItem): Promise<void> {
const rid = String(item.frame.rid);
const tr = this.transport!;
try {
const data = await new Promise<unknown>((resolve, reject) => {
this.pending.set(rid, {
resolve: (rf) => {
if (!rf.ok) {
reject(new APIError(rf.error?.code ?? "bad_request", rf.error?.message ?? ""));
return;
}
resolve(rf.data);
},
reject,
});
void tr.publishUp(item.payload).catch((e) => {
this.pending.delete(rid);
reject(e);
});
});
const sd = (data ?? {}) as SendResult;
this.finishSend(item, { id: sd.id || item.id, send_at_ms: sd.send_at_ms, state: sd.state });
} catch (e) {
if (e instanceof APIError && e.code === "rate_limited") {
item.inflight = false;
this.inflight = Math.max(0, this.inflight - 1);
this.pending.delete(rid);
setTimeout(() => void this.drainSendQueue(), 1000);
return;
}
// 网络错误:保留队列
if (!(e instanceof APIError)) {
item.inflight = false;
this.inflight = Math.max(0, this.inflight - 1);
return;
}
this.finishSendErr(item, e);
}
}
private finishSend(item: SendItem, res: SendResult): void {
this.sendQ = this.sendQ.filter((x) => x !== item);
if (item.inflight) {
this.inflight = Math.max(0, this.inflight - 1);
item.inflight = false;
}
item.result.resolve(res);
void this.drainSendQueue();
}
private finishSendErr(item: SendItem, err: unknown): void {
this.sendQ = this.sendQ.filter((x) => x !== item);
if (item.inflight) {
this.inflight = Math.max(0, this.inflight - 1);
item.inflight = false;
}
item.result.reject(err);
void this.drainSendQueue();
}
private failQueued(err: unknown): void {
for (const it of this.sendQ) it.result.reject(err);
this.sendQ = [];
this.inflight = 0;
}
async recall(id: string): Promise<RecallResult> {
return (await this.request({ v: 1, type: "recall", rid: this.nextRid(), id }, false)) as RecallResult;
}
async status(id: string, cursor = "", limit = 0): Promise<unknown> {
const req: Record<string, unknown> = { v: 1, type: "status", rid: this.nextRid(), id };
if (cursor) req.cursor = cursor;
if (limit) req.limit = limit;
return this.request(req, false);
}
async unlock(endpointId: string, talkPassword: string): Promise<void> {
await this.request(
{ v: 1, type: "unlock", rid: this.nextRid(), endpoint_id: endpointId, talk_password: talkPassword },
false,
);
}
async presence(ids: string[]): Promise<unknown> {
return this.request({ v: 1, type: "presence.get", rid: this.nextRid(), ids }, false);
}
async directory(cursor = "", query = "", limit = 0): Promise<unknown> {
const req: Record<string, unknown> = { v: 1, type: "directory.list", rid: this.nextRid() };
if (cursor) req.cursor = cursor;
if (query) req.query = query;
if (limit) req.limit = limit;
return this.request(req, false);
}
async watchPresence(ids: string[] | "all"): Promise<void> {
const req: Record<string, unknown> = { v: 1, type: "presence.watch", rid: this.nextRid() };
if (ids === "all") req.all = true;
else req.ids = ids;
await this.request(req, false);
}
async getSelf(): Promise<unknown> {
return this.request({ v: 1, type: "self.get", rid: this.nextRid() }, false);
}
async updateSelf(name?: string, defaultDelayMs?: number): Promise<void> {
const req: Record<string, unknown> = { v: 1, type: "self.update", rid: this.nextRid() };
if (name != null) req.name = name;
if (defaultDelayMs != null) req.default_delay_ms = defaultDelayMs;
await this.request(req, false);
}
async setTalkPassword(talkPassword: string): Promise<void> {
await this.request(
{ v: 1, type: "self.talk_password", rid: this.nextRid(), talk_password: talkPassword },
false,
);
}
async changeLoginPassword(oldPassword: string, newPassword: string): Promise<void> {
const data = (await this.request(
{
v: 1,
type: "self.login_password",
rid: this.nextRid(),
old_password: oldPassword,
new_password: newPassword,
},
false,
)) as { session_token?: string };
if (data?.session_token) {
this.transport?.setCredential(data.session_token);
this.enqueueCb(() => this.onSession?.(data.session_token!));
}
}
async createGroup(id: string, name: string, members: GroupMemberIn[]): Promise<unknown> {
return this.request(
{
v: 1,
type: "group.create",
rid: this.nextRid(),
id,
name,
members: members.map((m) => ({ id: m.id, talk_password: m.talkPassword ?? "" })),
},
false,
);
}
async addGroupMembers(groupId: string, members: GroupMemberIn[]): Promise<unknown> {
return this.request(
{
v: 1,
type: "group.add",
rid: this.nextRid(),
group_id: groupId,
members: members.map((m) => ({ id: m.id, talk_password: m.talkPassword ?? "" })),
},
false,
);
}
async removeGroupMember(groupId: string, endpointId: string): Promise<void> {
await this.request(
{ v: 1, type: "group.remove", rid: this.nextRid(), group_id: groupId, endpoint_id: endpointId },
false,
);
}
async leaveGroup(groupId: string): Promise<void> {
await this.request({ v: 1, type: "group.leave", rid: this.nextRid(), group_id: groupId }, false);
}
async transferGroup(groupId: string, endpointId: string): Promise<void> {
await this.request(
{ v: 1, type: "group.transfer", rid: this.nextRid(), group_id: groupId, endpoint_id: endpointId },
false,
);
}
async renameGroup(groupId: string, name: string): Promise<void> {
await this.request(
{ v: 1, type: "group.rename", rid: this.nextRid(), group_id: groupId, name },
false,
);
}
async dissolveGroup(groupId: string): Promise<void> {
await this.request({ v: 1, type: "group.dissolve", rid: this.nextRid(), group_id: groupId }, false);
}
async listGroups(cursor = "", limit = 0): Promise<unknown> {
const req: Record<string, unknown> = { v: 1, type: "group.list", rid: this.nextRid() };
if (cursor) req.cursor = cursor;
if (limit) req.limit = limit;
return this.request(req, false);
}
async getGroup(groupId: string, cursor = "", limit = 0): Promise<unknown> {
const req: Record<string, unknown> = {
v: 1,
type: "group.get",
rid: this.nextRid(),
group_id: groupId,
};
if (cursor) req.cursor = cursor;
if (limit) req.limit = limit;
return this.request(req, false);
}
async logout(): Promise<void> {
try {
await this.request({ v: 1, type: "self.logout", rid: this.nextRid() }, false);
} finally {
this.stopReconnect = true;
await this.transport?.stop();
}
}
async close(): Promise<void> {
this.closed = true;
this.stopReconnect = true;
this.failQueued(new APIError("closed", "已关闭"));
this.setState("offline");
await this.transport?.stop();
this.transport = undefined;
}
}
function bodyDecodedLen(b: Body): number {
if (b.enc === "base64") {
const bin = atob(b.data);
return bin.length;
}
return new TextEncoder().encode(b.data).length;
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export async function register(
connectOrRegisterURL: string,
registrationCode: string,
opt: RegisterOptions = {},
): Promise<RegisterResult> {
let regURL = connectOrRegisterURL;
try {
regURL = registerURLFromConnect(connectOrRegisterURL);
} catch {
/* 已是注册 URL */
}
const resp = await fetch(regURL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: marshalJSON({
registration_code: registrationCode,
id: opt.id ?? "",
login_password: opt.loginPassword ?? "",
name: opt.name ?? "",
talk_password: opt.talkPassword ?? "",
}),
});
const wrap = (await resp.json()) as {
ok: boolean;
data?: { id: string; login_password?: string };
error?: { code: string; message: string };
};
if (!wrap.ok) {
throw new APIError(wrap.error?.code ?? "bad_request", wrap.error?.message ?? "注册失败");
}
return { id: wrap.data!.id, loginPassword: wrap.data!.login_password };
}
+137
View File
@@ -0,0 +1,137 @@
import {
APIError,
AuthReason,
Transport,
TransportConfig,
buildCleanConnectFlags,
marshalJSON,
} from "./types.js";
export interface FakeConnect {
cleanStart: boolean;
sessionExpiry: number;
password: string;
}
/** 单测用假 MQTT。 */
export class FakeTransport implements Transport {
private cfg?: TransportConfig;
private cred = "";
private up: string[] = [];
private connects: FakeConnect[] = [];
autoHello = true;
helloToken = "nst_test_token";
helloServerTimeMs = 1_750_000_000_000;
maxBodyBytes = 262144;
maxMetaBytes = 4096;
maxFrameBytes = 786432;
setCredential(passwordOrToken: string): void {
this.cred = passwordOrToken;
}
async start(cfg: TransportConfig): Promise<void> {
this.cfg = cfg;
await this.simulateConnectOK();
}
async publishUp(payload: Uint8Array | string): Promise<void> {
const s = typeof payload === "string" ? payload : new TextDecoder().decode(payload);
this.up.push(s);
if (this.autoHello) {
try {
const head = JSON.parse(s) as { type?: string; rid?: string };
if (head.type === "hello" && head.rid) this.replyHello(head.rid);
} catch {
/* ignore */
}
}
}
async stop(): Promise<void> {
this.cfg?.onOffline();
}
private replyHello(rid: string): void {
const resp = marshalJSON({
v: 1,
type: "resp",
rid,
ok: true,
data: {
server_time_ms: this.helloServerTimeMs,
server_version: "0.1.0",
max_body_bytes: this.maxBodyBytes,
max_meta_bytes: this.maxMetaBytes,
max_frame_bytes: this.maxFrameBytes,
max_ttl_seconds: 2592000,
max_schedule_seconds: 31536000,
ack_timeout_seconds: 300,
session_token: this.helloToken,
},
});
this.injectDown(resp);
}
async simulateConnectOK(): Promise<void> {
const { cleanStart, sessionExpiry } = buildCleanConnectFlags();
this.connects.push({ cleanStart, sessionExpiry, password: this.cred });
this.cfg?.onConnectPacket?.(cleanStart, sessionExpiry);
this.cfg?.backoff.markOnline();
await this.cfg?.mqttReady();
}
async simulateReconnect(): Promise<void> {
this.cfg?.backoff.markOffline();
this.cfg?.onOffline();
await this.simulateConnectOK();
}
simulateAuthFail(reason: AuthReason): void {
this.cfg?.onAuthFailed(reason);
}
simulateKick(): void {
this.cfg?.onKicked();
}
injectDown(payload: string | Uint8Array): void {
const bytes = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
this.cfg?.onDown(bytes);
}
replyOK(rid: string, data: unknown = {}): void {
this.injectDown(marshalJSON({ v: 1, type: "resp", rid, ok: true, data }));
}
replyErr(rid: string, code: string, message: string): void {
this.injectDown(
marshalJSON({ v: 1, type: "resp", rid, ok: false, error: { code, message } }),
);
}
upFrames(): string[] {
return [...this.up];
}
getConnects(): FakeConnect[] {
return [...this.connects];
}
findUp(type: string): Array<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = [];
for (const s of this.up) {
try {
const m = JSON.parse(s) as Record<string, unknown>;
if (m.type === type) out.push(m);
} catch {
/* ignore */
}
}
return out;
}
}
export function assertNever(_x: never): void {
throw new APIError("bad_request", "unreachable");
}
+11
View File
@@ -0,0 +1,11 @@
export {
APIError,
buildCleanConnectFlags,
registerURLFromConnect,
marshalJSON,
ReconnectBackoff,
} from "./types.js";
export type * from "./types.js";
export { Client, register } from "./client.js";
export { FakeTransport } from "./fake.js";
export { MqttTransport } from "./mqtt.js";
+168
View File
@@ -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));
}
+251
View File
@@ -0,0 +1,251 @@
/** NixMsg JS/TS SDK — 行为对齐 DEVELOPMENT 第 9 / 6 节。 */
export type ConnectionState =
| "connecting"
| "online"
| "reconnecting"
| "offline"
| "kicked"
| "auth_failed";
export type AuthReason = "session_invalid" | "bad_credentials";
export interface Credential {
password?: string;
sessionToken?: string;
}
export interface Target {
kind: "endpoint" | "group";
id: string;
}
export interface Body {
enc: "utf8" | "base64";
content_type?: string;
data: string;
}
export interface SendOptions {
sendAt?: Date;
delayMs?: number;
keep?: boolean;
ttl?: number;
receipt?: boolean;
talkPassword?: string;
contentType?: string;
meta?: Record<string, unknown>;
id?: string;
}
export interface SendResult {
id: string;
send_at_ms: number;
state: string;
}
export interface Message {
id: string;
from: string;
to: Target;
body: Body;
meta?: Record<string, unknown>;
send_at_ms: number;
}
export interface RecallResult {
result: string;
recalled: number;
accepted: number;
other: number;
}
export interface Receipt {
receipt_id: string;
id: string;
endpoint_id: string;
state: string;
reason: string;
at_ms: number;
}
export interface RevokedEvent {
id: string;
from: string;
reason: string;
}
export interface PresenceEvent {
id: string;
online: boolean;
at_ms: number;
}
export interface GroupEvent {
group_id: string;
event: string;
endpoint_id: string;
at_ms: number;
}
export interface ConnectionEvent {
state: ConnectionState;
reason?: string;
}
export interface HandshakeLimits {
server_time_ms: number;
server_version: string;
max_body_bytes: number;
max_meta_bytes: number;
max_frame_bytes: number;
max_ttl_seconds: number;
max_schedule_seconds: number;
ack_timeout_seconds: number;
}
export interface RegisterOptions {
id?: string;
loginPassword?: string;
name?: string;
talkPassword?: string;
}
export interface RegisterResult {
id: string;
loginPassword?: string;
}
export interface GroupMemberIn {
id: string;
talkPassword?: string;
}
export interface ClientOptions {
manualAck?: boolean;
allowTcp?: boolean;
connectTimeoutMs?: number;
clientLabel?: string;
maxReceiveBytes?: number;
sendQueueSize?: number;
maxInflight?: number;
dedupCapacity?: number;
/** @internal 测试注入 */
transport?: Transport;
}
export class APIError extends Error {
code: string;
constructor(code: string, message = "") {
super(message ? `${code}: ${message}` : code);
this.code = code;
this.name = "APIError";
}
}
export interface Transport {
setCredential(passwordOrToken: string): void;
start(cfg: TransportConfig): Promise<void>;
publishUp(payload: Uint8Array | string): Promise<void>;
stop(): Promise<void>;
}
export interface TransportConfig {
url: string;
endpointId: string;
connectTimeoutMs: number;
allowTcp: boolean;
backoff: ReconnectBackoff;
onDown: (payload: Uint8Array) => void;
onOffline: () => void;
onAuthFailed: (reason: AuthReason) => void;
onKicked: () => void;
mqttReady: () => Promise<void>;
onConnectPacket?: (cleanStart: boolean, sessionExpiry: number) => void;
}
export class ReconnectBackoff {
private base = 1000;
private onlineAt = 0;
private online = false;
private stable = false;
private timer: ReturnType<typeof setTimeout> | null = null;
delay(attempt: number): number {
if (attempt <= 0) return 0;
let d = this.base;
for (let i = 1; i < attempt; i++) {
d = Math.min(d * 2, 30000);
}
const f = 0.7 + Math.random() * 0.6;
return Math.floor(d * f);
}
markOnline(): void {
this.online = true;
this.onlineAt = Date.now();
this.stable = false;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
if (this.online) {
this.stable = true;
this.base = 1000;
}
}, 60_000);
}
markOffline(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const was = this.online;
this.online = false;
if (!was) {
this.base = Math.min(Math.max(this.base * 2, 1000), 30000);
return;
}
if (this.stable || Date.now() - this.onlineAt >= 60_000) {
this.base = 1000;
this.stable = false;
return;
}
this.base = Math.min(this.base * 2, 30000);
this.stable = false;
}
getBase(): number {
return this.base;
}
}
export function buildCleanConnectFlags(): { cleanStart: boolean; sessionExpiry: number } {
return { cleanStart: true, sessionExpiry: 0 };
}
export function registerURLFromConnect(connectURL: string): string {
const u = new URL(connectURL);
switch (u.protocol) {
case "wss:":
case "https:":
case "mqtts:":
u.protocol = "https:";
break;
case "ws:":
case "http:":
case "mqtt:":
u.protocol = "http:";
break;
default:
throw new APIError("bad_request", `无法从 ${u.protocol} 推出注册地址`);
}
u.pathname = "/api/client/register";
u.search = "";
u.hash = "";
return u.toString();
}
export function marshalJSON(v: unknown): string {
// JSON.stringify 默认不转义非 ASCII;与文档一致
return JSON.stringify(v);
}