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