@pmate/chat-sdk 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,3 +32,11 @@ must preserve the text offsets if transforming the message before transport.
32
32
  Storybook: `GroupMentions`, `MobileGroupMentions`, and `DarkGroupMentions` use the
33
33
  production components, include duplicate names and scrolling, and support empty
34
34
  results by typing an unmatched query.
35
+
36
+ ## Connection recovery
37
+
38
+ A dropped socket (including a network change or a suspended laptop) previously required callers to recreate the client. The runtime now reconnects with capped exponential backoff, probes the Hub with its binary heartbeat, and authenticates each replacement socket before sending chat traffic.
39
+
40
+ `client.watchConnection(handler)` immediately reports the current state and returns an unsubscribe function. States are `connecting`, `reconnecting`, `authenticating`, `connected`, `authentication_required`, and `closed`. Only `connected` means the Hub acknowledged authentication. A rejected authentication stops retries; supply a fresh login session using a new client. `close()` cancels retries, heartbeat timers and browser wake listeners.
41
+
42
+ Messages sent without an acknowledgement are rejected on disconnect and are **not replayed**: delivery is uncertain, so automatic retries could duplicate them. Callers should preserve drafts, display a reconnecting state, and refresh message history after recovery. No durable offline outbox is provided. An initial `connect()` wait times out after 30 seconds, but recovery continues until explicitly closed; retain the client and observe its connection state.
@@ -3,6 +3,7 @@ import type { ChatMention } from "../mentions/model";
3
3
  import { type BotLoginSession } from "./botAuth.js";
4
4
  import { type PmateBotKeyFile } from "./botKey.js";
5
5
  import { type ChatBody, type ChatRuntimeMessage, type RealtimeClient } from "./protocol.js";
6
+ export type ChatConnectionState = "connecting" | "reconnecting" | "authenticating" | "connected" | "authentication_required" | "closed";
6
7
  export type ChatClientOptions = {
7
8
  appId: string;
8
9
  profileId?: string;
@@ -17,6 +18,11 @@ export type ChatClientOptions = {
17
18
  };
18
19
  export declare class ChatClient {
19
20
  private readonly options;
21
+ private connection;
22
+ private readonly connectionHandlers;
23
+ private subscriptions;
24
+ private generation;
25
+ private connectPromise?;
20
26
  private client;
21
27
  private session;
22
28
  private profileIdValue;
@@ -27,7 +33,13 @@ export declare class ChatClient {
27
33
  private readonly messageHandlers;
28
34
  constructor(options: ChatClientOptions);
29
35
  connect(): Promise<this>;
36
+ private initialize;
30
37
  close(): void;
38
+ connectionState(): ChatConnectionState;
39
+ watchConnection(handler: (state: ChatConnectionState) => void): () => void;
40
+ private setConnection;
41
+ private rejectPending;
42
+ private authenticate;
31
43
  watchMessages(handler: (message: ChatRuntimeMessage) => void): () => boolean;
32
44
  watchActivities(handler: (activities: ChatActivity[]) => void): () => void;
33
45
  sendActivity(input: ActivityTarget & {
@@ -76,7 +88,7 @@ export declare class ChatClient {
76
88
  private sendAuth;
77
89
  private sendAndWaitForAck;
78
90
  private handleIncoming;
79
- private waitUntilConnected;
91
+ private waitUntilReady;
80
92
  private accountId;
81
93
  private resolveProfileId;
82
94
  private profileId;
@@ -7,6 +7,11 @@ const DEFAULT_AUTH_API_BASE_URL = "https://auth-api-v2.pmate.chat";
7
7
  const DEFAULT_ACK_TIMEOUT_MS = 5_000;
8
8
  export class ChatClient {
9
9
  options;
10
+ connection = "closed";
11
+ connectionHandlers = new Set();
12
+ subscriptions = [];
13
+ generation = 0;
14
+ connectPromise;
10
15
  client = null;
11
16
  session = null;
12
17
  profileIdValue = null;
@@ -23,7 +28,20 @@ export class ChatClient {
23
28
  this.session = options.session ?? null;
24
29
  this.client = options.realtimeClient ?? null;
25
30
  }
26
- async connect() {
31
+ connect() {
32
+ if (this.connectPromise)
33
+ return this.connectPromise;
34
+ if (this.connection === "connected")
35
+ return Promise.resolve(this);
36
+ const generation = this.generation;
37
+ this.setConnection("connecting");
38
+ const promise = this.initialize(generation);
39
+ this.connectPromise = promise;
40
+ void promise.finally(() => { if (this.connectPromise === promise)
41
+ this.connectPromise = undefined; }).catch(() => { });
42
+ return promise;
43
+ }
44
+ async initialize(generation) {
27
45
  if (!this.session) {
28
46
  if (!this.botKey) {
29
47
  throw new Error("ChatClient requires either session or PMate bot key JSON");
@@ -35,35 +53,87 @@ export class ChatClient {
35
53
  });
36
54
  }
37
55
  const profileId = await this.resolveProfileId();
56
+ if (generation !== this.generation)
57
+ throw new Error("ChatClient closed");
38
58
  if (!this.client) {
39
59
  const hubUrl = normalizeWsUrl(this.options.hubUrl ?? DEFAULT_HUB_URL, profileId);
40
60
  this.client = new RuntimeWebsocketClient(hubUrl);
41
61
  }
42
- this.client.on(ChatRealtimeEvents.Message, (message) => {
43
- this.handleIncoming(message);
44
- });
45
- this.client.on(ChatRealtimeEvents.Connected, (connected) => { if (!connected) {
46
- for (const publisher of this.publishers)
47
- void publisher.stop();
48
- this.activities.clear();
49
- } });
50
- await this.waitUntilConnected();
51
- await this.sendAuth();
62
+ if (!this.subscriptions.length) {
63
+ this.subscriptions.push(this.client.on(ChatRealtimeEvents.Message, message => this.handleIncoming(message)), this.client.on(ChatRealtimeEvents.Connected, connected => {
64
+ if (connected)
65
+ void this.authenticate();
66
+ else {
67
+ this.generation++;
68
+ this.setConnection("reconnecting");
69
+ this.rejectPending(new Error("聊天连接已断开,消息尚未确认送达"));
70
+ for (const publisher of this.publishers)
71
+ void publisher.stop();
72
+ this.activities.clear();
73
+ }
74
+ }));
75
+ }
76
+ if (this.client.isConnected())
77
+ void this.authenticate();
78
+ await this.waitUntilReady();
52
79
  return this;
53
80
  }
54
81
  close() {
82
+ this.generation++;
83
+ for (const off of this.subscriptions.splice(0))
84
+ off();
85
+ this.setConnection("closed");
55
86
  for (const publisher of this.publishers)
56
87
  void publisher.stop();
57
88
  this.publishers.clear();
58
89
  this.activities.clear();
59
90
  this.client?.close();
60
91
  this.client = null;
92
+ this.connectPromise = undefined;
93
+ this.rejectPending(new Error("ChatClient closed"));
94
+ }
95
+ connectionState() { return this.connection; }
96
+ watchConnection(handler) {
97
+ this.connectionHandlers.add(handler);
98
+ handler(this.connection);
99
+ return () => { this.connectionHandlers.delete(handler); };
100
+ }
101
+ setConnection(state) {
102
+ if (this.connection === state)
103
+ return;
104
+ this.connection = state;
105
+ for (const handler of this.connectionHandlers)
106
+ handler(state);
107
+ }
108
+ rejectPending(error) {
61
109
  for (const pending of this.pendingAcks.values()) {
62
- pending.reject(new Error("ChatClient closed"));
110
+ pending.reject(error);
63
111
  clearTimeout(pending.timer);
64
112
  }
65
113
  this.pendingAcks.clear();
66
114
  }
115
+ async authenticate() {
116
+ if (this.connection === "authenticating" || this.connection === "connected")
117
+ return;
118
+ const generation = this.generation;
119
+ this.setConnection("authenticating");
120
+ try {
121
+ await this.sendAuth();
122
+ if (generation === this.generation)
123
+ this.setConnection("connected");
124
+ }
125
+ catch (error) {
126
+ if (generation !== this.generation)
127
+ return;
128
+ if (error instanceof ChatAckError) {
129
+ this.close();
130
+ this.setConnection("authentication_required");
131
+ }
132
+ else {
133
+ this.client?.close(false);
134
+ }
135
+ }
136
+ }
67
137
  watchMessages(handler) {
68
138
  this.messageHandlers.add(handler);
69
139
  return () => this.messageHandlers.delete(handler);
@@ -165,6 +235,9 @@ export class ChatClient {
165
235
  }
166
236
  async sendAndWaitForAck(msg) {
167
237
  const client = this.requireClient();
238
+ if (msg.opcode !== ChatMsgOp.AUTH && this.connection !== "connected") {
239
+ throw new Error(this.connection === "authentication_required" ? "登录已失效,请重新登录" : "聊天连接正在恢复,请稍后重试");
240
+ }
168
241
  const ack = new Promise((resolve, reject) => {
169
242
  const timer = setTimeout(() => {
170
243
  this.pendingAcks.delete(msg.hash);
@@ -196,7 +269,7 @@ export class ChatClient {
196
269
  pending.resolve(ack);
197
270
  }
198
271
  else {
199
- pending.reject(new Error(ack.message || ack.code || "Ack failed"));
272
+ pending.reject(new ChatAckError(ack.message || ack.code || "Ack failed"));
200
273
  }
201
274
  }
202
275
  return;
@@ -214,24 +287,26 @@ export class ChatClient {
214
287
  }
215
288
  }
216
289
  }
217
- waitUntilConnected() {
218
- const client = this.requireClient();
219
- if (client.isConnected()) {
290
+ waitUntilReady() {
291
+ if (this.connection === "connected")
220
292
  return Promise.resolve();
221
- }
222
293
  return new Promise((resolve, reject) => {
223
- const offConnected = client.on(ChatRealtimeEvents.Connected, (connected) => {
224
- if (connected) {
225
- offConnected();
226
- offError();
294
+ const timer = setTimeout(() => {
295
+ this.connectionHandlers.delete(check);
296
+ reject(new Error("聊天连接正在恢复,请稍后重试"));
297
+ }, 30_000);
298
+ const check = (state) => {
299
+ if (!["connected", "closed", "authentication_required"].includes(state))
300
+ return;
301
+ clearTimeout(timer);
302
+ this.connectionHandlers.delete(check);
303
+ if (state === "connected")
227
304
  resolve();
228
- }
229
- });
230
- const offError = client.on(ChatRealtimeEvents.Error, (error) => {
231
- offConnected();
232
- offError();
233
- reject(error instanceof Error ? error : new Error("WebSocket connection failed"));
234
- });
305
+ else
306
+ reject(new Error(state === "authentication_required" ? "登录已失效,请重新登录" : "ChatClient closed"));
307
+ };
308
+ this.connectionHandlers.add(check);
309
+ check(this.connection);
235
310
  });
236
311
  }
237
312
  accountId() {
@@ -292,6 +367,8 @@ export class ChatClient {
292
367
  return this.client;
293
368
  }
294
369
  }
370
+ class ChatAckError extends Error {
371
+ }
295
372
  export function createChatClient(options) {
296
373
  return new ChatClient(options);
297
374
  }
@@ -72,13 +72,24 @@ export declare class RuntimeWebsocketClient implements RealtimeClient {
72
72
  private readonly wsUrl;
73
73
  private ws;
74
74
  private connected;
75
+ private closed;
76
+ private attempt;
77
+ private retryTimer?;
78
+ private connectTimer?;
79
+ private heartbeatTimer?;
80
+ private pongTimer?;
75
81
  private readonly listeners;
82
+ private readonly onWake;
76
83
  constructor(wsUrl: string);
77
84
  on<T>(topic: string, handler: (value: T) => void): () => boolean;
78
85
  isConnected(): boolean;
79
- close(): void;
86
+ close(manual?: boolean): void;
80
87
  sendMsg(msg: ChatRuntimeMessage): Promise<void>;
81
88
  getConnectionType(): "websocket";
82
89
  private connect;
90
+ private ping;
91
+ private dropSocket;
92
+ private clearRetry;
93
+ private scheduleReconnect;
83
94
  private emit;
84
95
  }
@@ -32,9 +32,31 @@ export class RuntimeWebsocketClient {
32
32
  wsUrl;
33
33
  ws = null;
34
34
  connected = false;
35
+ closed = false;
36
+ attempt = 0;
37
+ retryTimer;
38
+ connectTimer;
39
+ heartbeatTimer;
40
+ pongTimer;
35
41
  listeners = new Map();
42
+ onWake = () => {
43
+ if (this.closed || (typeof document !== "undefined" && document.hidden))
44
+ return;
45
+ if (this.connected)
46
+ this.ping();
47
+ else {
48
+ this.clearRetry();
49
+ if (!this.ws)
50
+ this.connect();
51
+ }
52
+ };
36
53
  constructor(wsUrl) {
37
54
  this.wsUrl = wsUrl;
55
+ if (typeof window !== "undefined") {
56
+ window.addEventListener("online", this.onWake);
57
+ window.addEventListener("pageshow", this.onWake);
58
+ document.addEventListener("visibilitychange", this.onWake);
59
+ }
38
60
  this.connect();
39
61
  }
40
62
  on(topic, handler) {
@@ -46,10 +68,17 @@ export class RuntimeWebsocketClient {
46
68
  isConnected() {
47
69
  return this.connected;
48
70
  }
49
- close() {
50
- this.ws?.close();
51
- this.ws = null;
52
- this.connected = false;
71
+ close(manual = true) {
72
+ this.closed = manual;
73
+ this.clearRetry();
74
+ this.dropSocket();
75
+ if (manual && typeof window !== "undefined") {
76
+ window.removeEventListener("online", this.onWake);
77
+ window.removeEventListener("pageshow", this.onWake);
78
+ document.removeEventListener("visibilitychange", this.onWake);
79
+ }
80
+ if (!manual)
81
+ this.scheduleReconnect();
53
82
  }
54
83
  async sendMsg(msg) {
55
84
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -61,20 +90,53 @@ export class RuntimeWebsocketClient {
61
90
  return "websocket";
62
91
  }
63
92
  connect() {
64
- this.ws = new WebSocket(this.wsUrl);
65
- this.ws.binaryType = "arraybuffer";
66
- this.ws.onopen = () => {
93
+ if (this.closed || this.ws)
94
+ return;
95
+ let ws;
96
+ try {
97
+ ws = new WebSocket(this.wsUrl);
98
+ }
99
+ catch (error) {
100
+ this.emit(ChatRealtimeEvents.Error, error);
101
+ this.scheduleReconnect();
102
+ return;
103
+ }
104
+ this.ws = ws;
105
+ ws.binaryType = "arraybuffer";
106
+ this.connectTimer = setTimeout(() => this.close(false), 10_000);
107
+ ws.onopen = () => {
108
+ if (this.ws !== ws)
109
+ return;
110
+ clearTimeout(this.connectTimer);
67
111
  this.connected = true;
112
+ this.attempt = 0;
113
+ this.heartbeatTimer = setInterval(() => this.ping(), 20_000);
68
114
  this.emit(ChatRealtimeEvents.Connected, true);
69
115
  };
70
- this.ws.onclose = () => {
71
- this.connected = false;
72
- this.emit(ChatRealtimeEvents.Connected, false);
73
- };
74
- this.ws.onerror = (event) => {
116
+ ws.onclose = () => { if (this.ws === ws)
117
+ this.close(false); };
118
+ ws.onerror = (event) => {
119
+ if (this.ws !== ws)
120
+ return;
75
121
  this.emit(ChatRealtimeEvents.Error, event);
122
+ this.close(false);
76
123
  };
77
- this.ws.onmessage = (event) => {
124
+ ws.onmessage = (event) => {
125
+ if (this.ws !== ws)
126
+ return;
127
+ // Hub uses single-byte binary PING/PONG frames, separate from MessagePack.
128
+ const bytes = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : event.data;
129
+ if (bytes instanceof Uint8Array && bytes.length === 1) {
130
+ if (bytes[0] === 2) {
131
+ clearTimeout(this.pongTimer);
132
+ this.pongTimer = undefined;
133
+ return;
134
+ }
135
+ if (bytes[0] === 1) {
136
+ ws.send(new Uint8Array([2]));
137
+ return;
138
+ }
139
+ }
78
140
  try {
79
141
  this.emit(ChatRealtimeEvents.Message, decodeWireMessage(event.data));
80
142
  }
@@ -83,6 +145,41 @@ export class RuntimeWebsocketClient {
83
145
  }
84
146
  };
85
147
  }
148
+ ping() {
149
+ if (!this.connected || this.pongTimer || !this.ws)
150
+ return;
151
+ this.pongTimer = setTimeout(() => this.close(false), 10_000);
152
+ try {
153
+ this.ws.send(new Uint8Array([1]));
154
+ }
155
+ catch {
156
+ this.close(false);
157
+ }
158
+ }
159
+ dropSocket() {
160
+ clearTimeout(this.connectTimer);
161
+ clearInterval(this.heartbeatTimer);
162
+ clearTimeout(this.pongTimer);
163
+ this.pongTimer = undefined;
164
+ const ws = this.ws;
165
+ this.ws = null;
166
+ this.connected = false;
167
+ if (ws) {
168
+ ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null;
169
+ ws.close();
170
+ }
171
+ this.emit(ChatRealtimeEvents.Connected, false);
172
+ }
173
+ clearRetry() { clearTimeout(this.retryTimer); this.retryTimer = undefined; }
174
+ scheduleReconnect() {
175
+ if (this.closed || this.retryTimer)
176
+ return;
177
+ const delay = Math.min(30_000, 500 * 2 ** Math.min(this.attempt++, 6));
178
+ this.retryTimer = setTimeout(() => {
179
+ this.retryTimer = undefined;
180
+ this.connect();
181
+ }, delay * (0.8 + Math.random() * 0.2));
182
+ }
86
183
  emit(topic, value) {
87
184
  for (const handler of this.listeners.get(topic) ?? []) {
88
185
  handler(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",