@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.
- package/cjs/AiLiveAssist.d.ts +588 -6
- package/cjs/AiLiveAssist.js +14892 -1240
- package/cjs/client.d.ts +71 -10
- package/cjs/client.js +316 -58
- package/cjs/context.d.ts +15 -5
- package/cjs/context.js +568 -73
- package/cjs/control.d.ts +72 -7
- package/cjs/control.js +594 -71
- package/cjs/dialogFocusScope.d.ts +37 -0
- package/cjs/dialogFocusScope.js +227 -0
- package/cjs/experienceState.js +1 -1
- package/cjs/index.d.ts +1 -1
- package/cjs/index.js +2 -2
- package/cjs/media.d.ts +14 -3
- package/cjs/media.js +128 -19
- package/cjs/messages.js +26 -3
- package/cjs/messengerLoader.d.ts +3 -0
- package/cjs/messengerLoader.js +156 -0
- package/cjs/pageAssistancePreference.d.ts +78 -0
- package/cjs/pageAssistancePreference.js +488 -0
- package/cjs/preview.d.ts +33 -0
- package/cjs/preview.js +567 -0
- package/cjs/tabLock.d.ts +54 -3
- package/cjs/tabLock.js +752 -72
- package/cjs/types.d.ts +152 -4
- package/cjs/ui.d.ts +200 -10
- package/cjs/ui.js +6946 -523
- package/cjs/version.d.ts +1 -1
- package/cjs/version.js +1 -1
- package/cjs/voicePresenceUi.d.ts +48 -9
- package/cjs/voicePresenceUi.js +863 -125
- package/lib/AiLiveAssist.d.ts +588 -6
- package/lib/AiLiveAssist.js +14897 -1248
- package/lib/client.d.ts +71 -10
- package/lib/client.js +313 -57
- package/lib/context.d.ts +15 -5
- package/lib/context.js +566 -72
- package/lib/control.d.ts +72 -7
- package/lib/control.js +594 -71
- package/lib/dialogFocusScope.d.ts +37 -0
- package/lib/dialogFocusScope.js +223 -0
- package/lib/experienceState.js +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +2 -2
- package/lib/media.d.ts +14 -3
- package/lib/media.js +128 -19
- package/lib/messages.js +26 -3
- package/lib/messengerLoader.d.ts +3 -0
- package/lib/messengerLoader.js +151 -0
- package/lib/pageAssistancePreference.d.ts +78 -0
- package/lib/pageAssistancePreference.js +482 -0
- package/lib/preview.d.ts +33 -0
- package/lib/preview.js +561 -0
- package/lib/tabLock.d.ts +54 -3
- package/lib/tabLock.js +752 -72
- package/lib/types.d.ts +152 -4
- package/lib/ui.d.ts +200 -10
- package/lib/ui.js +6947 -524
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/voicePresenceUi.d.ts +48 -9
- package/lib/voicePresenceUi.js +863 -125
- package/package.json +8 -3
- package/cjs/recordingPolicy.d.ts +0 -2
- package/cjs/recordingPolicy.js +0 -12
- package/lib/recordingPolicy.d.ts +0 -2
- 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.
|
|
20
|
-
this.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
54
|
-
|
|
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(
|
|
57
|
-
socket.addEventListener(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
68
|
-
|
|
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(
|
|
71
|
-
|
|
173
|
+
socket.addEventListener("error", () => {
|
|
174
|
+
if (!this.isCurrentSocket(socket, generation))
|
|
175
|
+
return;
|
|
72
176
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
73
|
-
|
|
177
|
+
this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel could not connect."));
|
|
74
178
|
}
|
|
75
179
|
}, { once: true });
|
|
76
|
-
socket.addEventListener(
|
|
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
|
-
|
|
79
|
-
|
|
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(
|
|
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:
|
|
262
|
+
sequence: operation.sequence,
|
|
96
263
|
fencingToken: this.fencingToken,
|
|
97
264
|
sentAt: new Date().toISOString(),
|
|
98
265
|
payload,
|
|
99
266
|
}));
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
114
|
-
|
|
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.
|
|
360
|
+
this.sendHeartbeat(this.serverSequence);
|
|
126
361
|
}
|
|
127
362
|
catch {
|
|
128
|
-
this.
|
|
363
|
+
this.notifyUnsafeDisconnect();
|
|
129
364
|
}
|
|
130
365
|
}, interval);
|
|
131
366
|
}
|
|
132
|
-
close(code = 1000, reason =
|
|
367
|
+
close(code = 1000, reason = "closed") {
|
|
133
368
|
this.intentionallyClosed = true;
|
|
134
369
|
this.stopHeartbeat();
|
|
135
|
-
this.socket
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
this.
|
|
141
|
-
this.
|
|
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
|
-
|
|
144
|
-
if (
|
|
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,
|
|
398
|
+
this.close(INVALID_MESSAGE_CLOSE_CODE, "invalid message");
|
|
152
399
|
return;
|
|
153
400
|
}
|
|
154
|
-
const directProtocolError = message.type ===
|
|
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
|
|
159
|
-
typeof message.type !==
|
|
409
|
+
(!directProtocolError && message.sequence < 1) ||
|
|
410
|
+
typeof message.type !== "string" ||
|
|
160
411
|
!message.payload ||
|
|
161
|
-
typeof message.payload !==
|
|
162
|
-
this.close(PROTOCOL_VIOLATION_CLOSE_CODE,
|
|
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
|
-
|
|
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
|
|
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(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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)
|