@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 +8 -0
- package/lib/runtime/chatClient.d.ts +13 -1
- package/lib/runtime/chatClient.js +105 -28
- package/lib/runtime/protocol.d.ts +12 -1
- package/lib/runtime/protocol.js +110 -13
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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.
|
|
43
|
-
this.handleIncoming(message)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
}
|
package/lib/runtime/protocol.js
CHANGED
|
@@ -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.
|
|
51
|
-
this.
|
|
52
|
-
this.
|
|
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.
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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);
|