@oxvo/ai-live-assist 7.3.4 → 7.4.23

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 (67) hide show
  1. package/cjs/AiLiveAssist.d.ts +588 -6
  2. package/cjs/AiLiveAssist.js +14892 -1240
  3. package/cjs/client.d.ts +71 -10
  4. package/cjs/client.js +316 -58
  5. package/cjs/context.d.ts +15 -5
  6. package/cjs/context.js +568 -73
  7. package/cjs/control.d.ts +72 -7
  8. package/cjs/control.js +594 -71
  9. package/cjs/dialogFocusScope.d.ts +37 -0
  10. package/cjs/dialogFocusScope.js +227 -0
  11. package/cjs/experienceState.js +1 -1
  12. package/cjs/index.d.ts +1 -1
  13. package/cjs/index.js +2 -2
  14. package/cjs/media.d.ts +14 -3
  15. package/cjs/media.js +128 -19
  16. package/cjs/messages.js +26 -3
  17. package/cjs/messengerLoader.d.ts +3 -0
  18. package/cjs/messengerLoader.js +156 -0
  19. package/cjs/pageAssistancePreference.d.ts +78 -0
  20. package/cjs/pageAssistancePreference.js +488 -0
  21. package/cjs/preview.d.ts +33 -0
  22. package/cjs/preview.js +567 -0
  23. package/cjs/tabLock.d.ts +54 -3
  24. package/cjs/tabLock.js +752 -72
  25. package/cjs/types.d.ts +152 -4
  26. package/cjs/ui.d.ts +200 -10
  27. package/cjs/ui.js +6946 -523
  28. package/cjs/version.d.ts +1 -1
  29. package/cjs/version.js +1 -1
  30. package/cjs/voicePresenceUi.d.ts +48 -9
  31. package/cjs/voicePresenceUi.js +863 -125
  32. package/lib/AiLiveAssist.d.ts +588 -6
  33. package/lib/AiLiveAssist.js +14897 -1248
  34. package/lib/client.d.ts +71 -10
  35. package/lib/client.js +313 -57
  36. package/lib/context.d.ts +15 -5
  37. package/lib/context.js +566 -72
  38. package/lib/control.d.ts +72 -7
  39. package/lib/control.js +594 -71
  40. package/lib/dialogFocusScope.d.ts +37 -0
  41. package/lib/dialogFocusScope.js +223 -0
  42. package/lib/experienceState.js +1 -1
  43. package/lib/index.d.ts +1 -1
  44. package/lib/index.js +2 -2
  45. package/lib/media.d.ts +14 -3
  46. package/lib/media.js +128 -19
  47. package/lib/messages.js +26 -3
  48. package/lib/messengerLoader.d.ts +3 -0
  49. package/lib/messengerLoader.js +151 -0
  50. package/lib/pageAssistancePreference.d.ts +78 -0
  51. package/lib/pageAssistancePreference.js +482 -0
  52. package/lib/preview.d.ts +33 -0
  53. package/lib/preview.js +561 -0
  54. package/lib/tabLock.d.ts +54 -3
  55. package/lib/tabLock.js +752 -72
  56. package/lib/types.d.ts +152 -4
  57. package/lib/ui.d.ts +200 -10
  58. package/lib/ui.js +6947 -524
  59. package/lib/version.d.ts +1 -1
  60. package/lib/version.js +1 -1
  61. package/lib/voicePresenceUi.d.ts +48 -9
  62. package/lib/voicePresenceUi.js +863 -125
  63. package/package.json +8 -3
  64. package/cjs/recordingPolicy.d.ts +0 -2
  65. package/cjs/recordingPolicy.js +0 -12
  66. package/lib/recordingPolicy.d.ts +0 -2
  67. package/lib/recordingPolicy.js +0 -8
package/cjs/control.js CHANGED
@@ -3,21 +3,68 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ControlChannel = void 0;
4
4
  const INVALID_MESSAGE_CLOSE_CODE = 4003;
5
5
  const PROTOCOL_VIOLATION_CLOSE_CODE = 4008;
6
+ const SERVER_SEQUENCE_GAP_CLOSE_CODE = 4009;
7
+ const HANDLER_FAILURE_CLOSE_CODE = 4010;
8
+ const HANDLER_TIMEOUT_MS = 15000;
9
+ const MAX_PENDING_HANDLER_MESSAGES = 64;
10
+ const MAX_PENDING_STATEFUL_OPERATIONS = 64;
11
+ const MAX_SETTLED_OPERATION_HISTORY = 100;
12
+ const CLIENT_MESSAGE_ID_PATTERN = /^[A-Za-z0-9_.:-]{8,128}$/;
13
+ const STATEFUL_CONTROL_TYPES = new Set([
14
+ "text.message",
15
+ "confirmation.result",
16
+ "consent.update",
17
+ "consent.reset.ack",
18
+ "session.mode",
19
+ "session.mode.upgrade",
20
+ "session.mode.upgrade.cancel",
21
+ "context.navigation",
22
+ "action.execution.begin",
23
+ "action.result",
24
+ "handoff.open.result",
25
+ "handoff.connected.ack",
26
+ "handoff.binding.terminal.ack",
27
+ "handoff_context.received",
28
+ "handoff_context.result",
29
+ "page_assistance.result",
30
+ "page_assistance.revoke",
31
+ "visual_context.result",
32
+ "presentation.result",
33
+ "session.pause",
34
+ "session.resume",
35
+ "experience.end",
36
+ "session.end",
37
+ ]);
38
+ const outcomeKey = (messageId, sequence) => `${messageId}\u0000${sequence}`;
6
39
  class ControlChannel {
7
- constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined) {
40
+ constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined) {
8
41
  this.result = result;
9
42
  this.tabId = tabId;
10
43
  this.clientVersion = clientVersion;
11
44
  this.onDisconnect = onDisconnect;
12
45
  this.onClientSequence = onClientSequence;
46
+ this.onUnsafeDisconnect = onUnsafeDisconnect;
13
47
  this.socket = null;
14
48
  this.serverSequence = 0;
49
+ this.serverBaselineEstablished = false;
50
+ this.sequenceGapDetected = false;
51
+ this.recoveryDisconnectNotified = false;
15
52
  this.fencingToken = 0;
16
53
  this.heartbeat = null;
17
54
  this.handlers = new Set();
55
+ this.ingressHandlers = new Set();
18
56
  this.intentionallyClosed = false;
19
- this.pendingAcks = new Map();
20
- this.recentAcks = new Set();
57
+ this.pendingOutcomes = new Map();
58
+ this.recentOutcomes = new Map();
59
+ this.outboundOperations = new Map();
60
+ this.latestOutboundSequence = new Map();
61
+ this.unsettledStatefulOperations = new Set();
62
+ this.socketGeneration = 0;
63
+ this.handlerTail = null;
64
+ this.pendingHandlerMessages = 0;
65
+ this.pendingConnection = null;
66
+ this.helloOperation = null;
67
+ this.fencedAuthorityConfirmed = false;
21
68
  this.clientSequence =
22
69
  Number.isSafeInteger(initialClientSequence) && initialClientSequence >= 0
23
70
  ? initialClientSequence
@@ -26,95 +73,283 @@ class ControlChannel {
26
73
  get leaseToken() {
27
74
  return this.fencingToken;
28
75
  }
76
+ get lastClientSequence() {
77
+ return this.clientSequence;
78
+ }
29
79
  get isOpen() {
30
80
  return this.socket?.readyState === WebSocket.OPEN;
31
81
  }
82
+ get connectionHelloOperation() {
83
+ return this.helloOperation ? { ...this.helloOperation } : null;
84
+ }
85
+ get hasFencedAuthority() {
86
+ return (this.isOpen && this.fencingToken > 0 && this.fencedAuthorityConfirmed);
87
+ }
32
88
  setFencingToken(value) {
33
89
  if (!Number.isSafeInteger(value) || value < 1) {
34
- throw new Error('Invalid AI Guide fencing token.');
90
+ throw new Error("Invalid AI Guide fencing token.");
35
91
  }
92
+ this.fencedAuthorityConfirmed = false;
36
93
  this.fencingToken = value;
37
94
  }
38
95
  subscribe(handler) {
39
96
  this.handlers.add(handler);
40
97
  return () => this.handlers.delete(handler);
41
98
  }
99
+ subscribeIngress(handler) {
100
+ this.ingressHandlers.add(handler);
101
+ return () => this.ingressHandlers.delete(handler);
102
+ }
42
103
  connect(signal, resume) {
104
+ if (signal.aborted) {
105
+ return Promise.reject(new DOMException("The operation was aborted.", "AbortError"));
106
+ }
43
107
  return new Promise((resolve, reject) => {
44
108
  const url = new URL(this.result.controlUrl);
45
- url.searchParams.set('session', this.result.sessionId);
109
+ url.searchParams.set("session", this.result.sessionId);
46
110
  const socket = new WebSocket(url, [
47
111
  `oxvo-ai-live-assist-v${this.result.protocolVersion}`,
48
112
  `oxvo-auth.${this.result.controlCredential}`,
49
113
  ]);
114
+ this.retireSocket("replaced");
115
+ const generation = ++this.socketGeneration;
50
116
  this.socket = socket;
51
117
  this.intentionallyClosed = false;
118
+ this.serverSequence = 0;
119
+ this.serverBaselineEstablished = false;
120
+ this.sequenceGapDetected = false;
121
+ this.recoveryDisconnectNotified = false;
122
+ this.helloOperation = null;
123
+ this.fencedAuthorityConfirmed = false;
124
+ this.handlerTail = null;
125
+ this.pendingHandlerMessages = 0;
52
126
  const abort = () => {
53
- socket.close(1000, 'cancelled');
54
- reject(new DOMException('The operation was aborted.', 'AbortError'));
127
+ if (!this.isCurrentSocket(socket, generation))
128
+ return;
129
+ this.rejectPendingConnection(socket, generation, new DOMException("The operation was aborted.", "AbortError"));
130
+ socket.close(1000, "cancelled");
131
+ };
132
+ this.pendingConnection = {
133
+ socket,
134
+ generation,
135
+ signal,
136
+ abort,
137
+ reject,
55
138
  };
56
- signal.addEventListener('abort', abort, { once: true });
57
- socket.addEventListener('open', () => {
58
- signal.removeEventListener('abort', abort);
59
- this.send('hello', {
60
- protocolMin: 1,
61
- protocolMax: this.result.protocolVersion,
62
- clientVersion: this.clientVersion,
63
- resume,
64
- });
139
+ signal.addEventListener("abort", abort, { once: true });
140
+ socket.addEventListener("open", () => {
141
+ if (!this.isCurrentSocket(socket, generation))
142
+ return;
143
+ if (signal.aborted) {
144
+ abort();
145
+ return;
146
+ }
147
+ this.clearPendingConnection(socket, generation);
148
+ try {
149
+ const messageId = this.send("hello", {
150
+ protocolMin: 1,
151
+ protocolMax: this.result.protocolVersion,
152
+ clientVersion: this.clientVersion,
153
+ resume,
154
+ });
155
+ this.helloOperation = {
156
+ messageId,
157
+ sequence: this.clientSequence,
158
+ type: "hello",
159
+ };
160
+ }
161
+ catch (error) {
162
+ socket.close(1011, "hello failed");
163
+ reject(error);
164
+ return;
165
+ }
65
166
  resolve();
66
167
  }, { once: true });
67
- socket.addEventListener('message', (event) => {
68
- void this.handleMessage(event.data);
168
+ socket.addEventListener("message", (event) => {
169
+ if (!this.isCurrentSocket(socket, generation))
170
+ return;
171
+ this.handleMessage(event.data, socket, generation);
69
172
  });
70
- socket.addEventListener('error', () => {
71
- signal.removeEventListener('abort', abort);
173
+ socket.addEventListener("error", () => {
174
+ if (!this.isCurrentSocket(socket, generation))
175
+ return;
72
176
  if (socket.readyState !== WebSocket.OPEN) {
73
- reject(new Error('The AI Guide control channel could not connect.'));
177
+ this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel could not connect."));
74
178
  }
75
179
  }, { once: true });
76
- socket.addEventListener('close', () => {
180
+ socket.addEventListener("close", () => {
181
+ if (!this.isCurrentSocket(socket, generation))
182
+ return;
183
+ const wasIntentionallyClosed = this.intentionallyClosed;
184
+ this.socket = null;
185
+ this.socketGeneration += 1;
77
186
  this.stopHeartbeat();
78
- if (!this.intentionallyClosed && !signal.aborted)
79
- this.onDisconnect();
187
+ this.invalidatePendingOutcomes();
188
+ this.recentOutcomes.clear();
189
+ this.outboundOperations.clear();
190
+ this.latestOutboundSequence.clear();
191
+ this.helloOperation = null;
192
+ this.fencedAuthorityConfirmed = false;
193
+ this.handlerTail = null;
194
+ this.pendingHandlerMessages = 0;
195
+ this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel closed before connecting."));
196
+ if (!wasIntentionallyClosed && !signal.aborted) {
197
+ this.notifyUnsafeDisconnect();
198
+ }
80
199
  });
81
200
  });
82
201
  }
83
- send(type, payload) {
202
+ send(type, payload, stableMessageId) {
84
203
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
85
- throw new Error('The AI Guide control channel is not connected.');
204
+ throw new Error("The AI Guide control channel is not connected.");
205
+ }
206
+ const messageId = stableMessageId ?? `msg_${crypto.randomUUID()}`;
207
+ if (!CLIENT_MESSAGE_ID_PATTERN.test(messageId)) {
208
+ throw new Error("Invalid AI Guide control message ID.");
209
+ }
210
+ if (STATEFUL_CONTROL_TYPES.has(type) &&
211
+ this.unsettledStatefulOperations.size >= MAX_PENDING_STATEFUL_OPERATIONS) {
212
+ throw new Error("Too many AI Guide control operations are pending.");
213
+ }
214
+ this.clientSequence += 1;
215
+ const operation = {
216
+ messageId,
217
+ sequence: this.clientSequence,
218
+ type,
219
+ };
220
+ this.sendReserved(operation, payload);
221
+ this.onClientSequence(this.clientSequence);
222
+ return messageId;
223
+ }
224
+ reserveOperationIdentity(type, stableMessageId) {
225
+ const messageId = stableMessageId ?? `msg_${crypto.randomUUID()}`;
226
+ if (!CLIENT_MESSAGE_ID_PATTERN.test(messageId)) {
227
+ throw new Error("Invalid AI Guide control message ID.");
228
+ }
229
+ if (!type || type.length > 128) {
230
+ throw new Error("Invalid AI Guide control message type.");
86
231
  }
87
- const id = `msg_${crypto.randomUUID()}`;
88
232
  this.clientSequence += 1;
233
+ this.onClientSequence(this.clientSequence);
234
+ return {
235
+ messageId,
236
+ sequence: this.clientSequence,
237
+ type,
238
+ };
239
+ }
240
+ sendReserved(operation, payload) {
241
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
242
+ throw new Error("The AI Guide control channel is not connected.");
243
+ }
244
+ if (!CLIENT_MESSAGE_ID_PATTERN.test(operation.messageId) ||
245
+ !Number.isSafeInteger(operation.sequence) ||
246
+ operation.sequence < 1 ||
247
+ operation.sequence !== this.clientSequence ||
248
+ !operation.type ||
249
+ operation.type.length > 128) {
250
+ throw new Error("Invalid AI Guide reserved control operation.");
251
+ }
252
+ if (STATEFUL_CONTROL_TYPES.has(operation.type) &&
253
+ this.unsettledStatefulOperations.size >= MAX_PENDING_STATEFUL_OPERATIONS) {
254
+ throw new Error("Too many AI Guide control operations are pending.");
255
+ }
89
256
  this.socket.send(JSON.stringify({
90
257
  v: this.result.protocolVersion,
91
- id,
92
- type,
258
+ id: operation.messageId,
259
+ type: operation.type,
93
260
  sessionId: this.result.sessionId,
94
261
  tabId: this.tabId,
95
- sequence: this.clientSequence,
262
+ sequence: operation.sequence,
96
263
  fencingToken: this.fencingToken,
97
264
  sentAt: new Date().toISOString(),
98
265
  payload,
99
266
  }));
100
- this.onClientSequence(this.clientSequence);
101
- return id;
267
+ const key = outcomeKey(operation.messageId, operation.sequence);
268
+ this.outboundOperations.set(key, operation);
269
+ this.latestOutboundSequence.set(operation.messageId, operation.sequence);
270
+ if (STATEFUL_CONTROL_TYPES.has(operation.type)) {
271
+ this.unsettledStatefulOperations.add(key);
272
+ }
273
+ this.pruneSettledOperationHistory();
274
+ return operation.messageId;
102
275
  }
103
- waitForAck(messageId, timeoutMs = 2500) {
104
- if (this.recentAcks.delete(messageId))
105
- return Promise.resolve(true);
276
+ outboundOperation(messageId, sequence = this.latestOutboundSequence.get(messageId) ?? 0) {
277
+ return this.outboundOperations.get(outcomeKey(messageId, sequence)) ?? null;
278
+ }
279
+ waitForOutcome(messageId, timeoutMs = 2500, expectedSequence = this.latestOutboundSequence.get(messageId) ?? 0) {
280
+ if (!CLIENT_MESSAGE_ID_PATTERN.test(messageId) ||
281
+ !Number.isSafeInteger(expectedSequence) ||
282
+ expectedSequence < 1) {
283
+ return Promise.resolve({
284
+ status: "invalidated",
285
+ messageId,
286
+ sequence: Number.isSafeInteger(expectedSequence) && expectedSequence >= 0
287
+ ? expectedSequence
288
+ : 0,
289
+ });
290
+ }
291
+ const key = outcomeKey(messageId, expectedSequence);
292
+ const recent = this.recentOutcomes.get(key);
293
+ if (recent)
294
+ return Promise.resolve(recent);
106
295
  return new Promise((resolve) => {
107
- const timeout = setTimeout(() => {
108
- this.pendingAcks.delete(messageId);
109
- resolve(false);
110
- }, Math.max(250, Math.min(10000, timeoutMs)));
111
- this.pendingAcks.set(messageId, (acknowledged) => {
296
+ let settled = false;
297
+ let timeout;
298
+ let pendingOutcome;
299
+ const settle = (outcome) => {
300
+ if (settled)
301
+ return;
302
+ settled = true;
112
303
  clearTimeout(timeout);
113
- this.pendingAcks.delete(messageId);
114
- resolve(acknowledged);
115
- });
304
+ const pending = this.pendingOutcomes.get(key);
305
+ pending?.delete(pendingOutcome);
306
+ if (pending?.size === 0)
307
+ this.pendingOutcomes.delete(key);
308
+ resolve(outcome);
309
+ };
310
+ pendingOutcome = { settle };
311
+ const pending = this.pendingOutcomes.get(key) ?? new Set();
312
+ pending.add(pendingOutcome);
313
+ this.pendingOutcomes.set(key, pending);
314
+ timeout = setTimeout(() => settle({
315
+ status: "timeout",
316
+ messageId,
317
+ sequence: expectedSequence,
318
+ }), Math.max(250, Math.min(10000, timeoutMs)));
116
319
  });
117
320
  }
321
+ waitForAck(messageId, timeoutMs = 2500) {
322
+ return this.waitForOutcome(messageId, timeoutMs).then((outcome) => outcome.status === "ack");
323
+ }
324
+ waitForHelloOutcome(timeoutMs = 5000) {
325
+ const hello = this.connectionHelloOperation;
326
+ if (!hello) {
327
+ return Promise.resolve({
328
+ status: "invalidated",
329
+ messageId: "",
330
+ sequence: 0,
331
+ });
332
+ }
333
+ return this.waitForOutcome(hello.messageId, timeoutMs, hello.sequence);
334
+ }
335
+ async proveFencedAuthority(timeoutMs = 5000) {
336
+ const socket = this.socket;
337
+ const generation = this.socketGeneration;
338
+ const fencingToken = this.fencingToken;
339
+ this.fencedAuthorityConfirmed = false;
340
+ if (!socket || socket.readyState !== WebSocket.OPEN || fencingToken < 1) {
341
+ throw new Error("The AI Guide control authority is not connected.");
342
+ }
343
+ const messageId = this.sendHeartbeat(0);
344
+ const sequence = this.clientSequence;
345
+ const outcome = await this.waitForOutcome(messageId, timeoutMs, sequence);
346
+ if (outcome.status === "ack" &&
347
+ this.isCurrentSocket(socket, generation) &&
348
+ this.fencingToken === fencingToken) {
349
+ this.fencedAuthorityConfirmed = true;
350
+ }
351
+ return outcome;
352
+ }
118
353
  startHeartbeat(seconds) {
119
354
  this.stopHeartbeat();
120
355
  const interval = Math.max(5, Math.min(30, seconds)) * 1000;
@@ -122,64 +357,352 @@ class ControlChannel {
122
357
  if (!this.isOpen || this.fencingToken < 1)
123
358
  return;
124
359
  try {
125
- this.send('heartbeat', { lastServerSequence: this.serverSequence });
360
+ this.sendHeartbeat(this.serverSequence);
126
361
  }
127
362
  catch {
128
- this.onDisconnect();
363
+ this.notifyUnsafeDisconnect();
129
364
  }
130
365
  }, interval);
131
366
  }
132
- close(code = 1000, reason = 'closed') {
367
+ close(code = 1000, reason = "closed") {
133
368
  this.intentionallyClosed = true;
134
369
  this.stopHeartbeat();
135
- this.socket?.close(code, reason.slice(0, 120));
370
+ const socket = this.socket;
371
+ const generation = this.socketGeneration;
136
372
  this.socket = null;
373
+ this.socketGeneration += 1;
374
+ this.handlerTail = null;
375
+ this.pendingHandlerMessages = 0;
376
+ this.rejectPendingConnection(socket, generation, new DOMException("The operation was aborted.", "AbortError"));
377
+ socket?.close(code, reason.slice(0, 120));
137
378
  this.handlers.clear();
138
- for (const resolve of this.pendingAcks.values())
139
- resolve(false);
140
- this.pendingAcks.clear();
141
- this.recentAcks.clear();
379
+ this.ingressHandlers.clear();
380
+ this.invalidatePendingOutcomes();
381
+ this.recentOutcomes.clear();
382
+ this.outboundOperations.clear();
383
+ this.latestOutboundSequence.clear();
384
+ this.helloOperation = null;
385
+ this.fencedAuthorityConfirmed = false;
386
+ this.unsettledStatefulOperations.clear();
142
387
  }
143
- async handleMessage(raw) {
144
- if (typeof raw !== 'string' || raw.length > 131072)
388
+ handleMessage(raw, socket = this.socket, generation = this.socketGeneration) {
389
+ if (this.sequenceGapDetected)
390
+ return;
391
+ if (typeof raw !== "string" || raw.length > 131072)
145
392
  return;
146
393
  let message;
147
394
  try {
148
395
  message = JSON.parse(raw);
149
396
  }
150
397
  catch {
151
- this.close(INVALID_MESSAGE_CLOSE_CODE, 'invalid message');
398
+ this.close(INVALID_MESSAGE_CLOSE_CODE, "invalid message");
152
399
  return;
153
400
  }
154
- const directProtocolError = message.type === 'error' && message.sequence === 0;
401
+ const directProtocolError = message.type === "error" && message.sequence === 0;
402
+ const orderedSequence = directProtocolError ||
403
+ (!this.serverBaselineEstablished
404
+ ? message.sequence > 0
405
+ : message.sequence === this.serverSequence + 1);
155
406
  if (message.v !== this.result.protocolVersion ||
156
407
  message.sessionId !== this.result.sessionId ||
157
408
  !Number.isSafeInteger(message.sequence) ||
158
- (!directProtocolError && message.sequence <= this.serverSequence) ||
159
- typeof message.type !== 'string' ||
409
+ (!directProtocolError && message.sequence < 1) ||
410
+ typeof message.type !== "string" ||
160
411
  !message.payload ||
161
- typeof message.payload !== 'object') {
162
- this.close(PROTOCOL_VIOLATION_CLOSE_CODE, 'protocol violation');
412
+ typeof message.payload !== "object") {
413
+ this.close(PROTOCOL_VIOLATION_CLOSE_CODE, "protocol violation");
414
+ return;
415
+ }
416
+ if (!orderedSequence) {
417
+ this.recoverFromSequenceGap(socket, generation);
163
418
  return;
164
419
  }
165
- if (!directProtocolError)
420
+ if (!directProtocolError) {
166
421
  this.serverSequence = message.sequence;
167
- if (message.type === 'ack' && typeof message.payload.messageId === 'string') {
422
+ this.serverBaselineEstablished = true;
423
+ }
424
+ if (message.type === "ack" &&
425
+ typeof message.payload.messageId === "string") {
168
426
  const messageId = message.payload.messageId;
169
- const pending = this.pendingAcks.get(messageId);
427
+ const latestSequence = this.latestOutboundSequence.get(messageId) ?? 0;
428
+ const sequence = Number.isSafeInteger(message.payload.sequence) &&
429
+ message.payload.sequence >= 1
430
+ ? message.payload.sequence
431
+ : latestSequence;
432
+ if (CLIENT_MESSAGE_ID_PATTERN.test(messageId) && sequence >= 1) {
433
+ this.settleOutcome({ status: "ack", messageId, sequence });
434
+ }
435
+ }
436
+ if (message.type === "error" &&
437
+ typeof message.payload.messageId === "string" &&
438
+ CLIENT_MESSAGE_ID_PATTERN.test(message.payload.messageId) &&
439
+ Number.isSafeInteger(message.payload.sequence) &&
440
+ message.payload.sequence >= 1) {
441
+ const messageId = message.payload.messageId;
442
+ this.settleOutcome({
443
+ status: "explicit_error",
444
+ messageId,
445
+ sequence: message.payload.sequence,
446
+ code: typeof message.payload.code === "string"
447
+ ? message.payload.code.slice(0, 64)
448
+ : "CONTROL_OPERATION_REJECTED",
449
+ message: typeof message.payload.message === "string"
450
+ ? message.payload.message.slice(0, 500)
451
+ : "The control operation was rejected.",
452
+ retryable: message.payload.retryable === true,
453
+ retryAfterMs: Number.isSafeInteger(message.payload.retryAfterMs) &&
454
+ message.payload.retryAfterMs >= 0
455
+ ? message.payload.retryAfterMs
456
+ : null,
457
+ });
458
+ }
459
+ if (!this.dispatchIngressHandlers(message, socket, generation))
460
+ return;
461
+ return this.enqueueHandlers(message, socket, generation);
462
+ }
463
+ recoverFromSequenceGap(socket, generation) {
464
+ if (this.sequenceGapDetected)
465
+ return;
466
+ this.sequenceGapDetected = true;
467
+ this.failCurrentGeneration(socket, generation, SERVER_SEQUENCE_GAP_CLOSE_CODE, "server sequence gap");
468
+ }
469
+ notifyUnsafeDisconnect() {
470
+ if (this.recoveryDisconnectNotified)
471
+ return;
472
+ this.recoveryDisconnectNotified = true;
473
+ try {
474
+ this.onUnsafeDisconnect();
475
+ }
476
+ finally {
477
+ this.onDisconnect();
478
+ }
479
+ }
480
+ enqueueHandlers(message, socket, generation) {
481
+ const isCurrent = () => this.isMessageSourceCurrent(socket, generation);
482
+ if (!isCurrent())
483
+ return Promise.resolve();
484
+ if (this.pendingHandlerMessages >= MAX_PENDING_HANDLER_MESSAGES) {
485
+ this.handleHandlerFailure(socket, generation, isCurrent);
486
+ return Promise.resolve();
487
+ }
488
+ this.pendingHandlerMessages += 1;
489
+ const handlers = [...this.handlers];
490
+ const dispatch = () => this.dispatchHandlers(message, handlers, isCurrent);
491
+ let pending;
492
+ try {
493
+ pending = this.handlerTail
494
+ ? this.handlerTail.then(dispatch, dispatch)
495
+ : dispatch();
496
+ }
497
+ catch {
498
+ if (isCurrent())
499
+ this.pendingHandlerMessages -= 1;
500
+ this.handleHandlerFailure(socket, generation, isCurrent);
501
+ return Promise.resolve();
502
+ }
503
+ if (!isCurrent()) {
170
504
  if (pending)
171
- pending(true);
172
- else {
173
- this.recentAcks.add(messageId);
174
- if (this.recentAcks.size > 100) {
175
- const oldest = this.recentAcks.values().next().value;
176
- if (typeof oldest === 'string')
177
- this.recentAcks.delete(oldest);
505
+ void Promise.resolve(pending).catch(() => undefined);
506
+ return Promise.resolve();
507
+ }
508
+ if (!pending) {
509
+ this.pendingHandlerMessages -= 1;
510
+ return Promise.resolve();
511
+ }
512
+ let tracked;
513
+ tracked = Promise.resolve(pending)
514
+ .catch(() => this.handleHandlerFailure(socket, generation, isCurrent))
515
+ .finally(() => {
516
+ if (!isCurrent())
517
+ return;
518
+ this.pendingHandlerMessages = Math.max(0, this.pendingHandlerMessages - 1);
519
+ if (this.handlerTail === tracked)
520
+ this.handlerTail = null;
521
+ });
522
+ this.handlerTail = tracked;
523
+ return tracked;
524
+ }
525
+ dispatchHandlers(message, handlers, isCurrent, startIndex = 0) {
526
+ if (!isCurrent())
527
+ return;
528
+ for (let index = startIndex; index < handlers.length; index += 1) {
529
+ const handler = handlers[index];
530
+ if (!handler || !this.handlers.has(handler))
531
+ continue;
532
+ const pending = handler(message, isCurrent);
533
+ if (pending && typeof pending.then === "function") {
534
+ return this.withHandlerDeadline(pending).then(() => this.dispatchHandlers(message, handlers, isCurrent, index + 1));
535
+ }
536
+ if (!isCurrent())
537
+ return;
538
+ }
539
+ }
540
+ handleHandlerFailure(socket, generation, isCurrent) {
541
+ if (!isCurrent())
542
+ return;
543
+ this.failCurrentGeneration(socket, generation, HANDLER_FAILURE_CLOSE_CODE, "handler failure");
544
+ }
545
+ dispatchIngressHandlers(message, socket, generation) {
546
+ const isCurrent = () => this.isMessageSourceCurrent(socket, generation);
547
+ const handlers = [...this.ingressHandlers];
548
+ try {
549
+ for (const handler of handlers) {
550
+ if (!isCurrent())
551
+ return false;
552
+ if (!this.ingressHandlers.has(handler))
553
+ continue;
554
+ const result = handler(message, isCurrent);
555
+ if (result &&
556
+ (typeof result === "object" || typeof result === "function") &&
557
+ "then" in result &&
558
+ typeof result.then === "function") {
559
+ void Promise.resolve(result).catch(() => undefined);
560
+ throw new Error("Ingress handlers must complete synchronously.");
178
561
  }
179
562
  }
180
563
  }
181
- for (const handler of this.handlers)
182
- await handler(message);
564
+ catch {
565
+ this.handleHandlerFailure(socket, generation, isCurrent);
566
+ return false;
567
+ }
568
+ return isCurrent();
569
+ }
570
+ failCurrentGeneration(socket, generation, code, reason) {
571
+ if (!this.isMessageSourceCurrent(socket, generation))
572
+ return;
573
+ this.stopHeartbeat();
574
+ this.invalidatePendingOutcomes();
575
+ this.recentOutcomes.clear();
576
+ this.outboundOperations.clear();
577
+ this.latestOutboundSequence.clear();
578
+ this.helloOperation = null;
579
+ this.fencedAuthorityConfirmed = false;
580
+ this.handlerTail = null;
581
+ this.pendingHandlerMessages = 0;
582
+ this.rejectPendingConnection(socket, generation, new Error(`The AI Guide control channel closed: ${reason}.`));
583
+ this.socket = null;
584
+ this.socketGeneration += 1;
585
+ try {
586
+ socket?.close(code, reason.slice(0, 120));
587
+ }
588
+ finally {
589
+ this.notifyUnsafeDisconnect();
590
+ }
591
+ }
592
+ isMessageSourceCurrent(socket, generation) {
593
+ return socket === null
594
+ ? this.socket === null && this.socketGeneration === generation
595
+ : this.isCurrentSocket(socket, generation);
596
+ }
597
+ isCurrentSocket(socket, generation) {
598
+ return this.socket === socket && this.socketGeneration === generation;
599
+ }
600
+ retireSocket(reason) {
601
+ const socket = this.socket;
602
+ const generation = this.socketGeneration;
603
+ if (!socket)
604
+ return;
605
+ this.stopHeartbeat();
606
+ this.socket = null;
607
+ this.socketGeneration += 1;
608
+ this.handlerTail = null;
609
+ this.pendingHandlerMessages = 0;
610
+ this.rejectPendingConnection(socket, generation, new DOMException("The operation was aborted.", "AbortError"));
611
+ this.invalidatePendingOutcomes();
612
+ this.recentOutcomes.clear();
613
+ this.outboundOperations.clear();
614
+ this.latestOutboundSequence.clear();
615
+ this.helloOperation = null;
616
+ this.fencedAuthorityConfirmed = false;
617
+ socket.close(1000, reason.slice(0, 120));
618
+ }
619
+ sendHeartbeat(lastServerSequence) {
620
+ return this.send("heartbeat", { lastServerSequence });
621
+ }
622
+ withHandlerDeadline(pending) {
623
+ return new Promise((resolve, reject) => {
624
+ const timeout = setTimeout(() => reject(new Error("AI Guide control handler timed out.")), HANDLER_TIMEOUT_MS);
625
+ pending.then(() => {
626
+ clearTimeout(timeout);
627
+ resolve();
628
+ }, (error) => {
629
+ clearTimeout(timeout);
630
+ reject(error);
631
+ });
632
+ });
633
+ }
634
+ clearPendingConnection(socket, generation) {
635
+ const pending = this.pendingConnection;
636
+ if (!pending ||
637
+ pending.socket !== socket ||
638
+ pending.generation !== generation) {
639
+ return;
640
+ }
641
+ pending.signal.removeEventListener("abort", pending.abort);
642
+ this.pendingConnection = null;
643
+ }
644
+ rejectPendingConnection(socket, generation, reason) {
645
+ const pending = this.pendingConnection;
646
+ if (!socket ||
647
+ !pending ||
648
+ pending.socket !== socket ||
649
+ pending.generation !== generation) {
650
+ return;
651
+ }
652
+ this.clearPendingConnection(socket, generation);
653
+ pending.reject(reason);
654
+ }
655
+ settleOutcome(outcome) {
656
+ const key = outcomeKey(outcome.messageId, outcome.sequence);
657
+ this.unsettledStatefulOperations.delete(key);
658
+ this.recentOutcomes.set(key, outcome);
659
+ if (this.recentOutcomes.size > 100) {
660
+ const oldest = this.recentOutcomes.keys().next().value;
661
+ if (typeof oldest === "string")
662
+ this.recentOutcomes.delete(oldest);
663
+ }
664
+ const pending = this.pendingOutcomes.get(key);
665
+ if (pending) {
666
+ this.pendingOutcomes.delete(key);
667
+ for (const value of [...pending])
668
+ value.settle(outcome);
669
+ }
670
+ // Keep the exact operation metadata through synchronous ingress dispatch.
671
+ // Operation-specific error handling runs after outcome settlement in the
672
+ // same socket event; pruning here could erase the oldest stateful identity
673
+ // after a long telemetry burst before that handler can classify it. The
674
+ // next send performs the normal bounded-history prune.
675
+ }
676
+ invalidatePendingOutcomes() {
677
+ const pending = [...this.pendingOutcomes.entries()];
678
+ this.pendingOutcomes.clear();
679
+ for (const [key, listeners] of pending) {
680
+ const separator = key.lastIndexOf("\u0000");
681
+ const messageId = key.slice(0, separator);
682
+ const sequence = Number(key.slice(separator + 1));
683
+ const outcome = {
684
+ status: "invalidated",
685
+ messageId,
686
+ sequence,
687
+ };
688
+ for (const value of [...listeners])
689
+ value.settle(outcome);
690
+ }
691
+ this.unsettledStatefulOperations.clear();
692
+ }
693
+ pruneSettledOperationHistory() {
694
+ const maximum = this.unsettledStatefulOperations.size + MAX_SETTLED_OPERATION_HISTORY;
695
+ while (this.outboundOperations.size > maximum) {
696
+ const candidate = [...this.outboundOperations.keys()].find((key) => !this.unsettledStatefulOperations.has(key));
697
+ if (!candidate)
698
+ return;
699
+ const retired = this.outboundOperations.get(candidate);
700
+ this.outboundOperations.delete(candidate);
701
+ if (retired &&
702
+ this.latestOutboundSequence.get(retired.messageId) === retired.sequence) {
703
+ this.latestOutboundSequence.delete(retired.messageId);
704
+ }
705
+ }
183
706
  }
184
707
  stopHeartbeat() {
185
708
  if (this.heartbeat)