@pmate/chat-sdk 0.6.2 → 0.7.0

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.
package/lib/index.d.ts CHANGED
@@ -33,3 +33,5 @@ export * from "./hooks/useMentions";
33
33
  export * from "./components/MentionMenu";
34
34
  export * from "./runtime/activity";
35
35
  export * from "./runtime/call";
36
+ export * from "./runtime/presence";
37
+ export * from "./runtime/meeting";
package/lib/index.js CHANGED
@@ -33,3 +33,5 @@ export * from "./hooks/useMentions";
33
33
  export * from "./components/MentionMenu";
34
34
  export * from "./runtime/activity";
35
35
  export * from "./runtime/call";
36
+ export * from "./runtime/presence";
37
+ export * from "./runtime/meeting";
@@ -1,8 +1,10 @@
1
+ import { type ChatPresence } from "./presence.js";
1
2
  import { ChatActivityPublisher, type ActivityTarget, type ChatActivity, type ChatActivityState, type ChatActivityUpdate } from "./activity.js";
2
3
  import type { ChatMention } from "../mentions/model";
3
4
  import { type BotLoginSession } from "./botAuth.js";
4
5
  import { type PmateBotKeyFile } from "./botKey.js";
5
6
  import { type ChatBody, type ChatRuntimeMessage, type RealtimeClient } from "./protocol.js";
7
+ export type ChatConnectionState = "connecting" | "reconnecting" | "authenticating" | "connected" | "authentication_required" | "closed";
6
8
  export type ChatClientOptions = {
7
9
  appId: string;
8
10
  profileId?: string;
@@ -13,10 +15,22 @@ export type ChatClientOptions = {
13
15
  realtimeClient?: RealtimeClient;
14
16
  fetchImpl?: typeof fetch;
15
17
  onActivityError?: (error: unknown) => void;
18
+ presence?: {
19
+ baseUrl?: string;
20
+ };
16
21
  ackTimeoutMs?: number;
17
22
  };
18
23
  export declare class ChatClient {
19
24
  private readonly options;
25
+ private readonly instanceId;
26
+ private presence?;
27
+ private presenceProfiles;
28
+ private presenceHandlers;
29
+ private connection;
30
+ private readonly connectionHandlers;
31
+ private subscriptions;
32
+ private generation;
33
+ private connectPromise?;
20
34
  private client;
21
35
  private session;
22
36
  private profileIdValue;
@@ -27,7 +41,15 @@ export declare class ChatClient {
27
41
  private readonly messageHandlers;
28
42
  constructor(options: ChatClientOptions);
29
43
  connect(): Promise<this>;
44
+ private initialize;
30
45
  close(): void;
46
+ connectionState(): ChatConnectionState;
47
+ watchConnection(handler: (state: ChatConnectionState) => void): () => void;
48
+ private setConnection;
49
+ private rejectPending;
50
+ private authenticate;
51
+ watchPresenceProfiles(ids: string[]): void;
52
+ watchPresence(handler: (items: ChatPresence[]) => void): () => void;
31
53
  watchMessages(handler: (message: ChatRuntimeMessage) => void): () => boolean;
32
54
  watchActivities(handler: (activities: ChatActivity[]) => void): () => void;
33
55
  sendActivity(input: ActivityTarget & {
@@ -76,7 +98,7 @@ export declare class ChatClient {
76
98
  private sendAuth;
77
99
  private sendAndWaitForAck;
78
100
  private handleIncoming;
79
- private waitUntilConnected;
101
+ private waitUntilReady;
80
102
  private accountId;
81
103
  private resolveProfileId;
82
104
  private profileId;
@@ -1,3 +1,4 @@
1
+ import { ChatPresenceClient } from "./presence.js";
1
2
  import { ChatActivityStore, ChatActivityPublisher, createActivityMessage, parseChatActivity } from "./activity.js";
2
3
  import { loginPmateBot } from "./botAuth.js";
3
4
  import { parsePmateBotKeyJson } from "./botKey.js";
@@ -7,6 +8,15 @@ const DEFAULT_AUTH_API_BASE_URL = "https://auth-api-v2.pmate.chat";
7
8
  const DEFAULT_ACK_TIMEOUT_MS = 5_000;
8
9
  export class ChatClient {
9
10
  options;
11
+ instanceId = crypto.randomUUID();
12
+ presence;
13
+ presenceProfiles = [];
14
+ presenceHandlers = new Set();
15
+ connection = "closed";
16
+ connectionHandlers = new Set();
17
+ subscriptions = [];
18
+ generation = 0;
19
+ connectPromise;
10
20
  client = null;
11
21
  session = null;
12
22
  profileIdValue = null;
@@ -23,7 +33,20 @@ export class ChatClient {
23
33
  this.session = options.session ?? null;
24
34
  this.client = options.realtimeClient ?? null;
25
35
  }
26
- async connect() {
36
+ connect() {
37
+ if (this.connectPromise)
38
+ return this.connectPromise;
39
+ if (this.connection === "connected")
40
+ return Promise.resolve(this);
41
+ const generation = this.generation;
42
+ this.setConnection("connecting");
43
+ const promise = this.initialize(generation);
44
+ this.connectPromise = promise;
45
+ void promise.finally(() => { if (this.connectPromise === promise)
46
+ this.connectPromise = undefined; }).catch(() => { });
47
+ return promise;
48
+ }
49
+ async initialize(generation) {
27
50
  if (!this.session) {
28
51
  if (!this.botKey) {
29
52
  throw new Error("ChatClient requires either session or PMate bot key JSON");
@@ -35,35 +58,103 @@ export class ChatClient {
35
58
  });
36
59
  }
37
60
  const profileId = await this.resolveProfileId();
61
+ if (generation !== this.generation)
62
+ throw new Error("ChatClient closed");
63
+ if (this.options.presence && !this.presence) {
64
+ this.presence = new ChatPresenceClient({ appId: this.options.appId, profileId, instanceId: this.instanceId, token: () => this.session.token, baseUrl: this.options.presence.baseUrl, fetchImpl: this.options.fetchImpl });
65
+ this.presence.watchProfiles(this.presenceProfiles);
66
+ this.presence.watch(items => { for (const handler of this.presenceHandlers)
67
+ handler(items); });
68
+ }
38
69
  if (!this.client) {
39
70
  const hubUrl = normalizeWsUrl(this.options.hubUrl ?? DEFAULT_HUB_URL, profileId);
40
71
  this.client = new RuntimeWebsocketClient(hubUrl);
41
72
  }
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();
73
+ if (!this.subscriptions.length) {
74
+ this.subscriptions.push(this.client.on(ChatRealtimeEvents.Message, message => this.handleIncoming(message)), this.client.on(ChatRealtimeEvents.Connected, connected => {
75
+ if (connected)
76
+ void this.authenticate();
77
+ else {
78
+ this.generation++;
79
+ this.setConnection("reconnecting");
80
+ this.rejectPending(new Error("聊天连接已断开,消息尚未确认送达"));
81
+ for (const publisher of this.publishers)
82
+ void publisher.stop();
83
+ this.activities.clear();
84
+ }
85
+ }));
86
+ }
87
+ if (this.client.isConnected())
88
+ void this.authenticate();
89
+ await this.waitUntilReady();
52
90
  return this;
53
91
  }
54
92
  close() {
93
+ this.generation++;
94
+ for (const off of this.subscriptions.splice(0))
95
+ off();
96
+ this.setConnection("closed");
55
97
  for (const publisher of this.publishers)
56
98
  void publisher.stop();
57
99
  this.publishers.clear();
58
100
  this.activities.clear();
59
101
  this.client?.close();
60
102
  this.client = null;
103
+ this.connectPromise = undefined;
104
+ this.rejectPending(new Error("ChatClient closed"));
105
+ }
106
+ connectionState() { return this.connection; }
107
+ watchConnection(handler) {
108
+ this.connectionHandlers.add(handler);
109
+ handler(this.connection);
110
+ return () => { this.connectionHandlers.delete(handler); };
111
+ }
112
+ setConnection(state) {
113
+ if (this.connection === state)
114
+ return;
115
+ this.connection = state;
116
+ if (state === "connected")
117
+ this.presence?.start();
118
+ else
119
+ this.presence?.stop();
120
+ for (const handler of this.connectionHandlers)
121
+ handler(state);
122
+ }
123
+ rejectPending(error) {
61
124
  for (const pending of this.pendingAcks.values()) {
62
- pending.reject(new Error("ChatClient closed"));
125
+ pending.reject(error);
63
126
  clearTimeout(pending.timer);
64
127
  }
65
128
  this.pendingAcks.clear();
66
129
  }
130
+ async authenticate() {
131
+ if (this.connection === "authenticating" || this.connection === "connected")
132
+ return;
133
+ const generation = this.generation;
134
+ this.setConnection("authenticating");
135
+ try {
136
+ await this.sendAuth();
137
+ if (generation === this.generation)
138
+ this.setConnection("connected");
139
+ }
140
+ catch (error) {
141
+ if (generation !== this.generation)
142
+ return;
143
+ if (error instanceof ChatAckError) {
144
+ this.close();
145
+ this.setConnection("authentication_required");
146
+ }
147
+ else {
148
+ this.client?.close(false);
149
+ }
150
+ }
151
+ }
152
+ watchPresenceProfiles(ids) { this.presenceProfiles = ids; this.presence?.watchProfiles(ids); }
153
+ watchPresence(handler) {
154
+ this.presenceHandlers.add(handler);
155
+ handler(this.presence?.snapshot() ?? []);
156
+ return () => { this.presenceHandlers.delete(handler); };
157
+ }
67
158
  watchMessages(handler) {
68
159
  this.messageHandlers.add(handler);
69
160
  return () => this.messageHandlers.delete(handler);
@@ -160,11 +251,15 @@ export class ChatClient {
160
251
  token,
161
252
  as: "user",
162
253
  profileId,
254
+ clientInstanceId: this.instanceId,
163
255
  }, ChatMsgKind.DM);
164
256
  await this.sendAndWaitForAck(auth);
165
257
  }
166
258
  async sendAndWaitForAck(msg) {
167
259
  const client = this.requireClient();
260
+ if (msg.opcode !== ChatMsgOp.AUTH && this.connection !== "connected") {
261
+ throw new Error(this.connection === "authentication_required" ? "登录已失效,请重新登录" : "聊天连接正在恢复,请稍后重试");
262
+ }
168
263
  const ack = new Promise((resolve, reject) => {
169
264
  const timer = setTimeout(() => {
170
265
  this.pendingAcks.delete(msg.hash);
@@ -196,7 +291,7 @@ export class ChatClient {
196
291
  pending.resolve(ack);
197
292
  }
198
293
  else {
199
- pending.reject(new Error(ack.message || ack.code || "Ack failed"));
294
+ pending.reject(new ChatAckError(ack.message || ack.code || "Ack failed"));
200
295
  }
201
296
  }
202
297
  return;
@@ -214,24 +309,26 @@ export class ChatClient {
214
309
  }
215
310
  }
216
311
  }
217
- waitUntilConnected() {
218
- const client = this.requireClient();
219
- if (client.isConnected()) {
312
+ waitUntilReady() {
313
+ if (this.connection === "connected")
220
314
  return Promise.resolve();
221
- }
222
315
  return new Promise((resolve, reject) => {
223
- const offConnected = client.on(ChatRealtimeEvents.Connected, (connected) => {
224
- if (connected) {
225
- offConnected();
226
- offError();
316
+ const timer = setTimeout(() => {
317
+ this.connectionHandlers.delete(check);
318
+ reject(new Error("聊天连接正在恢复,请稍后重试"));
319
+ }, 30_000);
320
+ const check = (state) => {
321
+ if (!["connected", "closed", "authentication_required"].includes(state))
322
+ return;
323
+ clearTimeout(timer);
324
+ this.connectionHandlers.delete(check);
325
+ if (state === "connected")
227
326
  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
- });
327
+ else
328
+ reject(new Error(state === "authentication_required" ? "登录已失效,请重新登录" : "ChatClient closed"));
329
+ };
330
+ this.connectionHandlers.add(check);
331
+ check(this.connection);
235
332
  });
236
333
  }
237
334
  accountId() {
@@ -292,6 +389,8 @@ export class ChatClient {
292
389
  return this.client;
293
390
  }
294
391
  }
392
+ class ChatAckError extends Error {
393
+ }
295
394
  export function createChatClient(options) {
296
395
  return new ChatClient(options);
297
396
  }
@@ -6,3 +6,5 @@ export * from "./readerClient.js";
6
6
  export * from "./thread.js";
7
7
  export * from "./activity.js";
8
8
  export * from "./call.js";
9
+ export * from "./presence.js";
10
+ export * from "./meeting";
@@ -6,3 +6,5 @@ export * from "./readerClient.js";
6
6
  export * from "./thread.js";
7
7
  export * from "./activity.js";
8
8
  export * from "./call.js";
9
+ export * from "./presence.js";
10
+ export * from "./meeting";
@@ -0,0 +1,54 @@
1
+ import type { CallClientOptions } from "./call";
2
+ export type MeetingParticipant = {
3
+ id: string;
4
+ profileId: string;
5
+ muted: boolean;
6
+ connection: "connecting" | "connected" | "failed";
7
+ stream?: MediaStream;
8
+ };
9
+ export type MeetingSnapshot = {
10
+ phase: "idle" | "joining" | "joined" | "ended";
11
+ groupId?: string;
12
+ meetingId?: string;
13
+ selfId?: string;
14
+ participants: MeetingParticipant[];
15
+ muted: boolean;
16
+ sharingId?: string;
17
+ localScreen?: MediaStream;
18
+ error?: string;
19
+ };
20
+ /** Small-group mesh (six people maximum), media capture only after explicit join/share. */
21
+ export declare class GroupMeetingClient {
22
+ private options;
23
+ private state;
24
+ private listeners;
25
+ private ws?;
26
+ private microphone?;
27
+ private screen?;
28
+ private links;
29
+ private generation;
30
+ private pendingCapture;
31
+ private timer?;
32
+ private heartbeat?;
33
+ private lastSeen;
34
+ private iceServers;
35
+ private queue;
36
+ constructor(options: CallClientOptions);
37
+ snapshot(): MeetingSnapshot;
38
+ subscribe(fn: (s: MeetingSnapshot) => void): () => void;
39
+ join(groupId: string): Promise<void>;
40
+ leave(): void;
41
+ dismiss(): void;
42
+ close(): void;
43
+ mute(): void;
44
+ shareScreen(): Promise<void>;
45
+ stopSharing(): Promise<void>;
46
+ private update;
47
+ private send;
48
+ private fail;
49
+ private release;
50
+ private participant;
51
+ private link;
52
+ private syncScreen;
53
+ private receive;
54
+ }
@@ -0,0 +1,262 @@
1
+ /** Small-group mesh (six people maximum), media capture only after explicit join/share. */
2
+ export class GroupMeetingClient {
3
+ options;
4
+ state = { phase: "idle", participants: [], muted: false };
5
+ listeners = new Set();
6
+ ws;
7
+ microphone;
8
+ screen;
9
+ links = new Map();
10
+ generation = 0;
11
+ pendingCapture = false;
12
+ timer;
13
+ heartbeat;
14
+ lastSeen = 0;
15
+ iceServers = [];
16
+ queue = Promise.resolve();
17
+ constructor(options) {
18
+ this.options = options;
19
+ }
20
+ snapshot() { return this.state; }
21
+ subscribe(fn) { this.listeners.add(fn); fn(this.state); return () => { this.listeners.delete(fn); }; }
22
+ async join(groupId) {
23
+ if (!["idle", "ended"].includes(this.state.phase))
24
+ return;
25
+ this.release();
26
+ const gen = this.generation;
27
+ this.update({ phase: "joining", groupId, error: undefined });
28
+ this.timer = setTimeout(() => this.fail("CONNECTION_TIMEOUT"), 30000);
29
+ try {
30
+ const stream = await (this.options.mediaDevices ?? navigator.mediaDevices).getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
31
+ if (gen !== this.generation) {
32
+ stream.getTracks().forEach(t => t.stop());
33
+ return;
34
+ }
35
+ this.microphone = stream;
36
+ for (const t of stream.getAudioTracks())
37
+ t.onended = () => { if (gen === this.generation)
38
+ this.fail("MICROPHONE_DENIED"); };
39
+ const ws = (this.options.createWebSocket ?? (url => new WebSocket(url)))(this.options.url ?? "wss://calls.pmate.chat/call");
40
+ this.ws = ws;
41
+ ws.onopen = () => { if (gen === this.generation)
42
+ this.send({ type: "auth", mode: "meeting", profileId: this.options.profileId, token: this.options.token }); };
43
+ ws.onmessage = e => { this.lastSeen = Date.now(); this.queue = this.queue.then(async () => { if (gen === this.generation)
44
+ await this.receive(JSON.parse(String(e.data)), gen); }).catch(() => { if (gen === this.generation)
45
+ this.fail("CONNECTION_FAILED"); }); };
46
+ ws.onerror = () => { };
47
+ ws.onclose = () => { if (gen === this.generation)
48
+ this.fail("DISCONNECTED"); };
49
+ }
50
+ catch {
51
+ if (gen === this.generation)
52
+ this.fail(this.microphone ? "CONNECTION_FAILED" : "MICROPHONE_DENIED");
53
+ }
54
+ }
55
+ leave() { this.send({ type: "room.leave", meetingId: this.state.meetingId }); this.release(); this.update({ phase: "ended" }); }
56
+ dismiss() { if (this.state.phase === "ended")
57
+ this.update({ phase: "idle", error: undefined }); }
58
+ close() { this.leave(); this.listeners.clear(); }
59
+ mute() { if (this.state.phase !== "joined")
60
+ return; const muted = !this.state.muted; this.microphone?.getAudioTracks().forEach(t => t.enabled = !muted); this.update({ muted }); this.send({ type: "room.mute", meetingId: this.state.meetingId, muted }); }
61
+ async shareScreen() {
62
+ if (this.state.phase !== "joined" || this.pendingCapture)
63
+ return;
64
+ if (this.screen)
65
+ return this.stopSharing();
66
+ if (this.state.sharingId && this.state.sharingId !== this.state.selfId) {
67
+ this.update({ error: "SCREEN_BUSY" });
68
+ return;
69
+ }
70
+ const gen = this.generation;
71
+ this.pendingCapture = true;
72
+ try {
73
+ const stream = await (this.options.mediaDevices ?? navigator.mediaDevices).getDisplayMedia({ video: { width: { ideal: 1280, max: 1920 }, height: { ideal: 720, max: 1080 }, frameRate: { ideal: 10, max: 15 } }, audio: false });
74
+ if (gen !== this.generation) {
75
+ stream.getTracks().forEach(t => t.stop());
76
+ return;
77
+ }
78
+ this.screen = stream;
79
+ const track = stream.getVideoTracks()[0];
80
+ if (!track)
81
+ throw new Error("No video");
82
+ track.contentHint = "detail";
83
+ track.onended = () => { if (gen === this.generation)
84
+ void this.stopSharing(); };
85
+ this.send({ type: "room.sharing", meetingId: this.state.meetingId, active: true });
86
+ this.update({ localScreen: stream, error: undefined });
87
+ }
88
+ catch (e) {
89
+ if (gen === this.generation) {
90
+ await this.stopSharing();
91
+ if (!(e instanceof DOMException && e.name === "NotAllowedError"))
92
+ this.update({ error: "SCREEN_UNAVAILABLE" });
93
+ }
94
+ }
95
+ finally {
96
+ if (gen === this.generation)
97
+ this.pendingCapture = false;
98
+ }
99
+ }
100
+ async stopSharing() {
101
+ const gen = this.generation;
102
+ const stream = this.screen;
103
+ this.screen = undefined;
104
+ stream?.getTracks().forEach(t => { t.onended = null; t.stop(); });
105
+ await Promise.all([...this.links.values()].map(l => l.video.sender.replaceTrack(null).catch(() => { })));
106
+ if (gen !== this.generation)
107
+ return;
108
+ this.send({ type: "room.sharing", meetingId: this.state.meetingId, active: false });
109
+ this.update({ localScreen: undefined });
110
+ }
111
+ update(p) { this.state = { ...this.state, ...p }; for (const fn of this.listeners)
112
+ fn(this.state); }
113
+ send(m) { if (this.ws?.readyState === 1)
114
+ this.ws.send(JSON.stringify(m)); }
115
+ fail(error) { this.leave(); this.update({ error }); }
116
+ release() {
117
+ this.generation++;
118
+ clearTimeout(this.timer);
119
+ clearInterval(this.heartbeat);
120
+ this.pendingCapture = false;
121
+ const ws = this.ws;
122
+ this.ws = undefined;
123
+ ws?.close();
124
+ for (const link of this.links.values()) {
125
+ clearTimeout(link.timer);
126
+ link.pc.close();
127
+ }
128
+ this.links.clear();
129
+ for (const stream of [this.microphone, this.screen])
130
+ stream?.getTracks().forEach(t => { t.onended = null; t.stop(); });
131
+ this.microphone = undefined;
132
+ this.screen = undefined;
133
+ this.update({ participants: [], meetingId: undefined, selfId: undefined, sharingId: undefined, localScreen: undefined, muted: false });
134
+ }
135
+ participant(id, patch) { this.update({ participants: this.state.participants.map(p => p.id === id ? { ...p, ...patch } : p) }); }
136
+ link(id, gen) {
137
+ const previous = this.links.get(id);
138
+ if (previous)
139
+ return previous;
140
+ const pc = (this.options.createPeerConnection ?? (c => new RTCPeerConnection(c)))({ bundlePolicy: "max-bundle", iceServers: this.iceServers, iceTransportPolicy: this.options.iceTransportPolicy ?? "all" });
141
+ for (const track of this.microphone.getAudioTracks())
142
+ pc.addTrack(track, this.microphone);
143
+ const video = pc.addTransceiver("video", { direction: "sendrecv", sendEncodings: [{ maxBitrate: 800000, maxFramerate: 15 }] });
144
+ const link = { pc, video, remote: new MediaStream(), candidates: [] };
145
+ this.links.set(id, link);
146
+ const current = () => gen === this.generation && this.links.get(id) === link;
147
+ link.timer = setTimeout(() => { if (current())
148
+ this.participant(id, { connection: "failed" }); }, 30000);
149
+ pc.ontrack = e => { if (current()) {
150
+ link.remote.addTrack(e.track);
151
+ this.participant(id, { stream: link.remote });
152
+ } };
153
+ pc.onicecandidate = e => { if (current() && e.candidate)
154
+ this.send({ type: "room.ice", meetingId: this.state.meetingId, peerId: id, candidate: e.candidate.toJSON() }); };
155
+ pc.onconnectionstatechange = () => {
156
+ if (!current())
157
+ return;
158
+ if (pc.connectionState === "connected") {
159
+ clearTimeout(link.timer);
160
+ this.participant(id, { connection: "connected" });
161
+ }
162
+ else if (["failed", "disconnected", "closed"].includes(pc.connectionState))
163
+ this.participant(id, { connection: pc.connectionState === "disconnected" ? "connecting" : "failed" });
164
+ };
165
+ return link;
166
+ }
167
+ async syncScreen() {
168
+ const track = this.state.sharingId === this.state.selfId ? this.screen?.getVideoTracks()[0] ?? null : null;
169
+ await Promise.all([...this.links.values()].map(l => l.video.sender.replaceTrack(track)));
170
+ }
171
+ async receive(m, gen) {
172
+ if (m.type === "ready") {
173
+ this.send({ type: "room.join", groupId: this.state.groupId, requestId: String(gen) });
174
+ this.lastSeen = Date.now();
175
+ this.heartbeat = setInterval(() => { if (Date.now() - this.lastSeen > 60000)
176
+ this.fail("DISCONNECTED");
177
+ else
178
+ this.send({ type: "ping" }); }, 20000);
179
+ return;
180
+ }
181
+ if (m.type === "pong")
182
+ return;
183
+ if (m.type === "error") {
184
+ if (m.code === "SCREEN_BUSY") {
185
+ await this.stopSharing();
186
+ this.update({ error: "SCREEN_BUSY" });
187
+ return;
188
+ }
189
+ this.fail(m.code);
190
+ return;
191
+ }
192
+ if (m.type === "room.joined") {
193
+ if (m.requestId !== String(gen))
194
+ return;
195
+ clearTimeout(this.timer);
196
+ this.iceServers = m.iceServers;
197
+ this.update({ phase: "joined", meetingId: m.meetingId, selfId: m.selfId });
198
+ return;
199
+ }
200
+ if (m.meetingId !== this.state.meetingId || this.state.phase !== "joined")
201
+ return;
202
+ if (m.type === "room.state") {
203
+ const old = new Map(this.state.participants.map(p => [p.id, p]));
204
+ this.update({ sharingId: m.sharingId ?? undefined, participants: m.participants.map((p) => ({ ...old.get(p.id), ...p, connection: p.id === this.state.selfId ? "connected" : old.get(p.id)?.connection ?? "connecting" })) });
205
+ for (const [id, l] of this.links)
206
+ if (!this.state.participants.some(p => p.id === id)) {
207
+ this.links.delete(id);
208
+ clearTimeout(l.timer);
209
+ l.pc.close();
210
+ }
211
+ for (const p of this.state.participants)
212
+ if (p.id !== this.state.selfId)
213
+ this.link(p.id, gen);
214
+ await this.syncScreen();
215
+ return;
216
+ }
217
+ if (!this.state.participants.some(p => p.id === m.peerId) || m.peerId === this.state.selfId)
218
+ return;
219
+ const l = this.link(m.peerId, gen), pc = l.pc;
220
+ if (m.type === "room.offer") {
221
+ await pc.setLocalDescription(await pc.createOffer());
222
+ if (gen === this.generation)
223
+ this.send({ type: "room.sdp", meetingId: this.state.meetingId, peerId: m.peerId, description: pc.localDescription });
224
+ }
225
+ else if (m.type === "room.sdp") {
226
+ await pc.setRemoteDescription(m.description);
227
+ if (gen !== this.generation)
228
+ return;
229
+ for (const c of l.candidates.splice(0))
230
+ await pc.addIceCandidate(c);
231
+ if (m.description.type === "offer") {
232
+ // setRemoteDescription may create the answerer's receiving transceiver;
233
+ // attach sharing to that negotiated m-line, not an unassociated local one.
234
+ const negotiated = pc.getTransceivers().find(t => t.mid !== null && t.receiver.track.kind === "video");
235
+ if (negotiated) {
236
+ if (l.video !== negotiated && l.video.mid === null)
237
+ l.video.stop();
238
+ l.video = negotiated;
239
+ l.video.direction = "sendrecv";
240
+ await l.video.sender.replaceTrack(this.state.sharingId === this.state.selfId ? this.screen?.getVideoTracks()[0] ?? null : null);
241
+ }
242
+ await pc.setLocalDescription(await pc.createAnswer());
243
+ if (gen === this.generation)
244
+ this.send({ type: "room.sdp", meetingId: this.state.meetingId, peerId: m.peerId, description: pc.localDescription });
245
+ }
246
+ for (const sender of pc.getSenders()) {
247
+ const params = sender.getParameters();
248
+ if (params.encodings?.length) {
249
+ for (const encoding of params.encodings)
250
+ encoding.maxBitrate = sender === l.video.sender ? 800000 : 64000;
251
+ await sender.setParameters(params);
252
+ }
253
+ }
254
+ }
255
+ else if (m.type === "room.ice") {
256
+ if (pc.remoteDescription)
257
+ await pc.addIceCandidate(m.candidate);
258
+ else if (l.candidates.length < 128)
259
+ l.candidates.push(m.candidate);
260
+ }
261
+ }
262
+ }
@@ -0,0 +1,44 @@
1
+ export type PresenceStatus = "online" | "offline" | "unknown";
2
+ export type ChatPresence = {
3
+ appId: string;
4
+ profileId: string;
5
+ status: PresenceStatus;
6
+ availability: "normal" | "degraded";
7
+ };
8
+ export type PresenceOptions = {
9
+ appId: string;
10
+ profileId: string;
11
+ instanceId?: string;
12
+ token: () => Promise<string> | string;
13
+ baseUrl?: string;
14
+ fetchImpl?: typeof fetch;
15
+ onError?: (error: unknown) => void;
16
+ };
17
+ /** One instance is one authenticated client/runtime lease, never a whole profile. */
18
+ export declare class ChatPresenceClient {
19
+ private options;
20
+ readonly instanceId: string;
21
+ private seq;
22
+ private active;
23
+ private generation;
24
+ private beatTimer?;
25
+ private queryTimer?;
26
+ private expiryTimer?;
27
+ private beating;
28
+ private querying;
29
+ private profiles;
30
+ private entries;
31
+ private handlers;
32
+ private availability;
33
+ constructor(options: PresenceOptions);
34
+ snapshot(): ChatPresence[];
35
+ watch(handler: (items: ChatPresence[]) => void): () => void;
36
+ watchProfiles(ids: string[]): void;
37
+ setAvailability(value: "normal" | "degraded"): void;
38
+ start(): void;
39
+ stop(): void;
40
+ private request;
41
+ private heartbeat;
42
+ private query;
43
+ private emit;
44
+ }
@@ -0,0 +1,132 @@
1
+ /** One instance is one authenticated client/runtime lease, never a whole profile. */
2
+ export class ChatPresenceClient {
3
+ options;
4
+ instanceId;
5
+ seq = 0;
6
+ active = false;
7
+ generation = 0;
8
+ beatTimer;
9
+ queryTimer;
10
+ expiryTimer;
11
+ beating = false;
12
+ querying = false;
13
+ profiles = [];
14
+ entries = new Map();
15
+ handlers = new Set();
16
+ availability = "normal";
17
+ constructor(options) {
18
+ this.options = options;
19
+ this.instanceId = options.instanceId ?? crypto.randomUUID();
20
+ }
21
+ snapshot() {
22
+ return this.profiles.map(profileId => {
23
+ const entry = this.entries.get(profileId);
24
+ return entry && entry.expires > Date.now() ? { appId: entry.appId, profileId, status: entry.status, availability: entry.availability }
25
+ : { appId: this.options.appId, profileId, status: "unknown", availability: "normal" };
26
+ });
27
+ }
28
+ watch(handler) { this.handlers.add(handler); handler(this.snapshot()); return () => { this.handlers.delete(handler); }; }
29
+ watchProfiles(ids) {
30
+ const profiles = [...new Set(ids)].sort().slice(0, 50);
31
+ if (JSON.stringify(profiles) === JSON.stringify(this.profiles))
32
+ return;
33
+ this.profiles = profiles;
34
+ this.generation++;
35
+ this.entries.clear();
36
+ this.emit();
37
+ if (this.active)
38
+ void this.query();
39
+ }
40
+ setAvailability(value) { this.availability = value; }
41
+ start() {
42
+ if (this.active)
43
+ return;
44
+ this.active = true;
45
+ this.generation++;
46
+ void this.heartbeat();
47
+ void this.query();
48
+ this.beatTimer = setInterval(() => void this.heartbeat(), 20_000);
49
+ this.queryTimer = setInterval(() => void this.query(), 5_000);
50
+ this.expiryTimer = setInterval(() => {
51
+ if ([...this.entries.values()].some(e => e.expires <= Date.now())) {
52
+ for (const [id, e] of this.entries)
53
+ if (e.expires <= Date.now())
54
+ this.entries.delete(id);
55
+ this.emit();
56
+ }
57
+ }, 1000);
58
+ }
59
+ stop() {
60
+ const wasActive = this.active;
61
+ this.active = false;
62
+ this.generation++;
63
+ clearInterval(this.beatTimer);
64
+ clearInterval(this.queryTimer);
65
+ clearInterval(this.expiryTimer);
66
+ this.entries.clear();
67
+ this.emit();
68
+ // Sequence tombstones prevent an in-flight heartbeat from undoing release.
69
+ if (wasActive)
70
+ void this.request("release", { instanceId: this.instanceId, seq: ++this.seq }).catch(e => this.options.onError?.(e));
71
+ }
72
+ async request(action, params) {
73
+ const token = await this.options.token();
74
+ const response = await (this.options.fetchImpl ?? fetch)(`${(this.options.baseUrl ?? "https://hub.pmate.chat").replace(/\/$/, "")}/api/chat-presence/${action}`, {
75
+ method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
76
+ body: JSON.stringify({ appId: this.options.appId, profileId: this.options.profileId, ...params }), signal: AbortSignal.timeout(12_000), keepalive: action === "release",
77
+ });
78
+ if (!response.ok)
79
+ throw new Error(`Presence request failed (${response.status})`);
80
+ const result = await response.json();
81
+ if (!result.success)
82
+ throw new Error("Presence unavailable");
83
+ return result.data;
84
+ }
85
+ async heartbeat() {
86
+ if (!this.active || this.beating)
87
+ return;
88
+ this.beating = true;
89
+ try {
90
+ await this.request("heartbeat", { instanceId: this.instanceId, seq: ++this.seq, availability: this.availability });
91
+ }
92
+ catch (e) {
93
+ this.options.onError?.(e);
94
+ }
95
+ finally {
96
+ this.beating = false;
97
+ }
98
+ }
99
+ async query() {
100
+ if (!this.active || !this.profiles.length || this.querying)
101
+ return;
102
+ this.querying = true;
103
+ const generation = this.generation, requested = this.profiles.slice(), started = Date.now();
104
+ try {
105
+ const result = await this.request("query", { profileIds: requested });
106
+ if (!this.active || generation !== this.generation)
107
+ return;
108
+ if (!Array.isArray(result?.items))
109
+ throw new Error("Invalid presence snapshot");
110
+ const entries = new Map();
111
+ for (const item of result.items) {
112
+ if (item.appId !== this.options.appId || !requested.includes(item.profileId) || !["online", "offline", "unknown"].includes(item.status) || !Number.isFinite(item.validForMs) || item.validForMs < 0)
113
+ throw new Error("Invalid presence item");
114
+ entries.set(item.profileId, { ...item, availability: item.availability === "degraded" ? "degraded" : "normal", expires: started + Math.min(item.validForMs, 10_000) });
115
+ }
116
+ this.entries = entries;
117
+ this.emit();
118
+ }
119
+ catch (e) {
120
+ if (generation === this.generation) {
121
+ this.entries.clear();
122
+ this.emit();
123
+ }
124
+ this.options.onError?.(e);
125
+ }
126
+ finally {
127
+ this.querying = false;
128
+ }
129
+ }
130
+ emit() { const items = this.snapshot(); for (const h of this.handlers)
131
+ h(items); }
132
+ }
@@ -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.7.0",
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",