@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.
Files changed (55) hide show
  1. package/README.md +57 -8
  2. package/dist/agui.d.cts +1 -1
  3. package/dist/agui.d.ts +1 -1
  4. package/dist/audio.d.cts +1 -1
  5. package/dist/audio.d.ts +1 -1
  6. package/dist/{client-C10qSd6b.d.ts → chat-interaction-BKdImgZ5.d.ts} +5604 -1531
  7. package/dist/{client-Ct0soeip.d.cts → chat-interaction-DLfC_h_P.d.cts} +5604 -1531
  8. package/dist/{chunk-3HAV5XUJ.js → chunk-HRUMZ46V.js} +1 -1
  9. package/dist/chunk-HRUMZ46V.js.map +1 -0
  10. package/dist/{chunk-WMA6YCAS.js → chunk-N446FGCT.js} +1230 -201
  11. package/dist/chunk-N446FGCT.js.map +1 -0
  12. package/dist/{chunk-7OLBLLV7.js → chunk-P2E6LU7I.js} +57 -7
  13. package/dist/chunk-P2E6LU7I.js.map +1 -0
  14. package/dist/helpers.cjs +54 -1
  15. package/dist/helpers.cjs.map +1 -1
  16. package/dist/helpers.d.cts +3 -2
  17. package/dist/helpers.d.ts +3 -2
  18. package/dist/helpers.js +11 -3
  19. package/dist/helpers.js.map +1 -1
  20. package/dist/index.cjs +2291 -148
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +509 -6
  23. package/dist/index.d.ts +509 -6
  24. package/dist/index.js +1032 -23
  25. package/dist/index.js.map +1 -1
  26. package/dist/internal.cjs +1320 -220
  27. package/dist/internal.cjs.map +1 -1
  28. package/dist/internal.d.cts +33 -49
  29. package/dist/internal.d.ts +33 -49
  30. package/dist/internal.js +73 -33
  31. package/dist/internal.js.map +1 -1
  32. package/dist/realtime.cjs +279 -14
  33. package/dist/realtime.cjs.map +1 -1
  34. package/dist/realtime.d.cts +169 -17
  35. package/dist/realtime.d.ts +169 -17
  36. package/dist/realtime.js +264 -13
  37. package/dist/realtime.js.map +1 -1
  38. package/dist/{revision-state-CCPTiW-i.d.cts → revision-state-Bsq5dKwi.d.cts} +236 -5
  39. package/dist/{revision-state-C6aM-PHu.d.ts → revision-state-CQTYcIRG.d.ts} +236 -5
  40. package/dist/schemas.cjs +3 -1
  41. package/dist/schemas.cjs.map +1 -1
  42. package/dist/schemas.d.cts +31 -13
  43. package/dist/schemas.d.ts +31 -13
  44. package/dist/schemas.js +4 -2
  45. package/dist/schemas.js.map +1 -1
  46. package/dist/{tasks-WH_VfzMv.d.cts → tasks-B-31JPuq.d.cts} +298 -114
  47. package/dist/{tasks-WH_VfzMv.d.ts → tasks-B-31JPuq.d.ts} +298 -114
  48. package/dist/types-0c6_NGU4.d.cts +90 -0
  49. package/dist/types-0c6_NGU4.d.ts +90 -0
  50. package/package.json +3 -3
  51. package/dist/chunk-3HAV5XUJ.js.map +0 -1
  52. package/dist/chunk-7OLBLLV7.js.map +0 -1
  53. package/dist/chunk-WMA6YCAS.js.map +0 -1
  54. package/dist/types-DkYyrvoC.d.cts +0 -19
  55. package/dist/types-DkYyrvoC.d.ts +0 -19
@@ -1,41 +1,193 @@
1
- import { R as RealtimeAuthSession, a as ChannelListener, U as Unsubscribe, O as OperatorSessionState, b as OperatorCallPhase, C as ChannelEvent } from './types-DkYyrvoC.js';
2
- export { i as isActiveCallPhase } from './types-DkYyrvoC.js';
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
- * Unified workspace WebSocket client for real-time events.
5
+ * The realtime socket state machine, shared by both mounts.
6
6
  *
7
- * Mirrors `/api/v2/w/{wid}/ws/realtime`: login with OAuth access token,
8
- * subscribe to channels, receive AGUI events.
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, unsubscribed, event, error
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
- /** Build the workspace realtime WebSocket URL from session config. */
16
- declare function buildWorkspaceWsUrl(session: RealtimeAuthSession): string;
17
- declare class WorkspaceRealtimeClient {
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?: RealtimeAuthSession | (() => RealtimeAuthSession | null));
23
- private resolveSession;
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
- setSession(session: RealtimeAuthSession): void;
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
- private ensureConnection;
32
- private closeConnection;
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/workspace-client.ts
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
- function buildWorkspaceWsUrl(session) {
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.session?.accessToken !== session.accessToken || this.session?.workspaceUuid !== session.workspaceUuid || this.session?.apiUrl !== session.apiUrl;
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
- const ws = new WebSocket(buildWorkspaceWsUrl(session));
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 (!this.state.intentionalClose && this.state.listeners.size > 0 && this.state.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
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", { interaction_id: interactionId });
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
- subscribeWorkspaceChannel
870
+ subscribeAccountChannel,
871
+ subscribeWorkspaceChannel,
872
+ workspaceChannel
622
873
  };
623
874
  //# sourceMappingURL=realtime.js.map