Compare commits

..
Author SHA1 Message Date
Nixevol 9650cbff76 feat: 实现 Go 与 JS/TS SDK 连接收发与其余接口 2026-09-30 07:03:33 +08:00
32 changed files with 7145 additions and 1 deletions
+40 -1
View File
@@ -422,7 +422,46 @@
## SDK 一 S1
暂无。
### S1.1 传输层可注入假实现(Go / JS)
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
- 实际做法:Go 与 JS SDK 均以 `transport` 接口隔离 MQTT;单测注入 `FakeTransport`,自动回复 `hello`、可注入下行帧与 `resp`。真实路径仍分别走 autopaho / MQTT.js。
- 原因:否则无法在无服务端时稳定覆盖 Clean Start、去重再 ack、本地超限、令牌回调、重交不变号。
- 备选:起嵌入式 mochi;被否是因为任务明确禁止真实服务器,且会与并行 Agent 抢端口。
### S1.2 Go autopaho `OnConnectionUp` 内握手改异步
- 相关文档:autopaho 要求 `OnConnectionUp` 不得阻塞;DEVELOPMENT 第 9 节要求订阅 down 后发 `hello` 并等待成功。
- 实际做法:订阅与 `hello` 放在 `OnConnectionUp` 触发的 goroutine 中;`Connect` 轮询 `handshook` 直至超时(默认 30s)。
- 原因:在回调里同步 `hello` 会违反库约束并可能死锁。
- 备选:自定义连接循环不用 autopaho 的 `OnConnectionUp`;未采用,因文档指定 autopaho。
### S1.3 重连退避与「稳定在线 60s」状态机自管
- 相关文档:DEVELOPMENT 第 9 节退避规则。
- 实际做法:自实现 `reconnectBackoff`(1s 起、加倍、上限 30s、±30% 抖动;在线满 60s 将 base 恢复为 1s)。Go 将其接到 autopaho 的 `ReconnectBackoff`;JS 因 `reconnectPeriod: 0` 自管重连循环并使用同一算法。
- 原因:autopaho 自带指数退避参数模型与文档不完全一致,且每次 `establishServerConnection` 会重置 attempt,无法单独表达「未稳定在线则跨周期继续抬升」。
- 备选:直接用 `autopaho.NewExponentialBackoff`;未采用,以免与文档抖动与 60s 恢复语义偏离。
### S1.4 Go 模块许可证标注
- 相关文档:DEVELOPMENT 第 9 节「Go 写 SEE LICENSE,包里带上仓库根目录 LICENSE」。
- 实际做法:`sdk/go/LICENSE` 为仓库根 `LICENSE` 副本;`doc.go` 注明专有许可见 LICENSE(Go modules 无 npm 式 license 字段)。
- 原因:go.mod 无标准 license 键。
- 备选:另加 `LICENSE.md` 指向根目录相对路径;副本更利于 `go get` 后独立阅读。
### S1.5 JS 回调命名与文档概念名
- 相关文档:第 9 节概念方法名 `onSession` / `onMessage` 等。
- 实际做法:TypeScript 对外提供 `onSessionHandler`、`onMessageHandler` 等,避免与 EventEmitter 风格或属性赋值混淆;语义与文档一致。
- 原因:`onSession` 作方法名在部分风格指南中易被误认为事件订阅属性。
- 备选:完全同名方法;可在任务 5 文档化时再加别名。
### S1.6 未做范围
- 任务 4 真实服务器接入清单(15 条)未做,按总控安排留给后续波次。
- 任务 5 打包试跑/README/示例未做(本期只完成任务 1–3 与许可证文件副本;JS 已能 `npm run build` 出 ESM+CJS,但未发布 npm)。
- Go 裸 TCP(`AllowTCP`)与 JS `allowTcp` 已接线,但无集成级验证(属接入清单)。
## SDK 二 S2
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.
+224
View File
@@ -0,0 +1,224 @@
package nixmsg
import (
"context"
"encoding/json"
)
// Recall 撤回消息。
func (c *Client) Recall(ctx context.Context, id string) (RecallResult, error) {
data, err := c.request(ctx, map[string]any{"v": 1, "type": "recall", "rid": c.nextRID(), "id": id}, false)
if err != nil {
return RecallResult{}, err
}
var r RecallResult
_ = json.Unmarshal(data, &r)
return r, nil
}
// Status 查询发送状态。
func (c *Client) Status(ctx context.Context, id, cursor string, limit int) (json.RawMessage, error) {
req := map[string]any{"v": 1, "type": "status", "rid": c.nextRID(), "id": id}
if cursor != "" {
req["cursor"] = cursor
}
if limit > 0 {
req["limit"] = limit
}
return c.request(ctx, req, false)
}
// Unlock 解锁对话密码。
func (c *Client) Unlock(ctx context.Context, endpointID, talkPassword string) error {
_, err := c.request(ctx, map[string]any{
"v": 1, "type": "unlock", "rid": c.nextRID(),
"endpoint_id": endpointID, "talk_password": talkPassword,
}, false)
return err
}
// Presence 查询在线。
func (c *Client) Presence(ctx context.Context, ids []string) ([]PresenceItem, error) {
data, err := c.request(ctx, map[string]any{"v": 1, "type": "presence.get", "rid": c.nextRID(), "ids": ids}, false)
if err != nil {
return nil, err
}
var wrap struct {
Items []PresenceItem `json:"items"`
}
if json.Unmarshal(data, &wrap) == nil && wrap.Items != nil {
return wrap.Items, nil
}
var items []PresenceItem
if err := json.Unmarshal(data, &items); err == nil {
return items, nil
}
return nil, nil
}
// Directory 列目录。
func (c *Client) Directory(ctx context.Context, cursor, query string, limit int) (json.RawMessage, error) {
req := map[string]any{"v": 1, "type": "directory.list", "rid": c.nextRID()}
if cursor != "" {
req["cursor"] = cursor
}
if query != "" {
req["query"] = query
}
if limit > 0 {
req["limit"] = limit
}
return c.request(ctx, req, false)
}
// WatchPresence 订阅上下线;ids 为空且 all 为 true 表示全部。
func (c *Client) WatchPresence(ctx context.Context, ids []string, all bool) error {
req := map[string]any{"v": 1, "type": "presence.watch", "rid": c.nextRID(), "all": all}
if len(ids) > 0 {
req["ids"] = ids
}
_, err := c.request(ctx, req, false)
return err
}
// GetSelf 读取自己的资料。
func (c *Client) GetSelf(ctx context.Context) (SelfInfo, error) {
data, err := c.request(ctx, map[string]any{"v": 1, "type": "self.get", "rid": c.nextRID()}, false)
if err != nil {
return SelfInfo{}, err
}
var s SelfInfo
_ = json.Unmarshal(data, &s)
return s, nil
}
// UpdateSelf 更新资料。
func (c *Client) UpdateSelf(ctx context.Context, name string, defaultDelayMs *int64) error {
req := map[string]any{"v": 1, "type": "self.update", "rid": c.nextRID()}
if name != "" {
req["name"] = name
}
if defaultDelayMs != nil {
req["default_delay_ms"] = *defaultDelayMs
}
_, err := c.request(ctx, req, false)
return err
}
// SetTalkPassword 设置对话密码;空字符串清除。
func (c *Client) SetTalkPassword(ctx context.Context, talkPassword string) error {
_, err := c.request(ctx, map[string]any{
"v": 1, "type": "self.talk_password", "rid": c.nextRID(), "talk_password": talkPassword,
}, false)
return err
}
// ChangeLoginPassword 修改登录密码;成功后若返回新令牌会经 onSession 交出。
func (c *Client) ChangeLoginPassword(ctx context.Context, oldPassword, newPassword string) error {
data, err := c.request(ctx, map[string]any{
"v": 1, "type": "self.login_password", "rid": c.nextRID(),
"old_password": oldPassword, "new_password": newPassword,
}, false)
if err != nil {
return err
}
var d struct {
SessionToken string `json:"session_token"`
}
if json.Unmarshal(data, &d) == nil && d.SessionToken != "" {
c.mu.Lock()
c.session = d.SessionToken
if c.transport != nil {
c.transport.SetCredential(d.SessionToken)
}
c.mu.Unlock()
if c.onSession != nil {
c.cbMu.Lock()
c.onSession(d.SessionToken)
c.cbMu.Unlock()
}
}
return nil
}
// CreateGroup 建群。
func (c *Client) CreateGroup(ctx context.Context, id, name string, members []GroupMemberIn) (json.RawMessage, error) {
ms := make([]map[string]any, 0, len(members))
for _, m := range members {
ms = append(ms, map[string]any{"id": m.ID, "talk_password": m.TalkPassword})
}
return c.request(ctx, map[string]any{
"v": 1, "type": "group.create", "rid": c.nextRID(), "id": id, "name": name, "members": ms,
}, false)
}
// AddGroupMembers 加成员。
func (c *Client) AddGroupMembers(ctx context.Context, groupID string, members []GroupMemberIn) (json.RawMessage, error) {
ms := make([]map[string]any, 0, len(members))
for _, m := range members {
ms = append(ms, map[string]any{"id": m.ID, "talk_password": m.TalkPassword})
}
return c.request(ctx, map[string]any{
"v": 1, "type": "group.add", "rid": c.nextRID(), "group_id": groupID, "members": ms,
}, false)
}
// RemoveGroupMember 移除成员。
func (c *Client) RemoveGroupMember(ctx context.Context, groupID, endpointID string) error {
_, err := c.request(ctx, map[string]any{
"v": 1, "type": "group.remove", "rid": c.nextRID(), "group_id": groupID, "endpoint_id": endpointID,
}, false)
return err
}
// LeaveGroup 退群。
func (c *Client) LeaveGroup(ctx context.Context, groupID string) error {
_, err := c.request(ctx, map[string]any{"v": 1, "type": "group.leave", "rid": c.nextRID(), "group_id": groupID}, false)
return err
}
// TransferGroup 转让群主。
func (c *Client) TransferGroup(ctx context.Context, groupID, endpointID string) error {
_, err := c.request(ctx, map[string]any{
"v": 1, "type": "group.transfer", "rid": c.nextRID(), "group_id": groupID, "endpoint_id": endpointID,
}, false)
return err
}
// RenameGroup 改群名。
func (c *Client) RenameGroup(ctx context.Context, groupID, name string) error {
_, err := c.request(ctx, map[string]any{
"v": 1, "type": "group.rename", "rid": c.nextRID(), "group_id": groupID, "name": name,
}, false)
return err
}
// DissolveGroup 解散群。
func (c *Client) DissolveGroup(ctx context.Context, groupID string) error {
_, err := c.request(ctx, map[string]any{"v": 1, "type": "group.dissolve", "rid": c.nextRID(), "group_id": groupID}, false)
return err
}
// ListGroups 列出我的群。
func (c *Client) ListGroups(ctx context.Context, cursor string, limit int) (json.RawMessage, error) {
req := map[string]any{"v": 1, "type": "group.list", "rid": c.nextRID()}
if cursor != "" {
req["cursor"] = cursor
}
if limit > 0 {
req["limit"] = limit
}
return c.request(ctx, req, false)
}
// GetGroup 获取群详情。
func (c *Client) GetGroup(ctx context.Context, groupID, cursor string, limit int) (json.RawMessage, error) {
req := map[string]any{"v": 1, "type": "group.get", "rid": c.nextRID(), "group_id": groupID}
if cursor != "" {
req["cursor"] = cursor
}
if limit > 0 {
req["limit"] = limit
}
return c.request(ctx, req, false)
}
+122
View File
@@ -0,0 +1,122 @@
package nixmsg
import (
"math/rand"
"sync"
"time"
)
// reconnectBackoff 按 DEVELOPMENT 第 9 节:1s 起、加倍、上限 30s、±30% 抖动;
// 稳定在线 60s 后恢复到 1s。
type reconnectBackoff struct {
mu sync.Mutex
base time.Duration
onlineAt time.Time
online bool
stable bool
timer *time.Timer
}
func newReconnectBackoff() *reconnectBackoff {
return &reconnectBackoff{base: time.Second}
}
func (b *reconnectBackoff) Func(attempt int) time.Duration {
b.mu.Lock()
defer b.mu.Unlock()
if attempt <= 0 {
return 0
}
d := b.base
for i := 1; i < attempt; i++ {
d *= 2
if d > 30*time.Second {
d = 30 * time.Second
break
}
}
return withJitter(d)
}
func (b *reconnectBackoff) MarkOnline() {
b.mu.Lock()
defer b.mu.Unlock()
b.online = true
b.onlineAt = time.Now()
b.stable = false
if b.timer != nil {
b.timer.Stop()
}
b.timer = time.AfterFunc(60*time.Second, func() {
b.mu.Lock()
defer b.mu.Unlock()
if b.online {
b.stable = true
b.base = time.Second
}
})
}
func (b *reconnectBackoff) MarkOffline() {
b.mu.Lock()
defer b.mu.Unlock()
if b.timer != nil {
b.timer.Stop()
b.timer = nil
}
wasOnline := b.online
b.online = false
if !wasOnline {
// 连接尚未成功就失败:在 Func 内已按 attempt 加倍,这里把 base 提到下次周期的起点。
next := b.base * 2
if next > 30*time.Second {
next = 30 * time.Second
}
if next < time.Second {
next = time.Second
}
b.base = next
return
}
if b.stable || time.Since(b.onlineAt) >= 60*time.Second {
b.base = time.Second
b.stable = false
return
}
next := b.base * 2
if next > 30*time.Second {
next = 30 * time.Second
}
b.base = next
b.stable = false
}
func (b *reconnectBackoff) Base() time.Duration {
b.mu.Lock()
defer b.mu.Unlock()
return b.base
}
func withJitter(d time.Duration) time.Duration {
// ±30%
f := 0.7 + rand.Float64()*0.6
return time.Duration(float64(d) * f)
}
// computeBackoffDelay 供单测:无 attempt 与 base 计算无抖动前的标称延迟。
func computeBackoffDelay(base time.Duration, attempt int) time.Duration {
if attempt <= 0 {
return 0
}
d := base
for i := 1; i < attempt; i++ {
d *= 2
if d > 30*time.Second {
return 30 * time.Second
}
}
if d > 30*time.Second {
return 30 * time.Second
}
return d
}
+140
View File
@@ -0,0 +1,140 @@
package nixmsg
import (
"context"
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
)
type pendingReq struct {
rid string
ch chan respFrame
}
type sendItem struct {
frame map[string]any
payload []byte
id string
sendAtMs *int64
result chan sendOutcome
inflight bool
}
type sendOutcome struct {
res SendResult
err error
}
type dedupState int
const (
dedupDelivered dedupState = iota + 1
dedupAcked
)
type dedupEntry struct {
state dedupState
from string
id string
}
// Client NixMsg 客户端。
type Client struct {
opts Options
endpointID string
url string
credKind string
transport transport
backoff *reconnectBackoff
mu sync.Mutex
state ConnectionState
handshook bool
limits HandshakeLimits
clockSkew int64
session string
stopReconnect bool
closed bool
ridSeq atomic.Uint64
pending map[string]*pendingReq
sendQ []*sendItem
inflight int
dedup map[string]*dedupEntry
dedupOrd []string
receiptSeen map[string]struct{}
cbMu sync.Mutex
onSession func(token string)
onMessage func(msg Message) error
onReceipt func(r Receipt)
onRevoked func(e RevokedEvent)
onPresence func(e PresenceEvent)
onGroupEvent func(e GroupEvent)
onConnection func(ev ConnectionEvent)
helloSentAt time.Time
ctx context.Context
cancel context.CancelFunc
}
// New 创建客户端(尚未连接)。
func New() *Client {
return &Client{
pending: make(map[string]*pendingReq),
dedup: make(map[string]*dedupEntry),
receiptSeen: make(map[string]struct{}),
state: StateOffline,
backoff: newReconnectBackoff(),
}
}
// OnSession 会话令牌回调。
func (c *Client) OnSession(h func(token string)) { c.onSession = h }
// OnMessage 消息回调。自动模式下返回 error 则不发 ack 并删去重记录。
func (c *Client) OnMessage(h func(msg Message) error) { c.onMessage = h }
// OnReceipt 回执回调。
func (c *Client) OnReceipt(h func(r Receipt)) { c.onReceipt = h }
// OnRevoked 撤回/作废回调。
func (c *Client) OnRevoked(h func(e RevokedEvent)) { c.onRevoked = h }
// OnPresence 上下线回调。
func (c *Client) OnPresence(h func(e PresenceEvent)) { c.onPresence = h }
// OnGroupEvent 群事件回调。
func (c *Client) OnGroupEvent(h func(e GroupEvent)) { c.onGroupEvent = h }
// OnConnection 连接状态回调。
func (c *Client) OnConnection(h func(ev ConnectionEvent)) { c.onConnection = h }
func newMessageID() string {
id, err := uuid.NewV7()
if err != nil {
return uuid.NewString()
}
return id.String()
}
func (c *Client) nextRID() string {
return fmt.Sprintf("%d", c.ridSeq.Add(1))
}
type respFrame struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
+254
View File
@@ -0,0 +1,254 @@
package nixmsg
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func connectFake(t *testing.T, fake *FakeTransport) *Client {
t.Helper()
c := New()
opts := Options{transport: fake, ConnectTimeout: 5 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := c.Connect(ctx, "ws://example.test/mqtt", "ep1", Credential{Password: "secret"}, opts); err != nil {
t.Fatalf("connect: %v", err)
}
return c
}
func TestCleanStartEveryConnect(t *testing.T) {
fake := NewFakeTransport()
c := connectFake(t, fake)
defer c.Close()
if err := fake.SimulateReconnect(); err != nil {
t.Fatal(err)
}
if err := fake.SimulateReconnect(); err != nil {
t.Fatal(err)
}
cs := fake.Connects()
if len(cs) < 3 {
t.Fatalf("connects=%d want >=3", len(cs))
}
for i, c0 := range cs {
if !c0.CleanStart {
t.Fatalf("connect %d CleanStart=false", i)
}
if c0.SessionExpiry != 0 {
t.Fatalf("connect %d SessionExpiry=%d", i, c0.SessionExpiry)
}
}
}
func TestSessionTokenCallback(t *testing.T) {
fake := NewFakeTransport()
fake.HelloToken = "nst_abc"
c := New()
var got string
c.OnSession(func(tok string) { got = tok })
opts := Options{transport: fake, ConnectTimeout: 5 * time.Second}
ctx := context.Background()
if err := c.Connect(ctx, "ws://example.test/mqtt", "ep1", Credential{Password: "p"}, opts); err != nil {
t.Fatal(err)
}
defer c.Close()
if got != "nst_abc" {
t.Fatalf("token=%q", got)
}
}
func TestDedupReack(t *testing.T) {
fake := NewFakeTransport()
c := connectFake(t, fake)
defer c.Close()
var calls int
var mu sync.Mutex
c.OnMessage(func(msg Message) error {
mu.Lock()
calls++
mu.Unlock()
return nil
})
// 自动回复 ack
go func() {
for i := 0; i < 50; i++ {
for _, fr := range fake.FindUp("ack") {
rid, _ := fr["rid"].(string)
if rid != "" {
fake.ReplyOK(rid, map[string]any{"result": "accepted"})
}
}
time.Sleep(5 * time.Millisecond)
}
}()
msg, _ := marshalJSON(map[string]any{
"v": 1, "type": "msg", "id": "m1", "from": "a",
"to": map[string]any{"kind": "endpoint", "id": "ep1"},
"body": map[string]any{"enc": "utf8", "data": "hi"},
"send_at_ms": 1,
})
fake.InjectDown(msg)
fake.InjectDown(msg) // 已交给未确认:忽略
time.Sleep(50 * time.Millisecond)
// 等 ack 发出并标记已确认后再推一次
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(fake.FindUp("ack")) >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
time.Sleep(30 * time.Millisecond)
fake.InjectDown(msg) // 已确认:再 ack,不回调
time.Sleep(80 * time.Millisecond)
mu.Lock()
n := calls
mu.Unlock()
if n != 1 {
t.Fatalf("callbacks=%d want 1", n)
}
acks := fake.FindUp("ack")
if len(acks) < 2 {
t.Fatalf("acks=%d want >=2 (re-ack)", len(acks))
}
}
func TestBodyTooLargeLocal(t *testing.T) {
fake := NewFakeTransport()
fake.MaxBodyBytes = 16
c := connectFake(t, fake)
defer c.Close()
body := Body{Enc: "utf8", Data: strings.Repeat("x", 64)}
_, err := c.Send(context.Background(), Target{Kind: "endpoint", ID: "b"}, body, SendOptions{})
var ae *APIError
if !errors.As(err, &ae) || ae.Code != CodeBodyTooLarge {
t.Fatalf("err=%v", err)
}
}
func TestFrameTooLargeLocal(t *testing.T) {
fake := NewFakeTransport()
fake.MaxBodyBytes = 1 << 20
fake.MaxFrameBytes = 200
c := connectFake(t, fake)
defer c.Close()
body := Body{Enc: "utf8", Data: strings.Repeat("y", 180)}
_, err := c.Send(context.Background(), Target{Kind: "endpoint", ID: "b"}, body, SendOptions{})
var ae *APIError
if !errors.As(err, &ae) || ae.Code != CodeFrameTooLarge {
t.Fatalf("err=%v", err)
}
}
func TestResendKeepsIDAndSendAt(t *testing.T) {
fake := NewFakeTransport()
c := connectFake(t, fake)
defer c.Close()
at := time.UnixMilli(1_700_000_000_000)
var firstID string
var firstSendAt any
var once sync.Once
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
default:
}
sends := fake.FindUp("send")
if len(sends) == 0 {
time.Sleep(5 * time.Millisecond)
continue
}
last := sends[len(sends)-1]
rid, _ := last["rid"].(string)
once.Do(func() {
firstID, _ = last["id"].(string)
firstSendAt = last["send_at_ms"]
fake.ReplyErr(rid, CodeRateLimited, "slow")
})
if len(sends) >= 2 {
second := sends[1]
if second["id"] != firstID {
t.Errorf("id changed %v -> %v", firstID, second["id"])
}
if second["send_at_ms"] != firstSendAt {
t.Errorf("send_at_ms changed %v -> %v", firstSendAt, second["send_at_ms"])
}
fake.ReplyOK(second["rid"].(string), map[string]any{
"id": firstID, "send_at_ms": firstSendAt, "state": "scheduled",
})
return
}
time.Sleep(5 * time.Millisecond)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := c.Send(ctx, Target{Kind: "endpoint", ID: "b"}, Body{Enc: "utf8", Data: "hi"}, SendOptions{SendAt: &at})
close(done)
if err != nil {
t.Fatal(err)
}
if res.ID == "" || res.ID != firstID {
t.Fatalf("result id=%q first=%q", res.ID, firstID)
}
}
func TestRegisterHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/client/register" {
t.Fatalf("path %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "data": map[string]any{"id": "e_1", "login_password": "genpass"},
})
}))
defer srv.Close()
// 从 ws 地址推出
ws := "ws" + strings.TrimPrefix(srv.URL, "http") + "/mqtt"
res, err := Register(context.Background(), ws, "code", RegisterOptions{Name: "n"})
if err != nil {
t.Fatal(err)
}
if res.ID != "e_1" || res.LoginPassword != "genpass" {
t.Fatalf("%+v", res)
}
}
func TestBuildCleanConnectFlags(t *testing.T) {
clean, exp := buildCleanConnectFlags()
if !clean || exp != 0 {
t.Fatalf("clean=%v exp=%d", clean, exp)
}
}
func TestBackoffNominal(t *testing.T) {
if d := computeBackoffDelay(time.Second, 1); d != time.Second {
t.Fatal(d)
}
if d := computeBackoffDelay(time.Second, 6); d != 30*time.Second {
t.Fatal(d)
}
}
+265
View File
@@ -0,0 +1,265 @@
package nixmsg
import (
"context"
"encoding/json"
"time"
)
// Connect 连接服务器。credential 为密码或会话令牌。
func (c *Client) Connect(ctx context.Context, rawURL, endpointID string, credential Credential, opts Options) error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return apiErr(CodeClosed, "已关闭")
}
if c.transport != nil {
c.mu.Unlock()
return apiErr(CodeBadRequest, "已在连接中")
}
o := opts.withDefaults()
c.opts = o
c.endpointID = endpointID
c.url = rawURL
c.stopReconnect = false
c.handshook = false
pass := credential.Password
if credential.SessionToken != "" {
pass = credential.SessionToken
c.credKind = "token"
} else {
c.credKind = "password"
}
var tr transport
if o.transport != nil {
tr = o.transport
} else {
tr = newMQTTTransport()
}
c.transport = tr
c.backoff = newReconnectBackoff()
inner, cancel := context.WithCancel(context.Background())
c.ctx = inner
c.cancel = cancel
c.setStateLocked(StateConnecting, "")
c.mu.Unlock()
tr.SetCredential(pass)
cfg := transportConfig{
URL: rawURL,
EndpointID: endpointID,
ConnectTimeout: o.ConnectTimeout,
AllowTCP: o.AllowTCP,
Backoff: c.backoff,
OnDown: c.handleDown,
OnOffline: func() {
c.mu.Lock()
c.handshook = false
if !c.stopReconnect && !c.closed {
c.setStateLocked(StateReconnecting, "")
}
c.mu.Unlock()
},
OnAuthFailed: func(reason AuthReason) { c.failAuth(reason) },
OnKicked: func() { c.failKicked() },
MQTTReady: func(readyCtx context.Context) error { return c.doHello(readyCtx) },
}
if err := tr.Start(inner, cfg); err != nil {
return err
}
deadline := time.Now().Add(o.ConnectTimeout)
for time.Now().Before(deadline) {
c.mu.Lock()
ok := c.handshook
failed := c.stopReconnect
st := c.state
c.mu.Unlock()
if ok {
return nil
}
if failed || st == StateAuthFailed || st == StateKicked {
return apiErr(CodeAuthFailed, string(st))
}
select {
case <-ctx.Done():
_ = c.Close()
return ctx.Err()
case <-time.After(20 * time.Millisecond):
}
}
_ = c.Close()
return apiErr(CodeNotConnected, "连接超时")
}
func (c *Client) failAuth(reason AuthReason) {
c.mu.Lock()
c.stopReconnect = true
c.handshook = false
c.setStateLocked(StateAuthFailed, string(reason))
c.failQueuedLocked(apiErr(string(reason), "认证失败,停止重连"))
cancel := c.cancel
c.mu.Unlock()
if cancel != nil {
cancel()
}
}
func (c *Client) failKicked() {
c.mu.Lock()
c.stopReconnect = true
c.handshook = false
c.setStateLocked(StateKicked, "0x8E")
c.failQueuedLocked(apiErr(CodeKicked, "被顶号,停止重连"))
cancel := c.cancel
c.mu.Unlock()
if cancel != nil {
cancel()
}
}
func (c *Client) setStateLocked(st ConnectionState, reason string) {
c.state = st
h := c.onConnection
ev := ConnectionEvent{State: st, Reason: reason}
go func() {
c.cbMu.Lock()
defer c.cbMu.Unlock()
if h != nil {
h(ev)
}
}()
}
func (c *Client) doHello(ctx context.Context) error {
c.mu.Lock()
c.helloSentAt = time.Now()
sentAt := c.helloSentAt
label := c.opts.ClientLabel
maxRecv := c.opts.MaxReceiveBytes
c.mu.Unlock()
req := map[string]any{
"v": 1,
"type": "hello",
"rid": c.nextRID(),
"client": label,
}
if maxRecv > 0 {
req["max_receive_bytes"] = maxRecv
}
data, err := c.request(ctx, req, true)
if err != nil {
return err
}
recvAt := time.Now()
var hd struct {
ServerTimeMs int64 `json:"server_time_ms"`
ServerVersion string `json:"server_version"`
MaxBodyBytes int `json:"max_body_bytes"`
MaxMetaBytes int `json:"max_meta_bytes"`
MaxFrameBytes int `json:"max_frame_bytes"`
MaxTTLSeconds int64 `json:"max_ttl_seconds"`
MaxScheduleSeconds int64 `json:"max_schedule_seconds"`
AckTimeoutSeconds int64 `json:"ack_timeout_seconds"`
SessionToken string `json:"session_token"`
}
if err := json.Unmarshal(data, &hd); err != nil {
return err
}
mid := (sentAt.UnixMilli() + recvAt.UnixMilli()) / 2
skew := hd.ServerTimeMs - mid
c.mu.Lock()
c.limits = HandshakeLimits{
ServerTimeMs: hd.ServerTimeMs,
ServerVersion: hd.ServerVersion,
MaxBodyBytes: hd.MaxBodyBytes,
MaxMetaBytes: hd.MaxMetaBytes,
MaxFrameBytes: hd.MaxFrameBytes,
MaxTTLSeconds: hd.MaxTTLSeconds,
MaxScheduleSeconds: hd.MaxScheduleSeconds,
AckTimeoutSeconds: hd.AckTimeoutSeconds,
}
c.clockSkew = skew
c.handshook = true
c.setStateLocked(StateOnline, "")
token := hd.SessionToken
if token != "" {
c.session = token
c.transport.SetCredential(token)
c.credKind = "token"
}
c.mu.Unlock()
if token != "" && c.onSession != nil {
c.cbMu.Lock()
c.onSession(token)
c.cbMu.Unlock()
}
c.drainSendQueue()
return nil
}
// ClockSkewMs 当前服务器时间偏差(毫秒)。
func (c *Client) ClockSkewMs() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.clockSkew
}
// Limits 握手上限。
func (c *Client) Limits() HandshakeLimits {
c.mu.Lock()
defer c.mu.Unlock()
return c.limits
}
// Logout 作废会话并停止重连。
func (c *Client) Logout(ctx context.Context) error {
req := map[string]any{"v": 1, "type": "self.logout", "rid": c.nextRID()}
_, err := c.request(ctx, req, false)
c.mu.Lock()
c.stopReconnect = true
c.session = ""
cancel := c.cancel
c.mu.Unlock()
if cancel != nil {
cancel()
}
return err
}
// Close 关闭连接并停止重连。
func (c *Client) Close() error {
c.mu.Lock()
c.closed = true
c.stopReconnect = true
c.failQueuedLocked(apiErr(CodeClosed, "已关闭"))
tr := c.transport
cancel := c.cancel
c.setStateLocked(StateOffline, "")
c.mu.Unlock()
if cancel != nil {
cancel()
}
if tr != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return tr.Stop(ctx)
}
return nil
}
func (c *Client) failQueuedLocked(err error) {
for _, it := range c.sendQ {
if it.result != nil {
select {
case it.result <- sendOutcome{err: err}:
default:
}
}
}
c.sendQ = nil
c.inflight = 0
}
+7
View File
@@ -0,0 +1,7 @@
// Package nixmsg 是 NixMsg 的 Go SDK。
//
// 模块路径:git.asio.asia/nixevol/NixMsg/sdk/go
// 行为以 docs/DEVELOPMENT.md 第 9 节与第 6 节为准。
//
// 许可证:SEE LICENSE(本目录 LICENSE 为仓库根许可证副本)。
package nixmsg
+46
View File
@@ -0,0 +1,46 @@
package nixmsg
import "fmt"
// APIError 是带协议错误码的错误。
type APIError struct {
Code string
Message string
}
func (e *APIError) Error() string {
if e.Message == "" {
return e.Code
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func apiErr(code, msg string) *APIError {
return &APIError{Code: code, Message: msg}
}
// 常用错误码(与 DEVELOPMENT 第 6.10 节一致)。
const (
CodeBodyTooLarge = "body_too_large"
CodeFrameTooLarge = "frame_too_large"
CodeMetaTooLarge = "meta_too_large"
CodeRateLimited = "rate_limited"
CodeBadRequest = "bad_request"
CodeNotReady = "not_ready"
CodeBusy = "busy"
CodeSessionInvalid = "session_invalid"
CodeBadCredentials = "bad_credentials"
CodeQueueFull = "queue_full"
CodeNotConnected = "not_connected"
CodeClosed = "closed"
CodeAuthFailed = "auth_failed"
CodeKicked = "kicked"
)
// AuthReason 是认证失败原因。
type AuthReason string
const (
AuthSessionInvalid AuthReason = "session_invalid"
AuthBadCredentials AuthReason = "bad_credentials"
)
+13
View File
@@ -0,0 +1,13 @@
module git.asio.asia/nixevol/NixMsg/sdk/go
go 1.27.1
require (
github.com/eclipse/paho.golang v0.23.0
github.com/google/uuid v1.6.0
)
require (
github.com/gorilla/websocket v1.5.3 // indirect
golang.org/x/net v0.43.0 // indirect
)
+20
View File
@@ -0,0 +1,20 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+36
View File
@@ -0,0 +1,36 @@
package nixmsg
import (
"bytes"
"encoding/json"
"io"
)
func marshalJSON(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
b := buf.Bytes()
if n := len(b); n > 0 && b[n-1] == '\n' {
b = b[:n-1]
}
return b, nil
}
func unmarshalJSON(data []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
return dec.Decode(v)
}
func writeJSON(w io.Writer, v any) error {
b, err := marshalJSON(v)
if err != nil {
return err
}
_, err = w.Write(b)
return err
}
+326
View File
@@ -0,0 +1,326 @@
package nixmsg
import (
"context"
"encoding/base64"
"encoding/json"
"time"
)
func (c *Client) handleDown(payload []byte) {
var head struct {
Type string `json:"type"`
RID string `json:"rid"`
}
if err := unmarshalJSON(payload, &head); err != nil {
return
}
switch head.Type {
case "resp":
var rf respFrame
if err := unmarshalJSON(payload, &rf); err != nil {
return
}
c.mu.Lock()
p := c.pending[head.RID]
if p != nil {
delete(c.pending, head.RID)
}
c.mu.Unlock()
if p != nil {
select {
case p.ch <- rf:
default:
}
}
case "msg":
c.handleMsg(payload)
case "receipt":
c.handleReceipt(payload)
case "revoked":
c.handleRevoked(payload)
case "presence":
c.handlePresence(payload)
case "group_event":
c.handleGroupEvent(payload)
case "fatal":
var f struct {
Reason string `json:"reason"`
}
_ = unmarshalJSON(payload, &f)
c.mu.Lock()
c.stopReconnect = true
c.setStateLocked(StateAuthFailed, f.Reason)
c.failQueuedLocked(apiErr("fatal", f.Reason))
cancel := c.cancel
c.mu.Unlock()
if cancel != nil {
cancel()
}
}
}
func (c *Client) handleMsg(payload []byte) {
var m struct {
ID string `json:"id"`
From string `json:"from"`
To Target `json:"to"`
Body Body `json:"body"`
Meta map[string]any `json:"meta"`
SendAtMs int64 `json:"send_at_ms"`
}
if err := unmarshalJSON(payload, &m); err != nil {
return
}
key := m.From + "\x00" + m.ID
c.mu.Lock()
ent := c.dedup[key]
manual := c.opts.ManualAck
if ent != nil && ent.state == dedupAcked {
c.mu.Unlock()
_ = c.sendAckFrame(m.From, m.ID)
return
}
if ent != nil && ent.state == dedupDelivered {
c.mu.Unlock()
return
}
c.rememberDedupLocked(key, m.From, m.ID, dedupDelivered)
c.mu.Unlock()
msg := Message{ID: m.ID, From: m.From, To: m.To, Body: m.Body, Meta: m.Meta, SendAtMs: m.SendAtMs}
var cbErr error
c.cbMu.Lock()
if c.onMessage != nil {
cbErr = c.onMessage(msg)
}
c.cbMu.Unlock()
if manual {
return
}
if cbErr != nil {
c.mu.Lock()
delete(c.dedup, key)
c.mu.Unlock()
return
}
_ = c.sendAckFrame(m.From, m.ID)
c.mu.Lock()
if e := c.dedup[key]; e != nil {
e.state = dedupAcked
}
c.mu.Unlock()
}
func (c *Client) rememberDedupLocked(key, from, id string, st dedupState) {
if _, ok := c.dedup[key]; !ok {
c.dedupOrd = append(c.dedupOrd, key)
for len(c.dedupOrd) > c.opts.DedupCapacity {
old := c.dedupOrd[0]
c.dedupOrd = c.dedupOrd[1:]
delete(c.dedup, old)
}
}
c.dedup[key] = &dedupEntry{state: st, from: from, id: id}
}
// Ack 手动确认。
func (c *Client) Ack(msg Message) error {
if err := c.sendAckFrame(msg.From, msg.ID); err != nil {
return err
}
key := msg.From + "\x00" + msg.ID
c.mu.Lock()
c.rememberDedupLocked(key, msg.From, msg.ID, dedupAcked)
c.mu.Unlock()
return nil
}
func (c *Client) sendAckFrame(from, id string) error {
req := map[string]any{
"v": 1, "type": "ack", "rid": c.nextRID(), "from": from, "id": id,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
data, err := c.request(ctx, req, true)
if err != nil {
return err
}
if len(data) > 0 {
var d struct {
Result string `json:"result"`
}
if json.Unmarshal(data, &d) == nil && d.Result != "" && d.Result != "accepted" {
c.emitRevoked(RevokedEvent{ID: id, From: from, Reason: d.Result})
}
}
return nil
}
func (c *Client) handleReceipt(payload []byte) {
var r struct {
ReceiptID string `json:"receipt_id"`
ID string `json:"id"`
EndpointID string `json:"endpoint_id"`
State string `json:"state"`
Reason string `json:"reason"`
AtMs int64 `json:"at_ms"`
}
if err := unmarshalJSON(payload, &r); err != nil {
return
}
c.mu.Lock()
if _, ok := c.receiptSeen[r.ReceiptID]; ok {
c.mu.Unlock()
_ = c.sendReceiptAck(r.ReceiptID)
return
}
c.receiptSeen[r.ReceiptID] = struct{}{}
c.mu.Unlock()
ev := Receipt{ReceiptID: r.ReceiptID, ID: r.ID, EndpointID: r.EndpointID, State: r.State, Reason: r.Reason, AtMs: r.AtMs}
c.cbMu.Lock()
if c.onReceipt != nil {
c.onReceipt(ev)
}
c.cbMu.Unlock()
_ = c.sendReceiptAck(r.ReceiptID)
}
func (c *Client) sendReceiptAck(receiptID string) error {
req := map[string]any{"v": 1, "type": "receipt_ack", "rid": c.nextRID(), "receipt_id": receiptID}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := c.request(ctx, req, true)
return err
}
func (c *Client) handleRevoked(payload []byte) {
var r struct {
ID string `json:"id"`
From string `json:"from"`
Reason string `json:"reason"`
}
if err := unmarshalJSON(payload, &r); err != nil {
return
}
key := r.From + "\x00" + r.ID
c.mu.Lock()
ent := c.dedup[key]
if ent == nil || ent.state == dedupAcked {
c.mu.Unlock()
return
}
delete(c.dedup, key)
c.mu.Unlock()
c.emitRevoked(RevokedEvent{ID: r.ID, From: r.From, Reason: r.Reason})
}
func (c *Client) emitRevoked(e RevokedEvent) {
c.cbMu.Lock()
defer c.cbMu.Unlock()
if c.onRevoked != nil {
c.onRevoked(e)
}
}
func (c *Client) handlePresence(payload []byte) {
var p struct {
ID string `json:"id"`
Online bool `json:"online"`
AtMs int64 `json:"at_ms"`
}
if err := unmarshalJSON(payload, &p); err != nil {
return
}
c.cbMu.Lock()
defer c.cbMu.Unlock()
if c.onPresence != nil {
c.onPresence(PresenceEvent{ID: p.ID, Online: p.Online, AtMs: p.AtMs})
}
}
func (c *Client) handleGroupEvent(payload []byte) {
var g struct {
GroupID string `json:"group_id"`
Event string `json:"event"`
EndpointID string `json:"endpoint_id"`
AtMs int64 `json:"at_ms"`
}
if err := unmarshalJSON(payload, &g); err != nil {
return
}
c.cbMu.Lock()
defer c.cbMu.Unlock()
if c.onGroupEvent != nil {
c.onGroupEvent(GroupEvent{GroupID: g.GroupID, Event: g.Event, EndpointID: g.EndpointID, AtMs: g.AtMs})
}
}
func (c *Client) request(ctx context.Context, frame map[string]any, allowUnready bool) (json.RawMessage, error) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, apiErr(CodeClosed, "已关闭")
}
if !allowUnready && !c.handshook {
c.mu.Unlock()
return nil, apiErr(CodeNotConnected, "未握手")
}
tr := c.transport
c.mu.Unlock()
if tr == nil {
return nil, apiErr(CodeNotConnected, "未连接")
}
rid, _ := frame["rid"].(string)
if rid == "" {
rid = c.nextRID()
frame["rid"] = rid
}
payload, err := marshalJSON(frame)
if err != nil {
return nil, err
}
ch := make(chan respFrame, 1)
c.mu.Lock()
c.pending[rid] = &pendingReq{rid: rid, ch: ch}
c.mu.Unlock()
if err := tr.PublishUp(payload); err != nil {
c.mu.Lock()
delete(c.pending, rid)
c.mu.Unlock()
return nil, err
}
select {
case <-ctx.Done():
c.mu.Lock()
delete(c.pending, rid)
c.mu.Unlock()
return nil, ctx.Err()
case rf := <-ch:
if !rf.OK {
code, msg := CodeBadRequest, "请求失败"
if rf.Error != nil {
code, msg = rf.Error.Code, rf.Error.Message
}
return nil, apiErr(code, msg)
}
return rf.Data, nil
}
}
// bodyDecodedLen 按解码后字节计正文大小。
func bodyDecodedLen(b Body) (int, error) {
switch b.Enc {
case "utf8", "":
return len([]byte(b.Data)), nil
case "base64":
raw, err := base64.StdEncoding.DecodeString(b.Data)
if err != nil {
return 0, apiErr(CodeBadRequest, "base64 正文无效")
}
return len(raw), nil
default:
return 0, apiErr(CodeBadRequest, "未知 enc")
}
}
+65
View File
@@ -0,0 +1,65 @@
package nixmsg
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// Register 静态方法:HTTP 自助注册。url 可为连接地址或注册地址。
func Register(ctx context.Context, connectOrRegisterURL, registrationCode string, opt RegisterOptions) (RegisterResult, error) {
regURL := connectOrRegisterURL
if u, err := RegisterURLFromConnect(connectOrRegisterURL); err == nil {
regURL = u
}
body := map[string]any{
"registration_code": registrationCode,
"id": opt.ID,
"login_password": opt.LoginPassword,
"name": opt.Name,
"talk_password": opt.TalkPassword,
}
payload, err := marshalJSON(body)
if err != nil {
return RegisterResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, regURL, bytes.NewReader(payload))
if err != nil {
return RegisterResult{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return RegisterResult{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return RegisterResult{}, err
}
var wrap struct {
OK bool `json:"ok"`
Data struct {
ID string `json:"id"`
LoginPassword string `json:"login_password"`
} `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &wrap); err != nil {
return RegisterResult{}, fmt.Errorf("注册响应无效: %w", err)
}
if !wrap.OK {
code, msg := CodeBadRequest, "注册失败"
if wrap.Error != nil {
code, msg = wrap.Error.Code, wrap.Error.Message
}
return RegisterResult{}, apiErr(code, msg)
}
return RegisterResult{ID: wrap.Data.ID, LoginPassword: wrap.Data.LoginPassword}, nil
}
+238
View File
@@ -0,0 +1,238 @@
package nixmsg
import (
"context"
"encoding/json"
"time"
)
// Send 发送消息;可在未连接时入队,重连后按原内容重交。
func (c *Client) Send(ctx context.Context, to Target, body Body, opt SendOptions) (SendResult, error) {
c.mu.Lock()
limits := c.limits
skew := c.clockSkew
handshook := c.handshook
maxQ := c.opts.SendQueueSize
qLen := len(c.sendQ)
c.mu.Unlock()
if body.Enc == "" {
body.Enc = "utf8"
}
if body.ContentType == "" && opt.ContentType != "" {
body.ContentType = opt.ContentType
} else if body.ContentType == "" {
if body.Enc == "base64" {
body.ContentType = "application/octet-stream"
} else {
body.ContentType = "text/plain; charset=utf-8"
}
} else if opt.ContentType != "" {
body.ContentType = opt.ContentType
}
n, err := bodyDecodedLen(body)
if err != nil {
return SendResult{}, err
}
maxBody := limits.MaxBodyBytes
if maxBody <= 0 {
maxBody = 262144
}
if handshook && n > maxBody {
return SendResult{}, apiErr(CodeBodyTooLarge, "正文超限")
}
if !handshook && n > 262144 {
return SendResult{}, apiErr(CodeBodyTooLarge, "正文超限")
}
id := opt.ID
if id == "" {
id = newMessageID()
}
frame := map[string]any{
"v": 1,
"type": "send",
"rid": c.nextRID(),
"id": id,
"to": to,
"body": body,
}
if opt.Meta != nil {
frame["meta"] = opt.Meta
}
if opt.TalkPassword != "" {
frame["talk_password"] = opt.TalkPassword
}
if opt.Receipt != nil {
frame["receipt"] = *opt.Receipt
}
if opt.Keep {
off := map[string]any{"keep": true}
if opt.TTL != nil {
off["ttl_seconds"] = *opt.TTL
}
frame["offline"] = off
}
var sendAtMs *int64
if opt.SendAt != nil && opt.Delay != nil {
return SendResult{}, apiErr(CodeBadRequest, "sendAt 与 delay 互斥")
}
if opt.SendAt != nil {
// sendAt 使用本机时间 + 服务器偏差,换算后写入,重交不重算
ms := opt.SendAt.UnixMilli() + skew
sendAtMs = &ms
frame["send_at_ms"] = ms
} else if opt.Delay != nil {
frame["delay_ms"] = opt.Delay.Milliseconds()
}
payload, err := marshalJSON(frame)
if err != nil {
return SendResult{}, err
}
maxFrame := limits.MaxFrameBytes
if maxFrame <= 0 {
maxFrame = 786432
}
if handshook && len(payload) > maxFrame {
return SendResult{}, apiErr(CodeFrameTooLarge, "整帧超限")
}
item := &sendItem{
frame: frame,
payload: payload,
id: id,
sendAtMs: sendAtMs,
result: make(chan sendOutcome, 1),
}
c.mu.Lock()
if c.closed || c.stopReconnect {
c.mu.Unlock()
return SendResult{}, apiErr(CodeClosed, "已关闭")
}
if qLen >= maxQ {
c.mu.Unlock()
return SendResult{}, apiErr(CodeQueueFull, "发送队列已满")
}
c.sendQ = append(c.sendQ, item)
c.mu.Unlock()
c.drainSendQueue()
select {
case <-ctx.Done():
return SendResult{}, ctx.Err()
case out := <-item.result:
return out.res, out.err
}
}
func (c *Client) drainSendQueue() {
for {
c.mu.Lock()
if !c.handshook || c.transport == nil {
c.mu.Unlock()
return
}
var next *sendItem
for _, it := range c.sendQ {
if !it.inflight {
next = it
break
}
}
if next == nil || c.inflight >= c.opts.MaxInflight {
c.mu.Unlock()
return
}
next.inflight = true
c.inflight++
tr := c.transport
payload := next.payload
rid, _ := next.frame["rid"].(string)
item := next
c.mu.Unlock()
go c.dispatchSend(tr, item, rid, payload)
}
}
func (c *Client) dispatchSend(tr transport, item *sendItem, rid string, payload []byte) {
ch := make(chan respFrame, 1)
c.mu.Lock()
c.pending[rid] = &pendingReq{rid: rid, ch: ch}
c.mu.Unlock()
if err := tr.PublishUp(payload); err != nil {
c.mu.Lock()
delete(c.pending, rid)
item.inflight = false
c.inflight--
c.mu.Unlock()
// 网络错误:保留队列等重连
return
}
rf := <-ch
if !rf.OK {
code, msg := CodeBadRequest, "发送失败"
if rf.Error != nil {
code, msg = rf.Error.Code, rf.Error.Message
}
if code == CodeRateLimited {
// 自动重交:保持同一 payload(含 id / send_at_ms)
c.mu.Lock()
item.inflight = false
c.inflight--
delete(c.pending, rid)
c.mu.Unlock()
time.AfterFunc(time.Second, func() { c.drainSendQueue() })
return
}
c.finishSend(item, SendResult{}, apiErr(code, msg))
return
}
var sd SendResult
_ = json.Unmarshal(rf.Data, &sd)
if sd.ID == "" {
sd.ID = item.id
}
c.finishSend(item, sd, nil)
}
func (c *Client) finishSend(item *sendItem, res SendResult, err error) {
c.mu.Lock()
// 从队列移除
out := item.result
nq := c.sendQ[:0]
for _, it := range c.sendQ {
if it != item {
nq = append(nq, it)
}
}
c.sendQ = nq
if item.inflight {
c.inflight--
item.inflight = false
}
c.mu.Unlock()
select {
case out <- sendOutcome{res: res, err: err}:
default:
}
c.drainSendQueue()
}
// ResendPayloadForTest 返回队列中第一条发送帧的编码(测试用)。
func (c *Client) ResendPayloadForTest() []byte {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.sendQ) == 0 {
return nil
}
return append([]byte(nil), c.sendQ[0].payload...)
}
+40
View File
@@ -0,0 +1,40 @@
package nixmsg
import (
"context"
"time"
)
// transport 抽象 MQTT 应用层通道,便于单测注入假实现。
type transport interface {
// Start 开始连接循环(含重连)。凭据在每次 CONNECT 时读取。
Start(ctx context.Context, cfg transportConfig) error
// PublishUp 向 up 主题发布一帧。
PublishUp(payload []byte) error
// Stop 停止并断开,不再重连。
Stop(ctx context.Context) error
// Credential 当前用于 CONNECT 的密码字段(密码或令牌)。
SetCredential(passwordOrToken string)
}
type transportConfig struct {
URL string // ws(s):// 或 mqtt(s)://
EndpointID string
ConnectTimeout time.Duration
AllowTCP bool
Backoff *reconnectBackoff
OnDown func(payload []byte)
OnOnline func() // MQTT 已连上且可发(订阅完成前也会调;hello 由上层做)
OnOffline func()
OnAuthFailed func(reason AuthReason)
OnKicked func()
// OnConnectPacket 每次 CONNECT 构建后回调(单测核对 Clean Start)。
OnConnectPacket func(cleanStart bool, sessionExpiry uint32)
// MQTTConnected 在 CONNACK 成功、订阅 down 完成后调用,上层在此发 hello。
MQTTReady func(ctx context.Context) error
}
// buildCleanConnect 供真实与假传输共用:每次连接 Clean Start=true,会话过期=0。
func buildCleanConnectFlags() (cleanStart bool, sessionExpiry uint32) {
return true, 0
}
+266
View File
@@ -0,0 +1,266 @@
package nixmsg
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
)
// FakeTransport 单测用假 MQTT:不启真实网络。
type FakeTransport struct {
mu sync.Mutex
cfg transportConfig
cred atomic.Value
up [][]byte
stopped atomic.Bool
online bool
connects []fakeConnect
// AutoHello 为 true 时自动回复 hello(默认 true)。
AutoHello bool
// HelloToken 自动 hello 响应里的 session_token。
HelloToken string
// HelloServerTimeMs 自动 hello 的 server_time_ms。
HelloServerTimeMs int64
MaxBodyBytes int
MaxMetaBytes int
MaxFrameBytes int
}
type fakeConnect struct {
CleanStart bool
SessionExpiry uint32
Password string
}
// NewFakeTransport 创建假传输;默认自动回复 hello。
func NewFakeTransport() *FakeTransport {
f := &FakeTransport{
AutoHello: true,
HelloToken: "nst_test_token",
HelloServerTimeMs: 1_750_000_000_000,
MaxBodyBytes: 262144,
MaxMetaBytes: 4096,
MaxFrameBytes: 786432,
}
f.cred.Store("")
return f
}
func (f *FakeTransport) SetCredential(passwordOrToken string) {
f.cred.Store(passwordOrToken)
}
func (f *FakeTransport) Start(_ context.Context, cfg transportConfig) error {
f.mu.Lock()
f.cfg = cfg
f.mu.Unlock()
return f.SimulateConnectOK()
}
func (f *FakeTransport) PublishUp(payload []byte) error {
f.mu.Lock()
cp := append([]byte(nil), payload...)
f.up = append(f.up, cp)
auto := f.AutoHello
f.mu.Unlock()
var head struct {
Type string `json:"type"`
RID string `json:"rid"`
}
_ = unmarshalJSON(payload, &head)
if auto && head.Type == "hello" && head.RID != "" {
f.replyHello(head.RID)
}
return nil
}
func (f *FakeTransport) replyHello(rid string) {
f.mu.Lock()
token := f.HelloToken
st := f.HelloServerTimeMs
mb, mm, mf := f.MaxBodyBytes, f.MaxMetaBytes, f.MaxFrameBytes
f.mu.Unlock()
resp, _ := marshalJSON(map[string]any{
"v": 1, "type": "resp", "rid": rid, "ok": true,
"data": map[string]any{
"server_time_ms": st,
"server_version": "0.1.0",
"max_body_bytes": mb,
"max_meta_bytes": mm,
"max_frame_bytes": mf,
"max_ttl_seconds": 2592000,
"max_schedule_seconds": 31536000,
"ack_timeout_seconds": 300,
"session_token": token,
},
})
f.InjectDown(resp)
}
func (f *FakeTransport) Stop(_ context.Context) error {
f.stopped.Store(true)
f.mu.Lock()
cfg := f.cfg
f.online = false
f.mu.Unlock()
if cfg.OnOffline != nil {
cfg.OnOffline()
}
return nil
}
// SimulateConnectOK 模拟一次成功 CONNECT(含 Clean Start 记录)并完成订阅就绪。
func (f *FakeTransport) SimulateConnectOK() error {
clean, expiry := buildCleanConnectFlags()
pass, _ := f.cred.Load().(string)
f.mu.Lock()
f.connects = append(f.connects, fakeConnect{CleanStart: clean, SessionExpiry: expiry, Password: pass})
cfg := f.cfg
f.online = true
f.mu.Unlock()
if cfg.OnConnectPacket != nil {
cfg.OnConnectPacket(clean, expiry)
}
if cfg.Backoff != nil {
cfg.Backoff.MarkOnline()
}
if cfg.MQTTReady != nil {
if err := cfg.MQTTReady(context.Background()); err != nil {
return err
}
}
if cfg.OnOnline != nil {
cfg.OnOnline()
}
return nil
}
// SimulateReconnect 模拟断线再连(再次 Clean Start)。
func (f *FakeTransport) SimulateReconnect() error {
f.mu.Lock()
cfg := f.cfg
f.online = false
f.mu.Unlock()
if cfg.Backoff != nil {
cfg.Backoff.MarkOffline()
}
if cfg.OnOffline != nil {
cfg.OnOffline()
}
return f.SimulateConnectOK()
}
// SimulateAuthFail 模拟 CONNACK 认证失败。
func (f *FakeTransport) SimulateAuthFail(reason AuthReason) {
f.stopped.Store(true)
f.mu.Lock()
cfg := f.cfg
f.mu.Unlock()
if cfg.OnAuthFailed != nil {
cfg.OnAuthFailed(reason)
}
}
// SimulateKick 模拟 0x8E。
func (f *FakeTransport) SimulateKick() {
f.stopped.Store(true)
f.mu.Lock()
cfg := f.cfg
f.mu.Unlock()
if cfg.OnKicked != nil {
cfg.OnKicked()
}
}
// InjectDown 注入下行帧。
func (f *FakeTransport) InjectDown(payload []byte) {
f.mu.Lock()
cfg := f.cfg
f.mu.Unlock()
if cfg.OnDown != nil {
cfg.OnDown(payload)
}
}
// ReplyOK 对指定 rid 回成功 resp。
func (f *FakeTransport) ReplyOK(rid string, data any) {
var raw json.RawMessage
if data != nil {
raw, _ = marshalJSON(data)
} else {
raw = json.RawMessage(`{}`)
}
resp, _ := marshalJSON(map[string]any{
"v": 1, "type": "resp", "rid": rid, "ok": true, "data": raw,
})
f.InjectDown(resp)
}
// ReplyErr 对指定 rid 回失败 resp。
func (f *FakeTransport) ReplyErr(rid, code, message string) {
resp, _ := marshalJSON(map[string]any{
"v": 1, "type": "resp", "rid": rid, "ok": false,
"error": map[string]any{"code": code, "message": message},
})
f.InjectDown(resp)
}
// UpFrames 已发布的上行帧副本。
func (f *FakeTransport) UpFrames() [][]byte {
f.mu.Lock()
defer f.mu.Unlock()
out := make([][]byte, len(f.up))
for i, p := range f.up {
out[i] = append([]byte(nil), p...)
}
return out
}
// Connects 历次 CONNECT 参数。
func (f *FakeTransport) Connects() []fakeConnect {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]fakeConnect, len(f.connects))
copy(out, f.connects)
return out
}
// DrainUp 取出并清空上行。
func (f *FakeTransport) DrainUp() [][]byte {
f.mu.Lock()
defer f.mu.Unlock()
out := f.up
f.up = nil
return out
}
// LastUp 最后一条上行(解析为通用 map)。
func (f *FakeTransport) LastUp() map[string]any {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.up) == 0 {
return nil
}
var m map[string]any
_ = unmarshalJSON(f.up[len(f.up)-1], &m)
return m
}
// FindUp 找指定 type 的上行帧。
func (f *FakeTransport) FindUp(typ string) []map[string]any {
f.mu.Lock()
defer f.mu.Unlock()
var out []map[string]any
for _, p := range f.up {
var m map[string]any
if unmarshalJSON(p, &m) == nil {
if m["type"] == typ {
out = append(out, m)
}
}
}
return out
}
+258
View File
@@ -0,0 +1,258 @@
package nixmsg
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/eclipse/paho.golang/autopaho"
"github.com/eclipse/paho.golang/paho"
)
type mqttTransport struct {
mu sync.Mutex
cm *autopaho.ConnectionManager
cancel context.CancelFunc
cfg transportConfig
cred atomic.Value // string
upTopic string
downTopic string
stopped atomic.Bool
ready chan struct{}
}
func newMQTTTransport() *mqttTransport {
t := &mqttTransport{ready: make(chan struct{})}
t.cred.Store("")
return t
}
func (t *mqttTransport) SetCredential(passwordOrToken string) {
t.cred.Store(passwordOrToken)
}
func (t *mqttTransport) Start(ctx context.Context, cfg transportConfig) error {
t.mu.Lock()
defer t.mu.Unlock()
if t.cm != nil {
return errors.New("transport already started")
}
t.cfg = cfg
t.upTopic = fmt.Sprintf("nix/c/%s/up", cfg.EndpointID)
t.downTopic = fmt.Sprintf("nix/c/%s/down", cfg.EndpointID)
u, err := normalizeMQTTURL(cfg.URL, cfg.AllowTCP)
if err != nil {
return err
}
innerCtx, cancel := context.WithCancel(ctx)
t.cancel = cancel
var sessionExpiry uint32 // 0;由 ConnectPacketBuilder 显式写入 Properties
cliCfg := autopaho.ClientConfig{
ServerUrls: []*url.URL{u},
KeepAlive: 30,
ConnectTimeout: cfg.ConnectTimeout,
CleanStartOnInitialConnection: false, // 不要只靠这个;每次用 ConnectPacketBuilder
SessionExpiryInterval: sessionExpiry,
ConnectUsername: cfg.EndpointID,
ReconnectBackoff: cfg.Backoff.Func,
OnConnectError: func(err error) {
var ce *autopaho.ConnackError
if errors.As(err, &ce) {
if isAuthCONNACK(ce.ReasonCode) {
reason := AuthBadCredentials
if tok, _ := t.cred.Load().(string); strings.HasPrefix(tok, "nst_") {
reason = AuthSessionInvalid
}
if cfg.OnAuthFailed != nil {
cfg.OnAuthFailed(reason)
}
cancel()
}
}
},
OnConnectionDown: func() bool {
if t.stopped.Load() {
return false
}
if cfg.Backoff != nil {
cfg.Backoff.MarkOffline()
}
if cfg.OnOffline != nil {
cfg.OnOffline()
}
return !t.stopped.Load()
},
OnConnectionUp: func(cm *autopaho.ConnectionManager, _ *paho.Connack) {
if cfg.Backoff != nil {
cfg.Backoff.MarkOnline()
}
go func() {
_, err := cm.Subscribe(innerCtx, &paho.Subscribe{
Subscriptions: []paho.SubscribeOptions{{
Topic: t.downTopic,
QoS: 1,
}},
})
if err != nil {
return
}
if cfg.MQTTReady != nil {
_ = cfg.MQTTReady(innerCtx)
}
if cfg.OnOnline != nil {
cfg.OnOnline()
}
}()
},
ClientConfig: paho.ClientConfig{
ClientID: cfg.EndpointID,
OnServerDisconnect: func(d *paho.Disconnect) {
if d != nil && d.ReasonCode == 0x8E {
if cfg.OnKicked != nil {
cfg.OnKicked()
}
cancel()
}
},
OnPublishReceived: []func(paho.PublishReceived) (bool, error){
func(pr paho.PublishReceived) (bool, error) {
if cfg.OnDown != nil && pr.Packet != nil {
cfg.OnDown(pr.Packet.Payload)
}
return true, nil
},
},
},
}
cliCfg.ConnectPacketBuilder = func(c *paho.Connect, _ *url.URL) (*paho.Connect, error) {
c.CleanStart = true
zero := uint32(0)
if c.Properties == nil {
c.Properties = &paho.ConnectProperties{}
}
c.Properties.SessionExpiryInterval = &zero
pass, _ := t.cred.Load().(string)
c.UsernameFlag = true
c.Username = cfg.EndpointID
c.PasswordFlag = true
c.Password = []byte(pass)
if cfg.OnConnectPacket != nil {
cfg.OnConnectPacket(c.CleanStart, zero)
}
return c, nil
}
cm, err := autopaho.NewConnection(innerCtx, cliCfg)
if err != nil {
cancel()
return err
}
t.cm = cm
return nil
}
func (t *mqttTransport) PublishUp(payload []byte) error {
t.mu.Lock()
cm := t.cm
topic := t.upTopic
t.mu.Unlock()
if cm == nil {
return apiErr(CodeNotConnected, "未连接")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := cm.Publish(ctx, &paho.Publish{
Topic: topic,
QoS: 1,
Payload: payload,
})
return err
}
func (t *mqttTransport) Stop(ctx context.Context) error {
t.stopped.Store(true)
t.mu.Lock()
cm := t.cm
cancel := t.cancel
t.mu.Unlock()
if cancel != nil {
cancel()
}
if cm != nil {
return cm.Disconnect(ctx)
}
return nil
}
func isAuthCONNACK(code byte) bool {
switch code {
case 0x86, 0x87, 0x8A, // MQTT 5
4, 5: // MQTT 3.1.1 bad user/pass, not authorized
return true
default:
return false
}
}
func normalizeMQTTURL(raw string, allowTCP bool) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
switch strings.ToLower(u.Scheme) {
case "ws", "wss":
if u.Path == "" || u.Path == "/" {
u.Path = "/mqtt"
}
return u, nil
case "http":
u.Scheme = "ws"
if u.Path == "" || u.Path == "/" {
u.Path = "/mqtt"
}
return u, nil
case "https":
u.Scheme = "wss"
if u.Path == "" || u.Path == "/" {
u.Path = "/mqtt"
}
return u, nil
case "mqtt", "tcp", "mqtts", "ssl", "tls":
if !allowTCP {
return nil, apiErr(CodeBadRequest, "裸 TCP 需在选项中显式打开 AllowTCP")
}
return u, nil
default:
return nil, fmt.Errorf("不支持的 URL scheme: %s", u.Scheme)
}
}
// RegisterURLFromConnect 从连接地址推出注册 HTTP 地址(第 6.9 节)。
func RegisterURLFromConnect(connectURL string) (string, error) {
u, err := url.Parse(connectURL)
if err != nil {
return "", err
}
out := *u
switch strings.ToLower(u.Scheme) {
case "wss", "https", "mqtts", "ssl", "tls":
out.Scheme = "https"
case "ws", "http", "mqtt", "tcp":
out.Scheme = "http"
default:
return "", fmt.Errorf("无法从 %s 推出注册地址", u.Scheme)
}
out.Path = "/api/client/register"
out.RawQuery = ""
out.Fragment = ""
return out.String(), nil
}
+231
View File
@@ -0,0 +1,231 @@
package nixmsg
import "time"
// ConnectionState 连接状态(onConnection)。
type ConnectionState string
const (
StateConnecting ConnectionState = "connecting"
StateOnline ConnectionState = "online"
StateReconnecting ConnectionState = "reconnecting"
StateOffline ConnectionState = "offline"
StateKicked ConnectionState = "kicked"
StateAuthFailed ConnectionState = "auth_failed"
)
// Credential 登录凭据:密码或会话令牌二选一。
type Credential struct {
Password string
SessionToken string
}
// Options 连接选项。
type Options struct {
// ManualAck 为 true 时手动确认模式。
ManualAck bool
// AllowTCP 显式允许裸 TCP(mqtt:// / mqtts://)。默认只用 WebSocket。
AllowTCP bool
// ConnectTimeout 连接超时,默认 30s。
ConnectTimeout time.Duration
// ClientLabel hello.client,默认 go-sdk/0.1。
ClientLabel string
// MaxReceiveBytes hello.max_receive_bytes;0 表示省略(不限)。
MaxReceiveBytes int
// SendQueueSize 发送队列上限,默认 1000。
SendQueueSize int
// MaxInflight 在途上限,默认 100。
MaxInflight int
// DedupCapacity from+id 去重容量,默认 10000。
DedupCapacity int
// HTTPClient 注册用;nil 用默认。
// 测试可注入 transport。
transport transport
}
func (o *Options) withDefaults() Options {
out := *o
if out.ConnectTimeout <= 0 {
out.ConnectTimeout = 30 * time.Second
}
if out.ClientLabel == "" {
out.ClientLabel = "go-sdk/0.1"
}
if out.SendQueueSize <= 0 {
out.SendQueueSize = 1000
}
if out.MaxInflight <= 0 {
out.MaxInflight = 100
}
if out.DedupCapacity <= 0 {
out.DedupCapacity = 10000
}
return out
}
// Target 发送目标。
type Target struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
// Body 消息正文。
type Body struct {
Enc string `json:"enc"`
ContentType string `json:"content_type,omitempty"`
Data string `json:"data"`
}
// SendOptions 发送选项。
type SendOptions struct {
SendAt *time.Time
Delay *time.Duration
Keep bool
TTL *int64
Receipt *bool
TalkPassword string
ContentType string
Meta map[string]any
ID string // 可选;空则生成 UUIDv7
}
// SendResult 发送结果。
type SendResult struct {
ID string
SendAtMs int64
State string
}
// Message 下行消息(交给应用)。
type Message struct {
ID string
From string
To Target
Body Body
Meta map[string]any
SendAtMs int64
}
// RecallResult 撤回结果。
type RecallResult struct {
Result string
Recalled int
Accepted int
Other int
}
// Receipt 回执。
type Receipt struct {
ReceiptID string
ID string
EndpointID string
State string
Reason string
AtMs int64
}
// RevokedEvent 撤回/作废事件。
type RevokedEvent struct {
ID string
From string
Reason string
}
// PresenceItem 在线查询项。
type PresenceItem struct {
ID string `json:"id"`
Online bool `json:"online"`
SinceMs int64 `json:"since_ms"`
}
// DirectoryItem 目录项。
type DirectoryItem struct {
ID string `json:"id"`
Name string `json:"name"`
Online bool `json:"online"`
OnlineSinceMs int64 `json:"online_since_ms"`
OfflineSinceMs int64 `json:"offline_since_ms"`
TalkPasswordSet bool `json:"talk_password_set"`
}
// PresenceEvent 上下线通知。
type PresenceEvent struct {
ID string
Online bool
AtMs int64
}
// GroupEvent 群事件。
type GroupEvent struct {
GroupID string
Event string
EndpointID string
AtMs int64
}
// SelfInfo 自己的资料。
type SelfInfo struct {
ID string `json:"id"`
Name string `json:"name"`
DefaultDelayMs int64 `json:"default_delay_ms"`
TalkPasswordSet bool `json:"talk_password_set"`
}
// GroupInfo 群摘要。
type GroupInfo struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"owner_id"`
MemberCount int `json:"member_count"`
}
// GroupMember 群成员。
type GroupMember struct {
ID string `json:"id"`
Name string `json:"name"`
Online bool `json:"online"`
}
// GroupMemberIn 建群/加群成员。
type GroupMemberIn struct {
ID string
TalkPassword string
}
// MemberFailure 部分成员失败。
type MemberFailure struct {
ID string `json:"id"`
Code string `json:"code"`
}
// RegisterOptions 注册选项。
type RegisterOptions struct {
ID string
LoginPassword string
Name string
TalkPassword string
}
// RegisterResult 注册结果。
type RegisterResult struct {
ID string
LoginPassword string // 仅请求未带密码时返回
}
// HandshakeLimits 握手参数。
type HandshakeLimits struct {
ServerTimeMs int64
ServerVersion string
MaxBodyBytes int
MaxMetaBytes int
MaxFrameBytes int
MaxTTLSeconds int64
MaxScheduleSeconds int64
AckTimeoutSeconds int64
}
// ConnectionEvent 连接状态事件。
type ConnectionEvent struct {
State ConnectionState
Reason string // auth_failed / kicked 时的原因
}
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"],
},
});