@pmate/chat-sdk 0.9.8 → 0.9.10
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/lib/runtime/call.d.ts +10 -0
- package/lib/runtime/call.js +85 -8
- package/lib/runtime/meeting.d.ts +2 -1
- package/lib/runtime/meeting.js +7 -3
- package/package.json +16 -17
package/lib/runtime/call.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import type { OnlineReachEvent } from "./reach.js";
|
|
1
2
|
export type MeetingRoom = {
|
|
2
3
|
groupId: string;
|
|
3
4
|
meetingId: string;
|
|
4
5
|
startedAt: number;
|
|
5
6
|
initiatorId: string;
|
|
6
7
|
profileIds: string[];
|
|
8
|
+
invitationId?: string;
|
|
9
|
+
inviteProfileIds?: string[];
|
|
7
10
|
};
|
|
8
11
|
export type CallPhase = "idle" | "requesting" | "ringing" | "incoming" | "connecting" | "connected" | "ended";
|
|
9
12
|
export type CallSnapshot = {
|
|
@@ -36,6 +39,7 @@ export type CallEnded = {
|
|
|
36
39
|
};
|
|
37
40
|
export type CallClientOptions = {
|
|
38
41
|
profileId: string;
|
|
42
|
+
clientInstanceId?: string;
|
|
39
43
|
token: string;
|
|
40
44
|
url?: string;
|
|
41
45
|
iceTransportPolicy?: RTCIceTransportPolicy;
|
|
@@ -59,6 +63,7 @@ export declare class CallClient {
|
|
|
59
63
|
private closed;
|
|
60
64
|
private capturePending;
|
|
61
65
|
private attachId?;
|
|
66
|
+
private attachWaiters;
|
|
62
67
|
private terminalCalls;
|
|
63
68
|
private requestId?;
|
|
64
69
|
private groupIds;
|
|
@@ -72,6 +77,11 @@ export declare class CallClient {
|
|
|
72
77
|
connect(): void;
|
|
73
78
|
/** Notification/reach entry: query authoritative state before showing a call. */
|
|
74
79
|
attach(callId: string): void;
|
|
80
|
+
/** Resolves only after an authoritative response has been applied, never after socket send. */
|
|
81
|
+
attachAndWait(callId: string, timeoutMs?: number): Promise<"handled" | "suppressed">;
|
|
82
|
+
private settleAttach;
|
|
83
|
+
/** Pass only events received through the authenticated OnlineReachReceiver. */
|
|
84
|
+
handleReach(event: OnlineReachEvent): Promise<"handled" | "suppressed">;
|
|
75
85
|
private rememberTerminal;
|
|
76
86
|
watchPeer(peerId?: string): void;
|
|
77
87
|
watchGroups(groupIds: string[]): void;
|
package/lib/runtime/call.js
CHANGED
|
@@ -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
|
-
|
|
251
|
-
this.send({ type: "call.attach", callId
|
|
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
|
-
|
|
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/lib/runtime/meeting.d.ts
CHANGED
|
@@ -35,10 +35,11 @@ export declare class GroupMeetingClient {
|
|
|
35
35
|
private lastSeen;
|
|
36
36
|
private iceServers;
|
|
37
37
|
private queue;
|
|
38
|
+
private inviteProfileIds;
|
|
38
39
|
constructor(options: CallClientOptions);
|
|
39
40
|
snapshot(): MeetingSnapshot;
|
|
40
41
|
subscribe(fn: (s: MeetingSnapshot) => void): () => void;
|
|
41
|
-
join(groupId: string): Promise<void>;
|
|
42
|
+
join(groupId: string, inviteProfileIds?: string[]): Promise<void>;
|
|
42
43
|
leave(): void;
|
|
43
44
|
dismiss(): void;
|
|
44
45
|
close(): void;
|
package/lib/runtime/meeting.js
CHANGED
|
@@ -14,15 +14,19 @@ export class GroupMeetingClient {
|
|
|
14
14
|
lastSeen = 0;
|
|
15
15
|
iceServers = [];
|
|
16
16
|
queue = Promise.resolve();
|
|
17
|
+
inviteProfileIds = [];
|
|
17
18
|
constructor(options) {
|
|
18
19
|
this.options = options;
|
|
19
20
|
}
|
|
20
21
|
snapshot() { return this.state; }
|
|
21
22
|
subscribe(fn) { this.listeners.add(fn); fn(this.state); return () => { this.listeners.delete(fn); }; }
|
|
22
|
-
async join(groupId) {
|
|
23
|
+
async join(groupId, inviteProfileIds = []) {
|
|
24
|
+
if (!Array.isArray(inviteProfileIds) || inviteProfileIds.length > 256 || inviteProfileIds.some(id => typeof id !== "string" || !id || id.length > 128))
|
|
25
|
+
throw new Error("INVALID_INVITATION");
|
|
23
26
|
if (!["idle", "ended"].includes(this.state.phase))
|
|
24
27
|
return;
|
|
25
28
|
this.release();
|
|
29
|
+
this.inviteProfileIds = [...new Set(inviteProfileIds)].filter(id => id !== this.options.profileId);
|
|
26
30
|
const gen = this.generation;
|
|
27
31
|
this.update({ phase: "joining", groupId, error: undefined });
|
|
28
32
|
this.timer = setTimeout(() => this.fail("CONNECTION_TIMEOUT"), 30000);
|
|
@@ -39,7 +43,7 @@ export class GroupMeetingClient {
|
|
|
39
43
|
const ws = (this.options.createWebSocket ?? (url => new WebSocket(url)))(this.options.url ?? "wss://calls.pmate.chat/call");
|
|
40
44
|
this.ws = ws;
|
|
41
45
|
ws.onopen = () => { if (gen === this.generation)
|
|
42
|
-
this.send({ type: "auth", mode: "meeting", profileId: this.options.profileId, token: this.options.token }); };
|
|
46
|
+
this.send({ type: "auth", mode: "meeting", profileId: this.options.profileId, token: this.options.token, clientInstanceId: this.options.clientInstanceId }); };
|
|
43
47
|
ws.onmessage = e => { this.lastSeen = Date.now(); this.queue = this.queue.then(async () => { if (gen === this.generation)
|
|
44
48
|
await this.receive(JSON.parse(String(e.data)), gen); }).catch(() => { if (gen === this.generation)
|
|
45
49
|
this.fail("CONNECTION_FAILED"); }); };
|
|
@@ -172,7 +176,7 @@ export class GroupMeetingClient {
|
|
|
172
176
|
}
|
|
173
177
|
async receive(m, gen) {
|
|
174
178
|
if (m.type === "ready") {
|
|
175
|
-
this.send({ type: "room.join", groupId: this.state.groupId, requestId: String(gen) });
|
|
179
|
+
this.send({ type: "room.join", groupId: this.state.groupId, requestId: String(gen), inviteProfileIds: this.inviteProfileIds });
|
|
176
180
|
this.lastSeen = Date.now();
|
|
177
181
|
this.heartbeat = setInterval(() => { if (Date.now() - this.lastSeen > 60000)
|
|
178
182
|
this.fail("DISCONNECTED");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/chat-sdk",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.10",
|
|
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": "
|
|
55
|
-
"@types/react": "
|
|
56
|
-
"@types/react-dom": "
|
|
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": "
|
|
59
|
-
"react-dom": "
|
|
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": "
|
|
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
|
+
}
|