@geometra/mcp 1.64.0 → 1.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -7
- package/dist/index.js +2 -5
- package/dist/proxy-spawn.d.ts +29 -0
- package/dist/proxy-spawn.js +49 -17
- package/dist/server.js +849 -219
- package/dist/session-state.js +262 -80
- package/dist/session.d.ts +93 -13
- package/dist/session.js +1365 -321
- package/dist/state-privacy.d.ts +23 -0
- package/dist/state-privacy.js +171 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +32 -0
- package/package.json +4 -4
package/dist/session.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { performance } from 'node:perf_hooks';
|
|
3
3
|
import WebSocket from 'ws';
|
|
4
4
|
import { resolveStealthMode, spawnGeometraProxy, startEmbeddedGeometraProxy, } from './proxy-spawn.js';
|
|
5
5
|
import { completeSessionLifecycle, failSessionLifecycle, heartbeatSessionLifecycle, initializeSessionLifecycle, recordSessionSnapshot, } from './session-state.js';
|
|
6
|
+
import { REDACTED_STATE_URL, sanitizeUrlToOrigin } from './state-privacy.js';
|
|
6
7
|
/**
|
|
7
8
|
* Stable identity for an outbound proxy config, used as the reusable-pool
|
|
8
9
|
* partition key. Two sessions with different proxy configs MUST NOT share a
|
|
@@ -20,12 +21,22 @@ const activeSessions = new Map();
|
|
|
20
21
|
let defaultSessionId = null;
|
|
21
22
|
const MAX_ACTIVE_SESSIONS = 5;
|
|
22
23
|
function generateSessionId() { return `s_${randomUUID()}`; }
|
|
24
|
+
class SessionCapacityError extends Error {
|
|
25
|
+
constructor(active, pending) {
|
|
26
|
+
super(`Geometra MCP already has ${active} active sessions and ${pending} pending connection(s) ` +
|
|
27
|
+
`(limit ${MAX_ACTIVE_SESSIONS}). Disconnect an explicit session before connecting another; ` +
|
|
28
|
+
'active owners are never evicted implicitly.');
|
|
29
|
+
this.name = 'SessionCapacityError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const pendingSessionOwnership = new Set();
|
|
23
33
|
let reusableProxies = [];
|
|
24
34
|
const REUSABLE_PROXY_POOL_LIMIT = 6;
|
|
25
35
|
/** Close idle reusable proxies after 5 minutes of inactivity. */
|
|
26
36
|
const REUSABLE_PROXY_IDLE_TTL_MS = 5 * 60 * 1000;
|
|
27
37
|
let idleProxyTimer = null;
|
|
28
38
|
const trackedReusableProxyChildren = new WeakSet();
|
|
39
|
+
const embeddedProxyClosePromises = new WeakMap();
|
|
29
40
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
30
41
|
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
31
42
|
const FILL_BATCH_BASE_TIMEOUT_MS = 2500;
|
|
@@ -37,13 +48,418 @@ const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
|
|
|
37
48
|
const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
|
|
38
49
|
const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
|
|
39
50
|
const SESSION_RECONNECT_TIMEOUT_MS = 5_000;
|
|
40
|
-
const
|
|
41
|
-
|
|
51
|
+
const GEOMETRY_PROTOCOL_VERSION = 1;
|
|
52
|
+
const PROXY_ACTION_PROTOCOL_VERSION = 2;
|
|
53
|
+
const MAX_AMBIGUOUS_OPERATIONS_PER_SESSION = 64;
|
|
54
|
+
const MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION = 1024 * 1024;
|
|
55
|
+
const MUTATING_PROXY_ACTION_TYPES = new Set([
|
|
56
|
+
'event',
|
|
57
|
+
'key',
|
|
58
|
+
'typeText',
|
|
59
|
+
'resize',
|
|
60
|
+
'navigate',
|
|
61
|
+
'composition',
|
|
62
|
+
'file',
|
|
63
|
+
'setFieldText',
|
|
64
|
+
'setFieldChoice',
|
|
65
|
+
'fillFields',
|
|
66
|
+
'fillOtp',
|
|
67
|
+
'listboxPick',
|
|
68
|
+
'selectOption',
|
|
69
|
+
'setChecked',
|
|
70
|
+
'wheel',
|
|
71
|
+
'pdfGenerate',
|
|
72
|
+
]);
|
|
73
|
+
class GeometraWireError extends Error {
|
|
74
|
+
code;
|
|
75
|
+
requestId;
|
|
76
|
+
actionId;
|
|
77
|
+
constructor(message, code, requestId, actionId) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = 'GeometraWireError';
|
|
80
|
+
this.code = code;
|
|
81
|
+
this.requestId = requestId;
|
|
82
|
+
this.actionId = actionId;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const SAFE_NON_EXECUTION_WIRE_CODES = new Set([
|
|
86
|
+
'ACTION_EXPIRED',
|
|
87
|
+
'REQUEST_ID_CONFLICT',
|
|
88
|
+
'REQUEST_LEDGER_CAPACITY',
|
|
89
|
+
]);
|
|
90
|
+
function wireErrorFromMessage(msg, actionId) {
|
|
91
|
+
return new GeometraWireError(typeof msg.message === 'string' ? msg.message : 'Geometra server error', typeof msg.code === 'string' ? msg.code : undefined, typeof msg.requestId === 'string' ? msg.requestId : undefined, actionId);
|
|
92
|
+
}
|
|
93
|
+
function isRecord(value) {
|
|
94
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
95
|
+
}
|
|
96
|
+
function optionalFiniteProtocolVersion(value, field) {
|
|
97
|
+
if (value === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
100
|
+
throw new Error(`Invalid Geometra server message: ${field} must be a finite number when present`);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
function validatePatchMessage(patches) {
|
|
105
|
+
if (!Array.isArray(patches)) {
|
|
106
|
+
throw new Error('Invalid Geometra server patch: patches must be an array');
|
|
107
|
+
}
|
|
108
|
+
for (const patch of patches) {
|
|
109
|
+
if (!isRecord(patch) || !Array.isArray(patch.path) || !patch.path.every(segment => typeof segment === 'number' && Number.isInteger(segment) && segment >= 0)) {
|
|
110
|
+
throw new Error('Invalid Geometra server patch: every patch needs a non-negative integer path');
|
|
111
|
+
}
|
|
112
|
+
for (const key of ['x', 'y', 'width', 'height']) {
|
|
113
|
+
const value = patch[key];
|
|
114
|
+
if (value === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || ((key === 'width' || key === 'height') && value < 0)) {
|
|
117
|
+
throw new Error(`Invalid Geometra server patch: ${key} must be a finite${key === 'width' || key === 'height' ? ' non-negative' : ''} number`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function parseInboundServerMessage(data) {
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(String(data));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
throw new Error('Invalid Geometra server message: expected valid JSON');
|
|
129
|
+
}
|
|
130
|
+
if (!isRecord(parsed) || typeof parsed.type !== 'string') {
|
|
131
|
+
throw new Error('Invalid Geometra server message: expected an object with a string type');
|
|
132
|
+
}
|
|
133
|
+
const geometryVersion = optionalFiniteProtocolVersion(parsed.geometryProtocolVersion, 'geometryProtocolVersion');
|
|
134
|
+
const proxyActionVersion = optionalFiniteProtocolVersion(parsed.proxyActionProtocolVersion, 'proxyActionProtocolVersion');
|
|
135
|
+
const legacyVersion = optionalFiniteProtocolVersion(parsed.protocolVersion, 'protocolVersion');
|
|
136
|
+
if (geometryVersion !== undefined && geometryVersion > GEOMETRY_PROTOCOL_VERSION) {
|
|
137
|
+
throw new Error(`Server geometry protocol ${geometryVersion} is newer than MCP geometry protocol ${GEOMETRY_PROTOCOL_VERSION}`);
|
|
138
|
+
}
|
|
139
|
+
if (proxyActionVersion !== undefined && proxyActionVersion > PROXY_ACTION_PROTOCOL_VERSION) {
|
|
140
|
+
throw new Error(`Server proxy-action protocol ${proxyActionVersion} is newer than MCP proxy-action protocol ${PROXY_ACTION_PROTOCOL_VERSION}`);
|
|
141
|
+
}
|
|
142
|
+
if (geometryVersion === undefined && proxyActionVersion === undefined && legacyVersion !== undefined && legacyVersion > PROXY_ACTION_PROTOCOL_VERSION) {
|
|
143
|
+
throw new Error(`Server protocol ${legacyVersion} is newer than MCP's supported geometry/proxy protocols`);
|
|
144
|
+
}
|
|
145
|
+
if (parsed.type === 'frame') {
|
|
146
|
+
if (!isRecord(parsed.layout) || !isRecord(parsed.tree)) {
|
|
147
|
+
throw new Error('Invalid Geometra server frame: layout and tree must be objects');
|
|
148
|
+
}
|
|
149
|
+
for (const key of ['x', 'y', 'width', 'height']) {
|
|
150
|
+
const value = parsed.layout[key];
|
|
151
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || ((key === 'width' || key === 'height') && value < 0)) {
|
|
152
|
+
throw new Error(`Invalid Geometra server frame: layout.${key} is invalid`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(parsed.layout.children)) {
|
|
156
|
+
throw new Error('Invalid Geometra server frame: layout.children must be an array');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else if (parsed.type === 'patch') {
|
|
160
|
+
validatePatchMessage(parsed.patches);
|
|
161
|
+
}
|
|
162
|
+
else if (parsed.type === 'error' && typeof parsed.message !== 'string') {
|
|
163
|
+
throw new Error('Invalid Geometra server error: message must be a string');
|
|
164
|
+
}
|
|
165
|
+
else if (parsed.type === 'ack' && parsed.requestId !== undefined && typeof parsed.requestId !== 'string') {
|
|
166
|
+
throw new Error('Invalid Geometra server ack: requestId must be a string when present');
|
|
167
|
+
}
|
|
168
|
+
return parsed;
|
|
169
|
+
}
|
|
170
|
+
function updatePeerProtocol(session, msg) {
|
|
171
|
+
const geometryVersion = typeof msg.geometryProtocolVersion === 'number'
|
|
172
|
+
? msg.geometryProtocolVersion
|
|
173
|
+
: undefined;
|
|
174
|
+
const proxyActionVersion = typeof msg.proxyActionProtocolVersion === 'number'
|
|
175
|
+
? msg.proxyActionProtocolVersion
|
|
176
|
+
: undefined;
|
|
177
|
+
const legacyVersion = typeof msg.protocolVersion === 'number' ? msg.protocolVersion : undefined;
|
|
178
|
+
const capabilities = isRecord(msg.protocolCapabilities) ? msg.protocolCapabilities : undefined;
|
|
179
|
+
const transport = capabilities?.transport;
|
|
180
|
+
if (geometryVersion !== undefined || proxyActionVersion !== undefined || capabilities) {
|
|
181
|
+
session.peerAdvertisedSplitProtocol = true;
|
|
182
|
+
}
|
|
183
|
+
session.peerGeometryProtocolVersion = geometryVersion ?? (legacyVersion === PROXY_ACTION_PROTOCOL_VERSION ? GEOMETRY_PROTOCOL_VERSION : legacyVersion) ?? session.peerGeometryProtocolVersion ?? GEOMETRY_PROTOCOL_VERSION;
|
|
184
|
+
session.peerProxyActionProtocolVersion = proxyActionVersion ?? (legacyVersion === PROXY_ACTION_PROTOCOL_VERSION ? legacyVersion : session.peerProxyActionProtocolVersion);
|
|
185
|
+
if (transport === 'native' || transport === 'proxy') {
|
|
186
|
+
session.peerTransport = transport;
|
|
187
|
+
}
|
|
188
|
+
else if (proxyActionVersion !== undefined || legacyVersion === PROXY_ACTION_PROTOCOL_VERSION) {
|
|
189
|
+
session.peerTransport = 'proxy';
|
|
190
|
+
}
|
|
191
|
+
if (capabilities) {
|
|
192
|
+
session.peerProtocolCapabilities = {
|
|
193
|
+
authenticatedController: typeof capabilities.authenticatedController === 'boolean' ? capabilities.authenticatedController : undefined,
|
|
194
|
+
requestScopedAcks: typeof capabilities.requestScopedAcks === 'boolean' ? capabilities.requestScopedAcks : undefined,
|
|
195
|
+
actionDeadlines: typeof capabilities.actionDeadlines === 'boolean' ? capabilities.actionDeadlines : undefined,
|
|
196
|
+
idempotentRequestIds: typeof capabilities.idempotentRequestIds === 'boolean' ? capabilities.idempotentRequestIds : undefined,
|
|
197
|
+
atomicTypeText: typeof capabilities.atomicTypeText === 'boolean' ? capabilities.atomicTypeText : undefined,
|
|
198
|
+
proxyActions: typeof capabilities.proxyActions === 'boolean' ? capabilities.proxyActions : undefined,
|
|
199
|
+
exactFieldIdentity: typeof capabilities.exactFieldIdentity === 'boolean' ? capabilities.exactFieldIdentity : undefined,
|
|
200
|
+
verifiedFileUploads: typeof capabilities.verifiedFileUploads === 'boolean' ? capabilities.verifiedFileUploads : undefined,
|
|
201
|
+
binaryFraming: typeof capabilities.binaryFraming === 'boolean' ? capabilities.binaryFraming : undefined,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function outboundProtocolMetadata(session) {
|
|
206
|
+
const geometryVersion = Math.min(session.peerGeometryProtocolVersion ?? GEOMETRY_PROTOCOL_VERSION, GEOMETRY_PROTOCOL_VERSION);
|
|
207
|
+
const proxyActionVersion = session.peerProxyActionProtocolVersion === undefined
|
|
208
|
+
? undefined
|
|
209
|
+
: Math.min(session.peerProxyActionProtocolVersion, PROXY_ACTION_PROTOCOL_VERSION);
|
|
210
|
+
if (session.peerAdvertisedSplitProtocol) {
|
|
211
|
+
return {
|
|
212
|
+
protocolVersion: proxyActionVersion ?? geometryVersion,
|
|
213
|
+
geometryProtocolVersion: geometryVersion,
|
|
214
|
+
...(proxyActionVersion !== undefined ? { proxyActionProtocolVersion: proxyActionVersion } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { protocolVersion: proxyActionVersion ?? geometryVersion };
|
|
218
|
+
}
|
|
219
|
+
function canonicalActionJson(value) {
|
|
220
|
+
if (Array.isArray(value))
|
|
221
|
+
return `[${value.map(canonicalActionJson).join(',')}]`;
|
|
222
|
+
if (value !== null && typeof value === 'object') {
|
|
223
|
+
const record = value;
|
|
224
|
+
return `{${Object.keys(record)
|
|
225
|
+
.sort()
|
|
226
|
+
.map(key => `${JSON.stringify(key)}:${canonicalActionJson(record[key])}`)
|
|
227
|
+
.join(',')}}`;
|
|
228
|
+
}
|
|
229
|
+
return JSON.stringify(value) ?? 'null';
|
|
230
|
+
}
|
|
231
|
+
function actionFingerprint(value) {
|
|
232
|
+
return createHash('sha256').update(canonicalActionJson(value)).digest('hex');
|
|
233
|
+
}
|
|
234
|
+
function isMutatingProxyAction(message) {
|
|
235
|
+
return typeof message.type === 'string' && MUTATING_PROXY_ACTION_TYPES.has(message.type);
|
|
236
|
+
}
|
|
237
|
+
function actionTimeoutFor(session, message, timeoutMs) {
|
|
238
|
+
if (session.peerTransport !== 'proxy' ||
|
|
239
|
+
session.peerProtocolCapabilities?.actionDeadlines !== true ||
|
|
240
|
+
!isMutatingProxyAction(message)) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return timeoutMs;
|
|
244
|
+
}
|
|
245
|
+
function ambiguousOperationFor(session, fingerprint) {
|
|
246
|
+
return session.ambiguousOperations?.get(fingerprint) ?? session.inFlightMutations?.get(fingerprint);
|
|
247
|
+
}
|
|
248
|
+
function assertCanStartMutatingOperation(session, fingerprint) {
|
|
249
|
+
const operations = session.ambiguousOperations;
|
|
250
|
+
const trackedCount = (operations?.size ?? 0) + (session.inFlightMutations?.size ?? 0);
|
|
251
|
+
if (operations?.has(fingerprint) || session.inFlightMutations?.has(fingerprint) || trackedCount < MAX_AMBIGUOUS_OPERATIONS_PER_SESSION)
|
|
252
|
+
return;
|
|
253
|
+
throw new Error(`Session ${session.id} has ${trackedCount} unresolved action outcomes. ` +
|
|
254
|
+
'Inspect or reconnect the session before sending another mutation; Geometra refused to risk a duplicate action.');
|
|
255
|
+
}
|
|
256
|
+
function rememberAmbiguousOperation(session, operation) {
|
|
257
|
+
if (session.inFlightMutations?.get(operation.fingerprint) === operation) {
|
|
258
|
+
session.inFlightMutations.delete(operation.fingerprint);
|
|
259
|
+
}
|
|
260
|
+
const operations = session.ambiguousOperations ?? new Map();
|
|
261
|
+
session.ambiguousOperations = operations;
|
|
262
|
+
if (!operations.has(operation.fingerprint)) {
|
|
263
|
+
const retainedWireBytes = Array.from(operations.values()).reduce((total, candidate) => total + retainedOperationBytes(candidate), 0);
|
|
264
|
+
const operationWireBytes = retainedOperationBytes(operation);
|
|
265
|
+
if (retainedWireBytes + operationWireBytes > MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION) {
|
|
266
|
+
// Retain the hash and identities so the action remains blocked, but do
|
|
267
|
+
// not retain potentially sensitive field values merely for ergonomics.
|
|
268
|
+
operation.wireMessages = [];
|
|
269
|
+
operation.idempotent = false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
operations.set(operation.fingerprint, operation);
|
|
273
|
+
}
|
|
274
|
+
const OMITTED_TERMINAL_RESULT = {
|
|
275
|
+
retained: false,
|
|
276
|
+
reason: 'Terminal action result exceeded the 1 MiB per-session ambiguity retention cap.',
|
|
277
|
+
};
|
|
278
|
+
function retainedCompletionBytes(operation) {
|
|
279
|
+
if (operation.completion?.kind !== 'result' || operation.completion.value.result === undefined)
|
|
280
|
+
return 0;
|
|
281
|
+
try {
|
|
282
|
+
return Buffer.byteLength(JSON.stringify(operation.completion.value.result));
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function retainedOperationBytes(operation) {
|
|
289
|
+
return operation.wireMessages.reduce((sum, wire) => sum + Buffer.byteLength(wire), 0) +
|
|
290
|
+
retainedCompletionBytes(operation);
|
|
291
|
+
}
|
|
292
|
+
function boundedTerminalResult(session, operation, result) {
|
|
293
|
+
if (!operation.permanentTombstone || result === undefined)
|
|
294
|
+
return result;
|
|
295
|
+
let resultBytes;
|
|
296
|
+
try {
|
|
297
|
+
resultBytes = Buffer.byteLength(JSON.stringify(result));
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return OMITTED_TERMINAL_RESULT;
|
|
301
|
+
}
|
|
302
|
+
const otherOperations = new Set([
|
|
303
|
+
...Array.from(session.ambiguousOperations?.values() ?? []),
|
|
304
|
+
...Array.from(session.inFlightMutations?.values() ?? []),
|
|
305
|
+
]);
|
|
306
|
+
otherOperations.delete(operation);
|
|
307
|
+
const retainedBytes = Array.from(otherOperations).reduce((total, candidate) => total + retainedOperationBytes(candidate), 0);
|
|
308
|
+
if (retainedBytes + resultBytes <= MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION)
|
|
309
|
+
return result;
|
|
310
|
+
const markerBytes = Buffer.byteLength(JSON.stringify(OMITTED_TERMINAL_RESULT));
|
|
311
|
+
return retainedBytes + markerBytes <= MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION
|
|
312
|
+
? OMITTED_TERMINAL_RESULT
|
|
313
|
+
: undefined;
|
|
314
|
+
}
|
|
315
|
+
function retainTerminalResult(session, operation, value) {
|
|
316
|
+
operation.wireMessages = [];
|
|
317
|
+
const result = boundedTerminalResult(session, operation, value.result);
|
|
318
|
+
operation.completion = {
|
|
319
|
+
kind: 'result',
|
|
320
|
+
value: {
|
|
321
|
+
...value,
|
|
322
|
+
...(result !== undefined ? { result } : {}),
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function retainTerminalError(operation, error) {
|
|
327
|
+
operation.wireMessages = [];
|
|
328
|
+
operation.completion = { kind: 'error', error };
|
|
329
|
+
}
|
|
330
|
+
function trackInFlightMutation(session, operation) {
|
|
331
|
+
if (!operation.mutating)
|
|
332
|
+
return;
|
|
333
|
+
const retainedWireBytes = [
|
|
334
|
+
...Array.from(session.ambiguousOperations?.values() ?? []),
|
|
335
|
+
...Array.from(session.inFlightMutations?.values() ?? []),
|
|
336
|
+
].reduce((total, candidate) => total + retainedOperationBytes(candidate), 0);
|
|
337
|
+
const operationWireBytes = retainedOperationBytes(operation);
|
|
338
|
+
if (retainedWireBytes + operationWireBytes > MAX_AMBIGUOUS_WIRE_BYTES_PER_SESSION) {
|
|
339
|
+
throw new Error('In-flight action replay data would exceed the 1 MiB per-session safety cap; Geometra refused to send the mutation.');
|
|
340
|
+
}
|
|
341
|
+
const operations = session.inFlightMutations ?? new Map();
|
|
342
|
+
session.inFlightMutations = operations;
|
|
343
|
+
operations.set(operation.fingerprint, operation);
|
|
344
|
+
}
|
|
345
|
+
function forgetAmbiguousOperation(session, operation) {
|
|
346
|
+
if (session.ambiguousOperations?.get(operation.fingerprint) === operation) {
|
|
347
|
+
session.ambiguousOperations.delete(operation.fingerprint);
|
|
348
|
+
}
|
|
349
|
+
if (session.inFlightMutations?.get(operation.fingerprint) === operation) {
|
|
350
|
+
session.inFlightMutations.delete(operation.fingerprint);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function stickyAmbiguousError(operation, error) {
|
|
354
|
+
const marker = `Outcome is ambiguous for actionId ${operation.actionId} (requestId ${operation.requestId})`;
|
|
355
|
+
if (error instanceof GeometraWireError &&
|
|
356
|
+
error.requestId === operation.requestId &&
|
|
357
|
+
error.actionId === operation.actionId &&
|
|
358
|
+
error.message.includes(marker)) {
|
|
359
|
+
return error;
|
|
360
|
+
}
|
|
361
|
+
const sourceMessage = error instanceof Error ? error.message : String(error);
|
|
362
|
+
return new GeometraWireError(`${sourceMessage} ${marker}; do not retry blindly.`, error instanceof GeometraWireError
|
|
363
|
+
? error.code ?? 'ACTION_OUTCOME_AMBIGUOUS'
|
|
364
|
+
: 'ACTION_OUTCOME_AMBIGUOUS', operation.requestId, operation.actionId);
|
|
365
|
+
}
|
|
366
|
+
function cacheLateAmbiguousOutcome(session, msg) {
|
|
367
|
+
const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
368
|
+
if (!requestId)
|
|
369
|
+
return;
|
|
370
|
+
const tracked = [
|
|
371
|
+
...Array.from(session.ambiguousOperations?.values() ?? []),
|
|
372
|
+
...Array.from(session.inFlightMutations?.values() ?? []),
|
|
373
|
+
];
|
|
374
|
+
const operation = tracked.find(candidate => candidate.requestIds.includes(requestId));
|
|
375
|
+
if (!operation || operation.completion)
|
|
376
|
+
return;
|
|
377
|
+
if (msg.type === 'error') {
|
|
378
|
+
if (msg.code === 'DUPLICATE_REQUEST')
|
|
379
|
+
return;
|
|
380
|
+
const error = wireErrorFromMessage(msg, operation.actionId);
|
|
381
|
+
if (typeof msg.code === 'string' &&
|
|
382
|
+
SAFE_NON_EXECUTION_WIRE_CODES.has(msg.code) &&
|
|
383
|
+
operation.requestIds.length === 1) {
|
|
384
|
+
retainTerminalError(operation, error);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
// A logical action may have many phases. Once any phase fails, a later
|
|
388
|
+
// final-phase ACK cannot prove that the whole action completed.
|
|
389
|
+
operation.wireMessages = [];
|
|
390
|
+
operation.stickyError = stickyAmbiguousError(operation, error);
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (msg.type === 'ack') {
|
|
395
|
+
if (requestId !== operation.requestId)
|
|
396
|
+
return;
|
|
397
|
+
if (operation.stickyError)
|
|
398
|
+
return;
|
|
399
|
+
// Navigation and similar operations require a fresh post-action frame.
|
|
400
|
+
// A late ACK cannot establish that evidence on its own.
|
|
401
|
+
if (operation.requireUpdateOnAck)
|
|
402
|
+
return;
|
|
403
|
+
const peerProtocolVersion = typeof msg.proxyActionProtocolVersion === 'number'
|
|
404
|
+
? msg.proxyActionProtocolVersion
|
|
405
|
+
: typeof msg.protocolVersion === 'number'
|
|
406
|
+
? msg.protocolVersion
|
|
407
|
+
: session.peerProxyActionProtocolVersion;
|
|
408
|
+
if (operation.requiredProtocolVersion !== undefined && (peerProtocolVersion === undefined || peerProtocolVersion < operation.requiredProtocolVersion)) {
|
|
409
|
+
operation.wireMessages = [];
|
|
410
|
+
operation.stickyError = stickyAmbiguousError(operation, new Error(`Proxy protocol ${peerProtocolVersion ?? 'unknown'} cannot guarantee exact field identity; ` +
|
|
411
|
+
`protocol ${operation.requiredProtocolVersion}+ is required. Update and reconnect the Geometra proxy.`));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
retainTerminalResult(session, operation, {
|
|
415
|
+
status: 'acknowledged',
|
|
416
|
+
timeoutMs: operation.timeoutMs,
|
|
417
|
+
requestId: operation.requestId,
|
|
418
|
+
actionId: operation.actionId,
|
|
419
|
+
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
42
424
|
function invalidateSessionCaches(session) {
|
|
43
425
|
session.cachedA11y = null;
|
|
44
426
|
session.cachedA11yRevision = -1;
|
|
45
427
|
session.cachedFormSchemas?.clear();
|
|
46
428
|
}
|
|
429
|
+
function invalidateSessionUiState(session) {
|
|
430
|
+
session.hasFreshFrame = false;
|
|
431
|
+
session.layout = null;
|
|
432
|
+
session.tree = null;
|
|
433
|
+
invalidateSessionCaches(session);
|
|
434
|
+
}
|
|
435
|
+
function applyInboundSessionMessage(session, data) {
|
|
436
|
+
const msg = parseInboundServerMessage(data);
|
|
437
|
+
updatePeerProtocol(session, msg);
|
|
438
|
+
if (msg.type === 'frame') {
|
|
439
|
+
session.layout = msg.layout;
|
|
440
|
+
session.tree = msg.tree;
|
|
441
|
+
session.hasFreshFrame = true;
|
|
442
|
+
session.updateRevision++;
|
|
443
|
+
invalidateSessionCaches(session);
|
|
444
|
+
}
|
|
445
|
+
else if (msg.type === 'patch' && session.hasFreshFrame === true && session.layout) {
|
|
446
|
+
applyPatches(session.layout, msg.patches);
|
|
447
|
+
session.updateRevision++;
|
|
448
|
+
invalidateSessionCaches(session);
|
|
449
|
+
}
|
|
450
|
+
cacheLateAmbiguousOutcome(session, msg);
|
|
451
|
+
return msg;
|
|
452
|
+
}
|
|
453
|
+
function assertAuthenticatedProxyHandshake(session) {
|
|
454
|
+
const expectedAuthenticatedProxy = session.transportAuthToken !== undefined;
|
|
455
|
+
const advertisedProxy = session.peerTransport === 'proxy';
|
|
456
|
+
if (!expectedAuthenticatedProxy && !advertisedProxy)
|
|
457
|
+
return;
|
|
458
|
+
if (session.peerTransport !== 'proxy' ||
|
|
459
|
+
session.peerProtocolCapabilities?.authenticatedController !== true) {
|
|
460
|
+
throw new Error('Geometra proxy did not attest authenticated controller support. Rebuild or update @geometra/proxy; unauthenticated proxy transports are refused.');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
47
463
|
function sameReusableProxyEntry(entry, proxy) {
|
|
48
464
|
return ('child' in proxy && !!entry.child && entry.child === proxy.child)
|
|
49
465
|
|| ('runtime' in proxy && !!entry.runtime && entry.runtime === proxy.runtime);
|
|
@@ -62,6 +478,8 @@ function reusableProxyEntryIsActive(entry) {
|
|
|
62
478
|
}
|
|
63
479
|
function clearReusableProxiesIfExited() {
|
|
64
480
|
reusableProxies = reusableProxies.filter(entry => {
|
|
481
|
+
if (entry.closed)
|
|
482
|
+
return false;
|
|
65
483
|
if (entry.child) {
|
|
66
484
|
return !entry.child.killed && entry.child.exitCode === null && entry.child.signalCode === null;
|
|
67
485
|
}
|
|
@@ -76,7 +494,27 @@ function updateReusableProxySnapshotState(entry, session) {
|
|
|
76
494
|
entry.snapshotReady = true;
|
|
77
495
|
}
|
|
78
496
|
}
|
|
497
|
+
function closeEmbeddedProxyRuntime(runtime) {
|
|
498
|
+
const existing = embeddedProxyClosePromises.get(runtime);
|
|
499
|
+
if (existing)
|
|
500
|
+
return existing;
|
|
501
|
+
let settle;
|
|
502
|
+
const closing = new Promise(resolve => { settle = resolve; });
|
|
503
|
+
// Publish the promise before invoking user/runtime code so even a
|
|
504
|
+
// re-entrant cleanup observes the same close operation.
|
|
505
|
+
embeddedProxyClosePromises.set(runtime, closing);
|
|
506
|
+
try {
|
|
507
|
+
void runtime.close().then(settle, settle);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
settle();
|
|
511
|
+
}
|
|
512
|
+
return closing;
|
|
513
|
+
}
|
|
79
514
|
function closeReusableProxy(entry) {
|
|
515
|
+
if (entry.closed)
|
|
516
|
+
return;
|
|
517
|
+
entry.closed = true;
|
|
80
518
|
reusableProxies = reusableProxies.filter(candidate => candidate !== entry);
|
|
81
519
|
if (entry.child) {
|
|
82
520
|
try {
|
|
@@ -88,24 +526,15 @@ function closeReusableProxy(entry) {
|
|
|
88
526
|
ensureIdleProxyTimer();
|
|
89
527
|
return;
|
|
90
528
|
}
|
|
91
|
-
|
|
529
|
+
if (entry.runtime)
|
|
530
|
+
void closeEmbeddedProxyRuntime(entry.runtime);
|
|
92
531
|
ensureIdleProxyTimer();
|
|
93
532
|
}
|
|
94
533
|
function closeReusableProxies() {
|
|
95
534
|
clearReusableProxiesIfExited();
|
|
96
535
|
const proxies = [...reusableProxies];
|
|
97
|
-
reusableProxies = [];
|
|
98
536
|
for (const entry of proxies) {
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
entry.child.kill('SIGTERM');
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
/* ignore */
|
|
105
|
-
}
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
void entry.runtime?.close().catch(() => { });
|
|
537
|
+
closeReusableProxy(entry);
|
|
109
538
|
}
|
|
110
539
|
}
|
|
111
540
|
function evictIdleReusableProxies() {
|
|
@@ -143,11 +572,13 @@ function enforceReusableProxyPoolLimit() {
|
|
|
143
572
|
function setReusableProxy(proxy, wsUrl, opts) {
|
|
144
573
|
clearReusableProxiesIfExited();
|
|
145
574
|
const now = Date.now();
|
|
575
|
+
const authToken = 'child' in proxy ? proxy.authToken : proxy.runtime.authToken;
|
|
146
576
|
const proxyKey = proxyKeyFor(opts.proxy);
|
|
147
577
|
const stealth = resolveStealthMode(opts.stealth);
|
|
148
578
|
const existing = reusableProxies.find(entry => sameReusableProxyEntry(entry, proxy));
|
|
149
579
|
if (existing) {
|
|
150
580
|
existing.wsUrl = wsUrl;
|
|
581
|
+
existing.authToken = authToken;
|
|
151
582
|
existing.headless = opts.headless !== false;
|
|
152
583
|
existing.stealth = stealth;
|
|
153
584
|
existing.slowMo = opts.slowMo ?? 0;
|
|
@@ -164,6 +595,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
164
595
|
const entry = {
|
|
165
596
|
child,
|
|
166
597
|
wsUrl,
|
|
598
|
+
authToken,
|
|
167
599
|
headless: opts.headless !== false,
|
|
168
600
|
stealth,
|
|
169
601
|
slowMo: opts.slowMo ?? 0,
|
|
@@ -191,6 +623,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
191
623
|
reusableProxies.push({
|
|
192
624
|
runtime: proxy.runtime,
|
|
193
625
|
wsUrl,
|
|
626
|
+
authToken,
|
|
194
627
|
headless: opts.headless !== false,
|
|
195
628
|
stealth,
|
|
196
629
|
slowMo: opts.slowMo ?? 0,
|
|
@@ -236,23 +669,15 @@ function promoteDefaultSession() {
|
|
|
236
669
|
}
|
|
237
670
|
defaultSessionId = null;
|
|
238
671
|
}
|
|
239
|
-
/**
|
|
240
|
-
* Clear `defaultSessionId` if it currently points at the given session id.
|
|
241
|
-
* Used after tagging a fresh session as isolated — `connect()` set the
|
|
242
|
-
* default pointer before the isolation flag was applied, and we need to
|
|
243
|
-
* retract that assignment so this session is only addressable by its
|
|
244
|
-
* explicit id.
|
|
245
|
-
*/
|
|
246
|
-
function retractDefaultIfPointsTo(sessionId) {
|
|
247
|
-
if (defaultSessionId === sessionId) {
|
|
248
|
-
promoteDefaultSession();
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
672
|
function shutdownSession(id, opts) {
|
|
252
673
|
const prev = activeSessions.get(id);
|
|
253
674
|
if (!prev)
|
|
254
675
|
return;
|
|
255
|
-
const
|
|
676
|
+
const hasUnsettledMutation = Boolean(prev.inFlightMutations?.size || prev.ambiguousOperations?.size);
|
|
677
|
+
prev.disposed = true;
|
|
678
|
+
prev.ambiguousOperations?.clear();
|
|
679
|
+
prev.inFlightMutations?.clear();
|
|
680
|
+
const forceCloseProxy = prev.isolated === true || hasUnsettledMutation;
|
|
256
681
|
safeCompleteSessionLifecycle(prev, opts?.reason ?? 'disconnect', {
|
|
257
682
|
closeProxy: opts?.closeProxy ?? false,
|
|
258
683
|
forceCloseProxy,
|
|
@@ -261,7 +686,8 @@ function shutdownSession(id, opts) {
|
|
|
261
686
|
if (defaultSessionId === id)
|
|
262
687
|
promoteDefaultSession();
|
|
263
688
|
stopSessionHeartbeat(prev);
|
|
264
|
-
releaseSessionResources(prev, { closeProxy: opts?.closeProxy ?? false });
|
|
689
|
+
releaseSessionResources(prev, { closeProxy: (opts?.closeProxy ?? false) || hasUnsettledMutation });
|
|
690
|
+
invalidateSessionUiState(prev);
|
|
265
691
|
}
|
|
266
692
|
function releaseSessionResources(session, opts) {
|
|
267
693
|
try {
|
|
@@ -312,19 +738,65 @@ function releaseSessionResources(session, opts) {
|
|
|
312
738
|
closeReusableProxy(entry);
|
|
313
739
|
return;
|
|
314
740
|
}
|
|
315
|
-
void session.proxyRuntime
|
|
741
|
+
void closeEmbeddedProxyRuntime(session.proxyRuntime);
|
|
316
742
|
}
|
|
317
743
|
}
|
|
318
|
-
/**
|
|
319
|
-
|
|
320
|
-
|
|
744
|
+
/**
|
|
745
|
+
* Reserve one ownership slot before any asynchronous transport or browser
|
|
746
|
+
* startup. Passing the same lease is idempotent, which lets an embedded
|
|
747
|
+
* connection hand its reservation through to `connect()` and safely
|
|
748
|
+
* reacquire it before a child-process fallback.
|
|
749
|
+
*/
|
|
750
|
+
function reserveSessionOwnership(existing) {
|
|
751
|
+
if (existing?.reserved && pendingSessionOwnership.has(existing))
|
|
752
|
+
return existing;
|
|
753
|
+
pruneDisconnectedSessions();
|
|
754
|
+
if (activeSessions.size + pendingSessionOwnership.size >= MAX_ACTIVE_SESSIONS) {
|
|
755
|
+
throw new SessionCapacityError(activeSessions.size, pendingSessionOwnership.size);
|
|
756
|
+
}
|
|
757
|
+
const lease = existing ?? { id: randomUUID(), reserved: false, connectAttemptActive: false };
|
|
758
|
+
lease.reserved = true;
|
|
759
|
+
pendingSessionOwnership.add(lease);
|
|
760
|
+
return lease;
|
|
761
|
+
}
|
|
762
|
+
function beginSessionOwnershipAttempt(existing) {
|
|
763
|
+
const lease = reserveSessionOwnership(existing);
|
|
764
|
+
if (lease.connectAttemptActive) {
|
|
765
|
+
throw new Error(`Session ownership lease ${lease.id} is already assigned to a pending connection attempt`);
|
|
766
|
+
}
|
|
767
|
+
lease.connectAttemptActive = true;
|
|
768
|
+
return lease;
|
|
769
|
+
}
|
|
770
|
+
function releaseSessionOwnership(lease) {
|
|
771
|
+
if (!lease.reserved)
|
|
321
772
|
return;
|
|
322
|
-
|
|
323
|
-
|
|
773
|
+
pendingSessionOwnership.delete(lease);
|
|
774
|
+
lease.reserved = false;
|
|
775
|
+
lease.connectAttemptActive = false;
|
|
776
|
+
}
|
|
777
|
+
function claimSessionOwnership(lease, session) {
|
|
778
|
+
if (!lease.reserved || !pendingSessionOwnership.has(lease)) {
|
|
779
|
+
throw new Error(`Session ownership lease ${lease.id} was not reserved when connection completed`);
|
|
780
|
+
}
|
|
781
|
+
// The transition is synchronous: no other connect can observe a moment
|
|
782
|
+
// where both the pending reservation and active owner are absent.
|
|
783
|
+
pendingSessionOwnership.delete(lease);
|
|
784
|
+
lease.reserved = false;
|
|
785
|
+
lease.connectAttemptActive = false;
|
|
786
|
+
activeSessions.set(session.id, session);
|
|
324
787
|
}
|
|
325
788
|
function formatUnknownError(err) {
|
|
326
789
|
return err instanceof Error ? err.message : String(err);
|
|
327
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* Durable lifecycle records only need enough URL context to identify the
|
|
793
|
+
* remote origin. Paths, query strings, fragments, and credentials can carry
|
|
794
|
+
* application data or bearer capabilities, so call sites must not forward
|
|
795
|
+
* them to the persistence layer.
|
|
796
|
+
*/
|
|
797
|
+
function lifecycleUrlOrigin(rawUrl) {
|
|
798
|
+
return sanitizeUrlToOrigin(rawUrl) ?? REDACTED_STATE_URL;
|
|
799
|
+
}
|
|
328
800
|
function rejectOnRuntimeReadyFailure(runtime) {
|
|
329
801
|
return new Promise((_, reject) => {
|
|
330
802
|
runtime.ready.catch(reject);
|
|
@@ -424,11 +896,8 @@ function reusableProxyMatchesOptions(entry, options) {
|
|
|
424
896
|
function findExactReusableProxy(options) {
|
|
425
897
|
clearReusableProxiesIfExited();
|
|
426
898
|
return reusableProxies
|
|
427
|
-
.filter(entry => reusableProxyMatchesOptions(entry, options))
|
|
428
|
-
.sort((a, b) =>
|
|
429
|
-
const activeBonus = reusableProxyEntryIsActive(b) ? 1 : reusableProxyEntryIsActive(a) ? -1 : 0;
|
|
430
|
-
return activeBonus || b.lastUsedAt - a.lastUsedAt;
|
|
431
|
-
})[0];
|
|
899
|
+
.filter(entry => !reusableProxyEntryIsActive(entry) && reusableProxyMatchesOptions(entry, options))
|
|
900
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0];
|
|
432
901
|
}
|
|
433
902
|
function findReusableProxy(options) {
|
|
434
903
|
clearReusableProxiesIfExited();
|
|
@@ -439,7 +908,8 @@ function findReusableProxy(options) {
|
|
|
439
908
|
const desiredHeight = options.height ?? 720;
|
|
440
909
|
const desiredProxyKey = proxyKeyFor(options.proxy);
|
|
441
910
|
return reusableProxies
|
|
442
|
-
.filter(entry => entry
|
|
911
|
+
.filter(entry => !reusableProxyEntryIsActive(entry)
|
|
912
|
+
&& entry.headless === desiredHeadless
|
|
443
913
|
&& entry.stealth === desiredStealth
|
|
444
914
|
&& entry.slowMo === desiredSlowMo
|
|
445
915
|
// Proxy partition is hard: a session with a caller-provided proxy MUST
|
|
@@ -453,8 +923,6 @@ function findReusableProxy(options) {
|
|
|
453
923
|
value += 100;
|
|
454
924
|
if (entry.width === desiredWidth && entry.height === desiredHeight)
|
|
455
925
|
value += 10;
|
|
456
|
-
if (reusableProxyEntryIsActive(entry))
|
|
457
|
-
value += 5;
|
|
458
926
|
return value;
|
|
459
927
|
};
|
|
460
928
|
return score(b) - score(a) || b.lastUsedAt - a.lastUsedAt;
|
|
@@ -493,7 +961,7 @@ export async function prewarmProxy(options) {
|
|
|
493
961
|
await runtime.ready;
|
|
494
962
|
}
|
|
495
963
|
catch (err) {
|
|
496
|
-
await runtime
|
|
964
|
+
await closeEmbeddedProxyRuntime(runtime);
|
|
497
965
|
throw err;
|
|
498
966
|
}
|
|
499
967
|
setReusableProxy({ runtime }, wsUrl, {
|
|
@@ -522,7 +990,7 @@ export async function prewarmProxy(options) {
|
|
|
522
990
|
embeddedFailure = err;
|
|
523
991
|
}
|
|
524
992
|
try {
|
|
525
|
-
const { child, wsUrl } = await spawnGeometraProxy({
|
|
993
|
+
const { child, wsUrl, authToken } = await spawnGeometraProxy({
|
|
526
994
|
pageUrl: options.pageUrl,
|
|
527
995
|
port: options.port ?? 0,
|
|
528
996
|
headless: options.headless,
|
|
@@ -532,7 +1000,7 @@ export async function prewarmProxy(options) {
|
|
|
532
1000
|
stealth: options.stealth,
|
|
533
1001
|
proxy: options.proxy,
|
|
534
1002
|
});
|
|
535
|
-
setReusableProxy({ child }, wsUrl, {
|
|
1003
|
+
setReusableProxy({ child, authToken }, wsUrl, {
|
|
536
1004
|
headless: options.headless,
|
|
537
1005
|
slowMo: options.slowMo,
|
|
538
1006
|
stealth: options.stealth,
|
|
@@ -562,17 +1030,15 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
562
1030
|
const desiredWidth = options.width ?? proxy.width;
|
|
563
1031
|
const desiredHeight = options.height ?? proxy.height;
|
|
564
1032
|
const needsSnapshotKickoff = options.awaitInitialFrame !== false && !proxy.snapshotReady;
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if ((proxy.child && s.proxyChild === proxy.child) || (proxy.runtime && s.proxyRuntime === proxy.runtime)) {
|
|
568
|
-
reusedExistingSession = s;
|
|
569
|
-
break;
|
|
570
|
-
}
|
|
1033
|
+
if (reusableProxyEntryIsActive(proxy)) {
|
|
1034
|
+
throw new Error('Reusable proxy already has an active session owner');
|
|
571
1035
|
}
|
|
572
|
-
const session =
|
|
1036
|
+
const session = await connect(proxy.wsUrl, {
|
|
573
1037
|
skipInitialResize: true,
|
|
574
1038
|
closePreviousProxy: false,
|
|
575
1039
|
awaitInitialFrame: needsSnapshotKickoff ? false : options.awaitInitialFrame,
|
|
1040
|
+
authToken: proxy.authToken,
|
|
1041
|
+
isolated: false,
|
|
576
1042
|
});
|
|
577
1043
|
if (!session) {
|
|
578
1044
|
throw new Error('Failed to attach to reusable proxy session');
|
|
@@ -581,166 +1047,103 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
581
1047
|
session.proxyRuntime = proxy.runtime;
|
|
582
1048
|
session.proxyReusable = true;
|
|
583
1049
|
touchReusableProxy(proxy);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
1050
|
+
try {
|
|
1051
|
+
let resizeKickoffMs;
|
|
1052
|
+
if (needsSnapshotKickoff || desiredWidth !== proxy.width || desiredHeight !== proxy.height) {
|
|
1053
|
+
const resizeStartedAt = performance.now();
|
|
1054
|
+
const resizeWait = await sendResizeAndWaitForUpdate(session, desiredWidth, desiredHeight, 5_000);
|
|
1055
|
+
resizeKickoffMs = performance.now() - resizeStartedAt;
|
|
1056
|
+
if (needsSnapshotKickoff && resizeWait.status === 'timed_out' && (!session.tree || !session.layout)) {
|
|
1057
|
+
throw new Error('Timed out waiting for initial proxy snapshot after resize kickoff');
|
|
1058
|
+
}
|
|
1059
|
+
proxy.width = desiredWidth;
|
|
1060
|
+
proxy.height = desiredHeight;
|
|
1061
|
+
updateReusableProxySnapshotState(proxy, session);
|
|
1062
|
+
}
|
|
1063
|
+
const currentUrl = session.cachedA11y?.meta?.pageUrl ?? proxy.pageUrl;
|
|
1064
|
+
let navigateMs;
|
|
1065
|
+
if (currentUrl !== options.pageUrl) {
|
|
1066
|
+
const navigateStartedAt = performance.now();
|
|
1067
|
+
await sendNavigate(session, options.pageUrl, 15_000);
|
|
1068
|
+
navigateMs = performance.now() - navigateStartedAt;
|
|
1069
|
+
proxy.pageUrl = options.pageUrl;
|
|
1070
|
+
updateReusableProxySnapshotState(proxy, session);
|
|
1071
|
+
}
|
|
1072
|
+
const baseConnectTrace = session.connectTrace;
|
|
1073
|
+
session.connectTrace = {
|
|
1074
|
+
mode: 'reused-proxy',
|
|
1075
|
+
reused: true,
|
|
1076
|
+
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
1077
|
+
connectMs: baseConnectTrace?.totalMs ?? 0,
|
|
1078
|
+
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
1079
|
+
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
1080
|
+
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
1081
|
+
snapshotKickoff: needsSnapshotKickoff,
|
|
1082
|
+
resizeKickoffMs,
|
|
1083
|
+
navigateMs,
|
|
1084
|
+
totalMs: performance.now() - startedAt,
|
|
1085
|
+
};
|
|
594
1086
|
updateReusableProxySnapshotState(proxy, session);
|
|
1087
|
+
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
1088
|
+
transportMode: 'reused-proxy',
|
|
1089
|
+
targetPageOrigin: lifecycleUrlOrigin(options.pageUrl),
|
|
1090
|
+
reusedExistingSession: false,
|
|
1091
|
+
});
|
|
1092
|
+
return session;
|
|
595
1093
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
proxy.pageUrl = options.pageUrl;
|
|
603
|
-
updateReusableProxySnapshotState(proxy, session);
|
|
1094
|
+
catch (err) {
|
|
1095
|
+
// The provisional WebSocket session must not remain an active owner when
|
|
1096
|
+
// resize/navigation fails. Release only the session here; the caller owns
|
|
1097
|
+
// eviction of the stale warm proxy entry.
|
|
1098
|
+
shutdownSession(session.id, { closeProxy: false, reason: 'proxy_attach_failed' });
|
|
1099
|
+
throw err;
|
|
604
1100
|
}
|
|
605
|
-
const baseConnectTrace = !reusedExistingSession ? session.connectTrace : undefined;
|
|
606
|
-
session.connectTrace = {
|
|
607
|
-
mode: 'reused-proxy',
|
|
608
|
-
reused: true,
|
|
609
|
-
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
610
|
-
connectMs: baseConnectTrace?.totalMs ?? 0,
|
|
611
|
-
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
612
|
-
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
613
|
-
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
614
|
-
snapshotKickoff: needsSnapshotKickoff,
|
|
615
|
-
resizeKickoffMs,
|
|
616
|
-
navigateMs,
|
|
617
|
-
totalMs: performance.now() - startedAt,
|
|
618
|
-
};
|
|
619
|
-
updateReusableProxySnapshotState(proxy, session);
|
|
620
|
-
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
621
|
-
transportMode: 'reused-proxy',
|
|
622
|
-
targetPageUrl: options.pageUrl,
|
|
623
|
-
reusedExistingSession: reusedExistingSession !== null,
|
|
624
|
-
});
|
|
625
|
-
return session;
|
|
626
1101
|
}
|
|
627
|
-
async function startFreshProxySession(options) {
|
|
1102
|
+
async function startFreshProxySession(options, initialOwnershipLease) {
|
|
628
1103
|
const startedAt = performance.now();
|
|
629
1104
|
const eagerInitialExtract = options.eagerInitialExtract !== undefined
|
|
630
1105
|
? options.eagerInitialExtract
|
|
631
1106
|
: options.awaitInitialFrame !== false
|
|
632
1107
|
? undefined
|
|
633
1108
|
: false;
|
|
1109
|
+
let ownershipLease = reserveSessionOwnership(initialOwnershipLease);
|
|
634
1110
|
let pendingEmbeddedRuntime;
|
|
1111
|
+
let pendingEmbeddedConnect;
|
|
1112
|
+
let attachedEmbeddedSession;
|
|
635
1113
|
try {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
width: options.width,
|
|
642
|
-
height: options.height,
|
|
643
|
-
slowMo: options.slowMo,
|
|
644
|
-
stealth: options.stealth,
|
|
645
|
-
eagerInitialExtract,
|
|
646
|
-
proxy: options.proxy,
|
|
647
|
-
});
|
|
648
|
-
pendingEmbeddedRuntime = runtime;
|
|
649
|
-
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
650
|
-
const session = await Promise.race([
|
|
651
|
-
connect(wsUrl, {
|
|
652
|
-
skipInitialResize: true,
|
|
653
|
-
closePreviousProxy: false,
|
|
654
|
-
awaitInitialFrame: options.awaitInitialFrame,
|
|
655
|
-
}),
|
|
656
|
-
rejectOnRuntimeReadyFailure(runtime),
|
|
657
|
-
]);
|
|
658
|
-
// Connect succeeded — the session now owns the runtime, so the
|
|
659
|
-
// catch-block cleanup below must not also close it.
|
|
660
|
-
pendingEmbeddedRuntime = undefined;
|
|
661
|
-
session.proxyRuntime = runtime;
|
|
662
|
-
session.proxyReusable = !options.isolated;
|
|
663
|
-
if (options.isolated) {
|
|
664
|
-
session.isolated = true;
|
|
665
|
-
// connect() already set defaultSessionId to this session. Retract
|
|
666
|
-
// that assignment so the isolated session is only addressable by its
|
|
667
|
-
// explicit id — the implicit-default fallback is the contamination
|
|
668
|
-
// vector we're fixing, and an isolated session must never be the
|
|
669
|
-
// implicit target of a tool call that omits sessionId.
|
|
670
|
-
retractDefaultIfPointsTo(session.id);
|
|
671
|
-
}
|
|
672
|
-
else {
|
|
673
|
-
setReusableProxy({ runtime }, wsUrl, {
|
|
1114
|
+
try {
|
|
1115
|
+
const proxyStartStartedAt = performance.now();
|
|
1116
|
+
const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
|
|
1117
|
+
pageUrl: options.pageUrl,
|
|
1118
|
+
port: options.port ?? 0,
|
|
674
1119
|
headless: options.headless,
|
|
675
|
-
slowMo: options.slowMo,
|
|
676
|
-
stealth: options.stealth,
|
|
677
1120
|
width: options.width,
|
|
678
1121
|
height: options.height,
|
|
679
|
-
|
|
680
|
-
|
|
1122
|
+
slowMo: options.slowMo,
|
|
1123
|
+
stealth: options.stealth,
|
|
1124
|
+
eagerInitialExtract,
|
|
681
1125
|
proxy: options.proxy,
|
|
682
1126
|
});
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
mode: 'fresh-proxy',
|
|
687
|
-
reused: false,
|
|
688
|
-
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
689
|
-
proxyStartMode: 'embedded',
|
|
690
|
-
proxyStartMs,
|
|
691
|
-
connectMs: baseConnectTrace?.totalMs,
|
|
692
|
-
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
693
|
-
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
694
|
-
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
695
|
-
totalMs: performance.now() - startedAt,
|
|
696
|
-
};
|
|
697
|
-
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
698
|
-
transportMode: 'fresh-proxy',
|
|
699
|
-
proxyStartMode: 'embedded',
|
|
700
|
-
requestedPageUrl: options.pageUrl,
|
|
701
|
-
isolated: options.isolated === true,
|
|
702
|
-
});
|
|
703
|
-
return session;
|
|
704
|
-
}
|
|
705
|
-
catch (e) {
|
|
706
|
-
// If startEmbeddedGeometraProxy() returned but the subsequent connect()
|
|
707
|
-
// threw (e.g. WebSocket failure, first-frame timeout), the runtime
|
|
708
|
-
// still owns a launched Chromium that nobody is going to clean up.
|
|
709
|
-
// Without this, that Chromium leaks for the lifetime of the MCP server
|
|
710
|
-
// every time we fall through to the child-process fallback path.
|
|
711
|
-
if (pendingEmbeddedRuntime) {
|
|
712
|
-
const leaked = pendingEmbeddedRuntime;
|
|
713
|
-
void leaked.close().catch(() => { });
|
|
714
|
-
}
|
|
715
|
-
const proxyStartStartedAt = performance.now();
|
|
716
|
-
const { child, wsUrl } = await spawnGeometraProxy({
|
|
717
|
-
pageUrl: options.pageUrl,
|
|
718
|
-
port: options.port ?? 0,
|
|
719
|
-
headless: options.headless,
|
|
720
|
-
width: options.width,
|
|
721
|
-
height: options.height,
|
|
722
|
-
slowMo: options.slowMo,
|
|
723
|
-
stealth: options.stealth,
|
|
724
|
-
eagerInitialExtract,
|
|
725
|
-
proxy: options.proxy,
|
|
726
|
-
});
|
|
727
|
-
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
728
|
-
try {
|
|
729
|
-
const session = await connect(wsUrl, {
|
|
1127
|
+
pendingEmbeddedRuntime = runtime;
|
|
1128
|
+
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
1129
|
+
pendingEmbeddedConnect = connectWithOwnership(wsUrl, {
|
|
730
1130
|
skipInitialResize: true,
|
|
731
1131
|
closePreviousProxy: false,
|
|
732
1132
|
awaitInitialFrame: options.awaitInitialFrame,
|
|
1133
|
+
authToken: runtime.authToken,
|
|
1134
|
+
isolated: options.isolated === true,
|
|
1135
|
+
ownershipLease,
|
|
733
1136
|
});
|
|
734
|
-
session
|
|
1137
|
+
const session = await Promise.race([
|
|
1138
|
+
pendingEmbeddedConnect,
|
|
1139
|
+
rejectOnRuntimeReadyFailure(runtime),
|
|
1140
|
+
]);
|
|
1141
|
+
pendingEmbeddedConnect = undefined;
|
|
1142
|
+
attachedEmbeddedSession = session;
|
|
1143
|
+
session.proxyRuntime = runtime;
|
|
735
1144
|
session.proxyReusable = !options.isolated;
|
|
736
|
-
if (options.isolated) {
|
|
737
|
-
|
|
738
|
-
// See the embedded-runtime path above — an isolated session must
|
|
739
|
-
// not be the implicit default. Retract the pointer set by connect().
|
|
740
|
-
retractDefaultIfPointsTo(session.id);
|
|
741
|
-
}
|
|
742
|
-
else {
|
|
743
|
-
setReusableProxy({ child }, wsUrl, {
|
|
1145
|
+
if (!options.isolated) {
|
|
1146
|
+
setReusableProxy({ runtime }, wsUrl, {
|
|
744
1147
|
headless: options.headless,
|
|
745
1148
|
slowMo: options.slowMo,
|
|
746
1149
|
stealth: options.stealth,
|
|
@@ -756,7 +1159,7 @@ async function startFreshProxySession(options) {
|
|
|
756
1159
|
mode: 'fresh-proxy',
|
|
757
1160
|
reused: false,
|
|
758
1161
|
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
759
|
-
proxyStartMode: '
|
|
1162
|
+
proxyStartMode: 'embedded',
|
|
760
1163
|
proxyStartMs,
|
|
761
1164
|
connectMs: baseConnectTrace?.totalMs,
|
|
762
1165
|
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
@@ -766,39 +1169,155 @@ async function startFreshProxySession(options) {
|
|
|
766
1169
|
};
|
|
767
1170
|
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
768
1171
|
transportMode: 'fresh-proxy',
|
|
769
|
-
proxyStartMode: '
|
|
770
|
-
|
|
1172
|
+
proxyStartMode: 'embedded',
|
|
1173
|
+
requestedPageOrigin: lifecycleUrlOrigin(options.pageUrl),
|
|
771
1174
|
isolated: options.isolated === true,
|
|
772
1175
|
});
|
|
773
1176
|
return session;
|
|
774
1177
|
}
|
|
775
|
-
catch (
|
|
1178
|
+
catch (embeddedFailure) {
|
|
1179
|
+
// `runtime.ready` can reject before the parallel WebSocket connect
|
|
1180
|
+
// settles. Close the runtime and await that losing connect before this
|
|
1181
|
+
// lease is eligible for child fallback; one reservation can never back
|
|
1182
|
+
// two simultaneous attempts.
|
|
1183
|
+
if (attachedEmbeddedSession) {
|
|
1184
|
+
shutdownSession(attachedEmbeddedSession.id, { closeProxy: true, reason: 'embedded_proxy_start_failed' });
|
|
1185
|
+
}
|
|
1186
|
+
if (pendingEmbeddedRuntime) {
|
|
1187
|
+
await closeEmbeddedProxyRuntime(pendingEmbeddedRuntime);
|
|
1188
|
+
}
|
|
1189
|
+
if (pendingEmbeddedConnect) {
|
|
1190
|
+
const orphanedSession = await pendingEmbeddedConnect.catch(() => undefined);
|
|
1191
|
+
pendingEmbeddedConnect = undefined;
|
|
1192
|
+
if (orphanedSession) {
|
|
1193
|
+
orphanedSession.proxyRuntime = pendingEmbeddedRuntime;
|
|
1194
|
+
orphanedSession.proxyReusable = false;
|
|
1195
|
+
shutdownSession(orphanedSession.id, { closeProxy: true, reason: 'embedded_proxy_ready_failed' });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
ownershipLease = reserveSessionOwnership(ownershipLease);
|
|
1199
|
+
let pendingChild;
|
|
1200
|
+
let attachedChildSession;
|
|
776
1201
|
try {
|
|
777
|
-
|
|
1202
|
+
const proxyStartStartedAt = performance.now();
|
|
1203
|
+
const { child, wsUrl, authToken } = await spawnGeometraProxy({
|
|
1204
|
+
pageUrl: options.pageUrl,
|
|
1205
|
+
port: options.port ?? 0,
|
|
1206
|
+
headless: options.headless,
|
|
1207
|
+
width: options.width,
|
|
1208
|
+
height: options.height,
|
|
1209
|
+
slowMo: options.slowMo,
|
|
1210
|
+
stealth: options.stealth,
|
|
1211
|
+
eagerInitialExtract,
|
|
1212
|
+
proxy: options.proxy,
|
|
1213
|
+
});
|
|
1214
|
+
pendingChild = child;
|
|
1215
|
+
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
1216
|
+
const session = await connectWithOwnership(wsUrl, {
|
|
1217
|
+
skipInitialResize: true,
|
|
1218
|
+
closePreviousProxy: false,
|
|
1219
|
+
awaitInitialFrame: options.awaitInitialFrame,
|
|
1220
|
+
authToken,
|
|
1221
|
+
isolated: options.isolated === true,
|
|
1222
|
+
ownershipLease,
|
|
1223
|
+
});
|
|
1224
|
+
attachedChildSession = session;
|
|
1225
|
+
session.proxyChild = child;
|
|
1226
|
+
session.proxyReusable = !options.isolated;
|
|
1227
|
+
if (!options.isolated) {
|
|
1228
|
+
setReusableProxy({ child, authToken }, wsUrl, {
|
|
1229
|
+
headless: options.headless,
|
|
1230
|
+
slowMo: options.slowMo,
|
|
1231
|
+
stealth: options.stealth,
|
|
1232
|
+
width: options.width,
|
|
1233
|
+
height: options.height,
|
|
1234
|
+
pageUrl: options.pageUrl,
|
|
1235
|
+
snapshotReady: Boolean(session.tree && session.layout),
|
|
1236
|
+
proxy: options.proxy,
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
const baseConnectTrace = session.connectTrace;
|
|
1240
|
+
session.connectTrace = {
|
|
1241
|
+
mode: 'fresh-proxy',
|
|
1242
|
+
reused: false,
|
|
1243
|
+
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
1244
|
+
proxyStartMode: 'child',
|
|
1245
|
+
proxyStartMs,
|
|
1246
|
+
connectMs: baseConnectTrace?.totalMs,
|
|
1247
|
+
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
1248
|
+
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
1249
|
+
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
1250
|
+
totalMs: performance.now() - startedAt,
|
|
1251
|
+
};
|
|
1252
|
+
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
1253
|
+
transportMode: 'fresh-proxy',
|
|
1254
|
+
proxyStartMode: 'child',
|
|
1255
|
+
requestedPageOrigin: lifecycleUrlOrigin(options.pageUrl),
|
|
1256
|
+
isolated: options.isolated === true,
|
|
1257
|
+
});
|
|
1258
|
+
return session;
|
|
778
1259
|
}
|
|
779
|
-
catch {
|
|
780
|
-
|
|
1260
|
+
catch (fallbackError) {
|
|
1261
|
+
if (attachedChildSession) {
|
|
1262
|
+
shutdownSession(attachedChildSession.id, { closeProxy: true, reason: 'child_proxy_start_failed' });
|
|
1263
|
+
}
|
|
1264
|
+
else if (pendingChild) {
|
|
1265
|
+
try {
|
|
1266
|
+
pendingChild.kill('SIGTERM');
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
/* ignore */
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
if (fallbackError instanceof SessionCapacityError)
|
|
1273
|
+
throw fallbackError;
|
|
1274
|
+
throw new Error(`Failed to start embedded browser session: ${formatUnknownError(embeddedFailure)}\n` +
|
|
1275
|
+
`Child-process proxy fallback also failed: ${formatUnknownError(fallbackError)}`, { cause: fallbackError });
|
|
781
1276
|
}
|
|
782
|
-
throw fallbackError instanceof Error ? fallbackError : e;
|
|
783
1277
|
}
|
|
784
1278
|
}
|
|
1279
|
+
finally {
|
|
1280
|
+
releaseSessionOwnership(ownershipLease);
|
|
1281
|
+
}
|
|
785
1282
|
}
|
|
786
1283
|
/**
|
|
787
1284
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
788
1285
|
* layout/tree state is available immediately after connection.
|
|
789
1286
|
*/
|
|
790
1287
|
export function connect(url, opts) {
|
|
1288
|
+
return connectWithOwnership(url, opts);
|
|
1289
|
+
}
|
|
1290
|
+
function connectWithOwnership(url, opts) {
|
|
791
1291
|
return new Promise((resolve, reject) => {
|
|
792
1292
|
const startedAt = performance.now();
|
|
793
1293
|
clearReusableProxiesIfExited();
|
|
794
|
-
|
|
795
|
-
|
|
1294
|
+
let ownershipLease;
|
|
1295
|
+
try {
|
|
1296
|
+
ownershipLease = beginSessionOwnershipAttempt(opts?.ownershipLease);
|
|
1297
|
+
}
|
|
1298
|
+
catch (err) {
|
|
1299
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
let ws;
|
|
1303
|
+
try {
|
|
1304
|
+
ws = new WebSocket(url, opts?.authToken
|
|
1305
|
+
? { headers: { Authorization: `Bearer ${opts.authToken}` } }
|
|
1306
|
+
: undefined);
|
|
1307
|
+
}
|
|
1308
|
+
catch (err) {
|
|
1309
|
+
releaseSessionOwnership(ownershipLease);
|
|
1310
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
796
1313
|
const session = {
|
|
797
1314
|
id: generateSessionId(),
|
|
798
1315
|
ws,
|
|
799
1316
|
layout: null,
|
|
800
1317
|
tree: null,
|
|
801
1318
|
url,
|
|
1319
|
+
...(opts?.authToken ? { transportAuthToken: opts.authToken } : {}),
|
|
1320
|
+
isolated: opts?.isolated === true,
|
|
802
1321
|
updateRevision: 0,
|
|
803
1322
|
connectTrace: {
|
|
804
1323
|
mode: 'direct-ws',
|
|
@@ -809,6 +1328,8 @@ export function connect(url, opts) {
|
|
|
809
1328
|
cachedA11y: null,
|
|
810
1329
|
cachedA11yRevision: -1,
|
|
811
1330
|
cachedFormSchemas: new Map(),
|
|
1331
|
+
hasFreshFrame: false,
|
|
1332
|
+
disposed: false,
|
|
812
1333
|
heartbeatInterval: null,
|
|
813
1334
|
heartbeatLastMessageAt: Date.now(),
|
|
814
1335
|
heartbeatPendingPongBy: null,
|
|
@@ -823,6 +1344,7 @@ export function connect(url, opts) {
|
|
|
823
1344
|
catch {
|
|
824
1345
|
/* ignore */
|
|
825
1346
|
}
|
|
1347
|
+
releaseSessionOwnership(ownershipLease);
|
|
826
1348
|
reject(new Error(`Failed to initialize durable session state for ${url}: ${formatUnknownError(err)}`));
|
|
827
1349
|
return;
|
|
828
1350
|
}
|
|
@@ -833,7 +1355,7 @@ export function connect(url, opts) {
|
|
|
833
1355
|
const timeout = setTimeout(() => {
|
|
834
1356
|
if (!resolved) {
|
|
835
1357
|
safeFailSessionLifecycle(session, 'connect_timeout', {
|
|
836
|
-
|
|
1358
|
+
transportOrigin: lifecycleUrlOrigin(url),
|
|
837
1359
|
timeoutMs: 10_000,
|
|
838
1360
|
});
|
|
839
1361
|
resolved = true;
|
|
@@ -843,6 +1365,7 @@ export function connect(url, opts) {
|
|
|
843
1365
|
catch {
|
|
844
1366
|
/* ignore */
|
|
845
1367
|
}
|
|
1368
|
+
releaseSessionOwnership(ownershipLease);
|
|
846
1369
|
reject(new Error(`Connection to ${url} timed out after 10s`));
|
|
847
1370
|
}
|
|
848
1371
|
}, 10_000);
|
|
@@ -855,22 +1378,24 @@ export function connect(url, opts) {
|
|
|
855
1378
|
if (!opts?.skipInitialResize) {
|
|
856
1379
|
const width = opts?.width ?? 1024;
|
|
857
1380
|
const height = opts?.height ?? 768;
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1381
|
+
// Start with the shared GEOM v1 envelope. The first frame explicitly
|
|
1382
|
+
// advertises whether this is a native server or a proxy-action peer.
|
|
1383
|
+
// Legacy proxy v1/v2 endpoints both accept this compatibility hello.
|
|
1384
|
+
try {
|
|
1385
|
+
ws.send(JSON.stringify({ type: 'resize', width, height, protocolVersion: GEOMETRY_PROTOCOL_VERSION }));
|
|
1386
|
+
}
|
|
1387
|
+
catch (err) {
|
|
1388
|
+
if (resolved)
|
|
1389
|
+
return;
|
|
1390
|
+
resolved = true;
|
|
1391
|
+
clearTimeout(timeout);
|
|
1392
|
+
releaseSessionOwnership(ownershipLease);
|
|
1393
|
+
try {
|
|
1394
|
+
ws.close();
|
|
1395
|
+
}
|
|
1396
|
+
catch { /* ignore */ }
|
|
1397
|
+
reject(new Error(`Failed to initialize connection to ${url}: ${formatUnknownError(err)}`));
|
|
866
1398
|
}
|
|
867
|
-
activeSessions.set(session.id, session);
|
|
868
|
-
defaultSessionId = session.id;
|
|
869
|
-
startSessionHeartbeat(session);
|
|
870
|
-
safeRecordSessionSnapshot(session, 'session.open', {
|
|
871
|
-
awaitInitialFrame: false,
|
|
872
|
-
});
|
|
873
|
-
resolve(session);
|
|
874
1399
|
}
|
|
875
1400
|
});
|
|
876
1401
|
ws.on('message', (data) => {
|
|
@@ -878,25 +1403,42 @@ export function connect(url, opts) {
|
|
|
878
1403
|
if (session.ws !== ws)
|
|
879
1404
|
return;
|
|
880
1405
|
try {
|
|
881
|
-
const msg =
|
|
1406
|
+
const msg = applyInboundSessionMessage(session, data);
|
|
1407
|
+
if (msg.type === 'hello' || msg.type === 'frame') {
|
|
1408
|
+
assertAuthenticatedProxyHandshake(session);
|
|
1409
|
+
}
|
|
1410
|
+
if (msg.type === 'hello' && opts?.awaitInitialFrame === false && !resolved) {
|
|
1411
|
+
clearTimeout(timeout);
|
|
1412
|
+
if (session.connectTrace) {
|
|
1413
|
+
session.connectTrace.resolvedWithoutInitialFrame = true;
|
|
1414
|
+
session.connectTrace.totalMs = performance.now() - startedAt;
|
|
1415
|
+
}
|
|
1416
|
+
claimSessionOwnership(ownershipLease, session);
|
|
1417
|
+
resolved = true;
|
|
1418
|
+
if (!session.isolated)
|
|
1419
|
+
defaultSessionId = session.id;
|
|
1420
|
+
startSessionHeartbeat(session);
|
|
1421
|
+
safeRecordSessionSnapshot(session, 'session.open', {
|
|
1422
|
+
awaitInitialFrame: false,
|
|
1423
|
+
authenticatedProxyHandshake: true,
|
|
1424
|
+
});
|
|
1425
|
+
resolve(session);
|
|
1426
|
+
}
|
|
882
1427
|
if (msg.type === 'frame') {
|
|
883
|
-
session.layout = msg.layout;
|
|
884
|
-
session.tree = msg.tree;
|
|
885
|
-
session.updateRevision++;
|
|
886
|
-
invalidateSessionCaches(session);
|
|
887
1428
|
const connectTrace = session.connectTrace;
|
|
888
1429
|
const firstFrame = connectTrace?.firstFrameMs === undefined;
|
|
889
1430
|
if (connectTrace && connectTrace.firstFrameMs === undefined) {
|
|
890
1431
|
connectTrace.firstFrameMs = performance.now() - startedAt;
|
|
891
1432
|
}
|
|
892
1433
|
if (!resolved) {
|
|
893
|
-
resolved = true;
|
|
894
1434
|
clearTimeout(timeout);
|
|
895
1435
|
if (session.connectTrace) {
|
|
896
1436
|
session.connectTrace.totalMs = performance.now() - startedAt;
|
|
897
1437
|
}
|
|
898
|
-
|
|
899
|
-
|
|
1438
|
+
claimSessionOwnership(ownershipLease, session);
|
|
1439
|
+
resolved = true;
|
|
1440
|
+
if (!session.isolated)
|
|
1441
|
+
defaultSessionId = session.id;
|
|
900
1442
|
startSessionHeartbeat(session);
|
|
901
1443
|
safeRecordSessionSnapshot(session, 'session.connected');
|
|
902
1444
|
resolve(session);
|
|
@@ -907,34 +1449,62 @@ export function connect(url, opts) {
|
|
|
907
1449
|
});
|
|
908
1450
|
}
|
|
909
1451
|
}
|
|
910
|
-
else if (msg.type === '
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1452
|
+
else if (msg.type === 'error' && !resolved) {
|
|
1453
|
+
resolved = true;
|
|
1454
|
+
clearTimeout(timeout);
|
|
1455
|
+
try {
|
|
1456
|
+
ws.close();
|
|
1457
|
+
}
|
|
1458
|
+
catch { /* ignore */ }
|
|
1459
|
+
releaseSessionOwnership(ownershipLease);
|
|
1460
|
+
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
catch (err) {
|
|
1464
|
+
rememberReusableProxyPageUrl(session);
|
|
1465
|
+
try {
|
|
1466
|
+
ws.close(1002, 'Invalid protocol message');
|
|
1467
|
+
}
|
|
1468
|
+
catch { /* ignore */ }
|
|
1469
|
+
if (!resolved) {
|
|
1470
|
+
resolved = true;
|
|
1471
|
+
clearTimeout(timeout);
|
|
1472
|
+
releaseSessionOwnership(ownershipLease);
|
|
1473
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1474
|
+
}
|
|
1475
|
+
else {
|
|
1476
|
+
invalidateSessionUiState(session);
|
|
914
1477
|
}
|
|
915
1478
|
}
|
|
916
|
-
catch { /* ignore malformed messages */ }
|
|
917
1479
|
});
|
|
918
1480
|
ws.on('error', (err) => {
|
|
1481
|
+
if (session.ws === ws) {
|
|
1482
|
+
rememberReusableProxyPageUrl(session);
|
|
1483
|
+
invalidateSessionUiState(session);
|
|
1484
|
+
}
|
|
919
1485
|
if (!resolved) {
|
|
920
1486
|
safeFailSessionLifecycle(session, 'websocket_error', {
|
|
921
|
-
|
|
922
|
-
|
|
1487
|
+
transportOrigin: lifecycleUrlOrigin(url),
|
|
1488
|
+
errorCode: err.code ?? 'websocket_error',
|
|
923
1489
|
});
|
|
924
1490
|
resolved = true;
|
|
925
1491
|
clearTimeout(timeout);
|
|
1492
|
+
releaseSessionOwnership(ownershipLease);
|
|
926
1493
|
reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
|
|
927
1494
|
}
|
|
928
1495
|
});
|
|
929
1496
|
ws.on('close', () => {
|
|
930
1497
|
if (session.ws !== ws)
|
|
931
1498
|
return;
|
|
1499
|
+
rememberReusableProxyPageUrl(session);
|
|
1500
|
+
invalidateSessionUiState(session);
|
|
932
1501
|
if (!resolved) {
|
|
933
1502
|
safeFailSessionLifecycle(session, 'websocket_closed_before_ready', {
|
|
934
|
-
|
|
1503
|
+
transportOrigin: lifecycleUrlOrigin(url),
|
|
935
1504
|
});
|
|
936
1505
|
resolved = true;
|
|
937
1506
|
clearTimeout(timeout);
|
|
1507
|
+
releaseSessionOwnership(ownershipLease);
|
|
938
1508
|
reject(new Error(`Connection to ${url} closed before first frame`));
|
|
939
1509
|
return;
|
|
940
1510
|
}
|
|
@@ -952,20 +1522,22 @@ export function connect(url, opts) {
|
|
|
952
1522
|
*/
|
|
953
1523
|
export async function connectThroughProxy(options) {
|
|
954
1524
|
clearReusableProxiesIfExited();
|
|
1525
|
+
const isolated = options.isolated !== false;
|
|
1526
|
+
const normalizedOptions = { ...options, isolated };
|
|
955
1527
|
// Isolated sessions skip the pool entirely. They always get their own
|
|
956
1528
|
// brand-new Chromium and never reuse a proxy from a prior call. The
|
|
957
1529
|
// tag flows down so startFreshProxySession knows to keep this proxy out
|
|
958
1530
|
// of the pool on success and so shutdownSession knows to force-close it.
|
|
959
|
-
if (
|
|
960
|
-
|
|
1531
|
+
if (isolated) {
|
|
1532
|
+
const ownershipLease = reserveSessionOwnership();
|
|
1533
|
+
return await startFreshProxySession(normalizedOptions, ownershipLease);
|
|
961
1534
|
}
|
|
962
1535
|
let reuseFailure;
|
|
963
1536
|
// Loop because if a candidate is currently being attached by another
|
|
964
|
-
// concurrent connectThroughProxy call we wait for it, then re-pick.
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
// by the pool size to defend against pathological churn.
|
|
1537
|
+
// concurrent connectThroughProxy call we wait for it, then re-pick. Active
|
|
1538
|
+
// entries are never eligible: the second caller either claims another idle
|
|
1539
|
+
// warm browser or starts a fresh one. Bounded by the pool size to defend
|
|
1540
|
+
// against pathological churn.
|
|
969
1541
|
for (let attempt = 0; attempt < REUSABLE_PROXY_POOL_LIMIT + 1; attempt++) {
|
|
970
1542
|
const reusableProxy = findReusableProxy(options);
|
|
971
1543
|
if (!reusableProxy)
|
|
@@ -980,10 +1552,16 @@ export async function connectThroughProxy(options) {
|
|
|
980
1552
|
let releaseLock = () => { };
|
|
981
1553
|
reusableProxy.attachLock = new Promise(resolve => { releaseLock = resolve; });
|
|
982
1554
|
try {
|
|
1555
|
+
if (reusableProxyEntryIsActive(reusableProxy))
|
|
1556
|
+
continue;
|
|
983
1557
|
return await attachToReusableProxy(reusableProxy, options);
|
|
984
1558
|
}
|
|
985
1559
|
catch (err) {
|
|
1560
|
+
if (err instanceof SessionCapacityError)
|
|
1561
|
+
throw err;
|
|
986
1562
|
reuseFailure = err;
|
|
1563
|
+
if (reusableProxyEntryIsActive(reusableProxy))
|
|
1564
|
+
continue;
|
|
987
1565
|
closeReusableProxy(reusableProxy);
|
|
988
1566
|
break;
|
|
989
1567
|
}
|
|
@@ -993,9 +1571,12 @@ export async function connectThroughProxy(options) {
|
|
|
993
1571
|
}
|
|
994
1572
|
}
|
|
995
1573
|
try {
|
|
996
|
-
|
|
1574
|
+
const ownershipLease = reserveSessionOwnership();
|
|
1575
|
+
return await startFreshProxySession(normalizedOptions, ownershipLease);
|
|
997
1576
|
}
|
|
998
1577
|
catch (e) {
|
|
1578
|
+
if (e instanceof SessionCapacityError)
|
|
1579
|
+
throw e;
|
|
999
1580
|
if (reuseFailure) {
|
|
1000
1581
|
throw new Error(`Failed to recover reusable browser session after it became stale: ${formatUnknownError(reuseFailure)}\nFresh proxy start also failed: ${formatUnknownError(e)}`, { cause: e });
|
|
1001
1582
|
}
|
|
@@ -1012,13 +1593,22 @@ export function getSession(id) {
|
|
|
1012
1593
|
export function pruneDisconnectedSessions() {
|
|
1013
1594
|
const removedIds = [];
|
|
1014
1595
|
for (const [id, session] of activeSessions.entries()) {
|
|
1015
|
-
if (session.ws.readyState === WebSocket.OPEN
|
|
1596
|
+
if (session.ws.readyState === WebSocket.OPEN)
|
|
1597
|
+
continue;
|
|
1598
|
+
rememberReusableProxyPageUrl(session);
|
|
1599
|
+
invalidateSessionUiState(session);
|
|
1600
|
+
if (session.reconnectInFlight || reconnectUrlForSession(session))
|
|
1016
1601
|
continue;
|
|
1017
1602
|
removedIds.push(id);
|
|
1603
|
+
session.disposed = true;
|
|
1604
|
+
session.ambiguousOperations?.clear();
|
|
1605
|
+
session.inFlightMutations?.clear();
|
|
1018
1606
|
activeSessions.delete(id);
|
|
1019
1607
|
if (defaultSessionId === id) {
|
|
1020
1608
|
promoteDefaultSession();
|
|
1021
1609
|
}
|
|
1610
|
+
stopSessionHeartbeat(session);
|
|
1611
|
+
releaseSessionResources(session, { closeProxy: true });
|
|
1022
1612
|
}
|
|
1023
1613
|
return removedIds;
|
|
1024
1614
|
}
|
|
@@ -1059,13 +1649,28 @@ export function getDefaultSessionId() {
|
|
|
1059
1649
|
}
|
|
1060
1650
|
export function disconnect(opts) {
|
|
1061
1651
|
if (opts?.sessionId) {
|
|
1062
|
-
|
|
1652
|
+
if (!activeSessions.has(opts.sessionId))
|
|
1653
|
+
return;
|
|
1654
|
+
shutdownSession(opts.sessionId, { closeProxy: opts.closeProxy ?? false, reason: 'disconnect' });
|
|
1655
|
+
return;
|
|
1063
1656
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1657
|
+
const resolved = resolveSession();
|
|
1658
|
+
if (resolved.kind === 'none')
|
|
1659
|
+
return;
|
|
1660
|
+
if (resolved.kind === 'ambiguous') {
|
|
1661
|
+
throw new Error(`Cannot disconnect without an explicit sessionId while session routing is ambiguous. ` +
|
|
1662
|
+
`Active sessions: ${resolved.activeIds.join(', ')}.`);
|
|
1066
1663
|
}
|
|
1067
|
-
if (
|
|
1068
|
-
|
|
1664
|
+
if (resolved.kind === 'not_found')
|
|
1665
|
+
return;
|
|
1666
|
+
shutdownSession(resolved.session.id, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
|
|
1667
|
+
}
|
|
1668
|
+
/** Process/test teardown only. User-facing disconnects are always owner-scoped. */
|
|
1669
|
+
export function shutdownAllSessionsAndProxies() {
|
|
1670
|
+
for (const id of [...activeSessions.keys()]) {
|
|
1671
|
+
shutdownSession(id, { closeProxy: true, reason: 'process_shutdown' });
|
|
1672
|
+
}
|
|
1673
|
+
closeReusableProxies();
|
|
1069
1674
|
}
|
|
1070
1675
|
function estimateFillBatchTimeout(fields) {
|
|
1071
1676
|
let total = FILL_BATCH_BASE_TIMEOUT_MS;
|
|
@@ -1101,7 +1706,13 @@ function estimateFillBatchTimeout(fields) {
|
|
|
1101
1706
|
}
|
|
1102
1707
|
export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
1103
1708
|
return new Promise((resolve) => {
|
|
1709
|
+
const transport = session.ws;
|
|
1104
1710
|
const check = () => {
|
|
1711
|
+
if (session.ws !== transport) {
|
|
1712
|
+
cleanup();
|
|
1713
|
+
resolve(false);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1105
1716
|
let matched;
|
|
1106
1717
|
try {
|
|
1107
1718
|
matched = predicate();
|
|
@@ -1127,11 +1738,11 @@ export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
|
1127
1738
|
};
|
|
1128
1739
|
function cleanup() {
|
|
1129
1740
|
clearTimeout(timeout);
|
|
1130
|
-
|
|
1131
|
-
|
|
1741
|
+
transport.off('message', onMessage);
|
|
1742
|
+
transport.off('close', onClose);
|
|
1132
1743
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1744
|
+
transport.on('message', onMessage);
|
|
1745
|
+
transport.on('close', onClose);
|
|
1135
1746
|
check();
|
|
1136
1747
|
});
|
|
1137
1748
|
}
|
|
@@ -1148,9 +1759,19 @@ function reconnectUrlForSession(session) {
|
|
|
1148
1759
|
}
|
|
1149
1760
|
return null;
|
|
1150
1761
|
}
|
|
1151
|
-
async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
|
|
1762
|
+
async function openWebSocket(url, session, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS, viewport) {
|
|
1152
1763
|
return await new Promise((resolve, reject) => {
|
|
1153
|
-
const ws = new WebSocket(url
|
|
1764
|
+
const ws = new WebSocket(url, session.transportAuthToken
|
|
1765
|
+
? { headers: { Authorization: `Bearer ${session.transportAuthToken}` } }
|
|
1766
|
+
: undefined);
|
|
1767
|
+
// Every transport replacement must negotiate from fresh evidence. Keeping
|
|
1768
|
+
// the prior socket's capability flags would let a different process take
|
|
1769
|
+
// over the endpoint and inherit an authenticated-proxy attestation.
|
|
1770
|
+
session.peerTransport = undefined;
|
|
1771
|
+
session.peerGeometryProtocolVersion = undefined;
|
|
1772
|
+
session.peerProxyActionProtocolVersion = undefined;
|
|
1773
|
+
session.peerAdvertisedSplitProtocol = undefined;
|
|
1774
|
+
session.peerProtocolCapabilities = undefined;
|
|
1154
1775
|
const timeout = setTimeout(() => {
|
|
1155
1776
|
cleanup();
|
|
1156
1777
|
try {
|
|
@@ -1159,23 +1780,61 @@ async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
|
|
|
1159
1780
|
catch {
|
|
1160
1781
|
/* ignore */
|
|
1161
1782
|
}
|
|
1162
|
-
reject(new Error(`Reconnect to ${url} timed out after ${timeoutMs}ms`));
|
|
1783
|
+
reject(new Error(`Reconnect handshake to ${url} timed out after ${timeoutMs}ms`));
|
|
1163
1784
|
}, timeoutMs);
|
|
1164
1785
|
const onOpen = () => {
|
|
1165
|
-
|
|
1166
|
-
|
|
1786
|
+
const width = viewport?.width ?? 1024;
|
|
1787
|
+
const height = viewport?.height ?? 768;
|
|
1788
|
+
// Current proxies attest immediately with `hello`. This compatibility
|
|
1789
|
+
// resize prompts native/legacy peers that only emit a frame after input.
|
|
1790
|
+
ws.send(JSON.stringify({
|
|
1791
|
+
type: 'resize',
|
|
1792
|
+
width,
|
|
1793
|
+
height,
|
|
1794
|
+
protocolVersion: GEOMETRY_PROTOCOL_VERSION,
|
|
1795
|
+
}));
|
|
1167
1796
|
};
|
|
1168
1797
|
const onError = (err) => {
|
|
1169
1798
|
cleanup();
|
|
1170
1799
|
reject(new Error(`WebSocket reconnect failed for ${url}: ${err.message}`));
|
|
1171
1800
|
};
|
|
1801
|
+
const onClose = () => {
|
|
1802
|
+
cleanup();
|
|
1803
|
+
reject(new Error(`WebSocket reconnect to ${url} closed before capability handshake`));
|
|
1804
|
+
};
|
|
1805
|
+
const onMessage = (data) => {
|
|
1806
|
+
try {
|
|
1807
|
+
const msg = parseInboundServerMessage(data);
|
|
1808
|
+
updatePeerProtocol(session, msg);
|
|
1809
|
+
if (msg.type === 'error') {
|
|
1810
|
+
throw new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error');
|
|
1811
|
+
}
|
|
1812
|
+
if (msg.type !== 'hello' && msg.type !== 'frame')
|
|
1813
|
+
return;
|
|
1814
|
+
assertAuthenticatedProxyHandshake(session);
|
|
1815
|
+
cleanup();
|
|
1816
|
+
resolve({ ws, handshakeMessage: data });
|
|
1817
|
+
}
|
|
1818
|
+
catch (err) {
|
|
1819
|
+
cleanup();
|
|
1820
|
+
try {
|
|
1821
|
+
ws.close(1002, 'Invalid capability handshake');
|
|
1822
|
+
}
|
|
1823
|
+
catch { /* ignore */ }
|
|
1824
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1172
1827
|
function cleanup() {
|
|
1173
1828
|
clearTimeout(timeout);
|
|
1174
1829
|
ws.off('open', onOpen);
|
|
1175
1830
|
ws.off('error', onError);
|
|
1831
|
+
ws.off('close', onClose);
|
|
1832
|
+
ws.off('message', onMessage);
|
|
1176
1833
|
}
|
|
1177
1834
|
ws.on('open', onOpen);
|
|
1178
1835
|
ws.on('error', onError);
|
|
1836
|
+
ws.on('close', onClose);
|
|
1837
|
+
ws.on('message', onMessage);
|
|
1179
1838
|
});
|
|
1180
1839
|
}
|
|
1181
1840
|
function bindReconnectedSocket(session, ws) {
|
|
@@ -1184,29 +1843,31 @@ function bindReconnectedSocket(session, ws) {
|
|
|
1184
1843
|
if (session.ws !== ws)
|
|
1185
1844
|
return;
|
|
1186
1845
|
try {
|
|
1187
|
-
|
|
1188
|
-
if (msg.type === 'frame') {
|
|
1189
|
-
session.layout = msg.layout;
|
|
1190
|
-
session.tree = msg.tree;
|
|
1191
|
-
session.updateRevision++;
|
|
1192
|
-
invalidateSessionCaches(session);
|
|
1193
|
-
}
|
|
1194
|
-
else if (msg.type === 'patch' && session.layout) {
|
|
1195
|
-
applyPatches(session.layout, msg.patches);
|
|
1196
|
-
session.updateRevision++;
|
|
1197
|
-
invalidateSessionCaches(session);
|
|
1198
|
-
}
|
|
1846
|
+
applyInboundSessionMessage(session, data);
|
|
1199
1847
|
}
|
|
1200
1848
|
catch {
|
|
1201
|
-
|
|
1849
|
+
rememberReusableProxyPageUrl(session);
|
|
1850
|
+
invalidateSessionUiState(session);
|
|
1851
|
+
try {
|
|
1852
|
+
ws.close(1002, 'Invalid protocol message');
|
|
1853
|
+
}
|
|
1854
|
+
catch { /* ignore */ }
|
|
1202
1855
|
}
|
|
1203
1856
|
});
|
|
1204
1857
|
ws.on('pong', () => {
|
|
1205
1858
|
noteSessionSocketActivity(session, ws);
|
|
1206
1859
|
});
|
|
1860
|
+
ws.on('error', () => {
|
|
1861
|
+
if (session.ws !== ws)
|
|
1862
|
+
return;
|
|
1863
|
+
rememberReusableProxyPageUrl(session);
|
|
1864
|
+
invalidateSessionUiState(session);
|
|
1865
|
+
});
|
|
1207
1866
|
ws.on('close', () => {
|
|
1208
1867
|
if (session.ws !== ws)
|
|
1209
1868
|
return;
|
|
1869
|
+
rememberReusableProxyPageUrl(session);
|
|
1870
|
+
invalidateSessionUiState(session);
|
|
1210
1871
|
if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
|
|
1211
1872
|
safeRecordSessionSnapshot(session, 'session.transport_closed', {
|
|
1212
1873
|
reconnectable: reconnectUrlForSession(session) !== null,
|
|
@@ -1214,7 +1875,13 @@ function bindReconnectedSocket(session, ws) {
|
|
|
1214
1875
|
}
|
|
1215
1876
|
});
|
|
1216
1877
|
}
|
|
1217
|
-
|
|
1878
|
+
function sessionIsDisposedOrUnowned(session) {
|
|
1879
|
+
return session.disposed === true || activeSessions.get(session.id) !== session;
|
|
1880
|
+
}
|
|
1881
|
+
export async function ensureSessionConnected(session) {
|
|
1882
|
+
if (sessionIsDisposedOrUnowned(session)) {
|
|
1883
|
+
throw new Error(`Session ${session.id} is disconnected or no longer owns its transport`);
|
|
1884
|
+
}
|
|
1218
1885
|
if (session.ws.readyState === WebSocket.OPEN)
|
|
1219
1886
|
return;
|
|
1220
1887
|
if (session.reconnectInFlight) {
|
|
@@ -1222,15 +1889,31 @@ async function ensureSessionConnected(session) {
|
|
|
1222
1889
|
if (!recovered) {
|
|
1223
1890
|
throw new Error('Not connected');
|
|
1224
1891
|
}
|
|
1892
|
+
if (sessionIsDisposedOrUnowned(session)) {
|
|
1893
|
+
throw new Error(`Session ${session.id} was disconnected while reconnecting`);
|
|
1894
|
+
}
|
|
1225
1895
|
return;
|
|
1226
1896
|
}
|
|
1227
1897
|
const targetUrl = reconnectUrlForSession(session);
|
|
1228
1898
|
if (!targetUrl) {
|
|
1229
1899
|
throw new Error('Not connected');
|
|
1230
1900
|
}
|
|
1901
|
+
const reconnectViewport = {
|
|
1902
|
+
width: typeof session.layout?.width === 'number' ? session.layout.width : 1024,
|
|
1903
|
+
height: typeof session.layout?.height === 'number' ? session.layout.height : 768,
|
|
1904
|
+
};
|
|
1905
|
+
rememberReusableProxyPageUrl(session);
|
|
1906
|
+
invalidateSessionUiState(session);
|
|
1231
1907
|
const reconnectPromise = (async () => {
|
|
1232
1908
|
try {
|
|
1233
|
-
const nextWs = await openWebSocket(targetUrl);
|
|
1909
|
+
const { ws: nextWs, handshakeMessage } = await openWebSocket(targetUrl, session, SESSION_RECONNECT_TIMEOUT_MS, reconnectViewport);
|
|
1910
|
+
if (sessionIsDisposedOrUnowned(session)) {
|
|
1911
|
+
try {
|
|
1912
|
+
nextWs.close();
|
|
1913
|
+
}
|
|
1914
|
+
catch { /* ignore */ }
|
|
1915
|
+
throw new Error(`Session ${session.id} was disconnected while reconnecting`);
|
|
1916
|
+
}
|
|
1234
1917
|
try {
|
|
1235
1918
|
session.ws.close();
|
|
1236
1919
|
}
|
|
@@ -1240,28 +1923,33 @@ async function ensureSessionConnected(session) {
|
|
|
1240
1923
|
session.ws = nextWs;
|
|
1241
1924
|
noteSessionSocketActivity(session, nextWs);
|
|
1242
1925
|
bindReconnectedSocket(session, nextWs);
|
|
1926
|
+
applyInboundSessionMessage(session, handshakeMessage);
|
|
1243
1927
|
activeSessions.set(session.id, session);
|
|
1244
1928
|
if (!session.isolated) {
|
|
1245
1929
|
defaultSessionId = session.id;
|
|
1246
1930
|
}
|
|
1247
1931
|
startSessionHeartbeat(session);
|
|
1248
1932
|
safeRecordSessionSnapshot(session, 'session.reconnected', {
|
|
1249
|
-
targetUrl,
|
|
1933
|
+
targetOrigin: lifecycleUrlOrigin(targetUrl),
|
|
1250
1934
|
});
|
|
1251
1935
|
return true;
|
|
1252
1936
|
}
|
|
1253
1937
|
catch (err) {
|
|
1254
1938
|
safeFailSessionLifecycle(session, 'reconnect_failed', {
|
|
1255
|
-
targetUrl,
|
|
1256
|
-
|
|
1939
|
+
targetOrigin: lifecycleUrlOrigin(targetUrl),
|
|
1940
|
+
errorName: err instanceof Error ? err.name : 'Error',
|
|
1257
1941
|
});
|
|
1258
1942
|
if (activeSessions.get(session.id) === session) {
|
|
1259
1943
|
activeSessions.delete(session.id);
|
|
1260
1944
|
if (defaultSessionId === session.id)
|
|
1261
1945
|
promoteDefaultSession();
|
|
1262
1946
|
}
|
|
1947
|
+
session.disposed = true;
|
|
1948
|
+
session.ambiguousOperations?.clear();
|
|
1949
|
+
session.inFlightMutations?.clear();
|
|
1263
1950
|
stopSessionHeartbeat(session);
|
|
1264
1951
|
releaseSessionResources(session, { closeProxy: true });
|
|
1952
|
+
invalidateSessionUiState(session);
|
|
1265
1953
|
throw err;
|
|
1266
1954
|
}
|
|
1267
1955
|
})();
|
|
@@ -1276,12 +1964,16 @@ async function ensureSessionConnected(session) {
|
|
|
1276
1964
|
if (!recovered) {
|
|
1277
1965
|
throw new Error('Not connected');
|
|
1278
1966
|
}
|
|
1967
|
+
if (sessionIsDisposedOrUnowned(session)) {
|
|
1968
|
+
throw new Error(`Session ${session.id} was disconnected while reconnecting`);
|
|
1969
|
+
}
|
|
1279
1970
|
}
|
|
1280
1971
|
async function sendResizeAndWaitForUpdate(session, width, height, timeoutMs = 5_000) {
|
|
1281
|
-
await
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1972
|
+
return await sendAndWaitForUpdate(session, {
|
|
1973
|
+
type: 'resize',
|
|
1974
|
+
width,
|
|
1975
|
+
height,
|
|
1976
|
+
}, timeoutMs);
|
|
1285
1977
|
}
|
|
1286
1978
|
/**
|
|
1287
1979
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
@@ -1300,11 +1992,30 @@ export function sendClick(session, x, y, timeoutMs) {
|
|
|
1300
1992
|
export function sendType(session, text, timeoutMs) {
|
|
1301
1993
|
return (async () => {
|
|
1302
1994
|
await ensureSessionConnected(session);
|
|
1303
|
-
|
|
1995
|
+
const waitTimeoutMs = timeoutMs ?? ACTION_UPDATE_TIMEOUT_MS;
|
|
1996
|
+
if (session.peerTransport === 'proxy' && session.peerProtocolCapabilities?.atomicTypeText === true) {
|
|
1997
|
+
if (text.length > 65_536) {
|
|
1998
|
+
throw new Error('geometra_type text exceeds the proxy atomic typing limit of 65,536 characters');
|
|
1999
|
+
}
|
|
2000
|
+
return await sendAndWaitForUpdate(session, { type: 'typeText', text }, waitTimeoutMs);
|
|
2001
|
+
}
|
|
2002
|
+
const fingerprint = actionFingerprint({ type: 'typeSequence', text });
|
|
2003
|
+
const existing = ambiguousOperationFor(session, fingerprint);
|
|
2004
|
+
if (existing) {
|
|
2005
|
+
return await sendPreparedActionOperation(session, existing, waitTimeoutMs, undefined, true);
|
|
2006
|
+
}
|
|
2007
|
+
assertCanStartMutatingOperation(session, fingerprint);
|
|
2008
|
+
const actionId = randomUUID();
|
|
2009
|
+
const actionTimeoutMs = actionTimeoutFor(session, { type: 'key' }, waitTimeoutMs);
|
|
2010
|
+
const keyEvents = [];
|
|
2011
|
+
// Give every key phase its own identity. Waiting on the final key-up is
|
|
2012
|
+
// sufficient because the proxy processes its action queue in wire order;
|
|
2013
|
+
// a scoped acknowledgement for that phase therefore confirms that every
|
|
2014
|
+
// earlier phase in this type sequence has also finished.
|
|
1304
2015
|
for (const char of text) {
|
|
1305
2016
|
const keyEvent = {
|
|
1306
2017
|
type: 'key',
|
|
1307
|
-
|
|
2018
|
+
...outboundProtocolMetadata(session),
|
|
1308
2019
|
eventType: 'onKeyDown',
|
|
1309
2020
|
key: char,
|
|
1310
2021
|
code: `Key${char.toUpperCase()}`,
|
|
@@ -1313,11 +2024,41 @@ export function sendType(session, text, timeoutMs) {
|
|
|
1313
2024
|
metaKey: false,
|
|
1314
2025
|
altKey: false,
|
|
1315
2026
|
};
|
|
1316
|
-
|
|
1317
|
-
|
|
2027
|
+
keyEvents.push({
|
|
2028
|
+
...keyEvent,
|
|
2029
|
+
...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
|
|
2030
|
+
requestId: randomUUID(),
|
|
2031
|
+
});
|
|
2032
|
+
keyEvents.push({
|
|
2033
|
+
...keyEvent,
|
|
2034
|
+
eventType: 'onKeyUp',
|
|
2035
|
+
...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
|
|
2036
|
+
requestId: randomUUID(),
|
|
2037
|
+
});
|
|
1318
2038
|
}
|
|
1319
|
-
|
|
1320
|
-
|
|
2039
|
+
if (keyEvents.length === 0) {
|
|
2040
|
+
return {
|
|
2041
|
+
status: 'acknowledged',
|
|
2042
|
+
timeoutMs: waitTimeoutMs,
|
|
2043
|
+
requestId: randomUUID(),
|
|
2044
|
+
actionId,
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
const finalRequestId = keyEvents[keyEvents.length - 1].requestId;
|
|
2048
|
+
const operation = {
|
|
2049
|
+
fingerprint,
|
|
2050
|
+
actionId,
|
|
2051
|
+
requestId: finalRequestId,
|
|
2052
|
+
requestIds: keyEvents.map(keyEvent => keyEvent.requestId),
|
|
2053
|
+
wireMessages: keyEvents.map(keyEvent => JSON.stringify(keyEvent)),
|
|
2054
|
+
...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
|
|
2055
|
+
timeoutMs: waitTimeoutMs,
|
|
2056
|
+
idempotent: session.peerTransport === 'proxy' &&
|
|
2057
|
+
session.peerProtocolCapabilities?.idempotentRequestIds === true,
|
|
2058
|
+
mutating: true,
|
|
2059
|
+
};
|
|
2060
|
+
trackInFlightMutation(session, operation);
|
|
2061
|
+
return await sendPreparedActionOperation(session, operation, waitTimeoutMs);
|
|
1321
2062
|
})();
|
|
1322
2063
|
}
|
|
1323
2064
|
/**
|
|
@@ -1337,7 +2078,9 @@ export function sendKey(session, key, modifiers, timeoutMs) {
|
|
|
1337
2078
|
}
|
|
1338
2079
|
/**
|
|
1339
2080
|
* Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
|
|
1340
|
-
* Optional `x`,`y` click opens a file chooser
|
|
2081
|
+
* Optional `x`,`y` click opens a file chooser. Callers must provide an explicit
|
|
2082
|
+
* coordinate or semantic target; MCP never intentionally requests a global
|
|
2083
|
+
* first-file-input fallback.
|
|
1341
2084
|
*/
|
|
1342
2085
|
export function sendFileUpload(session, paths, opts, timeoutMs) {
|
|
1343
2086
|
const payload = { type: 'file', paths };
|
|
@@ -1359,6 +2102,10 @@ export function sendFileUpload(session, paths, opts, timeoutMs) {
|
|
|
1359
2102
|
payload.dropX = opts.drop.x;
|
|
1360
2103
|
payload.dropY = opts.drop.y;
|
|
1361
2104
|
}
|
|
2105
|
+
if (opts?.contextText !== undefined)
|
|
2106
|
+
payload.contextText = opts.contextText;
|
|
2107
|
+
if (opts?.sectionText !== undefined)
|
|
2108
|
+
payload.sectionText = opts.sectionText;
|
|
1362
2109
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
1363
2110
|
}
|
|
1364
2111
|
/** Set a labeled text-like field (`input`, `textarea`, contenteditable, ARIA textbox) semantically. */
|
|
@@ -1429,10 +2176,6 @@ export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE
|
|
|
1429
2176
|
const payload = { type: 'listboxPick', label };
|
|
1430
2177
|
if (opts?.exact !== undefined)
|
|
1431
2178
|
payload.exact = opts.exact;
|
|
1432
|
-
if (opts?.open) {
|
|
1433
|
-
payload.openX = opts.open.x;
|
|
1434
|
-
payload.openY = opts.open.y;
|
|
1435
|
-
}
|
|
1436
2179
|
if (opts?.fieldLabel)
|
|
1437
2180
|
payload.fieldLabel = opts.fieldLabel;
|
|
1438
2181
|
if (opts?.query)
|
|
@@ -1502,9 +2245,8 @@ export function sendNavigate(session, url, timeoutMs = 15_000) {
|
|
|
1502
2245
|
url,
|
|
1503
2246
|
}, timeoutMs, { requireUpdateOnAck: true });
|
|
1504
2247
|
safeRecordSessionSnapshot(session, 'session.navigate', {
|
|
1505
|
-
|
|
2248
|
+
requestedOrigin: lifecycleUrlOrigin(url),
|
|
1506
2249
|
status: result.status,
|
|
1507
|
-
result: result.result ?? null,
|
|
1508
2250
|
});
|
|
1509
2251
|
return result;
|
|
1510
2252
|
})();
|
|
@@ -1559,6 +2301,11 @@ const FORM_FIELD_ROLES = new Set([
|
|
|
1559
2301
|
'checkbox',
|
|
1560
2302
|
'radio',
|
|
1561
2303
|
]);
|
|
2304
|
+
function isFormFieldNode(node) {
|
|
2305
|
+
if (node.meta?.coordinateOnly === true)
|
|
2306
|
+
return false;
|
|
2307
|
+
return FORM_FIELD_ROLES.has(node.role) || node.meta?.fileInput === true;
|
|
2308
|
+
}
|
|
1562
2309
|
const ACTION_ROLES = new Set([
|
|
1563
2310
|
'button',
|
|
1564
2311
|
'link',
|
|
@@ -2176,6 +2923,8 @@ function schemaOptionLabel(node) {
|
|
|
2176
2923
|
return sanitizeFieldName(node.name, 80) ?? sanitizeInlineName(node.name, 80);
|
|
2177
2924
|
}
|
|
2178
2925
|
function isGroupedChoiceControl(node) {
|
|
2926
|
+
if (node.meta?.coordinateOnly === true)
|
|
2927
|
+
return false;
|
|
2179
2928
|
return node.role === 'radio' || node.role === 'checkbox' || (node.role === 'button' && node.focusable);
|
|
2180
2929
|
}
|
|
2181
2930
|
function groupedChoiceForNode(root, formNode, seed) {
|
|
@@ -2241,8 +2990,14 @@ function buildFieldFormat(node) {
|
|
|
2241
2990
|
format.pattern = m.inputPattern;
|
|
2242
2991
|
if (m.inputType)
|
|
2243
2992
|
format.inputType = m.inputType;
|
|
2993
|
+
else if (m.fileInput)
|
|
2994
|
+
format.inputType = 'file';
|
|
2244
2995
|
if (m.autocomplete)
|
|
2245
2996
|
format.autocomplete = m.autocomplete;
|
|
2997
|
+
if (m.accept)
|
|
2998
|
+
format.accept = m.accept;
|
|
2999
|
+
if (m.multiple)
|
|
3000
|
+
format.multiple = true;
|
|
2246
3001
|
return Object.keys(format).length > 0 ? format : undefined;
|
|
2247
3002
|
}
|
|
2248
3003
|
function nativeSchemaOptions(node, fieldIdentity) {
|
|
@@ -2316,6 +3071,32 @@ function simpleSchemaField(root, node) {
|
|
|
2316
3071
|
...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
|
|
2317
3072
|
};
|
|
2318
3073
|
}
|
|
3074
|
+
function fileSchemaField(root, node) {
|
|
3075
|
+
const context = nodeContextForNode(root, node);
|
|
3076
|
+
// Hidden dropzone inputs are commonly missing an accessible name even when
|
|
3077
|
+
// they retain an authored `name`/`id`. Keep them in the schema so required
|
|
3078
|
+
// upload fields cannot disappear; the authored identity remains the exact
|
|
3079
|
+
// action target and the generated fallback is display-only when no such
|
|
3080
|
+
// identity exists.
|
|
3081
|
+
const label = fieldLabel(node)
|
|
3082
|
+
?? sanitizeInlineName(node.name, 80)
|
|
3083
|
+
?? sanitizeInlineName(node.meta?.controlName, 80)
|
|
3084
|
+
?? sanitizeInlineName(node.meta?.controlId, 80)
|
|
3085
|
+
?? context?.prompt
|
|
3086
|
+
?? 'Unlabeled file upload';
|
|
3087
|
+
const fieldKey = uniqueSchemaFieldKey(root, node);
|
|
3088
|
+
const format = buildFieldFormat(node);
|
|
3089
|
+
return {
|
|
3090
|
+
id: fieldKey ?? formFieldIdForPath(node.path),
|
|
3091
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
3092
|
+
kind: 'file',
|
|
3093
|
+
label,
|
|
3094
|
+
...(node.state?.required ? { required: true } : {}),
|
|
3095
|
+
...(node.state?.invalid ? { invalid: true } : {}),
|
|
3096
|
+
...(format ? { format } : {}),
|
|
3097
|
+
...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
|
|
3098
|
+
};
|
|
3099
|
+
}
|
|
2319
3100
|
function groupedSchemaField(root, grouped) {
|
|
2320
3101
|
const optionEntries = grouped.controls
|
|
2321
3102
|
.map(control => ({
|
|
@@ -2408,17 +3189,25 @@ function detectFormSections(formNode, fields) {
|
|
|
2408
3189
|
return sections;
|
|
2409
3190
|
}
|
|
2410
3191
|
function buildFormSchemaForNode(root, formNode, options) {
|
|
2411
|
-
const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.role === 'textbox' ||
|
|
3192
|
+
const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.meta?.coordinateOnly !== true && (candidate.role === 'textbox' ||
|
|
2412
3193
|
candidate.role === 'combobox' ||
|
|
2413
3194
|
candidate.role === 'checkbox' ||
|
|
2414
3195
|
candidate.role === 'radio' ||
|
|
2415
|
-
|
|
3196
|
+
candidate.meta?.fileInput === true ||
|
|
3197
|
+
(candidate.role === 'button' && candidate.focusable))));
|
|
2416
3198
|
const consumed = new Set();
|
|
2417
3199
|
const fields = [];
|
|
2418
3200
|
for (const candidate of candidates) {
|
|
2419
3201
|
const candidateKey = pathKey(candidate.path);
|
|
2420
3202
|
if (consumed.has(candidateKey))
|
|
2421
3203
|
continue;
|
|
3204
|
+
if (candidate.meta?.fileInput === true) {
|
|
3205
|
+
const field = fileSchemaField(root, candidate);
|
|
3206
|
+
if (field)
|
|
3207
|
+
fields.push(field);
|
|
3208
|
+
consumed.add(candidateKey);
|
|
3209
|
+
continue;
|
|
3210
|
+
}
|
|
2422
3211
|
if (candidate.role === 'textbox' || candidate.role === 'combobox') {
|
|
2423
3212
|
const field = simpleSchemaField(root, candidate);
|
|
2424
3213
|
if (field)
|
|
@@ -2713,7 +3502,7 @@ export function buildPageModel(root, options) {
|
|
|
2713
3502
|
landmarks.push(toLandmarkModel(node));
|
|
2714
3503
|
}
|
|
2715
3504
|
if (node.role === 'form') {
|
|
2716
|
-
const fields = collectDescendants(node,
|
|
3505
|
+
const fields = collectDescendants(node, isFormFieldNode);
|
|
2717
3506
|
const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
|
|
2718
3507
|
const name = sectionDisplayName(node, 'form');
|
|
2719
3508
|
forms.push({
|
|
@@ -2726,7 +3515,7 @@ export function buildPageModel(root, options) {
|
|
|
2726
3515
|
});
|
|
2727
3516
|
}
|
|
2728
3517
|
if (DIALOG_ROLES.has(node.role)) {
|
|
2729
|
-
const fields = collectDescendants(node,
|
|
3518
|
+
const fields = collectDescendants(node, isFormFieldNode);
|
|
2730
3519
|
const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
|
|
2731
3520
|
const name = sectionDisplayName(node, 'dialog');
|
|
2732
3521
|
dialogs.push({
|
|
@@ -2800,8 +3589,11 @@ export function buildFormSchemas(root, options) {
|
|
|
2800
3589
|
...(root.role === 'form' ? [root] : []),
|
|
2801
3590
|
...collectDescendants(root, candidate => candidate.role === 'form'),
|
|
2802
3591
|
];
|
|
2803
|
-
// Infer forms from group/region containers with 2+ form fields (e.g.
|
|
2804
|
-
|
|
3592
|
+
// Infer forms from group/region containers with 2+ form fields (e.g.
|
|
3593
|
+
// Ashby-style UIs without <form>). A standalone upload is also a complete
|
|
3594
|
+
// form surface when it is required or has a unique authored identity;
|
|
3595
|
+
// otherwise common one-field upload widgets disappear from the schema.
|
|
3596
|
+
const inferredCandidates = collectDescendants(root, candidate => {
|
|
2805
3597
|
if (candidate.role !== 'group' && candidate.role !== 'region')
|
|
2806
3598
|
return false;
|
|
2807
3599
|
// Skip descendants of explicit forms
|
|
@@ -2811,9 +3603,38 @@ export function buildFormSchemas(root, options) {
|
|
|
2811
3603
|
return false;
|
|
2812
3604
|
}
|
|
2813
3605
|
}
|
|
2814
|
-
const fields = collectDescendants(candidate,
|
|
2815
|
-
|
|
3606
|
+
const fields = collectDescendants(candidate, isFormFieldNode);
|
|
3607
|
+
if (fields.length >= 2)
|
|
3608
|
+
return true;
|
|
3609
|
+
if (fields.length !== 1 || fields[0]?.meta?.fileInput !== true)
|
|
3610
|
+
return false;
|
|
3611
|
+
const file = fields[0];
|
|
3612
|
+
return file.state?.required === true || uniqueSchemaFieldKey(root, file) !== undefined;
|
|
2816
3613
|
});
|
|
3614
|
+
// Nested layout groups often describe the same inferred form surface. Keep
|
|
3615
|
+
// one stable container for an identical field set so callers do not receive
|
|
3616
|
+
// duplicate formIds and then fail with an artificial ambiguous-form error.
|
|
3617
|
+
// Explicit native forms are intentionally untouched.
|
|
3618
|
+
const inferredByFieldSet = new Map();
|
|
3619
|
+
for (const candidate of inferredCandidates) {
|
|
3620
|
+
const fieldSet = collectDescendants(candidate, isFormFieldNode)
|
|
3621
|
+
.map(field => pathKey(field.path))
|
|
3622
|
+
.sort()
|
|
3623
|
+
.join('|');
|
|
3624
|
+
const current = inferredByFieldSet.get(fieldSet);
|
|
3625
|
+
if (!current) {
|
|
3626
|
+
inferredByFieldSet.set(fieldSet, candidate);
|
|
3627
|
+
continue;
|
|
3628
|
+
}
|
|
3629
|
+
const currentNamed = Boolean(sanitizeContainerName(current.name, 80));
|
|
3630
|
+
const candidateNamed = Boolean(sanitizeContainerName(candidate.name, 80));
|
|
3631
|
+
const candidateIsPreferred = candidateNamed !== currentNamed
|
|
3632
|
+
? candidateNamed
|
|
3633
|
+
: candidate.path.length > current.path.length || (candidate.path.length === current.path.length && pathKey(candidate.path) < pathKey(current.path));
|
|
3634
|
+
if (candidateIsPreferred)
|
|
3635
|
+
inferredByFieldSet.set(fieldSet, candidate);
|
|
3636
|
+
}
|
|
3637
|
+
const inferredForms = [...inferredByFieldSet.values()];
|
|
2817
3638
|
const forms = sortByBounds([...explicitForms, ...inferredForms]);
|
|
2818
3639
|
return forms
|
|
2819
3640
|
.filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
|
|
@@ -2884,6 +3705,7 @@ function formSchemaFieldToFormGraphField(field, opts) {
|
|
|
2884
3705
|
...(field.controlType ? { controlType: field.controlType } : {}),
|
|
2885
3706
|
...(field.booleanChoice ? { booleanChoice: true } : {}),
|
|
2886
3707
|
...(field.invalid ? { invalid: true } : {}),
|
|
3708
|
+
...(field.format ? { format: field.format } : {}),
|
|
2887
3709
|
...(field.context ? { context: field.context } : {}),
|
|
2888
3710
|
...(field.optionDetails ? { optionDetails: field.optionDetails } : {}),
|
|
2889
3711
|
},
|
|
@@ -3036,7 +3858,7 @@ export function expandPageSection(root, id, options) {
|
|
|
3036
3858
|
const maxTextPreview = options?.maxTextPreview ?? 6;
|
|
3037
3859
|
const includeBounds = options?.includeBounds ?? false;
|
|
3038
3860
|
const headingsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
|
|
3039
|
-
const fieldsAll = sortByBounds(collectDescendants(node,
|
|
3861
|
+
const fieldsAll = sortByBounds(collectDescendants(node, isFormFieldNode));
|
|
3040
3862
|
const actionsAll = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
|
|
3041
3863
|
const nestedListsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'list' && pathKey(candidate.path) !== pathKey(node.path)));
|
|
3042
3864
|
const itemsAll = actualKind === 'list'
|
|
@@ -3472,6 +4294,14 @@ function walkNode(element, layout, path) {
|
|
|
3472
4294
|
meta.inputType = semantic.inputType;
|
|
3473
4295
|
if (typeof semantic?.autocomplete === 'string')
|
|
3474
4296
|
meta.autocomplete = semantic.autocomplete;
|
|
4297
|
+
if (semantic?.fileInput === true)
|
|
4298
|
+
meta.fileInput = true;
|
|
4299
|
+
if (typeof semantic?.accept === 'string' && semantic.accept.trim().length > 0)
|
|
4300
|
+
meta.accept = semantic.accept.trim();
|
|
4301
|
+
if (semantic?.multiple === true)
|
|
4302
|
+
meta.multiple = true;
|
|
4303
|
+
if (semantic?.coordinateOnly === true)
|
|
4304
|
+
meta.coordinateOnly = true;
|
|
3475
4305
|
if (semantic?.isAutocompleteCombobox === true)
|
|
3476
4306
|
meta.isAutocompleteCombobox = true;
|
|
3477
4307
|
const children = [];
|
|
@@ -3575,30 +4405,186 @@ function applyPatches(layout, patches) {
|
|
|
3575
4405
|
}
|
|
3576
4406
|
async function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
|
|
3577
4407
|
await ensureSessionConnected(session);
|
|
3578
|
-
const
|
|
3579
|
-
|
|
3580
|
-
|
|
4408
|
+
const includesFileMutation = message.type === 'file' || (message.type === 'fillFields' &&
|
|
4409
|
+
Array.isArray(message.fields) &&
|
|
4410
|
+
message.fields.some(field => typeof field === 'object' && field !== null && field.kind === 'file'));
|
|
4411
|
+
if (includesFileMutation && session.peerProtocolCapabilities?.verifiedFileUploads !== true) {
|
|
4412
|
+
throw new Error('file_upload_capability_required: This peer does not advertise verified file uploads. Update @geometra/proxy to v1.65.0 or newer and reconnect. No file mutation was sent.');
|
|
4413
|
+
}
|
|
4414
|
+
const fingerprint = actionFingerprint(message);
|
|
4415
|
+
const existing = ambiguousOperationFor(session, fingerprint);
|
|
4416
|
+
if (existing) {
|
|
4417
|
+
return await sendPreparedActionOperation(session, existing, timeoutMs, opts, true);
|
|
4418
|
+
}
|
|
4419
|
+
if (isMutatingProxyAction(message))
|
|
4420
|
+
assertCanStartMutatingOperation(session, fingerprint);
|
|
3581
4421
|
const requiresExactFieldIdentity = typeof message.fieldKey === 'string' || (message.type === 'fillFields' &&
|
|
3582
4422
|
Array.isArray(message.fields) &&
|
|
3583
4423
|
message.fields.some(field => typeof field === 'object' && field !== null && typeof field.fieldKey === 'string'));
|
|
3584
|
-
|
|
4424
|
+
if (requiresExactFieldIdentity && session.peerProxyActionProtocolVersion !== undefined && (session.peerProxyActionProtocolVersion < PROXY_ACTION_PROTOCOL_VERSION ||
|
|
4425
|
+
session.peerProtocolCapabilities?.exactFieldIdentity === false)) {
|
|
4426
|
+
throw new Error(`Proxy protocol ${session.peerProxyActionProtocolVersion} cannot guarantee exact field identity; protocol ${PROXY_ACTION_PROTOCOL_VERSION}+ is required. Update and reconnect the Geometra proxy.`);
|
|
4427
|
+
}
|
|
4428
|
+
const actionId = randomUUID();
|
|
4429
|
+
const requestId = randomUUID();
|
|
4430
|
+
const actionTimeoutMs = actionTimeoutFor(session, message, timeoutMs);
|
|
4431
|
+
const wireMessage = {
|
|
4432
|
+
...message,
|
|
4433
|
+
...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
|
|
4434
|
+
requestId,
|
|
4435
|
+
...outboundProtocolMetadata(session),
|
|
4436
|
+
};
|
|
4437
|
+
const operation = {
|
|
4438
|
+
fingerprint,
|
|
4439
|
+
actionId,
|
|
4440
|
+
requestId,
|
|
4441
|
+
requestIds: [requestId],
|
|
4442
|
+
wireMessages: [JSON.stringify(wireMessage)],
|
|
4443
|
+
...(actionTimeoutMs !== undefined ? { actionTimeoutMs } : {}),
|
|
4444
|
+
timeoutMs,
|
|
4445
|
+
idempotent: session.peerTransport === 'proxy' &&
|
|
4446
|
+
session.peerProtocolCapabilities?.idempotentRequestIds === true,
|
|
4447
|
+
mutating: isMutatingProxyAction(message),
|
|
4448
|
+
...(opts?.requireUpdateOnAck ? { requireUpdateOnAck: true } : {}),
|
|
4449
|
+
...(requiresExactFieldIdentity ? { requiredProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION } :
|
|
4450
|
+
opts?.requiredProtocolVersion !== undefined ? { requiredProtocolVersion: opts.requiredProtocolVersion } : {}),
|
|
4451
|
+
};
|
|
4452
|
+
trackInFlightMutation(session, operation);
|
|
4453
|
+
return await sendPreparedActionOperation(session, operation, timeoutMs, {
|
|
3585
4454
|
...opts,
|
|
3586
|
-
...(requiresExactFieldIdentity ? { requiredProtocolVersion:
|
|
4455
|
+
...(requiresExactFieldIdentity ? { requiredProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION } : {}),
|
|
3587
4456
|
});
|
|
3588
4457
|
}
|
|
3589
|
-
function
|
|
4458
|
+
async function sendPreparedActionOperation(session, operation, timeoutMs, opts, retry = false) {
|
|
4459
|
+
if (operation.stickyError)
|
|
4460
|
+
throw operation.stickyError;
|
|
4461
|
+
if (operation.completion) {
|
|
4462
|
+
if (operation.completion.kind === 'error') {
|
|
4463
|
+
// Completion errors are restricted to proven non-execution. Deliver
|
|
4464
|
+
// that correlated evidence once, then allow a genuinely fresh intent.
|
|
4465
|
+
forgetAmbiguousOperation(session, operation);
|
|
4466
|
+
throw operation.completion.error;
|
|
4467
|
+
}
|
|
4468
|
+
if (!operation.permanentTombstone)
|
|
4469
|
+
forgetAmbiguousOperation(session, operation);
|
|
4470
|
+
return operation.completion.value;
|
|
4471
|
+
}
|
|
4472
|
+
if (operation.permanentTombstone) {
|
|
4473
|
+
// At least one indistinguishable caller already observed an unconfirmed
|
|
4474
|
+
// outcome. Never replay this fingerprint within the same session.
|
|
4475
|
+
return {
|
|
4476
|
+
status: 'timed_out',
|
|
4477
|
+
timeoutMs: operation.timeoutMs,
|
|
4478
|
+
requestId: operation.requestId,
|
|
4479
|
+
actionId: operation.actionId,
|
|
4480
|
+
};
|
|
4481
|
+
}
|
|
4482
|
+
if (retry && (!operation.idempotent ||
|
|
4483
|
+
session.peerTransport !== 'proxy' ||
|
|
4484
|
+
session.peerProtocolCapabilities?.idempotentRequestIds !== true)) {
|
|
4485
|
+
// An older/native peer cannot guarantee that replaying an identical wire
|
|
4486
|
+
// message is safe. The second indistinguishable caller therefore makes
|
|
4487
|
+
// this identity a permanent session tombstone even before the first
|
|
4488
|
+
// caller settles.
|
|
4489
|
+
operation.permanentTombstone = true;
|
|
4490
|
+
rememberAmbiguousOperation(session, operation);
|
|
4491
|
+
return {
|
|
4492
|
+
status: 'timed_out',
|
|
4493
|
+
timeoutMs: operation.timeoutMs,
|
|
4494
|
+
requestId: operation.requestId,
|
|
4495
|
+
actionId: operation.actionId,
|
|
4496
|
+
};
|
|
4497
|
+
}
|
|
4498
|
+
const startRevision = session.updateRevision;
|
|
4499
|
+
try {
|
|
4500
|
+
const result = await waitForNextUpdate(session, timeoutMs, operation, startRevision, {
|
|
4501
|
+
...(operation.requireUpdateOnAck ? { requireUpdateOnAck: true } : {}),
|
|
4502
|
+
...(operation.requiredProtocolVersion !== undefined
|
|
4503
|
+
? { requiredProtocolVersion: operation.requiredProtocolVersion }
|
|
4504
|
+
: {}),
|
|
4505
|
+
}, operation.idempotent, (transport) => {
|
|
4506
|
+
for (const wireMessage of operation.wireMessages)
|
|
4507
|
+
transport.send(wireMessage);
|
|
4508
|
+
});
|
|
4509
|
+
if (result.status === 'timed_out') {
|
|
4510
|
+
operation.permanentTombstone = true;
|
|
4511
|
+
rememberAmbiguousOperation(session, operation);
|
|
4512
|
+
return result;
|
|
4513
|
+
}
|
|
4514
|
+
retainTerminalResult(session, operation, result);
|
|
4515
|
+
if (!operation.permanentTombstone)
|
|
4516
|
+
forgetAmbiguousOperation(session, operation);
|
|
4517
|
+
return result;
|
|
4518
|
+
}
|
|
4519
|
+
catch (err) {
|
|
4520
|
+
const safelyDidNotExecute = err instanceof GeometraWireError &&
|
|
4521
|
+
err.code !== undefined &&
|
|
4522
|
+
SAFE_NON_EXECUTION_WIRE_CODES.has(err.code) &&
|
|
4523
|
+
operation.requestIds.length === 1;
|
|
4524
|
+
if (safelyDidNotExecute) {
|
|
4525
|
+
const terminalError = err instanceof Error ? err : new Error(String(err));
|
|
4526
|
+
retainTerminalError(operation, terminalError);
|
|
4527
|
+
// This caller received correlated proof that the mutation never began.
|
|
4528
|
+
// That delivery consumes the tombstone, even if another waiter had
|
|
4529
|
+
// previously timed out.
|
|
4530
|
+
forgetAmbiguousOperation(session, operation);
|
|
4531
|
+
throw terminalError;
|
|
4532
|
+
}
|
|
4533
|
+
if (operation.mutating) {
|
|
4534
|
+
// A transport/handler/extraction error can arrive after a browser-side
|
|
4535
|
+
// mutation. Preserve the exact identity so a later call cannot create
|
|
4536
|
+
// a fresh mutation merely because the first response was an error.
|
|
4537
|
+
rememberAmbiguousOperation(session, operation);
|
|
4538
|
+
const ambiguousError = stickyAmbiguousError(operation, err);
|
|
4539
|
+
operation.wireMessages = [];
|
|
4540
|
+
operation.stickyError = ambiguousError;
|
|
4541
|
+
throw ambiguousError;
|
|
4542
|
+
}
|
|
4543
|
+
else {
|
|
4544
|
+
forgetAmbiguousOperation(session, operation);
|
|
4545
|
+
}
|
|
4546
|
+
throw err;
|
|
4547
|
+
}
|
|
4548
|
+
}
|
|
4549
|
+
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, operation, startRevision = session.updateRevision, opts, ignoreDuplicateRequestError = false, sendAfterSubscribe) {
|
|
3590
4550
|
return new Promise((resolve, reject) => {
|
|
4551
|
+
const transport = session.ws;
|
|
4552
|
+
const { requestId, actionId } = operation;
|
|
4553
|
+
const operationRequestIds = new Set(operation.requestIds);
|
|
3591
4554
|
let ackSeen = false;
|
|
3592
4555
|
let ackResult;
|
|
3593
|
-
const ackPayload = () => (
|
|
4556
|
+
const ackPayload = () => ({
|
|
4557
|
+
requestId,
|
|
4558
|
+
actionId,
|
|
4559
|
+
...(ackSeen && ackResult !== undefined ? { result: ackResult } : {}),
|
|
4560
|
+
});
|
|
3594
4561
|
const onMessage = (data) => {
|
|
4562
|
+
if (session.ws !== transport)
|
|
4563
|
+
return;
|
|
3595
4564
|
try {
|
|
3596
|
-
const msg =
|
|
4565
|
+
const msg = parseInboundServerMessage(data);
|
|
4566
|
+
updatePeerProtocol(session, msg);
|
|
3597
4567
|
const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
3598
4568
|
if (requestId) {
|
|
3599
|
-
|
|
4569
|
+
const requiresScopedAck = session.peerProtocolCapabilities?.requestScopedAcks === true;
|
|
4570
|
+
if (ignoreDuplicateRequestError &&
|
|
4571
|
+
msg.type === 'error' &&
|
|
4572
|
+
messageRequestId !== undefined &&
|
|
4573
|
+
operationRequestIds.has(messageRequestId) &&
|
|
4574
|
+
msg.code === 'DUPLICATE_REQUEST') {
|
|
4575
|
+
// The proxy has guaranteed that this replay did not mutate. Keep
|
|
4576
|
+
// waiting for the original action's late correlated terminal ACK.
|
|
4577
|
+
return;
|
|
4578
|
+
}
|
|
4579
|
+
if (operation.stickyError) {
|
|
3600
4580
|
cleanup();
|
|
3601
|
-
reject(
|
|
4581
|
+
reject(operation.stickyError);
|
|
4582
|
+
return;
|
|
4583
|
+
}
|
|
4584
|
+
if (msg.type === 'error' && ((messageRequestId !== undefined && operationRequestIds.has(messageRequestId)) ||
|
|
4585
|
+
(!requiresScopedAck && messageRequestId === undefined))) {
|
|
4586
|
+
cleanup();
|
|
4587
|
+
reject(wireErrorFromMessage(msg, actionId));
|
|
3602
4588
|
return;
|
|
3603
4589
|
}
|
|
3604
4590
|
if ((msg.type === 'frame' || (msg.type === 'patch' && session.layout)) && ackSeen && session.updateRevision > startRevision) {
|
|
@@ -3611,10 +4597,18 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
3611
4597
|
return;
|
|
3612
4598
|
}
|
|
3613
4599
|
if (msg.type === 'ack' && messageRequestId === requestId) {
|
|
3614
|
-
const peerProtocolVersion = typeof msg.
|
|
4600
|
+
const peerProtocolVersion = typeof msg.proxyActionProtocolVersion === 'number'
|
|
4601
|
+
? msg.proxyActionProtocolVersion
|
|
4602
|
+
: typeof msg.protocolVersion === 'number'
|
|
4603
|
+
? msg.protocolVersion
|
|
4604
|
+
: session.peerProxyActionProtocolVersion;
|
|
3615
4605
|
if (opts?.requiredProtocolVersion !== undefined && (peerProtocolVersion === undefined || peerProtocolVersion < opts.requiredProtocolVersion)) {
|
|
3616
4606
|
cleanup();
|
|
3617
|
-
|
|
4607
|
+
const protocolError = stickyAmbiguousError(operation, new Error(`Proxy protocol ${peerProtocolVersion ?? 'unknown'} cannot guarantee exact field identity; protocol ${opts.requiredProtocolVersion}+ is required. Update and reconnect the Geometra proxy.`));
|
|
4608
|
+
operation.stickyError = protocolError;
|
|
4609
|
+
if (operation.mutating)
|
|
4610
|
+
rememberAmbiguousOperation(session, operation);
|
|
4611
|
+
reject(protocolError);
|
|
3618
4612
|
return;
|
|
3619
4613
|
}
|
|
3620
4614
|
ackSeen = true;
|
|
@@ -3632,45 +4626,95 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
3632
4626
|
}
|
|
3633
4627
|
if (msg.type === 'error') {
|
|
3634
4628
|
cleanup();
|
|
3635
|
-
reject(
|
|
4629
|
+
reject(wireErrorFromMessage(msg, actionId));
|
|
3636
4630
|
return;
|
|
3637
4631
|
}
|
|
3638
4632
|
if (msg.type === 'frame') {
|
|
3639
4633
|
cleanup();
|
|
3640
|
-
resolve({ status: 'updated', timeoutMs });
|
|
4634
|
+
resolve({ status: 'updated', timeoutMs, ...ackPayload() });
|
|
3641
4635
|
}
|
|
3642
4636
|
else if (msg.type === 'patch' && session.layout) {
|
|
3643
4637
|
cleanup();
|
|
3644
|
-
resolve({ status: 'updated', timeoutMs });
|
|
4638
|
+
resolve({ status: 'updated', timeoutMs, ...ackPayload() });
|
|
3645
4639
|
}
|
|
3646
4640
|
else if (msg.type === 'ack') {
|
|
3647
4641
|
cleanup();
|
|
3648
4642
|
resolve({
|
|
3649
4643
|
status: 'acknowledged',
|
|
3650
4644
|
timeoutMs,
|
|
4645
|
+
requestId,
|
|
4646
|
+
actionId,
|
|
3651
4647
|
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
3652
4648
|
});
|
|
3653
4649
|
}
|
|
3654
4650
|
}
|
|
3655
|
-
catch {
|
|
4651
|
+
catch (err) {
|
|
4652
|
+
cleanup();
|
|
4653
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
4654
|
+
}
|
|
3656
4655
|
};
|
|
3657
4656
|
// Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
|
|
3658
4657
|
const timeout = setTimeout(() => {
|
|
3659
4658
|
cleanup();
|
|
3660
|
-
if (
|
|
4659
|
+
if (session.ws !== transport) {
|
|
4660
|
+
if (operation.mutating)
|
|
4661
|
+
rememberAmbiguousOperation(session, operation);
|
|
4662
|
+
resolve({ status: 'timed_out', timeoutMs, requestId, actionId });
|
|
4663
|
+
return;
|
|
4664
|
+
}
|
|
4665
|
+
const requiresScopedAck = session.peerProtocolCapabilities?.requestScopedAcks === true;
|
|
4666
|
+
if (requestId && !requiresScopedAck && session.updateRevision > startRevision) {
|
|
3661
4667
|
resolve({ status: 'updated', timeoutMs, ...ackPayload() });
|
|
3662
4668
|
return;
|
|
3663
4669
|
}
|
|
3664
|
-
if (requestId && ackSeen) {
|
|
4670
|
+
if (requestId && ackSeen && (!opts?.requireUpdateOnAck || session.updateRevision > startRevision)) {
|
|
3665
4671
|
resolve({ status: 'acknowledged', timeoutMs, ...ackPayload() });
|
|
3666
4672
|
return;
|
|
3667
4673
|
}
|
|
3668
|
-
|
|
4674
|
+
if (operation.mutating)
|
|
4675
|
+
rememberAmbiguousOperation(session, operation);
|
|
4676
|
+
resolve({ status: 'timed_out', timeoutMs, requestId, actionId });
|
|
3669
4677
|
}, timeoutMs);
|
|
4678
|
+
const onClose = () => {
|
|
4679
|
+
cleanup();
|
|
4680
|
+
if (operation.mutating)
|
|
4681
|
+
rememberAmbiguousOperation(session, operation);
|
|
4682
|
+
reject(new GeometraWireError('Action transport closed before a correlated terminal response arrived', 'TRANSPORT_CLOSED', requestId, actionId));
|
|
4683
|
+
};
|
|
4684
|
+
const onError = (error) => {
|
|
4685
|
+
cleanup();
|
|
4686
|
+
if (operation.mutating)
|
|
4687
|
+
rememberAmbiguousOperation(session, operation);
|
|
4688
|
+
reject(new GeometraWireError(`Action transport failed before a correlated terminal response arrived: ${error.message}`, 'TRANSPORT_ERROR', requestId, actionId));
|
|
4689
|
+
};
|
|
3670
4690
|
function cleanup() {
|
|
3671
4691
|
clearTimeout(timeout);
|
|
3672
|
-
|
|
4692
|
+
transport.off('message', onMessage);
|
|
4693
|
+
transport.off('close', onClose);
|
|
4694
|
+
transport.off('error', onError);
|
|
4695
|
+
}
|
|
4696
|
+
transport.on('message', onMessage);
|
|
4697
|
+
transport.on('close', onClose);
|
|
4698
|
+
transport.on('error', onError);
|
|
4699
|
+
if (operation.completion) {
|
|
4700
|
+
cleanup();
|
|
4701
|
+
if (operation.completion.kind === 'error')
|
|
4702
|
+
reject(operation.completion.error);
|
|
4703
|
+
else
|
|
4704
|
+
resolve(operation.completion.value);
|
|
4705
|
+
return;
|
|
4706
|
+
}
|
|
4707
|
+
if (sendAfterSubscribe) {
|
|
4708
|
+
try {
|
|
4709
|
+
if (session.ws !== transport || transport.readyState !== WebSocket.OPEN) {
|
|
4710
|
+
throw new Error('Action transport changed before the request could be sent');
|
|
4711
|
+
}
|
|
4712
|
+
sendAfterSubscribe(transport);
|
|
4713
|
+
}
|
|
4714
|
+
catch (err) {
|
|
4715
|
+
cleanup();
|
|
4716
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
4717
|
+
}
|
|
3673
4718
|
}
|
|
3674
|
-
session.ws.on('message', onMessage);
|
|
3675
4719
|
});
|
|
3676
4720
|
}
|