@pmate/chat-sdk 0.9.8 → 0.9.9

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.
@@ -1,3 +1,4 @@
1
+ import type { OnlineReachEvent } from "./reach.js";
1
2
  export type MeetingRoom = {
2
3
  groupId: string;
3
4
  meetingId: string;
@@ -36,6 +37,7 @@ export type CallEnded = {
36
37
  };
37
38
  export type CallClientOptions = {
38
39
  profileId: string;
40
+ clientInstanceId?: string;
39
41
  token: string;
40
42
  url?: string;
41
43
  iceTransportPolicy?: RTCIceTransportPolicy;
@@ -59,6 +61,7 @@ export declare class CallClient {
59
61
  private closed;
60
62
  private capturePending;
61
63
  private attachId?;
64
+ private attachWaiters;
62
65
  private terminalCalls;
63
66
  private requestId?;
64
67
  private groupIds;
@@ -72,6 +75,11 @@ export declare class CallClient {
72
75
  connect(): void;
73
76
  /** Notification/reach entry: query authoritative state before showing a call. */
74
77
  attach(callId: string): void;
78
+ /** Resolves only after an authoritative response has been applied, never after socket send. */
79
+ attachAndWait(callId: string, timeoutMs?: number): Promise<"handled" | "suppressed">;
80
+ private settleAttach;
81
+ /** Pass only events received through the authenticated OnlineReachReceiver. */
82
+ handleReach(event: OnlineReachEvent): Promise<"handled" | "suppressed">;
75
83
  private rememberTerminal;
76
84
  watchPeer(peerId?: string): void;
77
85
  watchGroups(groupIds: string[]): void;
@@ -13,6 +13,7 @@ export class CallClient {
13
13
  closed = false;
14
14
  capturePending = false;
15
15
  attachId;
16
+ attachWaiters = new Map();
16
17
  terminalCalls = new Map();
17
18
  requestId;
18
19
  groupIds = [];
@@ -31,7 +32,7 @@ export class CallClient {
31
32
  const ws = (this.options.createWebSocket ?? (url => new WebSocket(url)))(this.options.url ?? "wss://calls.pmate.chat/call");
32
33
  this.ws = ws;
33
34
  ws.onopen = () => { if (this.ws === ws)
34
- this.send({ type: "auth", token: this.options.token, profileId: this.options.profileId }); };
35
+ this.send({ type: "auth", token: this.options.token, profileId: this.options.profileId, ...(this.options.clientInstanceId ? { clientInstanceId: this.options.clientInstanceId } : {}) }); };
35
36
  ws.onmessage = event => {
36
37
  if (this.ws !== ws)
37
38
  return;
@@ -71,6 +72,69 @@ export class CallClient {
71
72
  else
72
73
  this.connect();
73
74
  }
75
+ /** Resolves only after an authoritative response has been applied, never after socket send. */
76
+ attachAndWait(callId, timeoutMs = 4000) {
77
+ if (this.closed || !callId || callId.length > 128)
78
+ return Promise.reject(new Error("INVALID_CALL_ID"));
79
+ if (this.terminalCalls.has(callId))
80
+ return Promise.resolve("suppressed");
81
+ return new Promise((resolve, reject) => {
82
+ const finish = () => {
83
+ clearTimeout(timer);
84
+ const waiters = this.attachWaiters.get(callId);
85
+ waiters?.delete(waiter);
86
+ if (!waiters?.size)
87
+ this.attachWaiters.delete(callId);
88
+ };
89
+ const waiter = { resolve: (result) => { finish(); resolve(result); }, reject: (error) => { finish(); reject(error); } };
90
+ const timer = setTimeout(() => waiter.reject(new Error("CALL_ATTACH_TIMEOUT")), timeoutMs);
91
+ const waiters = this.attachWaiters.get(callId) ?? new Set();
92
+ waiters.add(waiter);
93
+ this.attachWaiters.set(callId, waiters);
94
+ try {
95
+ this.attach(callId);
96
+ }
97
+ catch (error) {
98
+ waiter.reject(error instanceof Error ? error : new Error("CALL_ATTACH_FAILED"));
99
+ }
100
+ });
101
+ }
102
+ settleAttach(callId, result) {
103
+ for (const waiter of [...(this.attachWaiters.get(callId) ?? [])]) {
104
+ if (result instanceof Error)
105
+ waiter.reject(result);
106
+ else
107
+ waiter.resolve(result);
108
+ }
109
+ if (this.attachId === callId)
110
+ this.attachId = undefined;
111
+ }
112
+ /** Pass only events received through the authenticated OnlineReachReceiver. */
113
+ async handleReach(event) {
114
+ if (this.closed)
115
+ throw new Error("CALL_CLIENT_CLOSED");
116
+ const { callId, callerId, calleeId, reason, winningClientInstanceId } = event.payload;
117
+ if (typeof callId !== "string" || !callId || callId.length > 128 || (callerId !== this.options.profileId && calleeId !== this.options.profileId))
118
+ throw new Error("INVALID_CALL_REACH");
119
+ if (event.expiresAtMs <= Date.now())
120
+ return "suppressed";
121
+ if (event.eventType === "call.incoming") {
122
+ if (calleeId !== this.options.profileId)
123
+ throw new Error("INVALID_CALL_RECIPIENT");
124
+ return this.attachAndWait(callId);
125
+ }
126
+ if (event.eventType !== "call.ended" || typeof reason !== "string" || !reason || reason.length > 128)
127
+ throw new Error("INVALID_CALL_REACH");
128
+ if (reason === "answered_elsewhere" && winningClientInstanceId && winningClientInstanceId === this.options.clientInstanceId)
129
+ return "suppressed";
130
+ this.rememberTerminal(callId);
131
+ if (this.state.callId === callId && !["idle", "ended"].includes(this.state.phase)) {
132
+ this.release();
133
+ this.update({ phase: "ended", reason });
134
+ }
135
+ this.settleAttach(callId, "suppressed");
136
+ return "handled";
137
+ }
74
138
  rememberTerminal(callId) {
75
139
  this.terminalCalls.set(callId, Date.now());
76
140
  for (const [id, time] of this.terminalCalls)
@@ -208,6 +272,8 @@ export class CallClient {
208
272
  this.closed = true;
209
273
  clearTimeout(this.reconnect);
210
274
  clearInterval(this.heartbeat);
275
+ for (const callId of [...this.attachWaiters.keys()])
276
+ this.settleAttach(callId, new Error("CALL_CLIENT_CLOSED"));
211
277
  this.hangup();
212
278
  this.ws?.close();
213
279
  this.ws = undefined;
@@ -247,8 +313,8 @@ export class CallClient {
247
313
  async receive(message) {
248
314
  if (message.type === "ready") {
249
315
  this.update({ ready: true, error: undefined });
250
- if (this.attachId)
251
- this.send({ type: "call.attach", callId: this.attachId });
316
+ for (const callId of new Set([...this.attachWaiters.keys(), ...(this.attachId ? [this.attachId] : [])]))
317
+ this.send({ type: "call.attach", callId });
252
318
  if (this.state.watchedPeerId)
253
319
  this.send({ type: "presence.watch", peerId: this.state.watchedPeerId });
254
320
  if (this.groupIds.length)
@@ -280,14 +346,15 @@ export class CallClient {
280
346
  }
281
347
  if (message.requestId && message.requestId !== this.requestId)
282
348
  return;
349
+ if (typeof message.callId === "string" && this.attachWaiters.has(message.callId))
350
+ this.settleAttach(message.callId, new Error(message.code ?? "CALL_ATTACH_FAILED"));
283
351
  if (!message.callId || message.callId === this.state.callId)
284
352
  this.fail(message.code);
285
353
  return;
286
354
  }
287
355
  if (message.type === "call.status") {
288
- if (message.callId !== this.attachId)
356
+ if (message.callId !== this.attachId && !this.attachWaiters.has(message.callId))
289
357
  return;
290
- this.attachId = undefined;
291
358
  if (message.state === "ended") {
292
359
  this.rememberTerminal(message.callId);
293
360
  if (["idle", "ended"].includes(this.state.phase) || this.state.callId === message.callId) {
@@ -295,15 +362,21 @@ export class CallClient {
295
362
  this.update({ phase: "ended", callId: message.callId, reason: message.reason });
296
363
  }
297
364
  }
365
+ this.settleAttach(message.callId, message.state === "accepted" && message.callId === this.state.callId ? "handled" : "suppressed");
298
366
  return;
299
367
  }
300
368
  if (message.type === "ringing") {
301
- if (this.terminalCalls.has(message.callId))
369
+ if (this.terminalCalls.has(message.callId)) {
370
+ this.settleAttach(message.callId, "suppressed");
302
371
  return;
303
- if (message.callId === this.state.callId && ["incoming", "ringing", "connecting", "connected"].includes(this.state.phase))
372
+ }
373
+ if (message.callId === this.state.callId && ["incoming", "ringing", "connecting", "connected"].includes(this.state.phase)) {
374
+ this.settleAttach(message.callId, "handled");
304
375
  return;
376
+ }
305
377
  if (typeof message.expiresAt === "number" && message.expiresAt <= Date.now()) {
306
378
  this.rememberTerminal(message.callId);
379
+ this.settleAttach(message.callId, "suppressed");
307
380
  return;
308
381
  }
309
382
  if (this.attachId === message.callId)
@@ -314,14 +387,18 @@ export class CallClient {
314
387
  }
315
388
  if (message.direction === "incoming" && !["idle", "ended"].includes(this.state.phase)) {
316
389
  this.send({ type: "hangup", callId: message.callId });
390
+ this.settleAttach(message.callId, "suppressed");
317
391
  return;
318
392
  }
319
393
  this.update({ phase: message.direction === "incoming" ? "incoming" : "ringing", callId: message.callId, peerId: message.peerId, direction: message.direction, error: undefined, reason: undefined, startedAt: undefined });
320
394
  this.armTimeout(typeof message.expiresAt === "number" ? Math.max(1, message.expiresAt - Date.now()) : 35000);
395
+ this.settleAttach(message.callId, "handled");
321
396
  return;
322
397
  }
323
- if (message.type === "ended" && typeof message.callId === "string")
398
+ if (message.type === "ended" && typeof message.callId === "string") {
324
399
  this.rememberTerminal(message.callId);
400
+ this.settleAttach(message.callId, "suppressed");
401
+ }
325
402
  if (message.callId !== this.state.callId)
326
403
  return;
327
404
  if (message.type === "ended") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
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",
@@ -28,15 +28,6 @@
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
- "scripts": {
32
- "clean": "rimraf lib",
33
- "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
34
- "prepack": "pnpm run build",
35
- "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
36
- "test": "vitest run",
37
- "storybook": "storybook dev -p 6009",
38
- "build-storybook": "storybook build --output-dir dist"
39
- },
40
31
  "dependencies": {
41
32
  "@emoji-mart/data": "^1.2.1",
42
33
  "@emoji-mart/react": "^1.1.1",
@@ -51,16 +42,24 @@
51
42
  "devDependencies": {
52
43
  "@storybook/addon-docs": "^9.0.17",
53
44
  "@storybook/react-vite": "9.0.17",
54
- "@tailwindcss/vite": "catalog:",
55
- "@types/react": "catalog:",
56
- "@types/react-dom": "catalog:",
45
+ "@tailwindcss/vite": "^4.1.3",
46
+ "@types/react": "^19.2.7",
47
+ "@types/react-dom": "^19.2.3",
57
48
  "fake-indexeddb": "^6.2.5",
58
- "react": "catalog:",
59
- "react-dom": "catalog:",
49
+ "react": "^19.1.1",
50
+ "react-dom": "^19.1.1",
60
51
  "rimraf": "^6.1.2",
61
52
  "storybook": "^9.0.17",
62
- "tailwindcss": "catalog:",
53
+ "tailwindcss": "^4.0.14",
63
54
  "typescript": "5.9.2",
64
55
  "vitest": "^4.0.17"
56
+ },
57
+ "scripts": {
58
+ "clean": "rimraf lib",
59
+ "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
60
+ "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
61
+ "test": "vitest run",
62
+ "storybook": "storybook dev -p 6009",
63
+ "build-storybook": "storybook build --output-dir dist"
65
64
  }
66
- }
65
+ }