@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 +8 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/runtime/chatClient.d.ts +23 -1
- package/lib/runtime/chatClient.js +127 -28
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/meeting.d.ts +54 -0
- package/lib/runtime/meeting.js +262 -0
- package/lib/runtime/presence.d.ts +44 -0
- package/lib/runtime/presence.js +132 -0
- 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.
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
43
|
-
this.handleIncoming(message)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
}
|
package/lib/runtime/index.d.ts
CHANGED
package/lib/runtime/index.js
CHANGED
|
@@ -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
|
}
|
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);
|