From 9650cbff76e51024539ab5ecdded18d8bf21b300 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 06:57:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Go=20=E4=B8=8E=20J?= =?UTF-8?q?S/TS=20SDK=20=E8=BF=9E=E6=8E=A5=E6=94=B6=E5=8F=91=E4=B8=8E?= =?UTF-8?q?=E5=85=B6=E4=BD=99=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 41 +- sdk/go/.gitkeep | 0 sdk/go/LICENSE | 10 + sdk/go/api.go | 224 +++ sdk/go/backoff.go | 122 ++ sdk/go/client.go | 140 ++ sdk/go/client_test.go | 254 +++ sdk/go/connect.go | 265 ++++ sdk/go/doc.go | 7 + sdk/go/errors.go | 46 + sdk/go/go.mod | 13 + sdk/go/go.sum | 20 + sdk/go/json.go | 36 + sdk/go/receive.go | 326 ++++ sdk/go/register.go | 65 + sdk/go/send.go | 238 +++ sdk/go/transport.go | 40 + sdk/go/transport_fake.go | 266 ++++ sdk/go/transport_mqtt.go | 258 ++++ sdk/go/types.go | 231 +++ sdk/js/.gitkeep | 0 sdk/js/LICENSE | 10 + sdk/js/package-lock.json | 2974 ++++++++++++++++++++++++++++++++++++ sdk/js/package.json | 39 + sdk/js/src/client.ts | 780 ++++++++++ sdk/js/src/fake.ts | 137 ++ sdk/js/src/index.ts | 11 + sdk/js/src/mqtt.ts | 168 ++ sdk/js/src/types.ts | 251 +++ sdk/js/test/client.test.ts | 152 ++ sdk/js/tsconfig.json | 14 + sdk/js/vitest.config.ts | 8 + 32 files changed, 7145 insertions(+), 1 deletion(-) delete mode 100644 sdk/go/.gitkeep create mode 100644 sdk/go/LICENSE create mode 100644 sdk/go/api.go create mode 100644 sdk/go/backoff.go create mode 100644 sdk/go/client.go create mode 100644 sdk/go/client_test.go create mode 100644 sdk/go/connect.go create mode 100644 sdk/go/doc.go create mode 100644 sdk/go/errors.go create mode 100644 sdk/go/go.mod create mode 100644 sdk/go/go.sum create mode 100644 sdk/go/json.go create mode 100644 sdk/go/receive.go create mode 100644 sdk/go/register.go create mode 100644 sdk/go/send.go create mode 100644 sdk/go/transport.go create mode 100644 sdk/go/transport_fake.go create mode 100644 sdk/go/transport_mqtt.go create mode 100644 sdk/go/types.go delete mode 100644 sdk/js/.gitkeep create mode 100644 sdk/js/LICENSE create mode 100644 sdk/js/package-lock.json create mode 100644 sdk/js/package.json create mode 100644 sdk/js/src/client.ts create mode 100644 sdk/js/src/fake.ts create mode 100644 sdk/js/src/index.ts create mode 100644 sdk/js/src/mqtt.ts create mode 100644 sdk/js/src/types.ts create mode 100644 sdk/js/test/client.test.ts create mode 100644 sdk/js/tsconfig.json create mode 100644 sdk/js/vitest.config.ts diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 4b0c6f1..831164a 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -422,7 +422,46 @@ ## SDK 一 S1 -暂无。 +### S1.1 传输层可注入假实现(Go / JS) + +- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。 +- 实际做法:Go 与 JS SDK 均以 `transport` 接口隔离 MQTT;单测注入 `FakeTransport`,自动回复 `hello`、可注入下行帧与 `resp`。真实路径仍分别走 autopaho / MQTT.js。 +- 原因:否则无法在无服务端时稳定覆盖 Clean Start、去重再 ack、本地超限、令牌回调、重交不变号。 +- 备选:起嵌入式 mochi;被否是因为任务明确禁止真实服务器,且会与并行 Agent 抢端口。 + +### S1.2 Go autopaho `OnConnectionUp` 内握手改异步 + +- 相关文档:autopaho 要求 `OnConnectionUp` 不得阻塞;DEVELOPMENT 第 9 节要求订阅 down 后发 `hello` 并等待成功。 +- 实际做法:订阅与 `hello` 放在 `OnConnectionUp` 触发的 goroutine 中;`Connect` 轮询 `handshook` 直至超时(默认 30s)。 +- 原因:在回调里同步 `hello` 会违反库约束并可能死锁。 +- 备选:自定义连接循环不用 autopaho 的 `OnConnectionUp`;未采用,因文档指定 autopaho。 + +### S1.3 重连退避与「稳定在线 60s」状态机自管 + +- 相关文档:DEVELOPMENT 第 9 节退避规则。 +- 实际做法:自实现 `reconnectBackoff`(1s 起、加倍、上限 30s、±30% 抖动;在线满 60s 将 base 恢复为 1s)。Go 将其接到 autopaho 的 `ReconnectBackoff`;JS 因 `reconnectPeriod: 0` 自管重连循环并使用同一算法。 +- 原因:autopaho 自带指数退避参数模型与文档不完全一致,且每次 `establishServerConnection` 会重置 attempt,无法单独表达「未稳定在线则跨周期继续抬升」。 +- 备选:直接用 `autopaho.NewExponentialBackoff`;未采用,以免与文档抖动与 60s 恢复语义偏离。 + +### S1.4 Go 模块许可证标注 + +- 相关文档:DEVELOPMENT 第 9 节「Go 写 SEE LICENSE,包里带上仓库根目录 LICENSE」。 +- 实际做法:`sdk/go/LICENSE` 为仓库根 `LICENSE` 副本;`doc.go` 注明专有许可见 LICENSE(Go modules 无 npm 式 license 字段)。 +- 原因:go.mod 无标准 license 键。 +- 备选:另加 `LICENSE.md` 指向根目录相对路径;副本更利于 `go get` 后独立阅读。 + +### S1.5 JS 回调命名与文档概念名 + +- 相关文档:第 9 节概念方法名 `onSession` / `onMessage` 等。 +- 实际做法:TypeScript 对外提供 `onSessionHandler`、`onMessageHandler` 等,避免与 EventEmitter 风格或属性赋值混淆;语义与文档一致。 +- 原因:`onSession` 作方法名在部分风格指南中易被误认为事件订阅属性。 +- 备选:完全同名方法;可在任务 5 文档化时再加别名。 + +### S1.6 未做范围 + +- 任务 4 真实服务器接入清单(15 条)未做,按总控安排留给后续波次。 +- 任务 5 打包试跑/README/示例未做(本期只完成任务 1–3 与许可证文件副本;JS 已能 `npm run build` 出 ESM+CJS,但未发布 npm)。 +- Go 裸 TCP(`AllowTCP`)与 JS `allowTcp` 已接线,但无集成级验证(属接入清单)。 ## SDK 二 S2 diff --git a/sdk/go/.gitkeep b/sdk/go/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/sdk/go/LICENSE b/sdk/go/LICENSE new file mode 100644 index 0000000..e1c9937 --- /dev/null +++ b/sdk/go/LICENSE @@ -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. diff --git a/sdk/go/api.go b/sdk/go/api.go new file mode 100644 index 0000000..e53cd6c --- /dev/null +++ b/sdk/go/api.go @@ -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) +} diff --git a/sdk/go/backoff.go b/sdk/go/backoff.go new file mode 100644 index 0000000..0123bee --- /dev/null +++ b/sdk/go/backoff.go @@ -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 +} diff --git a/sdk/go/client.go b/sdk/go/client.go new file mode 100644 index 0000000..969c5a0 --- /dev/null +++ b/sdk/go/client.go @@ -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"` +} diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go new file mode 100644 index 0000000..427e7da --- /dev/null +++ b/sdk/go/client_test.go @@ -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) + } +} diff --git a/sdk/go/connect.go b/sdk/go/connect.go new file mode 100644 index 0000000..7499f50 --- /dev/null +++ b/sdk/go/connect.go @@ -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 +} diff --git a/sdk/go/doc.go b/sdk/go/doc.go new file mode 100644 index 0000000..4c61c2d --- /dev/null +++ b/sdk/go/doc.go @@ -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 diff --git a/sdk/go/errors.go b/sdk/go/errors.go new file mode 100644 index 0000000..139a0f0 --- /dev/null +++ b/sdk/go/errors.go @@ -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" +) diff --git a/sdk/go/go.mod b/sdk/go/go.mod new file mode 100644 index 0000000..50f480a --- /dev/null +++ b/sdk/go/go.mod @@ -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 +) diff --git a/sdk/go/go.sum b/sdk/go/go.sum new file mode 100644 index 0000000..725d2d5 --- /dev/null +++ b/sdk/go/go.sum @@ -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= diff --git a/sdk/go/json.go b/sdk/go/json.go new file mode 100644 index 0000000..cdbdf94 --- /dev/null +++ b/sdk/go/json.go @@ -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 +} diff --git a/sdk/go/receive.go b/sdk/go/receive.go new file mode 100644 index 0000000..18532c7 --- /dev/null +++ b/sdk/go/receive.go @@ -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") + } +} diff --git a/sdk/go/register.go b/sdk/go/register.go new file mode 100644 index 0000000..5efa196 --- /dev/null +++ b/sdk/go/register.go @@ -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 +} diff --git a/sdk/go/send.go b/sdk/go/send.go new file mode 100644 index 0000000..d5784f6 --- /dev/null +++ b/sdk/go/send.go @@ -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...) +} diff --git a/sdk/go/transport.go b/sdk/go/transport.go new file mode 100644 index 0000000..175758a --- /dev/null +++ b/sdk/go/transport.go @@ -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 +} diff --git a/sdk/go/transport_fake.go b/sdk/go/transport_fake.go new file mode 100644 index 0000000..b03d2c7 --- /dev/null +++ b/sdk/go/transport_fake.go @@ -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 +} diff --git a/sdk/go/transport_mqtt.go b/sdk/go/transport_mqtt.go new file mode 100644 index 0000000..5b22765 --- /dev/null +++ b/sdk/go/transport_mqtt.go @@ -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 +} diff --git a/sdk/go/types.go b/sdk/go/types.go new file mode 100644 index 0000000..f5c680d --- /dev/null +++ b/sdk/go/types.go @@ -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 时的原因 +} diff --git a/sdk/js/.gitkeep b/sdk/js/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/sdk/js/LICENSE b/sdk/js/LICENSE new file mode 100644 index 0000000..e1c9937 --- /dev/null +++ b/sdk/js/LICENSE @@ -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. diff --git a/sdk/js/package-lock.json b/sdk/js/package-lock.json new file mode 100644 index 0000000..ad6da03 --- /dev/null +++ b/sdk/js/package-lock.json @@ -0,0 +1,2974 @@ +{ + "name": "@nixevol/nixmsg", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nixevol/nixmsg", + "version": "0.1.0", + "license": "SEE LICENSE IN LICENSE", + "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" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.25.tgz", + "integrity": "sha512-7GsxL8Wd5X3jh73/qgW7WCnbBvGSdD8U3E4Jiuus3/8I+PN20DtlkJtwFhr/sImXfDDQ2Z03i/9wNiIF3TVSsw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/broker-factory": { + "version": "3.1.15", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz", + "integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.50" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mqtt": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.16.0.tgz", + "integrity": "sha512-G0VsEtuJz5sJUgZJaaADMVfW7SIFPzkIuHR+d6rk+tBeSwdCfd9BaIRycRfd2AbyFDEmLlFM+SDQeti/+IbOnw==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/worker-factory": { + "version": "7.0.50", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz", + "integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.34", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz", + "integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.18", + "worker-timers-worker": "^9.0.15" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.18", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz", + "integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "broker-factory": "^3.1.15", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.15" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.15", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz", + "integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "tslib": "^2.8.1", + "worker-factory": "^7.0.50" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/sdk/js/package.json b/sdk/js/package.json new file mode 100644 index 0000000..56b65ee --- /dev/null +++ b/sdk/js/package.json @@ -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" + } +} diff --git a/sdk/js/src/client.ts b/sdk/js/src/client.ts new file mode 100644 index 0000000..3752427 --- /dev/null +++ b/sdk/js/src/client.ts @@ -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; + 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 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(); + private dedupOrd: string[] = []; + private receiptSeen = new Set(); + private cbChain: Promise = Promise.resolve(); + + private onSession?: (token: string) => void; + private onMessage?: (msg: Message) => void | Promise; + 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 { + 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 { + 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 { + this.cbChain = this.cbChain.then(async () => { + try { + await fn(); + } catch { + /* 回调错误不打断串行链 */ + } + }); + } + + private nextRid(): string { + this.ridSeq += 1; + return String(this.ridSeq); + } + + private async doHello(): Promise { + const sentAt = Date.now(); + const req: Record = { + 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; + 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; + send_at_ms: number; + }): Promise { + 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((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 { + await this.sendAckFrame(msg.from, msg.id); + this.rememberDedup(`${msg.from}\0${msg.id}`, "acked"); + } + + private async sendAckFrame(from: string, id: string): Promise { + 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 { + 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 { + 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, allowUnready: boolean): Promise { + 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 { + 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 = { + 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 = { 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((resolve, reject) => { + this.sendQ.push({ frame, payload, id, result: { resolve, reject }, inflight: false }); + void this.drainSendQueue(); + }); + } + + private async drainSendQueue(): Promise { + 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 { + const rid = String(item.frame.rid); + const tr = this.transport!; + try { + const data = await 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(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 { + return (await this.request({ v: 1, type: "recall", rid: this.nextRid(), id }, false)) as RecallResult; + } + + async status(id: string, cursor = "", limit = 0): Promise { + const req: Record = { 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 { + await this.request( + { v: 1, type: "unlock", rid: this.nextRid(), endpoint_id: endpointId, talk_password: talkPassword }, + false, + ); + } + + async presence(ids: string[]): Promise { + return this.request({ v: 1, type: "presence.get", rid: this.nextRid(), ids }, false); + } + + async directory(cursor = "", query = "", limit = 0): Promise { + const req: Record = { 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 { + const req: Record = { 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 { + return this.request({ v: 1, type: "self.get", rid: this.nextRid() }, false); + } + + async updateSelf(name?: string, defaultDelayMs?: number): Promise { + const req: Record = { 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 { + await this.request( + { v: 1, type: "self.talk_password", rid: this.nextRid(), talk_password: talkPassword }, + false, + ); + } + + async changeLoginPassword(oldPassword: string, newPassword: string): Promise { + 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 { + 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 { + 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 { + await this.request( + { v: 1, type: "group.remove", rid: this.nextRid(), group_id: groupId, endpoint_id: endpointId }, + false, + ); + } + + async leaveGroup(groupId: string): Promise { + await this.request({ v: 1, type: "group.leave", rid: this.nextRid(), group_id: groupId }, false); + } + + async transferGroup(groupId: string, endpointId: string): Promise { + 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 { + await this.request( + { v: 1, type: "group.rename", rid: this.nextRid(), group_id: groupId, name }, + false, + ); + } + + async dissolveGroup(groupId: string): Promise { + await this.request({ v: 1, type: "group.dissolve", rid: this.nextRid(), group_id: groupId }, false); + } + + async listGroups(cursor = "", limit = 0): Promise { + const req: Record = { 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 { + const req: Record = { + 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 { + try { + await this.request({ v: 1, type: "self.logout", rid: this.nextRid() }, false); + } finally { + this.stopReconnect = true; + await this.transport?.stop(); + } + } + + async close(): Promise { + 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 { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function register( + connectOrRegisterURL: string, + registrationCode: string, + opt: RegisterOptions = {}, +): Promise { + 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 }; +} diff --git a/sdk/js/src/fake.ts b/sdk/js/src/fake.ts new file mode 100644 index 0000000..afb4be0 --- /dev/null +++ b/sdk/js/src/fake.ts @@ -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 { + this.cfg = cfg; + await this.simulateConnectOK(); + } + + async publishUp(payload: Uint8Array | string): Promise { + 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 { + 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 { + 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 { + 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> { + const out: Array> = []; + for (const s of this.up) { + try { + const m = JSON.parse(s) as Record; + if (m.type === type) out.push(m); + } catch { + /* ignore */ + } + } + return out; + } +} + +export function assertNever(_x: never): void { + throw new APIError("bad_request", "unreachable"); +} diff --git a/sdk/js/src/index.ts b/sdk/js/src/index.ts new file mode 100644 index 0000000..8641274 --- /dev/null +++ b/sdk/js/src/index.ts @@ -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"; diff --git a/sdk/js/src/mqtt.ts b/sdk/js/src/mqtt.ts new file mode 100644 index 0000000..3db060e --- /dev/null +++ b/sdk/js/src/mqtt.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + this.stopped = true; + const c = this.client; + this.client = undefined; + if (c) await new Promise((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 { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/sdk/js/src/types.ts b/sdk/js/src/types.ts new file mode 100644 index 0000000..03d2266 --- /dev/null +++ b/sdk/js/src/types.ts @@ -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; + 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; + 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; + publishUp(payload: Uint8Array | string): Promise; + stop(): Promise; +} + +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; + onConnectPacket?: (cleanStart: boolean, sessionExpiry: number) => void; +} + +export class ReconnectBackoff { + private base = 1000; + private onlineAt = 0; + private online = false; + private stable = false; + private timer: ReturnType | 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); +} diff --git a/sdk/js/test/client.test.ts b/sdk/js/test/client.test.ts new file mode 100644 index 0000000..d73c575 --- /dev/null +++ b/sdk/js/test/client.test.ts @@ -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 { + 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((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((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"); + }); +}); diff --git a/sdk/js/tsconfig.json b/sdk/js/tsconfig.json new file mode 100644 index 0000000..fc912b3 --- /dev/null +++ b/sdk/js/tsconfig.json @@ -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/**/*"] +} diff --git a/sdk/js/vitest.config.ts b/sdk/js/vitest.config.ts new file mode 100644 index 0000000..ed8bf77 --- /dev/null +++ b/sdk/js/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +});