@gravity-rail/sdk 0.22.0 → 0.32.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 +57 -8
- package/dist/agui.d.cts +1 -1
- package/dist/agui.d.ts +1 -1
- package/dist/audio.d.cts +1 -1
- package/dist/audio.d.ts +1 -1
- package/dist/{client-C10qSd6b.d.ts → chat-interaction-BKdImgZ5.d.ts} +5604 -1531
- package/dist/{client-Ct0soeip.d.cts → chat-interaction-DLfC_h_P.d.cts} +5604 -1531
- package/dist/{chunk-3HAV5XUJ.js → chunk-HRUMZ46V.js} +1 -1
- package/dist/chunk-HRUMZ46V.js.map +1 -0
- package/dist/{chunk-WMA6YCAS.js → chunk-N446FGCT.js} +1230 -201
- package/dist/chunk-N446FGCT.js.map +1 -0
- package/dist/{chunk-7OLBLLV7.js → chunk-P2E6LU7I.js} +57 -7
- package/dist/chunk-P2E6LU7I.js.map +1 -0
- package/dist/helpers.cjs +54 -1
- package/dist/helpers.cjs.map +1 -1
- package/dist/helpers.d.cts +3 -2
- package/dist/helpers.d.ts +3 -2
- package/dist/helpers.js +11 -3
- package/dist/helpers.js.map +1 -1
- package/dist/index.cjs +2291 -148
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +509 -6
- package/dist/index.d.ts +509 -6
- package/dist/index.js +1032 -23
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +1320 -220
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +33 -49
- package/dist/internal.d.ts +33 -49
- package/dist/internal.js +73 -33
- package/dist/internal.js.map +1 -1
- package/dist/realtime.cjs +279 -14
- package/dist/realtime.cjs.map +1 -1
- package/dist/realtime.d.cts +169 -17
- package/dist/realtime.d.ts +169 -17
- package/dist/realtime.js +264 -13
- package/dist/realtime.js.map +1 -1
- package/dist/{revision-state-CCPTiW-i.d.cts → revision-state-Bsq5dKwi.d.cts} +236 -5
- package/dist/{revision-state-C6aM-PHu.d.ts → revision-state-CQTYcIRG.d.ts} +236 -5
- package/dist/schemas.cjs +3 -1
- package/dist/schemas.cjs.map +1 -1
- package/dist/schemas.d.cts +31 -13
- package/dist/schemas.d.ts +31 -13
- package/dist/schemas.js +4 -2
- package/dist/schemas.js.map +1 -1
- package/dist/{tasks-WH_VfzMv.d.cts → tasks-B-31JPuq.d.cts} +298 -114
- package/dist/{tasks-WH_VfzMv.d.ts → tasks-B-31JPuq.d.ts} +298 -114
- package/dist/types-0c6_NGU4.d.cts +90 -0
- package/dist/types-0c6_NGU4.d.ts +90 -0
- package/package.json +3 -3
- package/dist/chunk-3HAV5XUJ.js.map +0 -1
- package/dist/chunk-7OLBLLV7.js.map +0 -1
- package/dist/chunk-WMA6YCAS.js.map +0 -1
- package/dist/types-DkYyrvoC.d.cts +0 -19
- package/dist/types-DkYyrvoC.d.ts +0 -19
package/dist/realtime.d.ts
CHANGED
|
@@ -1,41 +1,193 @@
|
|
|
1
|
-
import { R as
|
|
2
|
-
export { i as isActiveCallPhase } from './types-
|
|
1
|
+
import { R as RealtimeSessionBase, a as ChannelListener, U as Unsubscribe, b as RealtimeAuthSession, A as AccountRealtimeSession, O as OperatorSessionState, c as OperatorCallPhase, C as ChannelEvent } from './types-0c6_NGU4.js';
|
|
2
|
+
export { d as ACCOUNT_ACTIVITY_CHANNEL, e as ACCOUNT_PRESENCE_CHANNEL, f as ACTIVITY_CHANNEL, g as ACTIVITY_MEMBER_CHANNEL, h as ActivityChannelName, i as ActivityInvalidationEvent, P as PRESENCE_CHANNEL, j as isActiveCallPhase, k as isActivityInvalidation } from './types-0c6_NGU4.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* The realtime socket state machine, shared by both mounts.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Two sockets speak the same protocol — `/api/v2/w/{wid}/ws/realtime` and
|
|
8
|
+
* `/api/v2/ws/realtime` — and differ in exactly two places: the URL they open,
|
|
9
|
+
* and the identity `protocol.init` hands back. Everything else (login,
|
|
10
|
+
* heartbeat, reconnect with backoff, per-channel subscribe retry, listener
|
|
11
|
+
* fan-out, deferred close) is one implementation here rather than two that
|
|
12
|
+
* drift.
|
|
13
|
+
*
|
|
14
|
+
* That matters more than it sounds. The subscribe-retry and reconnect logic is
|
|
15
|
+
* where the sharp edges live, and a second copy of it would be a second place
|
|
16
|
+
* for a fix to be applied once.
|
|
9
17
|
*
|
|
10
18
|
* Protocol:
|
|
11
|
-
* Client → Server: subscribe, unsubscribe, heartbeat, message
|
|
12
|
-
* Server → Client: protocol.init, subscribed,
|
|
19
|
+
* Client → Server: login, subscribe, unsubscribe, heartbeat, message
|
|
20
|
+
* Server → Client: protocol.init, subscribed, subscribe_error, unsubscribed,
|
|
21
|
+
* event, error
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
/** The `protocol.init` frame, before either subclass narrows it. */
|
|
25
|
+
interface ProtocolInitMessage {
|
|
26
|
+
memberId?: unknown;
|
|
27
|
+
accountUuid?: unknown;
|
|
28
|
+
workspaceUuids?: unknown;
|
|
29
|
+
}
|
|
30
|
+
declare abstract class RealtimeSocketClient<TSession extends RealtimeSessionBase> {
|
|
18
31
|
private session;
|
|
19
32
|
private readonly getSessionFn;
|
|
20
33
|
private readonly state;
|
|
21
34
|
private readonly connectionListeners;
|
|
22
|
-
constructor(config?:
|
|
23
|
-
|
|
35
|
+
constructor(config?: TSession | (() => TSession | null));
|
|
36
|
+
/** The WebSocket URL this mount connects to. */
|
|
37
|
+
protected abstract buildUrl(session: TSession): string;
|
|
38
|
+
/**
|
|
39
|
+
* Record whatever identity `protocol.init` carried for this mount.
|
|
40
|
+
*
|
|
41
|
+
* Called before the connection is announced, so a `onConnection` listener
|
|
42
|
+
* that immediately reads the identity always sees the new one.
|
|
43
|
+
*/
|
|
44
|
+
protected abstract readProtocolInit(msg: ProtocolInitMessage): void;
|
|
45
|
+
/** Forget the identity on a deliberate close. See {@link closeConnection}. */
|
|
46
|
+
protected abstract clearIdentity(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Whether replacing `previous` with `next` invalidates the open socket.
|
|
49
|
+
*
|
|
50
|
+
* The token always does; the rest is mount-specific — a workspace client
|
|
51
|
+
* changing workspace is a different socket, and an account client has no
|
|
52
|
+
* such axis.
|
|
53
|
+
*/
|
|
54
|
+
protected sessionRequiresReconnect(previous: TSession | null, next: TSession): boolean;
|
|
55
|
+
protected resolveSession(): TSession | null;
|
|
24
56
|
private notifyConnectionListeners;
|
|
25
57
|
private clearCloseTimer;
|
|
26
|
-
|
|
58
|
+
private clearSubscriptionRetry;
|
|
59
|
+
private scheduleSubscriptionRetry;
|
|
60
|
+
setSession(session: TSession): void;
|
|
27
61
|
subscribe(channel: string, listener: ChannelListener): Unsubscribe;
|
|
28
62
|
send(channel: string, data: unknown): void;
|
|
29
63
|
onConnection(listener: (connected: boolean) => void): () => void;
|
|
30
64
|
isConnected(): boolean;
|
|
31
|
-
|
|
32
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Whether the server has acknowledged this channel subscription.
|
|
67
|
+
*
|
|
68
|
+
* This is stricter than having a local listener: it becomes true only after
|
|
69
|
+
* a `subscribed` frame, returns to false on disconnect or server-driven
|
|
70
|
+
* unsubscribe, and stays false while automatic subscribe retry is pending.
|
|
71
|
+
* Consumers that must order a command after subscription admission should
|
|
72
|
+
* observe {@link getSubscriptionRevision} and re-read this method.
|
|
73
|
+
*/
|
|
74
|
+
isSubscribed(channel: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Monotonic local revision of acknowledged subscription state.
|
|
77
|
+
*
|
|
78
|
+
* It increments when `subscribed` or `unsubscribed` changes the server-
|
|
79
|
+
* acknowledged set. It is not a protocol sequence number and resets with a
|
|
80
|
+
* new client instance; its purpose is external-store invalidation before a
|
|
81
|
+
* consumer calls {@link isSubscribed} again.
|
|
82
|
+
*/
|
|
83
|
+
getSubscriptionRevision(): number;
|
|
84
|
+
/**
|
|
85
|
+
* A socket is open or opening and has not yet said `protocol.init`.
|
|
86
|
+
*
|
|
87
|
+
* Real state, read off the state machine. Consumers used to report a
|
|
88
|
+
* literal `false` here and render "Available" over a socket that had never
|
|
89
|
+
* connected; a toggle that cannot distinguish "not live" from "cannot tell
|
|
90
|
+
* yet" is the shape of that bug.
|
|
91
|
+
*/
|
|
92
|
+
isConnecting(): boolean;
|
|
93
|
+
/** The last failure the socket gave up on, or null. See {@link SocketState.lastError}. */
|
|
94
|
+
getLastError(): string | null;
|
|
95
|
+
protected ensureConnection(): void;
|
|
96
|
+
protected closeConnection(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Unified workspace WebSocket client for real-time events.
|
|
101
|
+
*
|
|
102
|
+
* Mirrors `/api/v2/w/{wid}/ws/realtime`: login with OAuth access token,
|
|
103
|
+
* subscribe to channels, receive AGUI events.
|
|
104
|
+
*
|
|
105
|
+
* The socket state machine — login, heartbeat, reconnect, subscribe retry —
|
|
106
|
+
* lives in {@link RealtimeSocketClient}, shared with the account client. This
|
|
107
|
+
* file is what is specific to the workspace mount: its URL, and the member id
|
|
108
|
+
* `protocol.init` hands back.
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/** Build the workspace realtime WebSocket URL from session config. */
|
|
112
|
+
declare function buildWorkspaceWsUrl(session: RealtimeAuthSession): string;
|
|
113
|
+
declare class WorkspaceRealtimeClient extends RealtimeSocketClient<RealtimeAuthSession> {
|
|
114
|
+
private memberId;
|
|
115
|
+
protected buildUrl(session: RealtimeAuthSession): string;
|
|
116
|
+
protected readProtocolInit(msg: ProtocolInitMessage): void;
|
|
117
|
+
protected clearIdentity(): void;
|
|
118
|
+
protected sessionRequiresReconnect(previous: RealtimeAuthSession | null, next: RealtimeAuthSession): boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Member id the server authenticated this socket as, or null before
|
|
121
|
+
* `protocol.init` arrives.
|
|
122
|
+
*
|
|
123
|
+
* Channels whose Redis key is workspace-wide can carry events addressed to
|
|
124
|
+
* one member (presence acks, for example). Consumers compare the event's
|
|
125
|
+
* addressee against this so a reply meant for another operator cannot be
|
|
126
|
+
* applied locally.
|
|
127
|
+
*/
|
|
128
|
+
getMemberId(): number | null;
|
|
33
129
|
}
|
|
34
130
|
declare function initWorkspaceSocket(session: RealtimeAuthSession): void;
|
|
35
131
|
declare function subscribeWorkspaceChannel(channel: string, listener: ChannelListener): Unsubscribe;
|
|
36
132
|
declare function onWorkspaceSocketConnection(listener: (connected: boolean) => void): () => void;
|
|
37
133
|
declare function isWorkspaceSocketConnected(): boolean;
|
|
38
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Account WebSocket client — one socket per tab, rooted at the Account.
|
|
137
|
+
*
|
|
138
|
+
* Mirrors `/api/v2/ws/realtime`. No workspace in the URL and none in the
|
|
139
|
+
* session: the token is the whole identity, and what the socket may see comes
|
|
140
|
+
* from the server's grant map, refreshed there.
|
|
141
|
+
*
|
|
142
|
+
* See `docs/projects/active/account-socket.md`. The point is not a second
|
|
143
|
+
* socket — it is *one*. An operator covering four workspaces holds four
|
|
144
|
+
* `WorkspaceRealtimeClient`s today, which is four connections, four
|
|
145
|
+
* heartbeats, and no place to stand for "what is happening across my estate".
|
|
146
|
+
* Workspace-scoped channels are reached here by qualifying their names, so the
|
|
147
|
+
* same channel handlers serve both mounts and the per-workspace socket can be
|
|
148
|
+
* deleted at cutover rather than lived alongside.
|
|
149
|
+
*
|
|
150
|
+
* ```ts
|
|
151
|
+
* const client = new AccountRealtimeClient({ apiUrl, accessToken });
|
|
152
|
+
* client.subscribe(workspaceChannel(wid, 'activity'), onInvalidate);
|
|
153
|
+
* client.subscribe(ACCOUNT_ACTIVITY_CHANNEL, onInvalidate);
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/** Build the account realtime WebSocket URL from session config. */
|
|
158
|
+
declare function buildAccountWsUrl(session: AccountRealtimeSession): string;
|
|
159
|
+
/**
|
|
160
|
+
* Qualify a workspace-scoped channel name for the account socket.
|
|
161
|
+
*
|
|
162
|
+
* `chat:42:monitor` in workspace `W` is `w/W/chat:42:monitor` here. Exported
|
|
163
|
+
* as a function rather than left to callers' template strings for the reason
|
|
164
|
+
* every channel name constant in this package exists: the server matches
|
|
165
|
+
* exactly, so a typo subscribes successfully to nothing.
|
|
166
|
+
*/
|
|
167
|
+
declare function workspaceChannel(workspaceUuid: string, channel: string): string;
|
|
168
|
+
declare class AccountRealtimeClient extends RealtimeSocketClient<AccountRealtimeSession> {
|
|
169
|
+
private accountUuid;
|
|
170
|
+
private workspaceUuids;
|
|
171
|
+
protected buildUrl(session: AccountRealtimeSession): string;
|
|
172
|
+
protected readProtocolInit(msg: ProtocolInitMessage): void;
|
|
173
|
+
protected clearIdentity(): void;
|
|
174
|
+
/** Account uuid the server authenticated this socket as, or null. */
|
|
175
|
+
getAccountUuid(): string | null;
|
|
176
|
+
/**
|
|
177
|
+
* Workspaces the server's grant map admitted at `protocol.init`.
|
|
178
|
+
*
|
|
179
|
+
* A **hint for rendering**, never an authorization decision: the server
|
|
180
|
+
* re-checks its own map on every subscribe, and refreshes it on a floor, so
|
|
181
|
+
* this snapshot can be stale in either direction. Use it to decide which
|
|
182
|
+
* panes are worth opening, not which data a viewer may see.
|
|
183
|
+
*/
|
|
184
|
+
getWorkspaceUuids(): readonly string[];
|
|
185
|
+
}
|
|
186
|
+
declare function initAccountSocket(session: AccountRealtimeSession): void;
|
|
187
|
+
declare function subscribeAccountChannel(channel: string, listener: ChannelListener): Unsubscribe;
|
|
188
|
+
declare function onAccountSocketConnection(listener: (connected: boolean) => void): () => void;
|
|
189
|
+
declare function isAccountSocketConnected(): boolean;
|
|
190
|
+
|
|
39
191
|
/**
|
|
40
192
|
* Human operator session WebSocket client.
|
|
41
193
|
*
|
|
@@ -98,7 +250,7 @@ declare class OperatorSessionClient {
|
|
|
98
250
|
* microphone before sending so the operator's audio is flowing the instant
|
|
99
251
|
* the bridge registers.
|
|
100
252
|
*/
|
|
101
|
-
answerCall(interactionId: string): Promise<void>;
|
|
253
|
+
answerCall(interactionId: string, answerToken: string): Promise<void>;
|
|
102
254
|
/**
|
|
103
255
|
* Mute or unmute the operator's outbound microphone. Muting gates mic frames
|
|
104
256
|
* locally (the caller stops hearing the operator) while callee audio keeps
|
|
@@ -117,4 +269,4 @@ declare class OperatorSessionClient {
|
|
|
117
269
|
disconnect(): Promise<void>;
|
|
118
270
|
}
|
|
119
271
|
|
|
120
|
-
export { ChannelEvent, ChannelListener, OperatorCallPhase, OperatorSessionClient, type OperatorSessionHandlers, OperatorSessionState, RealtimeAuthSession, Unsubscribe, WorkspaceRealtimeClient, buildWorkspaceWsUrl, initWorkspaceSocket, isWorkspaceSocketConnected, onWorkspaceSocketConnection, subscribeWorkspaceChannel };
|
|
272
|
+
export { AccountRealtimeClient, AccountRealtimeSession, ChannelEvent, ChannelListener, OperatorCallPhase, OperatorSessionClient, type OperatorSessionHandlers, OperatorSessionState, type ProtocolInitMessage, RealtimeAuthSession, RealtimeSessionBase, RealtimeSocketClient, Unsubscribe, WorkspaceRealtimeClient, buildAccountWsUrl, buildWorkspaceWsUrl, initAccountSocket, initWorkspaceSocket, isAccountSocketConnected, isWorkspaceSocketConnected, onAccountSocketConnection, onWorkspaceSocketConnection, subscribeAccountChannel, subscribeWorkspaceChannel, workspaceChannel };
|
package/dist/realtime.js
CHANGED
|
@@ -9,19 +9,37 @@ import {
|
|
|
9
9
|
function isActiveCallPhase(phase) {
|
|
10
10
|
return phase === "dialing" || phase === "live" || phase === "onHold";
|
|
11
11
|
}
|
|
12
|
+
var ACTIVITY_CHANNEL = "activity";
|
|
13
|
+
var ACTIVITY_MEMBER_CHANNEL = "activity:me";
|
|
14
|
+
var ACCOUNT_ACTIVITY_CHANNEL = "account:activity";
|
|
15
|
+
var ACCOUNT_PRESENCE_CHANNEL = "account:presence";
|
|
16
|
+
var PRESENCE_CHANNEL = "presence";
|
|
17
|
+
function isActivityInvalidation(event) {
|
|
18
|
+
if (event.type !== "invalidate") return false;
|
|
19
|
+
const data = event.data;
|
|
20
|
+
if (typeof data !== "object" || data === null) return false;
|
|
21
|
+
const scope = data.scope;
|
|
22
|
+
return scope === "workspace" || scope === "member" || scope === "account";
|
|
23
|
+
}
|
|
12
24
|
|
|
13
|
-
// src/realtime/
|
|
25
|
+
// src/realtime/socket-client.ts
|
|
14
26
|
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
15
27
|
var RECONNECT_DELAY_MS = 3e3;
|
|
16
28
|
var MAX_RECONNECT_ATTEMPTS = 5;
|
|
17
29
|
var CLOSE_DELAY_MS = 1e3;
|
|
30
|
+
var MAX_SUBSCRIBE_RETRY_DELAY_MS = 3e4;
|
|
18
31
|
function createSocketState() {
|
|
19
32
|
return {
|
|
20
33
|
ws: null,
|
|
21
34
|
connected: false,
|
|
22
35
|
connecting: false,
|
|
36
|
+
lastError: null,
|
|
23
37
|
listeners: /* @__PURE__ */ new Map(),
|
|
24
38
|
subscribedChannels: /* @__PURE__ */ new Set(),
|
|
39
|
+
subscriptionRevision: 0,
|
|
40
|
+
subscriptionRetryChannels: /* @__PURE__ */ new Set(),
|
|
41
|
+
subscriptionRetryAttempts: 0,
|
|
42
|
+
subscriptionRetryTimer: null,
|
|
25
43
|
reconnectAttempts: 0,
|
|
26
44
|
reconnectTimer: null,
|
|
27
45
|
heartbeatTimer: null,
|
|
@@ -29,12 +47,7 @@ function createSocketState() {
|
|
|
29
47
|
intentionalClose: false
|
|
30
48
|
};
|
|
31
49
|
}
|
|
32
|
-
|
|
33
|
-
const isSsl = session.apiUrl.startsWith("https");
|
|
34
|
-
const host = session.apiUrl.replace(/^https?:\/\//, "");
|
|
35
|
-
return `${isSsl ? "wss" : "ws"}://${host}/api/v2/w/${session.workspaceUuid}/ws/realtime`;
|
|
36
|
-
}
|
|
37
|
-
var WorkspaceRealtimeClient = class {
|
|
50
|
+
var RealtimeSocketClient = class {
|
|
38
51
|
session = null;
|
|
39
52
|
getSessionFn;
|
|
40
53
|
state = createSocketState();
|
|
@@ -49,6 +62,16 @@ var WorkspaceRealtimeClient = class {
|
|
|
49
62
|
}
|
|
50
63
|
}
|
|
51
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether replacing `previous` with `next` invalidates the open socket.
|
|
67
|
+
*
|
|
68
|
+
* The token always does; the rest is mount-specific — a workspace client
|
|
69
|
+
* changing workspace is a different socket, and an account client has no
|
|
70
|
+
* such axis.
|
|
71
|
+
*/
|
|
72
|
+
sessionRequiresReconnect(previous, next) {
|
|
73
|
+
return previous?.accessToken !== next.accessToken || previous?.apiUrl !== next.apiUrl;
|
|
74
|
+
}
|
|
52
75
|
resolveSession() {
|
|
53
76
|
return this.session ?? this.getSessionFn?.() ?? null;
|
|
54
77
|
}
|
|
@@ -63,8 +86,54 @@ var WorkspaceRealtimeClient = class {
|
|
|
63
86
|
this.state.closeTimer = null;
|
|
64
87
|
}
|
|
65
88
|
}
|
|
89
|
+
clearSubscriptionRetry(channel) {
|
|
90
|
+
if (channel) {
|
|
91
|
+
this.state.subscriptionRetryChannels.delete(channel);
|
|
92
|
+
} else {
|
|
93
|
+
this.state.subscriptionRetryChannels.clear();
|
|
94
|
+
}
|
|
95
|
+
if (this.state.subscriptionRetryChannels.size > 0) return;
|
|
96
|
+
if (this.state.subscriptionRetryTimer) {
|
|
97
|
+
clearTimeout(this.state.subscriptionRetryTimer);
|
|
98
|
+
this.state.subscriptionRetryTimer = null;
|
|
99
|
+
}
|
|
100
|
+
this.state.subscriptionRetryAttempts = 0;
|
|
101
|
+
}
|
|
102
|
+
scheduleSubscriptionRetry(channels) {
|
|
103
|
+
for (const channel of channels) {
|
|
104
|
+
if (this.state.listeners.has(channel) && !this.state.subscribedChannels.has(channel)) {
|
|
105
|
+
this.state.subscriptionRetryChannels.add(channel);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (this.state.subscriptionRetryChannels.size === 0 || this.state.subscriptionRetryTimer) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.state.subscriptionRetryAttempts += 1;
|
|
112
|
+
const delay = Math.min(
|
|
113
|
+
RECONNECT_DELAY_MS * this.state.subscriptionRetryAttempts,
|
|
114
|
+
MAX_SUBSCRIBE_RETRY_DELAY_MS
|
|
115
|
+
);
|
|
116
|
+
this.state.subscriptionRetryTimer = setTimeout(() => {
|
|
117
|
+
this.state.subscriptionRetryTimer = null;
|
|
118
|
+
const retryChannels = Array.from(this.state.subscriptionRetryChannels).filter(
|
|
119
|
+
(channel) => this.state.listeners.has(channel) && !this.state.subscribedChannels.has(channel)
|
|
120
|
+
);
|
|
121
|
+
for (const channel of this.state.subscriptionRetryChannels) {
|
|
122
|
+
if (!retryChannels.includes(channel)) {
|
|
123
|
+
this.state.subscriptionRetryChannels.delete(channel);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (retryChannels.length === 0) {
|
|
127
|
+
this.state.subscriptionRetryAttempts = 0;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (this.state.connected && this.state.ws?.readyState === WebSocket.OPEN) {
|
|
131
|
+
this.state.ws.send(JSON.stringify({ type: "subscribe", channels: retryChannels }));
|
|
132
|
+
}
|
|
133
|
+
}, delay);
|
|
134
|
+
}
|
|
66
135
|
setSession(session) {
|
|
67
|
-
const changed = this.
|
|
136
|
+
const changed = this.sessionRequiresReconnect(this.session, session);
|
|
68
137
|
this.session = session;
|
|
69
138
|
if (changed && this.state.ws) {
|
|
70
139
|
this.closeConnection();
|
|
@@ -91,6 +160,7 @@ var WorkspaceRealtimeClient = class {
|
|
|
91
160
|
if (channelListeners && channelListeners.size === 0) {
|
|
92
161
|
this.state.listeners.delete(channel);
|
|
93
162
|
this.state.subscribedChannels.delete(channel);
|
|
163
|
+
this.clearSubscriptionRetry(channel);
|
|
94
164
|
if (this.state.connected && this.state.ws?.readyState === WebSocket.OPEN) {
|
|
95
165
|
this.state.ws.send(JSON.stringify({ type: "unsubscribe", channels: [channel] }));
|
|
96
166
|
}
|
|
@@ -118,6 +188,44 @@ var WorkspaceRealtimeClient = class {
|
|
|
118
188
|
isConnected() {
|
|
119
189
|
return this.state.connected;
|
|
120
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Whether the server has acknowledged this channel subscription.
|
|
193
|
+
*
|
|
194
|
+
* This is stricter than having a local listener: it becomes true only after
|
|
195
|
+
* a `subscribed` frame, returns to false on disconnect or server-driven
|
|
196
|
+
* unsubscribe, and stays false while automatic subscribe retry is pending.
|
|
197
|
+
* Consumers that must order a command after subscription admission should
|
|
198
|
+
* observe {@link getSubscriptionRevision} and re-read this method.
|
|
199
|
+
*/
|
|
200
|
+
isSubscribed(channel) {
|
|
201
|
+
return this.state.subscribedChannels.has(channel);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Monotonic local revision of acknowledged subscription state.
|
|
205
|
+
*
|
|
206
|
+
* It increments when `subscribed` or `unsubscribed` changes the server-
|
|
207
|
+
* acknowledged set. It is not a protocol sequence number and resets with a
|
|
208
|
+
* new client instance; its purpose is external-store invalidation before a
|
|
209
|
+
* consumer calls {@link isSubscribed} again.
|
|
210
|
+
*/
|
|
211
|
+
getSubscriptionRevision() {
|
|
212
|
+
return this.state.subscriptionRevision;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A socket is open or opening and has not yet said `protocol.init`.
|
|
216
|
+
*
|
|
217
|
+
* Real state, read off the state machine. Consumers used to report a
|
|
218
|
+
* literal `false` here and render "Available" over a socket that had never
|
|
219
|
+
* connected; a toggle that cannot distinguish "not live" from "cannot tell
|
|
220
|
+
* yet" is the shape of that bug.
|
|
221
|
+
*/
|
|
222
|
+
isConnecting() {
|
|
223
|
+
return this.state.connecting;
|
|
224
|
+
}
|
|
225
|
+
/** The last failure the socket gave up on, or null. See {@link SocketState.lastError}. */
|
|
226
|
+
getLastError() {
|
|
227
|
+
return this.state.lastError;
|
|
228
|
+
}
|
|
121
229
|
ensureConnection() {
|
|
122
230
|
const session = this.resolveSession();
|
|
123
231
|
if (!session) return;
|
|
@@ -126,17 +234,21 @@ var WorkspaceRealtimeClient = class {
|
|
|
126
234
|
this.clearCloseTimer();
|
|
127
235
|
this.state.connecting = true;
|
|
128
236
|
this.state.intentionalClose = false;
|
|
129
|
-
|
|
237
|
+
this.notifyConnectionListeners();
|
|
238
|
+
const ws = new WebSocket(this.buildUrl(session));
|
|
130
239
|
ws.onopen = () => {
|
|
131
240
|
ws.send(JSON.stringify({ type: "login", token: session.accessToken }));
|
|
132
241
|
};
|
|
133
242
|
ws.onmessage = (event) => {
|
|
243
|
+
if (this.state.ws !== null && this.state.ws !== ws) return;
|
|
134
244
|
try {
|
|
135
245
|
const msg = JSON.parse(String(event.data));
|
|
136
246
|
if (msg.type === "protocol.init" && !this.state.connected) {
|
|
137
247
|
this.state.connected = true;
|
|
248
|
+
this.readProtocolInit(msg);
|
|
138
249
|
this.state.connecting = false;
|
|
139
250
|
this.state.reconnectAttempts = 0;
|
|
251
|
+
this.state.lastError = null;
|
|
140
252
|
if (this.state.heartbeatTimer) clearInterval(this.state.heartbeatTimer);
|
|
141
253
|
this.state.heartbeatTimer = setInterval(() => {
|
|
142
254
|
if (this.state.ws?.readyState === WebSocket.OPEN) {
|
|
@@ -157,13 +269,40 @@ var WorkspaceRealtimeClient = class {
|
|
|
157
269
|
if (msg.type === "subscribed" && Array.isArray(msg.channels)) {
|
|
158
270
|
for (const ch of msg.channels) {
|
|
159
271
|
this.state.subscribedChannels.add(ch);
|
|
272
|
+
this.clearSubscriptionRetry(ch);
|
|
160
273
|
}
|
|
274
|
+
this.state.subscriptionRevision += 1;
|
|
275
|
+
this.notifyConnectionListeners();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (msg.type === "subscribe_error" && Array.isArray(msg.errors)) {
|
|
279
|
+
const retryableChannels = [];
|
|
280
|
+
for (const rawError of msg.errors) {
|
|
281
|
+
if (!rawError || typeof rawError !== "object") continue;
|
|
282
|
+
const error = rawError;
|
|
283
|
+
if (typeof error.channel !== "string") continue;
|
|
284
|
+
if (error.retryable === true) {
|
|
285
|
+
retryableChannels.push(error.channel);
|
|
286
|
+
} else {
|
|
287
|
+
this.clearSubscriptionRetry(error.channel);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
this.scheduleSubscriptionRetry(retryableChannels);
|
|
161
291
|
return;
|
|
162
292
|
}
|
|
163
293
|
if (msg.type === "unsubscribed" && Array.isArray(msg.channels)) {
|
|
164
294
|
for (const ch of msg.channels) {
|
|
165
295
|
this.state.subscribedChannels.delete(ch);
|
|
166
296
|
}
|
|
297
|
+
this.state.subscriptionRevision += 1;
|
|
298
|
+
this.notifyConnectionListeners();
|
|
299
|
+
this.scheduleSubscriptionRetry(msg.channels);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (msg.type === "error") {
|
|
303
|
+
const message = msg.message;
|
|
304
|
+
this.state.lastError = typeof message === "string" && message ? message : "Realtime error";
|
|
305
|
+
this.notifyConnectionListeners();
|
|
167
306
|
return;
|
|
168
307
|
}
|
|
169
308
|
if (msg.type === "event" && msg.channel && msg.event) {
|
|
@@ -182,16 +321,22 @@ var WorkspaceRealtimeClient = class {
|
|
|
182
321
|
}
|
|
183
322
|
};
|
|
184
323
|
ws.onclose = () => {
|
|
324
|
+
if (this.state.ws !== null && this.state.ws !== ws) return;
|
|
185
325
|
this.state.connected = false;
|
|
186
326
|
this.state.connecting = false;
|
|
187
327
|
this.state.ws = null;
|
|
188
328
|
this.state.subscribedChannels.clear();
|
|
329
|
+
this.clearSubscriptionRetry();
|
|
189
330
|
if (this.state.heartbeatTimer) {
|
|
190
331
|
clearInterval(this.state.heartbeatTimer);
|
|
191
332
|
this.state.heartbeatTimer = null;
|
|
192
333
|
}
|
|
334
|
+
const willRetry = !this.state.intentionalClose && this.state.listeners.size > 0 && this.state.reconnectAttempts < MAX_RECONNECT_ATTEMPTS;
|
|
335
|
+
if (!willRetry && !this.state.intentionalClose && this.state.listeners.size > 0) {
|
|
336
|
+
this.state.lastError = "Realtime connection lost";
|
|
337
|
+
}
|
|
193
338
|
this.notifyConnectionListeners();
|
|
194
|
-
if (
|
|
339
|
+
if (willRetry) {
|
|
195
340
|
this.state.reconnectAttempts += 1;
|
|
196
341
|
this.state.reconnectTimer = setTimeout(() => {
|
|
197
342
|
this.ensureConnection();
|
|
@@ -216,11 +361,47 @@ var WorkspaceRealtimeClient = class {
|
|
|
216
361
|
this.state.ws?.close();
|
|
217
362
|
this.state.ws = null;
|
|
218
363
|
this.state.connected = false;
|
|
364
|
+
this.clearIdentity();
|
|
219
365
|
this.state.connecting = false;
|
|
220
366
|
this.state.subscribedChannels.clear();
|
|
367
|
+
this.clearSubscriptionRetry();
|
|
221
368
|
this.notifyConnectionListeners();
|
|
222
369
|
}
|
|
223
370
|
};
|
|
371
|
+
|
|
372
|
+
// src/realtime/workspace-client.ts
|
|
373
|
+
function buildWorkspaceWsUrl(session) {
|
|
374
|
+
const isSsl = session.apiUrl.startsWith("https");
|
|
375
|
+
const host = session.apiUrl.replace(/^https?:\/\//, "");
|
|
376
|
+
return `${isSsl ? "wss" : "ws"}://${host}/api/v2/w/${session.workspaceUuid}/ws/realtime`;
|
|
377
|
+
}
|
|
378
|
+
var WorkspaceRealtimeClient = class extends RealtimeSocketClient {
|
|
379
|
+
memberId = null;
|
|
380
|
+
buildUrl(session) {
|
|
381
|
+
return buildWorkspaceWsUrl(session);
|
|
382
|
+
}
|
|
383
|
+
readProtocolInit(msg) {
|
|
384
|
+
this.memberId = typeof msg.memberId === "number" ? msg.memberId : null;
|
|
385
|
+
}
|
|
386
|
+
clearIdentity() {
|
|
387
|
+
this.memberId = null;
|
|
388
|
+
}
|
|
389
|
+
sessionRequiresReconnect(previous, next) {
|
|
390
|
+
return super.sessionRequiresReconnect(previous, next) || previous?.workspaceUuid !== next.workspaceUuid;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Member id the server authenticated this socket as, or null before
|
|
394
|
+
* `protocol.init` arrives.
|
|
395
|
+
*
|
|
396
|
+
* Channels whose Redis key is workspace-wide can carry events addressed to
|
|
397
|
+
* one member (presence acks, for example). Consumers compare the event's
|
|
398
|
+
* addressee against this so a reply meant for another operator cannot be
|
|
399
|
+
* applied locally.
|
|
400
|
+
*/
|
|
401
|
+
getMemberId() {
|
|
402
|
+
return this.memberId;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
224
405
|
var defaultClient = new WorkspaceRealtimeClient();
|
|
225
406
|
function initWorkspaceSocket(session) {
|
|
226
407
|
defaultClient.setSession(session);
|
|
@@ -235,6 +416,59 @@ function isWorkspaceSocketConnected() {
|
|
|
235
416
|
return defaultClient.isConnected();
|
|
236
417
|
}
|
|
237
418
|
|
|
419
|
+
// src/realtime/account-client.ts
|
|
420
|
+
function buildAccountWsUrl(session) {
|
|
421
|
+
const isSsl = session.apiUrl.startsWith("https");
|
|
422
|
+
const host = session.apiUrl.replace(/^https?:\/\//, "");
|
|
423
|
+
return `${isSsl ? "wss" : "ws"}://${host}/api/v2/ws/realtime`;
|
|
424
|
+
}
|
|
425
|
+
function workspaceChannel(workspaceUuid, channel) {
|
|
426
|
+
return `w/${workspaceUuid}/${channel}`;
|
|
427
|
+
}
|
|
428
|
+
var AccountRealtimeClient = class extends RealtimeSocketClient {
|
|
429
|
+
accountUuid = null;
|
|
430
|
+
workspaceUuids = [];
|
|
431
|
+
buildUrl(session) {
|
|
432
|
+
return buildAccountWsUrl(session);
|
|
433
|
+
}
|
|
434
|
+
readProtocolInit(msg) {
|
|
435
|
+
this.accountUuid = typeof msg.accountUuid === "string" ? msg.accountUuid : null;
|
|
436
|
+
this.workspaceUuids = Array.isArray(msg.workspaceUuids) ? msg.workspaceUuids.filter((value) => typeof value === "string") : [];
|
|
437
|
+
}
|
|
438
|
+
clearIdentity() {
|
|
439
|
+
this.accountUuid = null;
|
|
440
|
+
this.workspaceUuids = [];
|
|
441
|
+
}
|
|
442
|
+
/** Account uuid the server authenticated this socket as, or null. */
|
|
443
|
+
getAccountUuid() {
|
|
444
|
+
return this.accountUuid;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Workspaces the server's grant map admitted at `protocol.init`.
|
|
448
|
+
*
|
|
449
|
+
* A **hint for rendering**, never an authorization decision: the server
|
|
450
|
+
* re-checks its own map on every subscribe, and refreshes it on a floor, so
|
|
451
|
+
* this snapshot can be stale in either direction. Use it to decide which
|
|
452
|
+
* panes are worth opening, not which data a viewer may see.
|
|
453
|
+
*/
|
|
454
|
+
getWorkspaceUuids() {
|
|
455
|
+
return this.workspaceUuids;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
var defaultClient2 = new AccountRealtimeClient();
|
|
459
|
+
function initAccountSocket(session) {
|
|
460
|
+
defaultClient2.setSession(session);
|
|
461
|
+
}
|
|
462
|
+
function subscribeAccountChannel(channel, listener) {
|
|
463
|
+
return defaultClient2.subscribe(channel, listener);
|
|
464
|
+
}
|
|
465
|
+
function onAccountSocketConnection(listener) {
|
|
466
|
+
return defaultClient2.onConnection(listener);
|
|
467
|
+
}
|
|
468
|
+
function isAccountSocketConnected() {
|
|
469
|
+
return defaultClient2.isConnected();
|
|
470
|
+
}
|
|
471
|
+
|
|
238
472
|
// src/realtime/operator-session-client.ts
|
|
239
473
|
var OperatorSessionClient = class {
|
|
240
474
|
constructor(handlers = {}) {
|
|
@@ -439,7 +673,7 @@ var OperatorSessionClient = class {
|
|
|
439
673
|
* microphone before sending so the operator's audio is flowing the instant
|
|
440
674
|
* the bridge registers.
|
|
441
675
|
*/
|
|
442
|
-
async answerCall(interactionId) {
|
|
676
|
+
async answerCall(interactionId, answerToken) {
|
|
443
677
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
444
678
|
throw new Error("Operator session WebSocket is not connected");
|
|
445
679
|
}
|
|
@@ -447,7 +681,10 @@ var OperatorSessionClient = class {
|
|
|
447
681
|
throw new Error("Operator session is not ready");
|
|
448
682
|
}
|
|
449
683
|
await this.startMicrophone();
|
|
450
|
-
this.sendCustom("operator.call.answer", {
|
|
684
|
+
this.sendCustom("operator.call.answer", {
|
|
685
|
+
interaction_id: interactionId,
|
|
686
|
+
answer_token: answerToken
|
|
687
|
+
});
|
|
451
688
|
}
|
|
452
689
|
/**
|
|
453
690
|
* Mute or unmute the operator's outbound microphone. Muting gates mic frames
|
|
@@ -611,13 +848,27 @@ var OperatorSessionClient = class {
|
|
|
611
848
|
}
|
|
612
849
|
};
|
|
613
850
|
export {
|
|
851
|
+
ACCOUNT_ACTIVITY_CHANNEL,
|
|
852
|
+
ACCOUNT_PRESENCE_CHANNEL,
|
|
853
|
+
ACTIVITY_CHANNEL,
|
|
854
|
+
ACTIVITY_MEMBER_CHANNEL,
|
|
855
|
+
AccountRealtimeClient,
|
|
614
856
|
OperatorSessionClient,
|
|
857
|
+
PRESENCE_CHANNEL,
|
|
858
|
+
RealtimeSocketClient,
|
|
615
859
|
WorkspaceRealtimeClient,
|
|
860
|
+
buildAccountWsUrl,
|
|
616
861
|
buildWorkspaceWsUrl,
|
|
862
|
+
initAccountSocket,
|
|
617
863
|
initWorkspaceSocket,
|
|
864
|
+
isAccountSocketConnected,
|
|
618
865
|
isActiveCallPhase,
|
|
866
|
+
isActivityInvalidation,
|
|
619
867
|
isWorkspaceSocketConnected,
|
|
868
|
+
onAccountSocketConnection,
|
|
620
869
|
onWorkspaceSocketConnection,
|
|
621
|
-
|
|
870
|
+
subscribeAccountChannel,
|
|
871
|
+
subscribeWorkspaceChannel,
|
|
872
|
+
workspaceChannel
|
|
622
873
|
};
|
|
623
874
|
//# sourceMappingURL=realtime.js.map
|