feat: 实现 Go 与 JS/TS SDK 连接收发与其余接口
This commit is contained in:
+40
-1
@@ -422,7 +422,46 @@
|
|||||||
|
|
||||||
## SDK 一 S1
|
## 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
|
## SDK 二 S2
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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 时的原因
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Copyright (c) 2026 Nixevol. All rights reserved.
|
||||||
|
|
||||||
|
本仓库的源代码、文档、各语言 SDK 和构建产物(包括发布的软件包和 Docker 镜像)均为专有软件。
|
||||||
|
源代码和发布物公开可读,不代表授予任何使用许可。未经版权所有者书面许可,不得使用、复制、
|
||||||
|
修改、合并、发布、分发、再许可或出售其任何部分。
|
||||||
|
|
||||||
|
This repository, including its source code, documentation, SDKs and build artifacts (including
|
||||||
|
published packages and Docker images), is proprietary software. Public visibility does not grant
|
||||||
|
any license. No part of it may be used, copied, modified, merged, published, distributed,
|
||||||
|
sublicensed or sold without prior written permission from the copyright holder.
|
||||||
Generated
+2974
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@nixevol/nixmsg",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "NixMsg JavaScript/TypeScript SDK",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prepack": "npm run build"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mqtt": "^5.10.0",
|
||||||
|
"uuid": "^11.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsup": "^8.3.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,780 @@
|
|||||||
|
import { v7 as uuidv7 } from "uuid";
|
||||||
|
import {
|
||||||
|
APIError,
|
||||||
|
AuthReason,
|
||||||
|
Body,
|
||||||
|
ClientOptions,
|
||||||
|
ConnectionEvent,
|
||||||
|
ConnectionState,
|
||||||
|
Credential,
|
||||||
|
GroupEvent,
|
||||||
|
GroupMemberIn,
|
||||||
|
HandshakeLimits,
|
||||||
|
Message,
|
||||||
|
PresenceEvent,
|
||||||
|
RecallResult,
|
||||||
|
Receipt,
|
||||||
|
RegisterOptions,
|
||||||
|
RegisterResult,
|
||||||
|
ReconnectBackoff,
|
||||||
|
RevokedEvent,
|
||||||
|
SendOptions,
|
||||||
|
SendResult,
|
||||||
|
Target,
|
||||||
|
Transport,
|
||||||
|
marshalJSON,
|
||||||
|
registerURLFromConnect,
|
||||||
|
} from "./types.js";
|
||||||
|
import { MqttTransport } from "./mqtt.js";
|
||||||
|
|
||||||
|
type RespFrame = {
|
||||||
|
ok: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type SendItem = {
|
||||||
|
frame: Record<string, unknown>;
|
||||||
|
payload: string;
|
||||||
|
id: string;
|
||||||
|
result: { resolve: (v: SendResult) => void; reject: (e: unknown) => void };
|
||||||
|
inflight: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DedupState = "delivered" | "acked";
|
||||||
|
|
||||||
|
export class Client {
|
||||||
|
private opts: Required<
|
||||||
|
Pick<
|
||||||
|
ClientOptions,
|
||||||
|
| "manualAck"
|
||||||
|
| "allowTcp"
|
||||||
|
| "connectTimeoutMs"
|
||||||
|
| "clientLabel"
|
||||||
|
| "sendQueueSize"
|
||||||
|
| "maxInflight"
|
||||||
|
| "dedupCapacity"
|
||||||
|
>
|
||||||
|
> &
|
||||||
|
ClientOptions = {
|
||||||
|
manualAck: false,
|
||||||
|
allowTcp: false,
|
||||||
|
connectTimeoutMs: 30_000,
|
||||||
|
clientLabel: "js-sdk/0.1",
|
||||||
|
sendQueueSize: 1000,
|
||||||
|
maxInflight: 100,
|
||||||
|
dedupCapacity: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
private transport?: Transport;
|
||||||
|
private backoff = new ReconnectBackoff();
|
||||||
|
private endpointId = "";
|
||||||
|
private ridSeq = 0;
|
||||||
|
private pending = new Map<string, { resolve: (r: RespFrame) => void; reject: (e: unknown) => void }>();
|
||||||
|
private sendQ: SendItem[] = [];
|
||||||
|
private inflight = 0;
|
||||||
|
private handshook = false;
|
||||||
|
private stopReconnect = false;
|
||||||
|
private closed = false;
|
||||||
|
private state: ConnectionState = "offline";
|
||||||
|
private limits: HandshakeLimits = {
|
||||||
|
server_time_ms: 0,
|
||||||
|
server_version: "",
|
||||||
|
max_body_bytes: 262144,
|
||||||
|
max_meta_bytes: 4096,
|
||||||
|
max_frame_bytes: 786432,
|
||||||
|
max_ttl_seconds: 2592000,
|
||||||
|
max_schedule_seconds: 31536000,
|
||||||
|
ack_timeout_seconds: 300,
|
||||||
|
};
|
||||||
|
private clockSkew = 0;
|
||||||
|
private dedup = new Map<string, DedupState>();
|
||||||
|
private dedupOrd: string[] = [];
|
||||||
|
private receiptSeen = new Set<string>();
|
||||||
|
private cbChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
private onSession?: (token: string) => void;
|
||||||
|
private onMessage?: (msg: Message) => void | Promise<void>;
|
||||||
|
private onReceipt?: (r: Receipt) => void;
|
||||||
|
private onRevoked?: (e: RevokedEvent) => void;
|
||||||
|
private onPresence?: (e: PresenceEvent) => void;
|
||||||
|
private onGroupEvent?: (e: GroupEvent) => void;
|
||||||
|
private onConnection?: (e: ConnectionEvent) => void;
|
||||||
|
|
||||||
|
onSessionHandler(h: (token: string) => void): void {
|
||||||
|
this.onSession = h;
|
||||||
|
}
|
||||||
|
onMessageHandler(h: (msg: Message) => void | Promise<void>): void {
|
||||||
|
this.onMessage = h;
|
||||||
|
}
|
||||||
|
onReceiptHandler(h: (r: Receipt) => void): void {
|
||||||
|
this.onReceipt = h;
|
||||||
|
}
|
||||||
|
onRevokedHandler(h: (e: RevokedEvent) => void): void {
|
||||||
|
this.onRevoked = h;
|
||||||
|
}
|
||||||
|
onPresenceHandler(h: (e: PresenceEvent) => void): void {
|
||||||
|
this.onPresence = h;
|
||||||
|
}
|
||||||
|
onGroupEventHandler(h: (e: GroupEvent) => void): void {
|
||||||
|
this.onGroupEvent = h;
|
||||||
|
}
|
||||||
|
onConnectionHandler(h: (e: ConnectionEvent) => void): void {
|
||||||
|
this.onConnection = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 别名,贴近文档 onSession(handler) */
|
||||||
|
onSessionCb(h: (token: string) => void): void {
|
||||||
|
this.onSessionHandler(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
clockSkewMs(): number {
|
||||||
|
return this.clockSkew;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLimits(): HandshakeLimits {
|
||||||
|
return { ...this.limits };
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(
|
||||||
|
url: string,
|
||||||
|
endpointId: string,
|
||||||
|
credential: Credential,
|
||||||
|
options: ClientOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.closed) throw new APIError("closed", "已关闭");
|
||||||
|
if (this.transport) throw new APIError("bad_request", "已在连接中");
|
||||||
|
this.opts = {
|
||||||
|
...this.opts,
|
||||||
|
...options,
|
||||||
|
connectTimeoutMs: options.connectTimeoutMs ?? 30_000,
|
||||||
|
clientLabel: options.clientLabel ?? "js-sdk/0.1",
|
||||||
|
sendQueueSize: options.sendQueueSize ?? 1000,
|
||||||
|
maxInflight: options.maxInflight ?? 100,
|
||||||
|
dedupCapacity: options.dedupCapacity ?? 10000,
|
||||||
|
manualAck: options.manualAck ?? false,
|
||||||
|
allowTcp: options.allowTcp ?? false,
|
||||||
|
};
|
||||||
|
this.endpointId = endpointId;
|
||||||
|
this.stopReconnect = false;
|
||||||
|
this.handshook = false;
|
||||||
|
this.backoff = new ReconnectBackoff();
|
||||||
|
const pass = credential.sessionToken ?? credential.password ?? "";
|
||||||
|
const tr = options.transport ?? new MqttTransport();
|
||||||
|
this.transport = tr;
|
||||||
|
tr.setCredential(pass);
|
||||||
|
this.setState("connecting");
|
||||||
|
|
||||||
|
await tr.start({
|
||||||
|
url,
|
||||||
|
endpointId,
|
||||||
|
connectTimeoutMs: this.opts.connectTimeoutMs!,
|
||||||
|
allowTcp: !!this.opts.allowTcp,
|
||||||
|
backoff: this.backoff,
|
||||||
|
onDown: (p) => this.handleDown(p),
|
||||||
|
onOffline: () => {
|
||||||
|
this.handshook = false;
|
||||||
|
if (!this.stopReconnect && !this.closed) this.setState("reconnecting");
|
||||||
|
},
|
||||||
|
onAuthFailed: (r) => this.failAuth(r),
|
||||||
|
onKicked: () => this.failKicked(),
|
||||||
|
mqttReady: () => this.doHello(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deadline = Date.now() + this.opts.connectTimeoutMs!;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (this.handshook) return;
|
||||||
|
if (this.stopReconnect || this.state === "auth_failed" || this.state === "kicked") {
|
||||||
|
throw new APIError("auth_failed", this.state);
|
||||||
|
}
|
||||||
|
await sleep(20);
|
||||||
|
}
|
||||||
|
await this.close();
|
||||||
|
throw new APIError("not_connected", "连接超时");
|
||||||
|
}
|
||||||
|
|
||||||
|
private failAuth(reason: AuthReason): void {
|
||||||
|
this.stopReconnect = true;
|
||||||
|
this.handshook = false;
|
||||||
|
this.setState("auth_failed", reason);
|
||||||
|
this.failQueued(new APIError(reason, "认证失败,停止重连"));
|
||||||
|
void this.transport?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private failKicked(): void {
|
||||||
|
this.stopReconnect = true;
|
||||||
|
this.handshook = false;
|
||||||
|
this.setState("kicked", "0x8E");
|
||||||
|
this.failQueued(new APIError("kicked", "被顶号,停止重连"));
|
||||||
|
void this.transport?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: ConnectionState, reason?: string): void {
|
||||||
|
this.state = state;
|
||||||
|
this.enqueueCb(() => this.onConnection?.({ state, reason }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueCb(fn: () => void | Promise<void>): void {
|
||||||
|
this.cbChain = this.cbChain.then(async () => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch {
|
||||||
|
/* 回调错误不打断串行链 */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRid(): string {
|
||||||
|
this.ridSeq += 1;
|
||||||
|
return String(this.ridSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doHello(): Promise<void> {
|
||||||
|
const sentAt = Date.now();
|
||||||
|
const req: Record<string, unknown> = {
|
||||||
|
v: 1,
|
||||||
|
type: "hello",
|
||||||
|
rid: this.nextRid(),
|
||||||
|
client: this.opts.clientLabel,
|
||||||
|
};
|
||||||
|
if (this.opts.maxReceiveBytes && this.opts.maxReceiveBytes > 0) {
|
||||||
|
req.max_receive_bytes = this.opts.maxReceiveBytes;
|
||||||
|
}
|
||||||
|
const data = (await this.request(req, true)) as Record<string, unknown>;
|
||||||
|
const recvAt = Date.now();
|
||||||
|
const serverTime = Number(data.server_time_ms ?? 0);
|
||||||
|
this.clockSkew = serverTime - Math.floor((sentAt + recvAt) / 2);
|
||||||
|
this.limits = {
|
||||||
|
server_time_ms: serverTime,
|
||||||
|
server_version: String(data.server_version ?? ""),
|
||||||
|
max_body_bytes: Number(data.max_body_bytes ?? 262144),
|
||||||
|
max_meta_bytes: Number(data.max_meta_bytes ?? 4096),
|
||||||
|
max_frame_bytes: Number(data.max_frame_bytes ?? 786432),
|
||||||
|
max_ttl_seconds: Number(data.max_ttl_seconds ?? 2592000),
|
||||||
|
max_schedule_seconds: Number(data.max_schedule_seconds ?? 31536000),
|
||||||
|
ack_timeout_seconds: Number(data.ack_timeout_seconds ?? 300),
|
||||||
|
};
|
||||||
|
this.handshook = true;
|
||||||
|
this.setState("online");
|
||||||
|
const token = data.session_token ? String(data.session_token) : "";
|
||||||
|
if (token) {
|
||||||
|
this.transport?.setCredential(token);
|
||||||
|
this.enqueueCb(() => this.onSession?.(token));
|
||||||
|
}
|
||||||
|
void this.drainSendQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDown(payload: Uint8Array): void {
|
||||||
|
let head: { type?: string; rid?: string };
|
||||||
|
try {
|
||||||
|
head = JSON.parse(new TextDecoder().decode(payload));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = new TextDecoder().decode(payload);
|
||||||
|
switch (head.type) {
|
||||||
|
case "resp": {
|
||||||
|
const rf = JSON.parse(text) as RespFrame & { rid: string };
|
||||||
|
const p = this.pending.get(rf.rid ?? head.rid!);
|
||||||
|
if (p) {
|
||||||
|
this.pending.delete(rf.rid ?? head.rid!);
|
||||||
|
p.resolve(rf);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "msg":
|
||||||
|
void this.handleMsg(JSON.parse(text));
|
||||||
|
break;
|
||||||
|
case "receipt":
|
||||||
|
void this.handleReceipt(JSON.parse(text));
|
||||||
|
break;
|
||||||
|
case "revoked":
|
||||||
|
this.handleRevoked(JSON.parse(text));
|
||||||
|
break;
|
||||||
|
case "presence": {
|
||||||
|
const p = JSON.parse(text) as PresenceEvent;
|
||||||
|
this.enqueueCb(() => this.onPresence?.(p));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "group_event": {
|
||||||
|
const g = JSON.parse(text) as GroupEvent;
|
||||||
|
this.enqueueCb(() => this.onGroupEvent?.(g));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "fatal": {
|
||||||
|
const f = JSON.parse(text) as { reason?: string };
|
||||||
|
this.stopReconnect = true;
|
||||||
|
this.setState("auth_failed", f.reason);
|
||||||
|
this.failQueued(new APIError("fatal", f.reason ?? ""));
|
||||||
|
void this.transport?.stop();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMsg(m: {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
to: Target;
|
||||||
|
body: Body;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
send_at_ms: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const key = `${m.from}\0${m.id}`;
|
||||||
|
const ent = this.dedup.get(key);
|
||||||
|
if (ent === "acked") {
|
||||||
|
await this.sendAckFrame(m.from, m.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ent === "delivered") return;
|
||||||
|
this.rememberDedup(key, "delivered");
|
||||||
|
|
||||||
|
const msg: Message = {
|
||||||
|
id: m.id,
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
body: m.body,
|
||||||
|
meta: m.meta,
|
||||||
|
send_at_ms: m.send_at_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cbErr: unknown;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.enqueueCb(async () => {
|
||||||
|
try {
|
||||||
|
await this.onMessage?.(msg);
|
||||||
|
} catch (e) {
|
||||||
|
cbErr = e;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.opts.manualAck) return;
|
||||||
|
if (cbErr) {
|
||||||
|
this.dedup.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sendAckFrame(m.from, m.id);
|
||||||
|
this.dedup.set(key, "acked");
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberDedup(key: string, st: DedupState): void {
|
||||||
|
if (!this.dedup.has(key)) {
|
||||||
|
this.dedupOrd.push(key);
|
||||||
|
while (this.dedupOrd.length > this.opts.dedupCapacity!) {
|
||||||
|
const old = this.dedupOrd.shift()!;
|
||||||
|
this.dedup.delete(old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.dedup.set(key, st);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ack(msg: Message): Promise<void> {
|
||||||
|
await this.sendAckFrame(msg.from, msg.id);
|
||||||
|
this.rememberDedup(`${msg.from}\0${msg.id}`, "acked");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendAckFrame(from: string, id: string): Promise<void> {
|
||||||
|
const data = await this.request(
|
||||||
|
{ v: 1, type: "ack", rid: this.nextRid(), from, id },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (data && typeof data === "object" && "result" in (data as object)) {
|
||||||
|
const result = String((data as { result: string }).result);
|
||||||
|
if (result && result !== "accepted") {
|
||||||
|
this.enqueueCb(() => this.onRevoked?.({ id, from, reason: result }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleReceipt(r: Receipt & { receipt_id: string }): Promise<void> {
|
||||||
|
if (this.receiptSeen.has(r.receipt_id)) {
|
||||||
|
await this.sendReceiptAck(r.receipt_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.receiptSeen.add(r.receipt_id);
|
||||||
|
this.enqueueCb(() => this.onReceipt?.(r));
|
||||||
|
await this.sendReceiptAck(r.receipt_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendReceiptAck(receiptId: string): Promise<void> {
|
||||||
|
await this.request({ v: 1, type: "receipt_ack", rid: this.nextRid(), receipt_id: receiptId }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRevoked(r: RevokedEvent): void {
|
||||||
|
const key = `${r.from}\0${r.id}`;
|
||||||
|
const ent = this.dedup.get(key);
|
||||||
|
if (!ent || ent === "acked") return;
|
||||||
|
this.dedup.delete(key);
|
||||||
|
this.enqueueCb(() => this.onRevoked?.(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(frame: Record<string, unknown>, allowUnready: boolean): Promise<unknown> {
|
||||||
|
if (this.closed) return Promise.reject(new APIError("closed"));
|
||||||
|
if (!allowUnready && !this.handshook) return Promise.reject(new APIError("not_connected", "未握手"));
|
||||||
|
const tr = this.transport;
|
||||||
|
if (!tr) return Promise.reject(new APIError("not_connected"));
|
||||||
|
const rid = String(frame.rid ?? this.nextRid());
|
||||||
|
frame.rid = rid;
|
||||||
|
const payload = marshalJSON(frame);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(rid, {
|
||||||
|
resolve: (rf) => {
|
||||||
|
if (!rf.ok) {
|
||||||
|
reject(new APIError(rf.error?.code ?? "bad_request", rf.error?.message ?? ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(rf.data);
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
void tr.publishUp(payload).catch((e) => {
|
||||||
|
this.pending.delete(rid);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(to: Target, body: Body, opt: SendOptions = {}): Promise<SendResult> {
|
||||||
|
const enc = body.enc || "utf8";
|
||||||
|
const b: Body = {
|
||||||
|
enc,
|
||||||
|
data: body.data,
|
||||||
|
content_type:
|
||||||
|
opt.contentType ||
|
||||||
|
body.content_type ||
|
||||||
|
(enc === "base64" ? "application/octet-stream" : "text/plain; charset=utf-8"),
|
||||||
|
};
|
||||||
|
const n = bodyDecodedLen(b);
|
||||||
|
const maxBody = this.limits.max_body_bytes || 262144;
|
||||||
|
if (n > maxBody) throw new APIError("body_too_large", "正文超限");
|
||||||
|
|
||||||
|
if (opt.sendAt && opt.delayMs != null) throw new APIError("bad_request", "sendAt 与 delay 互斥");
|
||||||
|
|
||||||
|
const id = opt.id || uuidv7();
|
||||||
|
const frame: Record<string, unknown> = {
|
||||||
|
v: 1,
|
||||||
|
type: "send",
|
||||||
|
rid: this.nextRid(),
|
||||||
|
id,
|
||||||
|
to,
|
||||||
|
body: b,
|
||||||
|
};
|
||||||
|
if (opt.meta) frame.meta = opt.meta;
|
||||||
|
if (opt.talkPassword) frame.talk_password = opt.talkPassword;
|
||||||
|
if (opt.receipt != null) frame.receipt = opt.receipt;
|
||||||
|
if (opt.keep) {
|
||||||
|
const off: Record<string, unknown> = { keep: true };
|
||||||
|
if (opt.ttl != null) off.ttl_seconds = opt.ttl;
|
||||||
|
frame.offline = off;
|
||||||
|
}
|
||||||
|
if (opt.sendAt) {
|
||||||
|
frame.send_at_ms = opt.sendAt.getTime() + this.clockSkew;
|
||||||
|
} else if (opt.delayMs != null) {
|
||||||
|
frame.delay_ms = opt.delayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = marshalJSON(frame);
|
||||||
|
const maxFrame = this.limits.max_frame_bytes || 786432;
|
||||||
|
if (this.handshook && payload.length > maxFrame) {
|
||||||
|
throw new APIError("frame_too_large", "整帧超限");
|
||||||
|
}
|
||||||
|
if (this.closed || this.stopReconnect) throw new APIError("closed");
|
||||||
|
if (this.sendQ.length >= this.opts.sendQueueSize!) {
|
||||||
|
throw new APIError("queue_full", "发送队列已满");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<SendResult>((resolve, reject) => {
|
||||||
|
this.sendQ.push({ frame, payload, id, result: { resolve, reject }, inflight: false });
|
||||||
|
void this.drainSendQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drainSendQueue(): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
if (!this.handshook || !this.transport) return;
|
||||||
|
const next = this.sendQ.find((x) => !x.inflight);
|
||||||
|
if (!next || this.inflight >= this.opts.maxInflight!) return;
|
||||||
|
next.inflight = true;
|
||||||
|
this.inflight++;
|
||||||
|
void this.dispatchSend(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatchSend(item: SendItem): Promise<void> {
|
||||||
|
const rid = String(item.frame.rid);
|
||||||
|
const tr = this.transport!;
|
||||||
|
try {
|
||||||
|
const data = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
this.pending.set(rid, {
|
||||||
|
resolve: (rf) => {
|
||||||
|
if (!rf.ok) {
|
||||||
|
reject(new APIError(rf.error?.code ?? "bad_request", rf.error?.message ?? ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(rf.data);
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
void tr.publishUp(item.payload).catch((e) => {
|
||||||
|
this.pending.delete(rid);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const sd = (data ?? {}) as SendResult;
|
||||||
|
this.finishSend(item, { id: sd.id || item.id, send_at_ms: sd.send_at_ms, state: sd.state });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof APIError && e.code === "rate_limited") {
|
||||||
|
item.inflight = false;
|
||||||
|
this.inflight = Math.max(0, this.inflight - 1);
|
||||||
|
this.pending.delete(rid);
|
||||||
|
setTimeout(() => void this.drainSendQueue(), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 网络错误:保留队列
|
||||||
|
if (!(e instanceof APIError)) {
|
||||||
|
item.inflight = false;
|
||||||
|
this.inflight = Math.max(0, this.inflight - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.finishSendErr(item, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishSend(item: SendItem, res: SendResult): void {
|
||||||
|
this.sendQ = this.sendQ.filter((x) => x !== item);
|
||||||
|
if (item.inflight) {
|
||||||
|
this.inflight = Math.max(0, this.inflight - 1);
|
||||||
|
item.inflight = false;
|
||||||
|
}
|
||||||
|
item.result.resolve(res);
|
||||||
|
void this.drainSendQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishSendErr(item: SendItem, err: unknown): void {
|
||||||
|
this.sendQ = this.sendQ.filter((x) => x !== item);
|
||||||
|
if (item.inflight) {
|
||||||
|
this.inflight = Math.max(0, this.inflight - 1);
|
||||||
|
item.inflight = false;
|
||||||
|
}
|
||||||
|
item.result.reject(err);
|
||||||
|
void this.drainSendQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private failQueued(err: unknown): void {
|
||||||
|
for (const it of this.sendQ) it.result.reject(err);
|
||||||
|
this.sendQ = [];
|
||||||
|
this.inflight = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recall(id: string): Promise<RecallResult> {
|
||||||
|
return (await this.request({ v: 1, type: "recall", rid: this.nextRid(), id }, false)) as RecallResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(id: string, cursor = "", limit = 0): Promise<unknown> {
|
||||||
|
const req: Record<string, unknown> = { v: 1, type: "status", rid: this.nextRid(), id };
|
||||||
|
if (cursor) req.cursor = cursor;
|
||||||
|
if (limit) req.limit = limit;
|
||||||
|
return this.request(req, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlock(endpointId: string, talkPassword: string): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
{ v: 1, type: "unlock", rid: this.nextRid(), endpoint_id: endpointId, talk_password: talkPassword },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async presence(ids: string[]): Promise<unknown> {
|
||||||
|
return this.request({ v: 1, type: "presence.get", rid: this.nextRid(), ids }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async directory(cursor = "", query = "", limit = 0): Promise<unknown> {
|
||||||
|
const req: Record<string, unknown> = { v: 1, type: "directory.list", rid: this.nextRid() };
|
||||||
|
if (cursor) req.cursor = cursor;
|
||||||
|
if (query) req.query = query;
|
||||||
|
if (limit) req.limit = limit;
|
||||||
|
return this.request(req, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async watchPresence(ids: string[] | "all"): Promise<void> {
|
||||||
|
const req: Record<string, unknown> = { v: 1, type: "presence.watch", rid: this.nextRid() };
|
||||||
|
if (ids === "all") req.all = true;
|
||||||
|
else req.ids = ids;
|
||||||
|
await this.request(req, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSelf(): Promise<unknown> {
|
||||||
|
return this.request({ v: 1, type: "self.get", rid: this.nextRid() }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSelf(name?: string, defaultDelayMs?: number): Promise<void> {
|
||||||
|
const req: Record<string, unknown> = { v: 1, type: "self.update", rid: this.nextRid() };
|
||||||
|
if (name != null) req.name = name;
|
||||||
|
if (defaultDelayMs != null) req.default_delay_ms = defaultDelayMs;
|
||||||
|
await this.request(req, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTalkPassword(talkPassword: string): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
{ v: 1, type: "self.talk_password", rid: this.nextRid(), talk_password: talkPassword },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeLoginPassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||||
|
const data = (await this.request(
|
||||||
|
{
|
||||||
|
v: 1,
|
||||||
|
type: "self.login_password",
|
||||||
|
rid: this.nextRid(),
|
||||||
|
old_password: oldPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)) as { session_token?: string };
|
||||||
|
if (data?.session_token) {
|
||||||
|
this.transport?.setCredential(data.session_token);
|
||||||
|
this.enqueueCb(() => this.onSession?.(data.session_token!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createGroup(id: string, name: string, members: GroupMemberIn[]): Promise<unknown> {
|
||||||
|
return this.request(
|
||||||
|
{
|
||||||
|
v: 1,
|
||||||
|
type: "group.create",
|
||||||
|
rid: this.nextRid(),
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
members: members.map((m) => ({ id: m.id, talk_password: m.talkPassword ?? "" })),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addGroupMembers(groupId: string, members: GroupMemberIn[]): Promise<unknown> {
|
||||||
|
return this.request(
|
||||||
|
{
|
||||||
|
v: 1,
|
||||||
|
type: "group.add",
|
||||||
|
rid: this.nextRid(),
|
||||||
|
group_id: groupId,
|
||||||
|
members: members.map((m) => ({ id: m.id, talk_password: m.talkPassword ?? "" })),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeGroupMember(groupId: string, endpointId: string): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
{ v: 1, type: "group.remove", rid: this.nextRid(), group_id: groupId, endpoint_id: endpointId },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveGroup(groupId: string): Promise<void> {
|
||||||
|
await this.request({ v: 1, type: "group.leave", rid: this.nextRid(), group_id: groupId }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async transferGroup(groupId: string, endpointId: string): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
{ v: 1, type: "group.transfer", rid: this.nextRid(), group_id: groupId, endpoint_id: endpointId },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameGroup(groupId: string, name: string): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
{ v: 1, type: "group.rename", rid: this.nextRid(), group_id: groupId, name },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dissolveGroup(groupId: string): Promise<void> {
|
||||||
|
await this.request({ v: 1, type: "group.dissolve", rid: this.nextRid(), group_id: groupId }, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGroups(cursor = "", limit = 0): Promise<unknown> {
|
||||||
|
const req: Record<string, unknown> = { v: 1, type: "group.list", rid: this.nextRid() };
|
||||||
|
if (cursor) req.cursor = cursor;
|
||||||
|
if (limit) req.limit = limit;
|
||||||
|
return this.request(req, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroup(groupId: string, cursor = "", limit = 0): Promise<unknown> {
|
||||||
|
const req: Record<string, unknown> = {
|
||||||
|
v: 1,
|
||||||
|
type: "group.get",
|
||||||
|
rid: this.nextRid(),
|
||||||
|
group_id: groupId,
|
||||||
|
};
|
||||||
|
if (cursor) req.cursor = cursor;
|
||||||
|
if (limit) req.limit = limit;
|
||||||
|
return this.request(req, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.request({ v: 1, type: "self.logout", rid: this.nextRid() }, false);
|
||||||
|
} finally {
|
||||||
|
this.stopReconnect = true;
|
||||||
|
await this.transport?.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.closed = true;
|
||||||
|
this.stopReconnect = true;
|
||||||
|
this.failQueued(new APIError("closed", "已关闭"));
|
||||||
|
this.setState("offline");
|
||||||
|
await this.transport?.stop();
|
||||||
|
this.transport = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyDecodedLen(b: Body): number {
|
||||||
|
if (b.enc === "base64") {
|
||||||
|
const bin = atob(b.data);
|
||||||
|
return bin.length;
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(b.data).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(
|
||||||
|
connectOrRegisterURL: string,
|
||||||
|
registrationCode: string,
|
||||||
|
opt: RegisterOptions = {},
|
||||||
|
): Promise<RegisterResult> {
|
||||||
|
let regURL = connectOrRegisterURL;
|
||||||
|
try {
|
||||||
|
regURL = registerURLFromConnect(connectOrRegisterURL);
|
||||||
|
} catch {
|
||||||
|
/* 已是注册 URL */
|
||||||
|
}
|
||||||
|
const resp = await fetch(regURL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: marshalJSON({
|
||||||
|
registration_code: registrationCode,
|
||||||
|
id: opt.id ?? "",
|
||||||
|
login_password: opt.loginPassword ?? "",
|
||||||
|
name: opt.name ?? "",
|
||||||
|
talk_password: opt.talkPassword ?? "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const wrap = (await resp.json()) as {
|
||||||
|
ok: boolean;
|
||||||
|
data?: { id: string; login_password?: string };
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
};
|
||||||
|
if (!wrap.ok) {
|
||||||
|
throw new APIError(wrap.error?.code ?? "bad_request", wrap.error?.message ?? "注册失败");
|
||||||
|
}
|
||||||
|
return { id: wrap.data!.id, loginPassword: wrap.data!.login_password };
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
APIError,
|
||||||
|
AuthReason,
|
||||||
|
Transport,
|
||||||
|
TransportConfig,
|
||||||
|
buildCleanConnectFlags,
|
||||||
|
marshalJSON,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export interface FakeConnect {
|
||||||
|
cleanStart: boolean;
|
||||||
|
sessionExpiry: number;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单测用假 MQTT。 */
|
||||||
|
export class FakeTransport implements Transport {
|
||||||
|
private cfg?: TransportConfig;
|
||||||
|
private cred = "";
|
||||||
|
private up: string[] = [];
|
||||||
|
private connects: FakeConnect[] = [];
|
||||||
|
autoHello = true;
|
||||||
|
helloToken = "nst_test_token";
|
||||||
|
helloServerTimeMs = 1_750_000_000_000;
|
||||||
|
maxBodyBytes = 262144;
|
||||||
|
maxMetaBytes = 4096;
|
||||||
|
maxFrameBytes = 786432;
|
||||||
|
|
||||||
|
setCredential(passwordOrToken: string): void {
|
||||||
|
this.cred = passwordOrToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(cfg: TransportConfig): Promise<void> {
|
||||||
|
this.cfg = cfg;
|
||||||
|
await this.simulateConnectOK();
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishUp(payload: Uint8Array | string): Promise<void> {
|
||||||
|
const s = typeof payload === "string" ? payload : new TextDecoder().decode(payload);
|
||||||
|
this.up.push(s);
|
||||||
|
if (this.autoHello) {
|
||||||
|
try {
|
||||||
|
const head = JSON.parse(s) as { type?: string; rid?: string };
|
||||||
|
if (head.type === "hello" && head.rid) this.replyHello(head.rid);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.cfg?.onOffline();
|
||||||
|
}
|
||||||
|
|
||||||
|
private replyHello(rid: string): void {
|
||||||
|
const resp = marshalJSON({
|
||||||
|
v: 1,
|
||||||
|
type: "resp",
|
||||||
|
rid,
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
server_time_ms: this.helloServerTimeMs,
|
||||||
|
server_version: "0.1.0",
|
||||||
|
max_body_bytes: this.maxBodyBytes,
|
||||||
|
max_meta_bytes: this.maxMetaBytes,
|
||||||
|
max_frame_bytes: this.maxFrameBytes,
|
||||||
|
max_ttl_seconds: 2592000,
|
||||||
|
max_schedule_seconds: 31536000,
|
||||||
|
ack_timeout_seconds: 300,
|
||||||
|
session_token: this.helloToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.injectDown(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async simulateConnectOK(): Promise<void> {
|
||||||
|
const { cleanStart, sessionExpiry } = buildCleanConnectFlags();
|
||||||
|
this.connects.push({ cleanStart, sessionExpiry, password: this.cred });
|
||||||
|
this.cfg?.onConnectPacket?.(cleanStart, sessionExpiry);
|
||||||
|
this.cfg?.backoff.markOnline();
|
||||||
|
await this.cfg?.mqttReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
async simulateReconnect(): Promise<void> {
|
||||||
|
this.cfg?.backoff.markOffline();
|
||||||
|
this.cfg?.onOffline();
|
||||||
|
await this.simulateConnectOK();
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateAuthFail(reason: AuthReason): void {
|
||||||
|
this.cfg?.onAuthFailed(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateKick(): void {
|
||||||
|
this.cfg?.onKicked();
|
||||||
|
}
|
||||||
|
|
||||||
|
injectDown(payload: string | Uint8Array): void {
|
||||||
|
const bytes = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
||||||
|
this.cfg?.onDown(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
replyOK(rid: string, data: unknown = {}): void {
|
||||||
|
this.injectDown(marshalJSON({ v: 1, type: "resp", rid, ok: true, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
replyErr(rid: string, code: string, message: string): void {
|
||||||
|
this.injectDown(
|
||||||
|
marshalJSON({ v: 1, type: "resp", rid, ok: false, error: { code, message } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
upFrames(): string[] {
|
||||||
|
return [...this.up];
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnects(): FakeConnect[] {
|
||||||
|
return [...this.connects];
|
||||||
|
}
|
||||||
|
|
||||||
|
findUp(type: string): Array<Record<string, unknown>> {
|
||||||
|
const out: Array<Record<string, unknown>> = [];
|
||||||
|
for (const s of this.up) {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(s) as Record<string, unknown>;
|
||||||
|
if (m.type === type) out.push(m);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertNever(_x: never): void {
|
||||||
|
throw new APIError("bad_request", "unreachable");
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
APIError,
|
||||||
|
buildCleanConnectFlags,
|
||||||
|
registerURLFromConnect,
|
||||||
|
marshalJSON,
|
||||||
|
ReconnectBackoff,
|
||||||
|
} from "./types.js";
|
||||||
|
export type * from "./types.js";
|
||||||
|
export { Client, register } from "./client.js";
|
||||||
|
export { FakeTransport } from "./fake.js";
|
||||||
|
export { MqttTransport } from "./mqtt.js";
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import mqtt, { type MqttClient, type IClientOptions } from "mqtt";
|
||||||
|
import {
|
||||||
|
AuthReason,
|
||||||
|
Transport,
|
||||||
|
TransportConfig,
|
||||||
|
buildCleanConnectFlags,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/** 基于 MQTT.js 5 的真实传输。 */
|
||||||
|
export class MqttTransport implements Transport {
|
||||||
|
private client?: MqttClient;
|
||||||
|
private cfg?: TransportConfig;
|
||||||
|
private cred = "";
|
||||||
|
private stopped = false;
|
||||||
|
private upTopic = "";
|
||||||
|
private downTopic = "";
|
||||||
|
private attempt = 0;
|
||||||
|
|
||||||
|
setCredential(passwordOrToken: string): void {
|
||||||
|
this.cred = passwordOrToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(cfg: TransportConfig): Promise<void> {
|
||||||
|
this.cfg = cfg;
|
||||||
|
this.stopped = false;
|
||||||
|
this.upTopic = `nix/c/${cfg.endpointId}/up`;
|
||||||
|
this.downTopic = `nix/c/${cfg.endpointId}/down`;
|
||||||
|
await this.connectLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectLoop(): Promise<void> {
|
||||||
|
const cfg = this.cfg!;
|
||||||
|
while (!this.stopped) {
|
||||||
|
const wait = cfg.backoff.delay(this.attempt);
|
||||||
|
if (wait > 0) await sleep(wait);
|
||||||
|
if (this.stopped) return;
|
||||||
|
try {
|
||||||
|
await this.oneConnect();
|
||||||
|
this.attempt = 0;
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
this.attempt++;
|
||||||
|
cfg.backoff.markOffline();
|
||||||
|
cfg.onOffline();
|
||||||
|
if (this.stopped) return;
|
||||||
|
// 认证失败等会设 stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private oneConnect(): Promise<void> {
|
||||||
|
const cfg = this.cfg!;
|
||||||
|
const { cleanStart, sessionExpiry } = buildCleanConnectFlags();
|
||||||
|
cfg.onConnectPacket?.(cleanStart, sessionExpiry);
|
||||||
|
|
||||||
|
const url = normalizeWSURL(cfg.url, cfg.allowTcp);
|
||||||
|
const opts: IClientOptions = {
|
||||||
|
clientId: cfg.endpointId,
|
||||||
|
username: cfg.endpointId,
|
||||||
|
password: this.cred,
|
||||||
|
protocolVersion: 5,
|
||||||
|
clean: cleanStart,
|
||||||
|
connectTimeout: cfg.connectTimeoutMs,
|
||||||
|
reconnectPeriod: 0, // 自行退避
|
||||||
|
properties: {
|
||||||
|
sessionExpiryInterval: sessionExpiry,
|
||||||
|
},
|
||||||
|
wsOptions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = mqtt.connect(url, opts);
|
||||||
|
this.client = client;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const fail = (err: Error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
client.end(true);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.on("connect", async () => {
|
||||||
|
try {
|
||||||
|
cfg.backoff.markOnline();
|
||||||
|
await client.subscribeAsync(this.downTopic, { qos: 1 });
|
||||||
|
await cfg.mqttReady();
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
// 断线后自行重连
|
||||||
|
client.on("close", () => {
|
||||||
|
if (this.stopped) return;
|
||||||
|
cfg.backoff.markOffline();
|
||||||
|
cfg.onOffline();
|
||||||
|
this.attempt++;
|
||||||
|
void this.connectLoop();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
fail(e as Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("message", (_topic, payload) => {
|
||||||
|
cfg.onDown(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("error", (err) => {
|
||||||
|
// MQTT.js 用 error 报告部分失败
|
||||||
|
const msg = String(err?.message ?? err);
|
||||||
|
if (/Not authorized|Bad user|banned|0x86|0x87|0x8A/i.test(msg)) {
|
||||||
|
const reason: AuthReason = this.cred.startsWith("nst_")
|
||||||
|
? "session_invalid"
|
||||||
|
: "bad_credentials";
|
||||||
|
this.stopped = true;
|
||||||
|
cfg.onAuthFailed(reason);
|
||||||
|
}
|
||||||
|
fail(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on("disconnect", (packet) => {
|
||||||
|
const code = packet?.reasonCode;
|
||||||
|
if (code === 0x8e) {
|
||||||
|
this.stopped = true;
|
||||||
|
cfg.onKicked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!settled) fail(new Error("connect timeout"));
|
||||||
|
}, cfg.connectTimeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishUp(payload: Uint8Array | string): Promise<void> {
|
||||||
|
const c = this.client;
|
||||||
|
if (!c || !c.connected) throw new Error("not connected");
|
||||||
|
const buf = typeof payload === "string" ? Buffer.from(payload) : Buffer.from(payload);
|
||||||
|
await c.publishAsync(this.upTopic, buf, { qos: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.stopped = true;
|
||||||
|
const c = this.client;
|
||||||
|
this.client = undefined;
|
||||||
|
if (c) await new Promise<void>((r) => c.end(true, {}, () => r()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWSURL(raw: string, allowTcp: boolean): string {
|
||||||
|
const u = new URL(raw);
|
||||||
|
if (u.protocol === "http:") u.protocol = "ws:";
|
||||||
|
if (u.protocol === "https:") u.protocol = "wss:";
|
||||||
|
if (u.protocol === "ws:" || u.protocol === "wss:") {
|
||||||
|
if (!u.pathname || u.pathname === "/") u.pathname = "/mqtt";
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
if (u.protocol === "mqtt:" || u.protocol === "mqtts:") {
|
||||||
|
if (!allowTcp) throw new Error("裸 TCP 需显式 allowTcp");
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
throw new Error(`unsupported scheme ${u.protocol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/** NixMsg JS/TS SDK — 行为对齐 DEVELOPMENT 第 9 / 6 节。 */
|
||||||
|
|
||||||
|
export type ConnectionState =
|
||||||
|
| "connecting"
|
||||||
|
| "online"
|
||||||
|
| "reconnecting"
|
||||||
|
| "offline"
|
||||||
|
| "kicked"
|
||||||
|
| "auth_failed";
|
||||||
|
|
||||||
|
export type AuthReason = "session_invalid" | "bad_credentials";
|
||||||
|
|
||||||
|
export interface Credential {
|
||||||
|
password?: string;
|
||||||
|
sessionToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Target {
|
||||||
|
kind: "endpoint" | "group";
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Body {
|
||||||
|
enc: "utf8" | "base64";
|
||||||
|
content_type?: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendOptions {
|
||||||
|
sendAt?: Date;
|
||||||
|
delayMs?: number;
|
||||||
|
keep?: boolean;
|
||||||
|
ttl?: number;
|
||||||
|
receipt?: boolean;
|
||||||
|
talkPassword?: string;
|
||||||
|
contentType?: string;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendResult {
|
||||||
|
id: string;
|
||||||
|
send_at_ms: number;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
to: Target;
|
||||||
|
body: Body;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
send_at_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecallResult {
|
||||||
|
result: string;
|
||||||
|
recalled: number;
|
||||||
|
accepted: number;
|
||||||
|
other: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Receipt {
|
||||||
|
receipt_id: string;
|
||||||
|
id: string;
|
||||||
|
endpoint_id: string;
|
||||||
|
state: string;
|
||||||
|
reason: string;
|
||||||
|
at_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevokedEvent {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceEvent {
|
||||||
|
id: string;
|
||||||
|
online: boolean;
|
||||||
|
at_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupEvent {
|
||||||
|
group_id: string;
|
||||||
|
event: string;
|
||||||
|
endpoint_id: string;
|
||||||
|
at_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionEvent {
|
||||||
|
state: ConnectionState;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandshakeLimits {
|
||||||
|
server_time_ms: number;
|
||||||
|
server_version: string;
|
||||||
|
max_body_bytes: number;
|
||||||
|
max_meta_bytes: number;
|
||||||
|
max_frame_bytes: number;
|
||||||
|
max_ttl_seconds: number;
|
||||||
|
max_schedule_seconds: number;
|
||||||
|
ack_timeout_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterOptions {
|
||||||
|
id?: string;
|
||||||
|
loginPassword?: string;
|
||||||
|
name?: string;
|
||||||
|
talkPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResult {
|
||||||
|
id: string;
|
||||||
|
loginPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberIn {
|
||||||
|
id: string;
|
||||||
|
talkPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
manualAck?: boolean;
|
||||||
|
allowTcp?: boolean;
|
||||||
|
connectTimeoutMs?: number;
|
||||||
|
clientLabel?: string;
|
||||||
|
maxReceiveBytes?: number;
|
||||||
|
sendQueueSize?: number;
|
||||||
|
maxInflight?: number;
|
||||||
|
dedupCapacity?: number;
|
||||||
|
/** @internal 测试注入 */
|
||||||
|
transport?: Transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class APIError extends Error {
|
||||||
|
code: string;
|
||||||
|
constructor(code: string, message = "") {
|
||||||
|
super(message ? `${code}: ${message}` : code);
|
||||||
|
this.code = code;
|
||||||
|
this.name = "APIError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transport {
|
||||||
|
setCredential(passwordOrToken: string): void;
|
||||||
|
start(cfg: TransportConfig): Promise<void>;
|
||||||
|
publishUp(payload: Uint8Array | string): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransportConfig {
|
||||||
|
url: string;
|
||||||
|
endpointId: string;
|
||||||
|
connectTimeoutMs: number;
|
||||||
|
allowTcp: boolean;
|
||||||
|
backoff: ReconnectBackoff;
|
||||||
|
onDown: (payload: Uint8Array) => void;
|
||||||
|
onOffline: () => void;
|
||||||
|
onAuthFailed: (reason: AuthReason) => void;
|
||||||
|
onKicked: () => void;
|
||||||
|
mqttReady: () => Promise<void>;
|
||||||
|
onConnectPacket?: (cleanStart: boolean, sessionExpiry: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReconnectBackoff {
|
||||||
|
private base = 1000;
|
||||||
|
private onlineAt = 0;
|
||||||
|
private online = false;
|
||||||
|
private stable = false;
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
delay(attempt: number): number {
|
||||||
|
if (attempt <= 0) return 0;
|
||||||
|
let d = this.base;
|
||||||
|
for (let i = 1; i < attempt; i++) {
|
||||||
|
d = Math.min(d * 2, 30000);
|
||||||
|
}
|
||||||
|
const f = 0.7 + Math.random() * 0.6;
|
||||||
|
return Math.floor(d * f);
|
||||||
|
}
|
||||||
|
|
||||||
|
markOnline(): void {
|
||||||
|
this.online = true;
|
||||||
|
this.onlineAt = Date.now();
|
||||||
|
this.stable = false;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
if (this.online) {
|
||||||
|
this.stable = true;
|
||||||
|
this.base = 1000;
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
markOffline(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
const was = this.online;
|
||||||
|
this.online = false;
|
||||||
|
if (!was) {
|
||||||
|
this.base = Math.min(Math.max(this.base * 2, 1000), 30000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.stable || Date.now() - this.onlineAt >= 60_000) {
|
||||||
|
this.base = 1000;
|
||||||
|
this.stable = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.base = Math.min(this.base * 2, 30000);
|
||||||
|
this.stable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBase(): number {
|
||||||
|
return this.base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCleanConnectFlags(): { cleanStart: boolean; sessionExpiry: number } {
|
||||||
|
return { cleanStart: true, sessionExpiry: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerURLFromConnect(connectURL: string): string {
|
||||||
|
const u = new URL(connectURL);
|
||||||
|
switch (u.protocol) {
|
||||||
|
case "wss:":
|
||||||
|
case "https:":
|
||||||
|
case "mqtts:":
|
||||||
|
u.protocol = "https:";
|
||||||
|
break;
|
||||||
|
case "ws:":
|
||||||
|
case "http:":
|
||||||
|
case "mqtt:":
|
||||||
|
u.protocol = "http:";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new APIError("bad_request", `无法从 ${u.protocol} 推出注册地址`);
|
||||||
|
}
|
||||||
|
u.pathname = "/api/client/register";
|
||||||
|
u.search = "";
|
||||||
|
u.hash = "";
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marshalJSON(v: unknown): string {
|
||||||
|
// JSON.stringify 默认不转义非 ASCII;与文档一致
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Client, FakeTransport, APIError, buildCleanConnectFlags, register } from "../src/index.js";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
|
async function connectFake(fake: FakeTransport): Promise<Client> {
|
||||||
|
const c = new Client();
|
||||||
|
await c.connect("ws://example.test/mqtt", "ep1", { password: "secret" }, { transport: fake });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("nixmsg sdk", () => {
|
||||||
|
it("Clean Start every connect", async () => {
|
||||||
|
const fake = new FakeTransport();
|
||||||
|
const c = await connectFake(fake);
|
||||||
|
await fake.simulateReconnect();
|
||||||
|
await fake.simulateReconnect();
|
||||||
|
const cs = fake.getConnects();
|
||||||
|
expect(cs.length).toBeGreaterThanOrEqual(3);
|
||||||
|
for (const x of cs) {
|
||||||
|
expect(x.cleanStart).toBe(true);
|
||||||
|
expect(x.sessionExpiry).toBe(0);
|
||||||
|
}
|
||||||
|
await c.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("session token callback", async () => {
|
||||||
|
const fake = new FakeTransport();
|
||||||
|
fake.helloToken = "nst_abc";
|
||||||
|
const c = new Client();
|
||||||
|
let got = "";
|
||||||
|
c.onSessionHandler((t) => {
|
||||||
|
got = t;
|
||||||
|
});
|
||||||
|
await c.connect("ws://example.test/mqtt", "ep1", { password: "p" }, { transport: fake });
|
||||||
|
// 等待串行回调
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(got).toBe("nst_abc");
|
||||||
|
await c.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedup then re-ack", async () => {
|
||||||
|
const fake = new FakeTransport();
|
||||||
|
const c = await connectFake(fake);
|
||||||
|
let calls = 0;
|
||||||
|
c.onMessageHandler(() => {
|
||||||
|
calls++;
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyAcks = setInterval(() => {
|
||||||
|
for (const fr of fake.findUp("ack")) {
|
||||||
|
fake.replyOK(String(fr.rid), { result: "accepted" });
|
||||||
|
}
|
||||||
|
}, 5);
|
||||||
|
|
||||||
|
const msg = JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
type: "msg",
|
||||||
|
id: "m1",
|
||||||
|
from: "a",
|
||||||
|
to: { kind: "endpoint", id: "ep1" },
|
||||||
|
body: { enc: "utf8", data: "hi" },
|
||||||
|
send_at_ms: 1,
|
||||||
|
});
|
||||||
|
fake.injectDown(msg);
|
||||||
|
fake.injectDown(msg);
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
fake.injectDown(msg);
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
clearInterval(replyAcks);
|
||||||
|
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(fake.findUp("ack").length).toBeGreaterThanOrEqual(2);
|
||||||
|
await c.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("body too large locally", async () => {
|
||||||
|
const fake = new FakeTransport();
|
||||||
|
fake.maxBodyBytes = 16;
|
||||||
|
const c = await connectFake(fake);
|
||||||
|
await expect(
|
||||||
|
c.send(
|
||||||
|
{ kind: "endpoint", id: "b" },
|
||||||
|
{ enc: "utf8", data: "x".repeat(64) },
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "body_too_large" });
|
||||||
|
await c.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resend keeps id and send_at_ms", async () => {
|
||||||
|
const fake = new FakeTransport();
|
||||||
|
const c = await connectFake(fake);
|
||||||
|
const at = new Date(1_700_000_000_000);
|
||||||
|
let firstId = "";
|
||||||
|
let firstSendAt: unknown;
|
||||||
|
let replied = false;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const sends = fake.findUp("send");
|
||||||
|
if (!sends.length) return;
|
||||||
|
if (!replied) {
|
||||||
|
replied = true;
|
||||||
|
firstId = String(sends[0].id);
|
||||||
|
firstSendAt = sends[0].send_at_ms;
|
||||||
|
fake.replyErr(String(sends[0].rid), "rate_limited", "slow");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sends.length >= 2) {
|
||||||
|
expect(sends[1].id).toBe(firstId);
|
||||||
|
expect(sends[1].send_at_ms).toBe(firstSendAt);
|
||||||
|
fake.replyOK(String(sends[1].rid), {
|
||||||
|
id: firstId,
|
||||||
|
send_at_ms: firstSendAt,
|
||||||
|
state: "scheduled",
|
||||||
|
});
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
const res = await c.send(
|
||||||
|
{ kind: "endpoint", id: "b" },
|
||||||
|
{ enc: "utf8", data: "hi" },
|
||||||
|
{ sendAt: at },
|
||||||
|
);
|
||||||
|
expect(res.id).toBe(firstId);
|
||||||
|
await c.close();
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
it("register HTTP from ws url", async () => {
|
||||||
|
const srv = createServer((req, res) => {
|
||||||
|
expect(req.url).toBe("/api/client/register");
|
||||||
|
res.setHeader("content-type", "application/json");
|
||||||
|
res.end(JSON.stringify({ ok: true, data: { id: "e_1", login_password: "gen" } }));
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => srv.listen(0, "127.0.0.1", r));
|
||||||
|
const addr = srv.address();
|
||||||
|
if (!addr || typeof addr === "string") throw new Error("addr");
|
||||||
|
const ws = `ws://127.0.0.1:${addr.port}/mqtt`;
|
||||||
|
const res = await register(ws, "code", { name: "n" });
|
||||||
|
expect(res.id).toBe("e_1");
|
||||||
|
expect(res.loginPassword).toBe("gen");
|
||||||
|
await new Promise<void>((r) => srv.close(() => r()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildCleanConnectFlags", () => {
|
||||||
|
expect(buildCleanConnectFlags()).toEqual({ cleanStart: true, sessionExpiry: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APIError shape", () => {
|
||||||
|
const e = new APIError("body_too_large", "x");
|
||||||
|
expect(e.code).toBe("body_too_large");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"declaration": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user