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
View File
+10
View File
@@ -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.
+2974
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -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"
}
}
+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);
}
+152
View File
@@ -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");
});
});
+14
View File
@@ -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/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
},
});