feat: 实现 Go 与 JS/TS SDK 连接收发与其余接口
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
Copyright (c) 2026 Nixevol. All rights reserved.
|
||||
|
||||
本仓库的源代码、文档、各语言 SDK 和构建产物(包括发布的软件包和 Docker 镜像)均为专有软件。
|
||||
源代码和发布物公开可读,不代表授予任何使用许可。未经版权所有者书面许可,不得使用、复制、
|
||||
修改、合并、发布、分发、再许可或出售其任何部分。
|
||||
|
||||
This repository, including its source code, documentation, SDKs and build artifacts (including
|
||||
published packages and Docker images), is proprietary software. Public visibility does not grant
|
||||
any license. No part of it may be used, copied, modified, merged, published, distributed,
|
||||
sublicensed or sold without prior written permission from the copyright holder.
|
||||
Generated
+2974
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@nixevol/nixmsg",
|
||||
"version": "0.1.0",
|
||||
"description": "NixMsg JavaScript/TypeScript SDK",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
||||
"test": "vitest run",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"mqtt": "^5.10.0",
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Client, FakeTransport, APIError, buildCleanConnectFlags, register } from "../src/index.js";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
async function connectFake(fake: FakeTransport): Promise<Client> {
|
||||
const c = new Client();
|
||||
await c.connect("ws://example.test/mqtt", "ep1", { password: "secret" }, { transport: fake });
|
||||
return c;
|
||||
}
|
||||
|
||||
describe("nixmsg sdk", () => {
|
||||
it("Clean Start every connect", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
await fake.simulateReconnect();
|
||||
await fake.simulateReconnect();
|
||||
const cs = fake.getConnects();
|
||||
expect(cs.length).toBeGreaterThanOrEqual(3);
|
||||
for (const x of cs) {
|
||||
expect(x.cleanStart).toBe(true);
|
||||
expect(x.sessionExpiry).toBe(0);
|
||||
}
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("session token callback", async () => {
|
||||
const fake = new FakeTransport();
|
||||
fake.helloToken = "nst_abc";
|
||||
const c = new Client();
|
||||
let got = "";
|
||||
c.onSessionHandler((t) => {
|
||||
got = t;
|
||||
});
|
||||
await c.connect("ws://example.test/mqtt", "ep1", { password: "p" }, { transport: fake });
|
||||
// 等待串行回调
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(got).toBe("nst_abc");
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("dedup then re-ack", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
let calls = 0;
|
||||
c.onMessageHandler(() => {
|
||||
calls++;
|
||||
});
|
||||
|
||||
const replyAcks = setInterval(() => {
|
||||
for (const fr of fake.findUp("ack")) {
|
||||
fake.replyOK(String(fr.rid), { result: "accepted" });
|
||||
}
|
||||
}, 5);
|
||||
|
||||
const msg = JSON.stringify({
|
||||
v: 1,
|
||||
type: "msg",
|
||||
id: "m1",
|
||||
from: "a",
|
||||
to: { kind: "endpoint", id: "ep1" },
|
||||
body: { enc: "utf8", data: "hi" },
|
||||
send_at_ms: 1,
|
||||
});
|
||||
fake.injectDown(msg);
|
||||
fake.injectDown(msg);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
fake.injectDown(msg);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
clearInterval(replyAcks);
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(fake.findUp("ack").length).toBeGreaterThanOrEqual(2);
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("body too large locally", async () => {
|
||||
const fake = new FakeTransport();
|
||||
fake.maxBodyBytes = 16;
|
||||
const c = await connectFake(fake);
|
||||
await expect(
|
||||
c.send(
|
||||
{ kind: "endpoint", id: "b" },
|
||||
{ enc: "utf8", data: "x".repeat(64) },
|
||||
),
|
||||
).rejects.toMatchObject({ code: "body_too_large" });
|
||||
await c.close();
|
||||
});
|
||||
|
||||
it("resend keeps id and send_at_ms", async () => {
|
||||
const fake = new FakeTransport();
|
||||
const c = await connectFake(fake);
|
||||
const at = new Date(1_700_000_000_000);
|
||||
let firstId = "";
|
||||
let firstSendAt: unknown;
|
||||
let replied = false;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const sends = fake.findUp("send");
|
||||
if (!sends.length) return;
|
||||
if (!replied) {
|
||||
replied = true;
|
||||
firstId = String(sends[0].id);
|
||||
firstSendAt = sends[0].send_at_ms;
|
||||
fake.replyErr(String(sends[0].rid), "rate_limited", "slow");
|
||||
return;
|
||||
}
|
||||
if (sends.length >= 2) {
|
||||
expect(sends[1].id).toBe(firstId);
|
||||
expect(sends[1].send_at_ms).toBe(firstSendAt);
|
||||
fake.replyOK(String(sends[1].rid), {
|
||||
id: firstId,
|
||||
send_at_ms: firstSendAt,
|
||||
state: "scheduled",
|
||||
});
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 20);
|
||||
|
||||
const res = await c.send(
|
||||
{ kind: "endpoint", id: "b" },
|
||||
{ enc: "utf8", data: "hi" },
|
||||
{ sendAt: at },
|
||||
);
|
||||
expect(res.id).toBe(firstId);
|
||||
await c.close();
|
||||
}, 10000);
|
||||
|
||||
it("register HTTP from ws url", async () => {
|
||||
const srv = createServer((req, res) => {
|
||||
expect(req.url).toBe("/api/client/register");
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, data: { id: "e_1", login_password: "gen" } }));
|
||||
});
|
||||
await new Promise<void>((r) => srv.listen(0, "127.0.0.1", r));
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("addr");
|
||||
const ws = `ws://127.0.0.1:${addr.port}/mqtt`;
|
||||
const res = await register(ws, "code", { name: "n" });
|
||||
expect(res.id).toBe("e_1");
|
||||
expect(res.loginPassword).toBe("gen");
|
||||
await new Promise<void>((r) => srv.close(() => r()));
|
||||
});
|
||||
|
||||
it("buildCleanConnectFlags", () => {
|
||||
expect(buildCleanConnectFlags()).toEqual({ cleanStart: true, sessionExpiry: 0 });
|
||||
});
|
||||
|
||||
it("APIError shape", () => {
|
||||
const e = new APIError("body_too_large", "x");
|
||||
expect(e.code).toBe("body_too_large");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user