@geometra/mcp 1.63.2 → 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 +1557 -526
- package/dist/session-state.js +262 -80
- package/dist/session.d.ts +189 -13
- package/dist/session.js +1600 -319
- 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,12 +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
|
-
|
|
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
|
+
}
|
|
41
424
|
function invalidateSessionCaches(session) {
|
|
42
425
|
session.cachedA11y = null;
|
|
43
426
|
session.cachedA11yRevision = -1;
|
|
44
427
|
session.cachedFormSchemas?.clear();
|
|
45
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
|
+
}
|
|
46
463
|
function sameReusableProxyEntry(entry, proxy) {
|
|
47
464
|
return ('child' in proxy && !!entry.child && entry.child === proxy.child)
|
|
48
465
|
|| ('runtime' in proxy && !!entry.runtime && entry.runtime === proxy.runtime);
|
|
@@ -61,6 +478,8 @@ function reusableProxyEntryIsActive(entry) {
|
|
|
61
478
|
}
|
|
62
479
|
function clearReusableProxiesIfExited() {
|
|
63
480
|
reusableProxies = reusableProxies.filter(entry => {
|
|
481
|
+
if (entry.closed)
|
|
482
|
+
return false;
|
|
64
483
|
if (entry.child) {
|
|
65
484
|
return !entry.child.killed && entry.child.exitCode === null && entry.child.signalCode === null;
|
|
66
485
|
}
|
|
@@ -75,7 +494,27 @@ function updateReusableProxySnapshotState(entry, session) {
|
|
|
75
494
|
entry.snapshotReady = true;
|
|
76
495
|
}
|
|
77
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
|
+
}
|
|
78
514
|
function closeReusableProxy(entry) {
|
|
515
|
+
if (entry.closed)
|
|
516
|
+
return;
|
|
517
|
+
entry.closed = true;
|
|
79
518
|
reusableProxies = reusableProxies.filter(candidate => candidate !== entry);
|
|
80
519
|
if (entry.child) {
|
|
81
520
|
try {
|
|
@@ -87,24 +526,15 @@ function closeReusableProxy(entry) {
|
|
|
87
526
|
ensureIdleProxyTimer();
|
|
88
527
|
return;
|
|
89
528
|
}
|
|
90
|
-
|
|
529
|
+
if (entry.runtime)
|
|
530
|
+
void closeEmbeddedProxyRuntime(entry.runtime);
|
|
91
531
|
ensureIdleProxyTimer();
|
|
92
532
|
}
|
|
93
533
|
function closeReusableProxies() {
|
|
94
534
|
clearReusableProxiesIfExited();
|
|
95
535
|
const proxies = [...reusableProxies];
|
|
96
|
-
reusableProxies = [];
|
|
97
536
|
for (const entry of proxies) {
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
entry.child.kill('SIGTERM');
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
/* ignore */
|
|
104
|
-
}
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
void entry.runtime?.close().catch(() => { });
|
|
537
|
+
closeReusableProxy(entry);
|
|
108
538
|
}
|
|
109
539
|
}
|
|
110
540
|
function evictIdleReusableProxies() {
|
|
@@ -142,11 +572,13 @@ function enforceReusableProxyPoolLimit() {
|
|
|
142
572
|
function setReusableProxy(proxy, wsUrl, opts) {
|
|
143
573
|
clearReusableProxiesIfExited();
|
|
144
574
|
const now = Date.now();
|
|
575
|
+
const authToken = 'child' in proxy ? proxy.authToken : proxy.runtime.authToken;
|
|
145
576
|
const proxyKey = proxyKeyFor(opts.proxy);
|
|
146
577
|
const stealth = resolveStealthMode(opts.stealth);
|
|
147
578
|
const existing = reusableProxies.find(entry => sameReusableProxyEntry(entry, proxy));
|
|
148
579
|
if (existing) {
|
|
149
580
|
existing.wsUrl = wsUrl;
|
|
581
|
+
existing.authToken = authToken;
|
|
150
582
|
existing.headless = opts.headless !== false;
|
|
151
583
|
existing.stealth = stealth;
|
|
152
584
|
existing.slowMo = opts.slowMo ?? 0;
|
|
@@ -163,6 +595,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
163
595
|
const entry = {
|
|
164
596
|
child,
|
|
165
597
|
wsUrl,
|
|
598
|
+
authToken,
|
|
166
599
|
headless: opts.headless !== false,
|
|
167
600
|
stealth,
|
|
168
601
|
slowMo: opts.slowMo ?? 0,
|
|
@@ -190,6 +623,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
190
623
|
reusableProxies.push({
|
|
191
624
|
runtime: proxy.runtime,
|
|
192
625
|
wsUrl,
|
|
626
|
+
authToken,
|
|
193
627
|
headless: opts.headless !== false,
|
|
194
628
|
stealth,
|
|
195
629
|
slowMo: opts.slowMo ?? 0,
|
|
@@ -235,23 +669,15 @@ function promoteDefaultSession() {
|
|
|
235
669
|
}
|
|
236
670
|
defaultSessionId = null;
|
|
237
671
|
}
|
|
238
|
-
/**
|
|
239
|
-
* Clear `defaultSessionId` if it currently points at the given session id.
|
|
240
|
-
* Used after tagging a fresh session as isolated — `connect()` set the
|
|
241
|
-
* default pointer before the isolation flag was applied, and we need to
|
|
242
|
-
* retract that assignment so this session is only addressable by its
|
|
243
|
-
* explicit id.
|
|
244
|
-
*/
|
|
245
|
-
function retractDefaultIfPointsTo(sessionId) {
|
|
246
|
-
if (defaultSessionId === sessionId) {
|
|
247
|
-
promoteDefaultSession();
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
672
|
function shutdownSession(id, opts) {
|
|
251
673
|
const prev = activeSessions.get(id);
|
|
252
674
|
if (!prev)
|
|
253
675
|
return;
|
|
254
|
-
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;
|
|
255
681
|
safeCompleteSessionLifecycle(prev, opts?.reason ?? 'disconnect', {
|
|
256
682
|
closeProxy: opts?.closeProxy ?? false,
|
|
257
683
|
forceCloseProxy,
|
|
@@ -260,7 +686,8 @@ function shutdownSession(id, opts) {
|
|
|
260
686
|
if (defaultSessionId === id)
|
|
261
687
|
promoteDefaultSession();
|
|
262
688
|
stopSessionHeartbeat(prev);
|
|
263
|
-
releaseSessionResources(prev, { closeProxy: opts?.closeProxy ?? false });
|
|
689
|
+
releaseSessionResources(prev, { closeProxy: (opts?.closeProxy ?? false) || hasUnsettledMutation });
|
|
690
|
+
invalidateSessionUiState(prev);
|
|
264
691
|
}
|
|
265
692
|
function releaseSessionResources(session, opts) {
|
|
266
693
|
try {
|
|
@@ -311,19 +738,65 @@ function releaseSessionResources(session, opts) {
|
|
|
311
738
|
closeReusableProxy(entry);
|
|
312
739
|
return;
|
|
313
740
|
}
|
|
314
|
-
void session.proxyRuntime
|
|
741
|
+
void closeEmbeddedProxyRuntime(session.proxyRuntime);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
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);
|
|
315
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;
|
|
316
769
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (activeSessions.size < MAX_ACTIVE_SESSIONS)
|
|
770
|
+
function releaseSessionOwnership(lease) {
|
|
771
|
+
if (!lease.reserved)
|
|
320
772
|
return;
|
|
321
|
-
|
|
322
|
-
|
|
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);
|
|
323
787
|
}
|
|
324
788
|
function formatUnknownError(err) {
|
|
325
789
|
return err instanceof Error ? err.message : String(err);
|
|
326
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
|
+
}
|
|
327
800
|
function rejectOnRuntimeReadyFailure(runtime) {
|
|
328
801
|
return new Promise((_, reject) => {
|
|
329
802
|
runtime.ready.catch(reject);
|
|
@@ -423,11 +896,8 @@ function reusableProxyMatchesOptions(entry, options) {
|
|
|
423
896
|
function findExactReusableProxy(options) {
|
|
424
897
|
clearReusableProxiesIfExited();
|
|
425
898
|
return reusableProxies
|
|
426
|
-
.filter(entry => reusableProxyMatchesOptions(entry, options))
|
|
427
|
-
.sort((a, b) =>
|
|
428
|
-
const activeBonus = reusableProxyEntryIsActive(b) ? 1 : reusableProxyEntryIsActive(a) ? -1 : 0;
|
|
429
|
-
return activeBonus || b.lastUsedAt - a.lastUsedAt;
|
|
430
|
-
})[0];
|
|
899
|
+
.filter(entry => !reusableProxyEntryIsActive(entry) && reusableProxyMatchesOptions(entry, options))
|
|
900
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0];
|
|
431
901
|
}
|
|
432
902
|
function findReusableProxy(options) {
|
|
433
903
|
clearReusableProxiesIfExited();
|
|
@@ -438,7 +908,8 @@ function findReusableProxy(options) {
|
|
|
438
908
|
const desiredHeight = options.height ?? 720;
|
|
439
909
|
const desiredProxyKey = proxyKeyFor(options.proxy);
|
|
440
910
|
return reusableProxies
|
|
441
|
-
.filter(entry => entry
|
|
911
|
+
.filter(entry => !reusableProxyEntryIsActive(entry)
|
|
912
|
+
&& entry.headless === desiredHeadless
|
|
442
913
|
&& entry.stealth === desiredStealth
|
|
443
914
|
&& entry.slowMo === desiredSlowMo
|
|
444
915
|
// Proxy partition is hard: a session with a caller-provided proxy MUST
|
|
@@ -452,8 +923,6 @@ function findReusableProxy(options) {
|
|
|
452
923
|
value += 100;
|
|
453
924
|
if (entry.width === desiredWidth && entry.height === desiredHeight)
|
|
454
925
|
value += 10;
|
|
455
|
-
if (reusableProxyEntryIsActive(entry))
|
|
456
|
-
value += 5;
|
|
457
926
|
return value;
|
|
458
927
|
};
|
|
459
928
|
return score(b) - score(a) || b.lastUsedAt - a.lastUsedAt;
|
|
@@ -492,7 +961,7 @@ export async function prewarmProxy(options) {
|
|
|
492
961
|
await runtime.ready;
|
|
493
962
|
}
|
|
494
963
|
catch (err) {
|
|
495
|
-
await runtime
|
|
964
|
+
await closeEmbeddedProxyRuntime(runtime);
|
|
496
965
|
throw err;
|
|
497
966
|
}
|
|
498
967
|
setReusableProxy({ runtime }, wsUrl, {
|
|
@@ -521,7 +990,7 @@ export async function prewarmProxy(options) {
|
|
|
521
990
|
embeddedFailure = err;
|
|
522
991
|
}
|
|
523
992
|
try {
|
|
524
|
-
const { child, wsUrl } = await spawnGeometraProxy({
|
|
993
|
+
const { child, wsUrl, authToken } = await spawnGeometraProxy({
|
|
525
994
|
pageUrl: options.pageUrl,
|
|
526
995
|
port: options.port ?? 0,
|
|
527
996
|
headless: options.headless,
|
|
@@ -531,7 +1000,7 @@ export async function prewarmProxy(options) {
|
|
|
531
1000
|
stealth: options.stealth,
|
|
532
1001
|
proxy: options.proxy,
|
|
533
1002
|
});
|
|
534
|
-
setReusableProxy({ child }, wsUrl, {
|
|
1003
|
+
setReusableProxy({ child, authToken }, wsUrl, {
|
|
535
1004
|
headless: options.headless,
|
|
536
1005
|
slowMo: options.slowMo,
|
|
537
1006
|
stealth: options.stealth,
|
|
@@ -561,17 +1030,15 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
561
1030
|
const desiredWidth = options.width ?? proxy.width;
|
|
562
1031
|
const desiredHeight = options.height ?? proxy.height;
|
|
563
1032
|
const needsSnapshotKickoff = options.awaitInitialFrame !== false && !proxy.snapshotReady;
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if ((proxy.child && s.proxyChild === proxy.child) || (proxy.runtime && s.proxyRuntime === proxy.runtime)) {
|
|
567
|
-
reusedExistingSession = s;
|
|
568
|
-
break;
|
|
569
|
-
}
|
|
1033
|
+
if (reusableProxyEntryIsActive(proxy)) {
|
|
1034
|
+
throw new Error('Reusable proxy already has an active session owner');
|
|
570
1035
|
}
|
|
571
|
-
const session =
|
|
1036
|
+
const session = await connect(proxy.wsUrl, {
|
|
572
1037
|
skipInitialResize: true,
|
|
573
1038
|
closePreviousProxy: false,
|
|
574
1039
|
awaitInitialFrame: needsSnapshotKickoff ? false : options.awaitInitialFrame,
|
|
1040
|
+
authToken: proxy.authToken,
|
|
1041
|
+
isolated: false,
|
|
575
1042
|
});
|
|
576
1043
|
if (!session) {
|
|
577
1044
|
throw new Error('Failed to attach to reusable proxy session');
|
|
@@ -580,166 +1047,103 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
580
1047
|
session.proxyRuntime = proxy.runtime;
|
|
581
1048
|
session.proxyReusable = true;
|
|
582
1049
|
touchReusableProxy(proxy);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
};
|
|
593
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;
|
|
594
1093
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
proxy.pageUrl = options.pageUrl;
|
|
602
|
-
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;
|
|
603
1100
|
}
|
|
604
|
-
const baseConnectTrace = !reusedExistingSession ? session.connectTrace : undefined;
|
|
605
|
-
session.connectTrace = {
|
|
606
|
-
mode: 'reused-proxy',
|
|
607
|
-
reused: true,
|
|
608
|
-
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
609
|
-
connectMs: baseConnectTrace?.totalMs ?? 0,
|
|
610
|
-
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
611
|
-
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
612
|
-
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
613
|
-
snapshotKickoff: needsSnapshotKickoff,
|
|
614
|
-
resizeKickoffMs,
|
|
615
|
-
navigateMs,
|
|
616
|
-
totalMs: performance.now() - startedAt,
|
|
617
|
-
};
|
|
618
|
-
updateReusableProxySnapshotState(proxy, session);
|
|
619
|
-
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
620
|
-
transportMode: 'reused-proxy',
|
|
621
|
-
targetPageUrl: options.pageUrl,
|
|
622
|
-
reusedExistingSession: reusedExistingSession !== null,
|
|
623
|
-
});
|
|
624
|
-
return session;
|
|
625
1101
|
}
|
|
626
|
-
async function startFreshProxySession(options) {
|
|
1102
|
+
async function startFreshProxySession(options, initialOwnershipLease) {
|
|
627
1103
|
const startedAt = performance.now();
|
|
628
1104
|
const eagerInitialExtract = options.eagerInitialExtract !== undefined
|
|
629
1105
|
? options.eagerInitialExtract
|
|
630
1106
|
: options.awaitInitialFrame !== false
|
|
631
1107
|
? undefined
|
|
632
1108
|
: false;
|
|
1109
|
+
let ownershipLease = reserveSessionOwnership(initialOwnershipLease);
|
|
633
1110
|
let pendingEmbeddedRuntime;
|
|
1111
|
+
let pendingEmbeddedConnect;
|
|
1112
|
+
let attachedEmbeddedSession;
|
|
634
1113
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
width: options.width,
|
|
641
|
-
height: options.height,
|
|
642
|
-
slowMo: options.slowMo,
|
|
643
|
-
stealth: options.stealth,
|
|
644
|
-
eagerInitialExtract,
|
|
645
|
-
proxy: options.proxy,
|
|
646
|
-
});
|
|
647
|
-
pendingEmbeddedRuntime = runtime;
|
|
648
|
-
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
649
|
-
const session = await Promise.race([
|
|
650
|
-
connect(wsUrl, {
|
|
651
|
-
skipInitialResize: true,
|
|
652
|
-
closePreviousProxy: false,
|
|
653
|
-
awaitInitialFrame: options.awaitInitialFrame,
|
|
654
|
-
}),
|
|
655
|
-
rejectOnRuntimeReadyFailure(runtime),
|
|
656
|
-
]);
|
|
657
|
-
// Connect succeeded — the session now owns the runtime, so the
|
|
658
|
-
// catch-block cleanup below must not also close it.
|
|
659
|
-
pendingEmbeddedRuntime = undefined;
|
|
660
|
-
session.proxyRuntime = runtime;
|
|
661
|
-
session.proxyReusable = !options.isolated;
|
|
662
|
-
if (options.isolated) {
|
|
663
|
-
session.isolated = true;
|
|
664
|
-
// connect() already set defaultSessionId to this session. Retract
|
|
665
|
-
// that assignment so the isolated session is only addressable by its
|
|
666
|
-
// explicit id — the implicit-default fallback is the contamination
|
|
667
|
-
// vector we're fixing, and an isolated session must never be the
|
|
668
|
-
// implicit target of a tool call that omits sessionId.
|
|
669
|
-
retractDefaultIfPointsTo(session.id);
|
|
670
|
-
}
|
|
671
|
-
else {
|
|
672
|
-
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,
|
|
673
1119
|
headless: options.headless,
|
|
674
|
-
slowMo: options.slowMo,
|
|
675
|
-
stealth: options.stealth,
|
|
676
1120
|
width: options.width,
|
|
677
1121
|
height: options.height,
|
|
678
|
-
|
|
679
|
-
|
|
1122
|
+
slowMo: options.slowMo,
|
|
1123
|
+
stealth: options.stealth,
|
|
1124
|
+
eagerInitialExtract,
|
|
680
1125
|
proxy: options.proxy,
|
|
681
1126
|
});
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
mode: 'fresh-proxy',
|
|
686
|
-
reused: false,
|
|
687
|
-
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
688
|
-
proxyStartMode: 'embedded',
|
|
689
|
-
proxyStartMs,
|
|
690
|
-
connectMs: baseConnectTrace?.totalMs,
|
|
691
|
-
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
692
|
-
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
693
|
-
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
694
|
-
totalMs: performance.now() - startedAt,
|
|
695
|
-
};
|
|
696
|
-
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
697
|
-
transportMode: 'fresh-proxy',
|
|
698
|
-
proxyStartMode: 'embedded',
|
|
699
|
-
requestedPageUrl: options.pageUrl,
|
|
700
|
-
isolated: options.isolated === true,
|
|
701
|
-
});
|
|
702
|
-
return session;
|
|
703
|
-
}
|
|
704
|
-
catch (e) {
|
|
705
|
-
// If startEmbeddedGeometraProxy() returned but the subsequent connect()
|
|
706
|
-
// threw (e.g. WebSocket failure, first-frame timeout), the runtime
|
|
707
|
-
// still owns a launched Chromium that nobody is going to clean up.
|
|
708
|
-
// Without this, that Chromium leaks for the lifetime of the MCP server
|
|
709
|
-
// every time we fall through to the child-process fallback path.
|
|
710
|
-
if (pendingEmbeddedRuntime) {
|
|
711
|
-
const leaked = pendingEmbeddedRuntime;
|
|
712
|
-
void leaked.close().catch(() => { });
|
|
713
|
-
}
|
|
714
|
-
const proxyStartStartedAt = performance.now();
|
|
715
|
-
const { child, wsUrl } = await spawnGeometraProxy({
|
|
716
|
-
pageUrl: options.pageUrl,
|
|
717
|
-
port: options.port ?? 0,
|
|
718
|
-
headless: options.headless,
|
|
719
|
-
width: options.width,
|
|
720
|
-
height: options.height,
|
|
721
|
-
slowMo: options.slowMo,
|
|
722
|
-
stealth: options.stealth,
|
|
723
|
-
eagerInitialExtract,
|
|
724
|
-
proxy: options.proxy,
|
|
725
|
-
});
|
|
726
|
-
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
727
|
-
try {
|
|
728
|
-
const session = await connect(wsUrl, {
|
|
1127
|
+
pendingEmbeddedRuntime = runtime;
|
|
1128
|
+
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
1129
|
+
pendingEmbeddedConnect = connectWithOwnership(wsUrl, {
|
|
729
1130
|
skipInitialResize: true,
|
|
730
1131
|
closePreviousProxy: false,
|
|
731
1132
|
awaitInitialFrame: options.awaitInitialFrame,
|
|
1133
|
+
authToken: runtime.authToken,
|
|
1134
|
+
isolated: options.isolated === true,
|
|
1135
|
+
ownershipLease,
|
|
732
1136
|
});
|
|
733
|
-
session
|
|
1137
|
+
const session = await Promise.race([
|
|
1138
|
+
pendingEmbeddedConnect,
|
|
1139
|
+
rejectOnRuntimeReadyFailure(runtime),
|
|
1140
|
+
]);
|
|
1141
|
+
pendingEmbeddedConnect = undefined;
|
|
1142
|
+
attachedEmbeddedSession = session;
|
|
1143
|
+
session.proxyRuntime = runtime;
|
|
734
1144
|
session.proxyReusable = !options.isolated;
|
|
735
|
-
if (options.isolated) {
|
|
736
|
-
|
|
737
|
-
// See the embedded-runtime path above — an isolated session must
|
|
738
|
-
// not be the implicit default. Retract the pointer set by connect().
|
|
739
|
-
retractDefaultIfPointsTo(session.id);
|
|
740
|
-
}
|
|
741
|
-
else {
|
|
742
|
-
setReusableProxy({ child }, wsUrl, {
|
|
1145
|
+
if (!options.isolated) {
|
|
1146
|
+
setReusableProxy({ runtime }, wsUrl, {
|
|
743
1147
|
headless: options.headless,
|
|
744
1148
|
slowMo: options.slowMo,
|
|
745
1149
|
stealth: options.stealth,
|
|
@@ -755,7 +1159,7 @@ async function startFreshProxySession(options) {
|
|
|
755
1159
|
mode: 'fresh-proxy',
|
|
756
1160
|
reused: false,
|
|
757
1161
|
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
758
|
-
proxyStartMode: '
|
|
1162
|
+
proxyStartMode: 'embedded',
|
|
759
1163
|
proxyStartMs,
|
|
760
1164
|
connectMs: baseConnectTrace?.totalMs,
|
|
761
1165
|
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
@@ -765,39 +1169,155 @@ async function startFreshProxySession(options) {
|
|
|
765
1169
|
};
|
|
766
1170
|
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
767
1171
|
transportMode: 'fresh-proxy',
|
|
768
|
-
proxyStartMode: '
|
|
769
|
-
|
|
1172
|
+
proxyStartMode: 'embedded',
|
|
1173
|
+
requestedPageOrigin: lifecycleUrlOrigin(options.pageUrl),
|
|
770
1174
|
isolated: options.isolated === true,
|
|
771
1175
|
});
|
|
772
1176
|
return session;
|
|
773
1177
|
}
|
|
774
|
-
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;
|
|
775
1201
|
try {
|
|
776
|
-
|
|
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;
|
|
777
1259
|
}
|
|
778
|
-
catch {
|
|
779
|
-
|
|
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 });
|
|
780
1276
|
}
|
|
781
|
-
throw fallbackError instanceof Error ? fallbackError : e;
|
|
782
1277
|
}
|
|
783
1278
|
}
|
|
1279
|
+
finally {
|
|
1280
|
+
releaseSessionOwnership(ownershipLease);
|
|
1281
|
+
}
|
|
784
1282
|
}
|
|
785
1283
|
/**
|
|
786
1284
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
787
1285
|
* layout/tree state is available immediately after connection.
|
|
788
1286
|
*/
|
|
789
1287
|
export function connect(url, opts) {
|
|
1288
|
+
return connectWithOwnership(url, opts);
|
|
1289
|
+
}
|
|
1290
|
+
function connectWithOwnership(url, opts) {
|
|
790
1291
|
return new Promise((resolve, reject) => {
|
|
791
1292
|
const startedAt = performance.now();
|
|
792
1293
|
clearReusableProxiesIfExited();
|
|
793
|
-
|
|
794
|
-
|
|
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
|
+
}
|
|
795
1313
|
const session = {
|
|
796
1314
|
id: generateSessionId(),
|
|
797
1315
|
ws,
|
|
798
1316
|
layout: null,
|
|
799
1317
|
tree: null,
|
|
800
1318
|
url,
|
|
1319
|
+
...(opts?.authToken ? { transportAuthToken: opts.authToken } : {}),
|
|
1320
|
+
isolated: opts?.isolated === true,
|
|
801
1321
|
updateRevision: 0,
|
|
802
1322
|
connectTrace: {
|
|
803
1323
|
mode: 'direct-ws',
|
|
@@ -808,6 +1328,8 @@ export function connect(url, opts) {
|
|
|
808
1328
|
cachedA11y: null,
|
|
809
1329
|
cachedA11yRevision: -1,
|
|
810
1330
|
cachedFormSchemas: new Map(),
|
|
1331
|
+
hasFreshFrame: false,
|
|
1332
|
+
disposed: false,
|
|
811
1333
|
heartbeatInterval: null,
|
|
812
1334
|
heartbeatLastMessageAt: Date.now(),
|
|
813
1335
|
heartbeatPendingPongBy: null,
|
|
@@ -822,6 +1344,7 @@ export function connect(url, opts) {
|
|
|
822
1344
|
catch {
|
|
823
1345
|
/* ignore */
|
|
824
1346
|
}
|
|
1347
|
+
releaseSessionOwnership(ownershipLease);
|
|
825
1348
|
reject(new Error(`Failed to initialize durable session state for ${url}: ${formatUnknownError(err)}`));
|
|
826
1349
|
return;
|
|
827
1350
|
}
|
|
@@ -832,7 +1355,7 @@ export function connect(url, opts) {
|
|
|
832
1355
|
const timeout = setTimeout(() => {
|
|
833
1356
|
if (!resolved) {
|
|
834
1357
|
safeFailSessionLifecycle(session, 'connect_timeout', {
|
|
835
|
-
|
|
1358
|
+
transportOrigin: lifecycleUrlOrigin(url),
|
|
836
1359
|
timeoutMs: 10_000,
|
|
837
1360
|
});
|
|
838
1361
|
resolved = true;
|
|
@@ -842,6 +1365,7 @@ export function connect(url, opts) {
|
|
|
842
1365
|
catch {
|
|
843
1366
|
/* ignore */
|
|
844
1367
|
}
|
|
1368
|
+
releaseSessionOwnership(ownershipLease);
|
|
845
1369
|
reject(new Error(`Connection to ${url} timed out after 10s`));
|
|
846
1370
|
}
|
|
847
1371
|
}, 10_000);
|
|
@@ -854,22 +1378,24 @@ export function connect(url, opts) {
|
|
|
854
1378
|
if (!opts?.skipInitialResize) {
|
|
855
1379
|
const width = opts?.width ?? 1024;
|
|
856
1380
|
const height = opts?.height ?? 768;
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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)}`));
|
|
865
1398
|
}
|
|
866
|
-
activeSessions.set(session.id, session);
|
|
867
|
-
defaultSessionId = session.id;
|
|
868
|
-
startSessionHeartbeat(session);
|
|
869
|
-
safeRecordSessionSnapshot(session, 'session.open', {
|
|
870
|
-
awaitInitialFrame: false,
|
|
871
|
-
});
|
|
872
|
-
resolve(session);
|
|
873
1399
|
}
|
|
874
1400
|
});
|
|
875
1401
|
ws.on('message', (data) => {
|
|
@@ -877,25 +1403,42 @@ export function connect(url, opts) {
|
|
|
877
1403
|
if (session.ws !== ws)
|
|
878
1404
|
return;
|
|
879
1405
|
try {
|
|
880
|
-
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
|
+
}
|
|
881
1427
|
if (msg.type === 'frame') {
|
|
882
|
-
session.layout = msg.layout;
|
|
883
|
-
session.tree = msg.tree;
|
|
884
|
-
session.updateRevision++;
|
|
885
|
-
invalidateSessionCaches(session);
|
|
886
1428
|
const connectTrace = session.connectTrace;
|
|
887
1429
|
const firstFrame = connectTrace?.firstFrameMs === undefined;
|
|
888
1430
|
if (connectTrace && connectTrace.firstFrameMs === undefined) {
|
|
889
1431
|
connectTrace.firstFrameMs = performance.now() - startedAt;
|
|
890
1432
|
}
|
|
891
1433
|
if (!resolved) {
|
|
892
|
-
resolved = true;
|
|
893
1434
|
clearTimeout(timeout);
|
|
894
1435
|
if (session.connectTrace) {
|
|
895
1436
|
session.connectTrace.totalMs = performance.now() - startedAt;
|
|
896
1437
|
}
|
|
897
|
-
|
|
898
|
-
|
|
1438
|
+
claimSessionOwnership(ownershipLease, session);
|
|
1439
|
+
resolved = true;
|
|
1440
|
+
if (!session.isolated)
|
|
1441
|
+
defaultSessionId = session.id;
|
|
899
1442
|
startSessionHeartbeat(session);
|
|
900
1443
|
safeRecordSessionSnapshot(session, 'session.connected');
|
|
901
1444
|
resolve(session);
|
|
@@ -906,34 +1449,62 @@ export function connect(url, opts) {
|
|
|
906
1449
|
});
|
|
907
1450
|
}
|
|
908
1451
|
}
|
|
909
|
-
else if (msg.type === '
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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);
|
|
913
1477
|
}
|
|
914
1478
|
}
|
|
915
|
-
catch { /* ignore malformed messages */ }
|
|
916
1479
|
});
|
|
917
1480
|
ws.on('error', (err) => {
|
|
1481
|
+
if (session.ws === ws) {
|
|
1482
|
+
rememberReusableProxyPageUrl(session);
|
|
1483
|
+
invalidateSessionUiState(session);
|
|
1484
|
+
}
|
|
918
1485
|
if (!resolved) {
|
|
919
1486
|
safeFailSessionLifecycle(session, 'websocket_error', {
|
|
920
|
-
|
|
921
|
-
|
|
1487
|
+
transportOrigin: lifecycleUrlOrigin(url),
|
|
1488
|
+
errorCode: err.code ?? 'websocket_error',
|
|
922
1489
|
});
|
|
923
1490
|
resolved = true;
|
|
924
1491
|
clearTimeout(timeout);
|
|
1492
|
+
releaseSessionOwnership(ownershipLease);
|
|
925
1493
|
reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
|
|
926
1494
|
}
|
|
927
1495
|
});
|
|
928
1496
|
ws.on('close', () => {
|
|
929
1497
|
if (session.ws !== ws)
|
|
930
1498
|
return;
|
|
1499
|
+
rememberReusableProxyPageUrl(session);
|
|
1500
|
+
invalidateSessionUiState(session);
|
|
931
1501
|
if (!resolved) {
|
|
932
1502
|
safeFailSessionLifecycle(session, 'websocket_closed_before_ready', {
|
|
933
|
-
|
|
1503
|
+
transportOrigin: lifecycleUrlOrigin(url),
|
|
934
1504
|
});
|
|
935
1505
|
resolved = true;
|
|
936
1506
|
clearTimeout(timeout);
|
|
1507
|
+
releaseSessionOwnership(ownershipLease);
|
|
937
1508
|
reject(new Error(`Connection to ${url} closed before first frame`));
|
|
938
1509
|
return;
|
|
939
1510
|
}
|
|
@@ -951,20 +1522,22 @@ export function connect(url, opts) {
|
|
|
951
1522
|
*/
|
|
952
1523
|
export async function connectThroughProxy(options) {
|
|
953
1524
|
clearReusableProxiesIfExited();
|
|
1525
|
+
const isolated = options.isolated !== false;
|
|
1526
|
+
const normalizedOptions = { ...options, isolated };
|
|
954
1527
|
// Isolated sessions skip the pool entirely. They always get their own
|
|
955
1528
|
// brand-new Chromium and never reuse a proxy from a prior call. The
|
|
956
1529
|
// tag flows down so startFreshProxySession knows to keep this proxy out
|
|
957
1530
|
// of the pool on success and so shutdownSession knows to force-close it.
|
|
958
|
-
if (
|
|
959
|
-
|
|
1531
|
+
if (isolated) {
|
|
1532
|
+
const ownershipLease = reserveSessionOwnership();
|
|
1533
|
+
return await startFreshProxySession(normalizedOptions, ownershipLease);
|
|
960
1534
|
}
|
|
961
1535
|
let reuseFailure;
|
|
962
1536
|
// Loop because if a candidate is currently being attached by another
|
|
963
|
-
// concurrent connectThroughProxy call we wait for it, then re-pick.
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
// 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.
|
|
968
1541
|
for (let attempt = 0; attempt < REUSABLE_PROXY_POOL_LIMIT + 1; attempt++) {
|
|
969
1542
|
const reusableProxy = findReusableProxy(options);
|
|
970
1543
|
if (!reusableProxy)
|
|
@@ -979,10 +1552,16 @@ export async function connectThroughProxy(options) {
|
|
|
979
1552
|
let releaseLock = () => { };
|
|
980
1553
|
reusableProxy.attachLock = new Promise(resolve => { releaseLock = resolve; });
|
|
981
1554
|
try {
|
|
1555
|
+
if (reusableProxyEntryIsActive(reusableProxy))
|
|
1556
|
+
continue;
|
|
982
1557
|
return await attachToReusableProxy(reusableProxy, options);
|
|
983
1558
|
}
|
|
984
1559
|
catch (err) {
|
|
1560
|
+
if (err instanceof SessionCapacityError)
|
|
1561
|
+
throw err;
|
|
985
1562
|
reuseFailure = err;
|
|
1563
|
+
if (reusableProxyEntryIsActive(reusableProxy))
|
|
1564
|
+
continue;
|
|
986
1565
|
closeReusableProxy(reusableProxy);
|
|
987
1566
|
break;
|
|
988
1567
|
}
|
|
@@ -992,9 +1571,12 @@ export async function connectThroughProxy(options) {
|
|
|
992
1571
|
}
|
|
993
1572
|
}
|
|
994
1573
|
try {
|
|
995
|
-
|
|
1574
|
+
const ownershipLease = reserveSessionOwnership();
|
|
1575
|
+
return await startFreshProxySession(normalizedOptions, ownershipLease);
|
|
996
1576
|
}
|
|
997
1577
|
catch (e) {
|
|
1578
|
+
if (e instanceof SessionCapacityError)
|
|
1579
|
+
throw e;
|
|
998
1580
|
if (reuseFailure) {
|
|
999
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 });
|
|
1000
1582
|
}
|
|
@@ -1011,13 +1593,22 @@ export function getSession(id) {
|
|
|
1011
1593
|
export function pruneDisconnectedSessions() {
|
|
1012
1594
|
const removedIds = [];
|
|
1013
1595
|
for (const [id, session] of activeSessions.entries()) {
|
|
1014
|
-
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))
|
|
1015
1601
|
continue;
|
|
1016
1602
|
removedIds.push(id);
|
|
1603
|
+
session.disposed = true;
|
|
1604
|
+
session.ambiguousOperations?.clear();
|
|
1605
|
+
session.inFlightMutations?.clear();
|
|
1017
1606
|
activeSessions.delete(id);
|
|
1018
1607
|
if (defaultSessionId === id) {
|
|
1019
1608
|
promoteDefaultSession();
|
|
1020
1609
|
}
|
|
1610
|
+
stopSessionHeartbeat(session);
|
|
1611
|
+
releaseSessionResources(session, { closeProxy: true });
|
|
1021
1612
|
}
|
|
1022
1613
|
return removedIds;
|
|
1023
1614
|
}
|
|
@@ -1058,13 +1649,28 @@ export function getDefaultSessionId() {
|
|
|
1058
1649
|
}
|
|
1059
1650
|
export function disconnect(opts) {
|
|
1060
1651
|
if (opts?.sessionId) {
|
|
1061
|
-
|
|
1652
|
+
if (!activeSessions.has(opts.sessionId))
|
|
1653
|
+
return;
|
|
1654
|
+
shutdownSession(opts.sessionId, { closeProxy: opts.closeProxy ?? false, reason: 'disconnect' });
|
|
1655
|
+
return;
|
|
1062
1656
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
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(', ')}.`);
|
|
1065
1663
|
}
|
|
1066
|
-
if (
|
|
1067
|
-
|
|
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();
|
|
1068
1674
|
}
|
|
1069
1675
|
function estimateFillBatchTimeout(fields) {
|
|
1070
1676
|
let total = FILL_BATCH_BASE_TIMEOUT_MS;
|
|
@@ -1100,7 +1706,13 @@ function estimateFillBatchTimeout(fields) {
|
|
|
1100
1706
|
}
|
|
1101
1707
|
export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
1102
1708
|
return new Promise((resolve) => {
|
|
1709
|
+
const transport = session.ws;
|
|
1103
1710
|
const check = () => {
|
|
1711
|
+
if (session.ws !== transport) {
|
|
1712
|
+
cleanup();
|
|
1713
|
+
resolve(false);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1104
1716
|
let matched;
|
|
1105
1717
|
try {
|
|
1106
1718
|
matched = predicate();
|
|
@@ -1126,11 +1738,11 @@ export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
|
1126
1738
|
};
|
|
1127
1739
|
function cleanup() {
|
|
1128
1740
|
clearTimeout(timeout);
|
|
1129
|
-
|
|
1130
|
-
|
|
1741
|
+
transport.off('message', onMessage);
|
|
1742
|
+
transport.off('close', onClose);
|
|
1131
1743
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1744
|
+
transport.on('message', onMessage);
|
|
1745
|
+
transport.on('close', onClose);
|
|
1134
1746
|
check();
|
|
1135
1747
|
});
|
|
1136
1748
|
}
|
|
@@ -1147,9 +1759,19 @@ function reconnectUrlForSession(session) {
|
|
|
1147
1759
|
}
|
|
1148
1760
|
return null;
|
|
1149
1761
|
}
|
|
1150
|
-
async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
|
|
1762
|
+
async function openWebSocket(url, session, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS, viewport) {
|
|
1151
1763
|
return await new Promise((resolve, reject) => {
|
|
1152
|
-
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;
|
|
1153
1775
|
const timeout = setTimeout(() => {
|
|
1154
1776
|
cleanup();
|
|
1155
1777
|
try {
|
|
@@ -1158,23 +1780,61 @@ async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
|
|
|
1158
1780
|
catch {
|
|
1159
1781
|
/* ignore */
|
|
1160
1782
|
}
|
|
1161
|
-
reject(new Error(`Reconnect to ${url} timed out after ${timeoutMs}ms`));
|
|
1783
|
+
reject(new Error(`Reconnect handshake to ${url} timed out after ${timeoutMs}ms`));
|
|
1162
1784
|
}, timeoutMs);
|
|
1163
1785
|
const onOpen = () => {
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
+
}));
|
|
1166
1796
|
};
|
|
1167
1797
|
const onError = (err) => {
|
|
1168
1798
|
cleanup();
|
|
1169
1799
|
reject(new Error(`WebSocket reconnect failed for ${url}: ${err.message}`));
|
|
1170
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
|
+
};
|
|
1171
1827
|
function cleanup() {
|
|
1172
1828
|
clearTimeout(timeout);
|
|
1173
1829
|
ws.off('open', onOpen);
|
|
1174
1830
|
ws.off('error', onError);
|
|
1831
|
+
ws.off('close', onClose);
|
|
1832
|
+
ws.off('message', onMessage);
|
|
1175
1833
|
}
|
|
1176
1834
|
ws.on('open', onOpen);
|
|
1177
1835
|
ws.on('error', onError);
|
|
1836
|
+
ws.on('close', onClose);
|
|
1837
|
+
ws.on('message', onMessage);
|
|
1178
1838
|
});
|
|
1179
1839
|
}
|
|
1180
1840
|
function bindReconnectedSocket(session, ws) {
|
|
@@ -1183,29 +1843,31 @@ function bindReconnectedSocket(session, ws) {
|
|
|
1183
1843
|
if (session.ws !== ws)
|
|
1184
1844
|
return;
|
|
1185
1845
|
try {
|
|
1186
|
-
|
|
1187
|
-
if (msg.type === 'frame') {
|
|
1188
|
-
session.layout = msg.layout;
|
|
1189
|
-
session.tree = msg.tree;
|
|
1190
|
-
session.updateRevision++;
|
|
1191
|
-
invalidateSessionCaches(session);
|
|
1192
|
-
}
|
|
1193
|
-
else if (msg.type === 'patch' && session.layout) {
|
|
1194
|
-
applyPatches(session.layout, msg.patches);
|
|
1195
|
-
session.updateRevision++;
|
|
1196
|
-
invalidateSessionCaches(session);
|
|
1197
|
-
}
|
|
1846
|
+
applyInboundSessionMessage(session, data);
|
|
1198
1847
|
}
|
|
1199
1848
|
catch {
|
|
1200
|
-
|
|
1849
|
+
rememberReusableProxyPageUrl(session);
|
|
1850
|
+
invalidateSessionUiState(session);
|
|
1851
|
+
try {
|
|
1852
|
+
ws.close(1002, 'Invalid protocol message');
|
|
1853
|
+
}
|
|
1854
|
+
catch { /* ignore */ }
|
|
1201
1855
|
}
|
|
1202
1856
|
});
|
|
1203
1857
|
ws.on('pong', () => {
|
|
1204
1858
|
noteSessionSocketActivity(session, ws);
|
|
1205
1859
|
});
|
|
1860
|
+
ws.on('error', () => {
|
|
1861
|
+
if (session.ws !== ws)
|
|
1862
|
+
return;
|
|
1863
|
+
rememberReusableProxyPageUrl(session);
|
|
1864
|
+
invalidateSessionUiState(session);
|
|
1865
|
+
});
|
|
1206
1866
|
ws.on('close', () => {
|
|
1207
1867
|
if (session.ws !== ws)
|
|
1208
1868
|
return;
|
|
1869
|
+
rememberReusableProxyPageUrl(session);
|
|
1870
|
+
invalidateSessionUiState(session);
|
|
1209
1871
|
if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
|
|
1210
1872
|
safeRecordSessionSnapshot(session, 'session.transport_closed', {
|
|
1211
1873
|
reconnectable: reconnectUrlForSession(session) !== null,
|
|
@@ -1213,7 +1875,13 @@ function bindReconnectedSocket(session, ws) {
|
|
|
1213
1875
|
}
|
|
1214
1876
|
});
|
|
1215
1877
|
}
|
|
1216
|
-
|
|
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
|
+
}
|
|
1217
1885
|
if (session.ws.readyState === WebSocket.OPEN)
|
|
1218
1886
|
return;
|
|
1219
1887
|
if (session.reconnectInFlight) {
|
|
@@ -1221,15 +1889,31 @@ async function ensureSessionConnected(session) {
|
|
|
1221
1889
|
if (!recovered) {
|
|
1222
1890
|
throw new Error('Not connected');
|
|
1223
1891
|
}
|
|
1892
|
+
if (sessionIsDisposedOrUnowned(session)) {
|
|
1893
|
+
throw new Error(`Session ${session.id} was disconnected while reconnecting`);
|
|
1894
|
+
}
|
|
1224
1895
|
return;
|
|
1225
1896
|
}
|
|
1226
1897
|
const targetUrl = reconnectUrlForSession(session);
|
|
1227
1898
|
if (!targetUrl) {
|
|
1228
1899
|
throw new Error('Not connected');
|
|
1229
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);
|
|
1230
1907
|
const reconnectPromise = (async () => {
|
|
1231
1908
|
try {
|
|
1232
|
-
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
|
+
}
|
|
1233
1917
|
try {
|
|
1234
1918
|
session.ws.close();
|
|
1235
1919
|
}
|
|
@@ -1239,28 +1923,33 @@ async function ensureSessionConnected(session) {
|
|
|
1239
1923
|
session.ws = nextWs;
|
|
1240
1924
|
noteSessionSocketActivity(session, nextWs);
|
|
1241
1925
|
bindReconnectedSocket(session, nextWs);
|
|
1926
|
+
applyInboundSessionMessage(session, handshakeMessage);
|
|
1242
1927
|
activeSessions.set(session.id, session);
|
|
1243
1928
|
if (!session.isolated) {
|
|
1244
1929
|
defaultSessionId = session.id;
|
|
1245
1930
|
}
|
|
1246
1931
|
startSessionHeartbeat(session);
|
|
1247
1932
|
safeRecordSessionSnapshot(session, 'session.reconnected', {
|
|
1248
|
-
targetUrl,
|
|
1933
|
+
targetOrigin: lifecycleUrlOrigin(targetUrl),
|
|
1249
1934
|
});
|
|
1250
1935
|
return true;
|
|
1251
1936
|
}
|
|
1252
1937
|
catch (err) {
|
|
1253
1938
|
safeFailSessionLifecycle(session, 'reconnect_failed', {
|
|
1254
|
-
targetUrl,
|
|
1255
|
-
|
|
1939
|
+
targetOrigin: lifecycleUrlOrigin(targetUrl),
|
|
1940
|
+
errorName: err instanceof Error ? err.name : 'Error',
|
|
1256
1941
|
});
|
|
1257
1942
|
if (activeSessions.get(session.id) === session) {
|
|
1258
1943
|
activeSessions.delete(session.id);
|
|
1259
1944
|
if (defaultSessionId === session.id)
|
|
1260
1945
|
promoteDefaultSession();
|
|
1261
1946
|
}
|
|
1947
|
+
session.disposed = true;
|
|
1948
|
+
session.ambiguousOperations?.clear();
|
|
1949
|
+
session.inFlightMutations?.clear();
|
|
1262
1950
|
stopSessionHeartbeat(session);
|
|
1263
1951
|
releaseSessionResources(session, { closeProxy: true });
|
|
1952
|
+
invalidateSessionUiState(session);
|
|
1264
1953
|
throw err;
|
|
1265
1954
|
}
|
|
1266
1955
|
})();
|
|
@@ -1275,12 +1964,16 @@ async function ensureSessionConnected(session) {
|
|
|
1275
1964
|
if (!recovered) {
|
|
1276
1965
|
throw new Error('Not connected');
|
|
1277
1966
|
}
|
|
1967
|
+
if (sessionIsDisposedOrUnowned(session)) {
|
|
1968
|
+
throw new Error(`Session ${session.id} was disconnected while reconnecting`);
|
|
1969
|
+
}
|
|
1278
1970
|
}
|
|
1279
1971
|
async function sendResizeAndWaitForUpdate(session, width, height, timeoutMs = 5_000) {
|
|
1280
|
-
await
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1972
|
+
return await sendAndWaitForUpdate(session, {
|
|
1973
|
+
type: 'resize',
|
|
1974
|
+
width,
|
|
1975
|
+
height,
|
|
1976
|
+
}, timeoutMs);
|
|
1284
1977
|
}
|
|
1285
1978
|
/**
|
|
1286
1979
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
@@ -1299,10 +1992,30 @@ export function sendClick(session, x, y, timeoutMs) {
|
|
|
1299
1992
|
export function sendType(session, text, timeoutMs) {
|
|
1300
1993
|
return (async () => {
|
|
1301
1994
|
await ensureSessionConnected(session);
|
|
1302
|
-
|
|
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.
|
|
1303
2015
|
for (const char of text) {
|
|
1304
2016
|
const keyEvent = {
|
|
1305
2017
|
type: 'key',
|
|
2018
|
+
...outboundProtocolMetadata(session),
|
|
1306
2019
|
eventType: 'onKeyDown',
|
|
1307
2020
|
key: char,
|
|
1308
2021
|
code: `Key${char.toUpperCase()}`,
|
|
@@ -1311,11 +2024,41 @@ export function sendType(session, text, timeoutMs) {
|
|
|
1311
2024
|
metaKey: false,
|
|
1312
2025
|
altKey: false,
|
|
1313
2026
|
};
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
if (keyEvents.length === 0) {
|
|
2040
|
+
return {
|
|
2041
|
+
status: 'acknowledged',
|
|
2042
|
+
timeoutMs: waitTimeoutMs,
|
|
2043
|
+
requestId: randomUUID(),
|
|
2044
|
+
actionId,
|
|
2045
|
+
};
|
|
1316
2046
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
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);
|
|
1319
2062
|
})();
|
|
1320
2063
|
}
|
|
1321
2064
|
/**
|
|
@@ -1335,7 +2078,9 @@ export function sendKey(session, key, modifiers, timeoutMs) {
|
|
|
1335
2078
|
}
|
|
1336
2079
|
/**
|
|
1337
2080
|
* Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
|
|
1338
|
-
* 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.
|
|
1339
2084
|
*/
|
|
1340
2085
|
export function sendFileUpload(session, paths, opts, timeoutMs) {
|
|
1341
2086
|
const payload = { type: 'file', paths };
|
|
@@ -1345,6 +2090,10 @@ export function sendFileUpload(session, paths, opts, timeoutMs) {
|
|
|
1345
2090
|
}
|
|
1346
2091
|
if (opts?.fieldLabel)
|
|
1347
2092
|
payload.fieldLabel = opts.fieldLabel;
|
|
2093
|
+
if (opts?.fieldId)
|
|
2094
|
+
payload.fieldId = opts.fieldId;
|
|
2095
|
+
if (opts?.fieldKey)
|
|
2096
|
+
payload.fieldKey = opts.fieldKey;
|
|
1348
2097
|
if (opts?.exact !== undefined)
|
|
1349
2098
|
payload.exact = opts.exact;
|
|
1350
2099
|
if (opts?.strategy)
|
|
@@ -1353,6 +2102,10 @@ export function sendFileUpload(session, paths, opts, timeoutMs) {
|
|
|
1353
2102
|
payload.dropX = opts.drop.x;
|
|
1354
2103
|
payload.dropY = opts.drop.y;
|
|
1355
2104
|
}
|
|
2105
|
+
if (opts?.contextText !== undefined)
|
|
2106
|
+
payload.contextText = opts.contextText;
|
|
2107
|
+
if (opts?.sectionText !== undefined)
|
|
2108
|
+
payload.sectionText = opts.sectionText;
|
|
1356
2109
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
1357
2110
|
}
|
|
1358
2111
|
/** Set a labeled text-like field (`input`, `textarea`, contenteditable, ARIA textbox) semantically. */
|
|
@@ -1366,6 +2119,8 @@ export function sendFieldText(session, fieldLabel, value, opts, timeoutMs) {
|
|
|
1366
2119
|
payload.exact = opts.exact;
|
|
1367
2120
|
if (opts?.fieldId)
|
|
1368
2121
|
payload.fieldId = opts.fieldId;
|
|
2122
|
+
if (opts?.fieldKey)
|
|
2123
|
+
payload.fieldKey = opts.fieldKey;
|
|
1369
2124
|
if (opts?.typingDelayMs !== undefined)
|
|
1370
2125
|
payload.typingDelayMs = opts.typingDelayMs;
|
|
1371
2126
|
if (opts?.imeFriendly !== undefined)
|
|
@@ -1387,6 +2142,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
|
|
|
1387
2142
|
payload.choiceType = opts.choiceType;
|
|
1388
2143
|
if (opts?.fieldId)
|
|
1389
2144
|
payload.fieldId = opts.fieldId;
|
|
2145
|
+
if (opts?.fieldKey)
|
|
2146
|
+
payload.fieldKey = opts.fieldKey;
|
|
2147
|
+
if (opts?.optionIndex !== undefined)
|
|
2148
|
+
payload.optionIndex = opts.optionIndex;
|
|
1390
2149
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
1391
2150
|
}
|
|
1392
2151
|
/** Fill several semantic form fields in one proxy-side batch. */
|
|
@@ -1417,14 +2176,14 @@ export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE
|
|
|
1417
2176
|
const payload = { type: 'listboxPick', label };
|
|
1418
2177
|
if (opts?.exact !== undefined)
|
|
1419
2178
|
payload.exact = opts.exact;
|
|
1420
|
-
if (opts?.open) {
|
|
1421
|
-
payload.openX = opts.open.x;
|
|
1422
|
-
payload.openY = opts.open.y;
|
|
1423
|
-
}
|
|
1424
2179
|
if (opts?.fieldLabel)
|
|
1425
2180
|
payload.fieldLabel = opts.fieldLabel;
|
|
1426
2181
|
if (opts?.query)
|
|
1427
2182
|
payload.query = opts.query;
|
|
2183
|
+
if (opts?.fieldId)
|
|
2184
|
+
payload.fieldId = opts.fieldId;
|
|
2185
|
+
if (opts?.fieldKey)
|
|
2186
|
+
payload.fieldKey = opts.fieldKey;
|
|
1428
2187
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
1429
2188
|
}
|
|
1430
2189
|
/** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
|
|
@@ -1445,6 +2204,12 @@ export function sendSetChecked(session, label, opts, timeoutMs) {
|
|
|
1445
2204
|
payload.exact = opts.exact;
|
|
1446
2205
|
if (opts?.controlType)
|
|
1447
2206
|
payload.controlType = opts.controlType;
|
|
2207
|
+
if (opts?.fieldKey)
|
|
2208
|
+
payload.fieldKey = opts.fieldKey;
|
|
2209
|
+
if (opts?.contextText)
|
|
2210
|
+
payload.contextText = opts.contextText;
|
|
2211
|
+
if (opts?.sectionText)
|
|
2212
|
+
payload.sectionText = opts.sectionText;
|
|
1448
2213
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
1449
2214
|
}
|
|
1450
2215
|
/** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
|
|
@@ -1480,9 +2245,8 @@ export function sendNavigate(session, url, timeoutMs = 15_000) {
|
|
|
1480
2245
|
url,
|
|
1481
2246
|
}, timeoutMs, { requireUpdateOnAck: true });
|
|
1482
2247
|
safeRecordSessionSnapshot(session, 'session.navigate', {
|
|
1483
|
-
|
|
2248
|
+
requestedOrigin: lifecycleUrlOrigin(url),
|
|
1484
2249
|
status: result.status,
|
|
1485
|
-
result: result.result ?? null,
|
|
1486
2250
|
});
|
|
1487
2251
|
return result;
|
|
1488
2252
|
})();
|
|
@@ -1537,6 +2301,11 @@ const FORM_FIELD_ROLES = new Set([
|
|
|
1537
2301
|
'checkbox',
|
|
1538
2302
|
'radio',
|
|
1539
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
|
+
}
|
|
1540
2309
|
const ACTION_ROLES = new Set([
|
|
1541
2310
|
'button',
|
|
1542
2311
|
'link',
|
|
@@ -2154,6 +2923,8 @@ function schemaOptionLabel(node) {
|
|
|
2154
2923
|
return sanitizeFieldName(node.name, 80) ?? sanitizeInlineName(node.name, 80);
|
|
2155
2924
|
}
|
|
2156
2925
|
function isGroupedChoiceControl(node) {
|
|
2926
|
+
if (node.meta?.coordinateOnly === true)
|
|
2927
|
+
return false;
|
|
2157
2928
|
return node.role === 'radio' || node.role === 'checkbox' || (node.role === 'button' && node.focusable);
|
|
2158
2929
|
}
|
|
2159
2930
|
function groupedChoiceForNode(root, formNode, seed) {
|
|
@@ -2219,10 +2990,36 @@ function buildFieldFormat(node) {
|
|
|
2219
2990
|
format.pattern = m.inputPattern;
|
|
2220
2991
|
if (m.inputType)
|
|
2221
2992
|
format.inputType = m.inputType;
|
|
2993
|
+
else if (m.fileInput)
|
|
2994
|
+
format.inputType = 'file';
|
|
2222
2995
|
if (m.autocomplete)
|
|
2223
2996
|
format.autocomplete = m.autocomplete;
|
|
2997
|
+
if (m.accept)
|
|
2998
|
+
format.accept = m.accept;
|
|
2999
|
+
if (m.multiple)
|
|
3000
|
+
format.multiple = true;
|
|
2224
3001
|
return Object.keys(format).length > 0 ? format : undefined;
|
|
2225
3002
|
}
|
|
3003
|
+
function nativeSchemaOptions(node, fieldIdentity) {
|
|
3004
|
+
const options = node.meta?.options;
|
|
3005
|
+
if (!options)
|
|
3006
|
+
return undefined;
|
|
3007
|
+
return options.map(option => ({
|
|
3008
|
+
id: `${fieldIdentity}:option:${option.index}`,
|
|
3009
|
+
value: option.value,
|
|
3010
|
+
label: option.label,
|
|
3011
|
+
index: option.index,
|
|
3012
|
+
...(option.disabled ? { disabled: true } : {}),
|
|
3013
|
+
...(option.selected ? { selected: true } : {}),
|
|
3014
|
+
}));
|
|
3015
|
+
}
|
|
3016
|
+
function uniqueSchemaFieldKey(root, node) {
|
|
3017
|
+
const fieldKey = node.meta?.controlKey;
|
|
3018
|
+
if (!fieldKey)
|
|
3019
|
+
return undefined;
|
|
3020
|
+
const matches = collectDescendants(root, candidate => candidate.meta?.controlKey === fieldKey);
|
|
3021
|
+
return matches.length === 1 ? fieldKey : undefined;
|
|
3022
|
+
}
|
|
2226
3023
|
function simpleSchemaField(root, node) {
|
|
2227
3024
|
const context = nodeContextForNode(root, node);
|
|
2228
3025
|
const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
|
|
@@ -2251,15 +3048,51 @@ function simpleSchemaField(root, node) {
|
|
|
2251
3048
|
? 'select'
|
|
2252
3049
|
: 'listbox'
|
|
2253
3050
|
: undefined;
|
|
3051
|
+
const fieldKey = uniqueSchemaFieldKey(root, node);
|
|
3052
|
+
const id = fieldKey ?? formFieldIdForPath(node.path);
|
|
2254
3053
|
const format = buildFieldFormat(node);
|
|
3054
|
+
const optionDetails = classifiedChoiceType === 'select' ? nativeSchemaOptions(node, id) : undefined;
|
|
3055
|
+
const enabledOptions = optionDetails
|
|
3056
|
+
? dedupeStrings(optionDetails.filter(option => !option.disabled).map(option => option.label), 64)
|
|
3057
|
+
: undefined;
|
|
2255
3058
|
return {
|
|
2256
|
-
id
|
|
3059
|
+
id,
|
|
3060
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
2257
3061
|
kind: classifiedRole === 'combobox' ? 'choice' : 'text',
|
|
2258
3062
|
label,
|
|
2259
3063
|
...(classifiedChoiceType ? { choiceType: classifiedChoiceType } : {}),
|
|
2260
3064
|
...(node.state?.required ? { required: true } : {}),
|
|
2261
3065
|
...(node.state?.invalid ? { invalid: true } : {}),
|
|
2262
3066
|
...compactSchemaValue(node.value, 72),
|
|
3067
|
+
...(optionDetails ? { optionCount: optionDetails.length, optionDetails } : {}),
|
|
3068
|
+
...(enabledOptions && enabledOptions.length > 0 ? { options: enabledOptions } : {}),
|
|
3069
|
+
...(enabledOptions && computeOptionAliases(enabledOptions) ? { aliases: computeOptionAliases(enabledOptions) } : {}),
|
|
3070
|
+
...(format ? { format } : {}),
|
|
3071
|
+
...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
|
|
3072
|
+
};
|
|
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 } : {}),
|
|
2263
3096
|
...(format ? { format } : {}),
|
|
2264
3097
|
...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
|
|
2265
3098
|
};
|
|
@@ -2304,8 +3137,10 @@ function toggleSchemaField(root, node) {
|
|
|
2304
3137
|
return null;
|
|
2305
3138
|
const context = nodeContextForNode(root, node);
|
|
2306
3139
|
const controlType = node.role === 'radio' ? 'radio' : 'checkbox';
|
|
3140
|
+
const fieldKey = uniqueSchemaFieldKey(root, node);
|
|
2307
3141
|
return {
|
|
2308
|
-
id: formFieldIdForPath(node.path),
|
|
3142
|
+
id: fieldKey ?? formFieldIdForPath(node.path),
|
|
3143
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
2309
3144
|
kind: 'toggle',
|
|
2310
3145
|
label,
|
|
2311
3146
|
controlType,
|
|
@@ -2330,7 +3165,10 @@ function detectFormSections(formNode, fields) {
|
|
|
2330
3165
|
return [];
|
|
2331
3166
|
const fieldIdToPath = new Map();
|
|
2332
3167
|
for (const field of fields) {
|
|
2333
|
-
const
|
|
3168
|
+
const authoredMatches = field.fieldKey
|
|
3169
|
+
? collectDescendants(formNode, node => node.meta?.controlKey === field.fieldKey)
|
|
3170
|
+
: [];
|
|
3171
|
+
const parsed = authoredMatches.length === 1 ? authoredMatches[0].path : parseFormFieldId(field.id);
|
|
2334
3172
|
if (parsed)
|
|
2335
3173
|
fieldIdToPath.set(field.id, parsed);
|
|
2336
3174
|
}
|
|
@@ -2351,17 +3189,25 @@ function detectFormSections(formNode, fields) {
|
|
|
2351
3189
|
return sections;
|
|
2352
3190
|
}
|
|
2353
3191
|
function buildFormSchemaForNode(root, formNode, options) {
|
|
2354
|
-
const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.role === 'textbox' ||
|
|
3192
|
+
const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.meta?.coordinateOnly !== true && (candidate.role === 'textbox' ||
|
|
2355
3193
|
candidate.role === 'combobox' ||
|
|
2356
3194
|
candidate.role === 'checkbox' ||
|
|
2357
3195
|
candidate.role === 'radio' ||
|
|
2358
|
-
|
|
3196
|
+
candidate.meta?.fileInput === true ||
|
|
3197
|
+
(candidate.role === 'button' && candidate.focusable))));
|
|
2359
3198
|
const consumed = new Set();
|
|
2360
3199
|
const fields = [];
|
|
2361
3200
|
for (const candidate of candidates) {
|
|
2362
3201
|
const candidateKey = pathKey(candidate.path);
|
|
2363
3202
|
if (consumed.has(candidateKey))
|
|
2364
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
|
+
}
|
|
2365
3211
|
if (candidate.role === 'textbox' || candidate.role === 'combobox') {
|
|
2366
3212
|
const field = simpleSchemaField(root, candidate);
|
|
2367
3213
|
if (field)
|
|
@@ -2428,6 +3274,7 @@ function presentFormSchemaFields(fields, options) {
|
|
|
2428
3274
|
next.booleanChoice = true;
|
|
2429
3275
|
if (!includeOptions) {
|
|
2430
3276
|
delete next.options;
|
|
3277
|
+
delete next.optionDetails;
|
|
2431
3278
|
delete next.aliases;
|
|
2432
3279
|
}
|
|
2433
3280
|
if (includeContext === 'none') {
|
|
@@ -2655,7 +3502,7 @@ export function buildPageModel(root, options) {
|
|
|
2655
3502
|
landmarks.push(toLandmarkModel(node));
|
|
2656
3503
|
}
|
|
2657
3504
|
if (node.role === 'form') {
|
|
2658
|
-
const fields = collectDescendants(node,
|
|
3505
|
+
const fields = collectDescendants(node, isFormFieldNode);
|
|
2659
3506
|
const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
|
|
2660
3507
|
const name = sectionDisplayName(node, 'form');
|
|
2661
3508
|
forms.push({
|
|
@@ -2668,7 +3515,7 @@ export function buildPageModel(root, options) {
|
|
|
2668
3515
|
});
|
|
2669
3516
|
}
|
|
2670
3517
|
if (DIALOG_ROLES.has(node.role)) {
|
|
2671
|
-
const fields = collectDescendants(node,
|
|
3518
|
+
const fields = collectDescendants(node, isFormFieldNode);
|
|
2672
3519
|
const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
|
|
2673
3520
|
const name = sectionDisplayName(node, 'dialog');
|
|
2674
3521
|
dialogs.push({
|
|
@@ -2742,8 +3589,11 @@ export function buildFormSchemas(root, options) {
|
|
|
2742
3589
|
...(root.role === 'form' ? [root] : []),
|
|
2743
3590
|
...collectDescendants(root, candidate => candidate.role === 'form'),
|
|
2744
3591
|
];
|
|
2745
|
-
// Infer forms from group/region containers with 2+ form fields (e.g.
|
|
2746
|
-
|
|
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 => {
|
|
2747
3597
|
if (candidate.role !== 'group' && candidate.role !== 'region')
|
|
2748
3598
|
return false;
|
|
2749
3599
|
// Skip descendants of explicit forms
|
|
@@ -2753,14 +3603,173 @@ export function buildFormSchemas(root, options) {
|
|
|
2753
3603
|
return false;
|
|
2754
3604
|
}
|
|
2755
3605
|
}
|
|
2756
|
-
const fields = collectDescendants(candidate,
|
|
2757
|
-
|
|
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;
|
|
2758
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()];
|
|
2759
3638
|
const forms = sortByBounds([...explicitForms, ...inferredForms]);
|
|
2760
3639
|
return forms
|
|
2761
3640
|
.filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
|
|
2762
3641
|
.map(form => buildFormSchemaForNode(root, form, options));
|
|
2763
3642
|
}
|
|
3643
|
+
export function buildFormGraphs(root, options) {
|
|
3644
|
+
const pageUrl = typeof root.meta?.pageUrl === 'string' && root.meta.pageUrl.length > 0 ? root.meta.pageUrl : undefined;
|
|
3645
|
+
return buildFormSchemas(root, {
|
|
3646
|
+
...options,
|
|
3647
|
+
includeOptions: true,
|
|
3648
|
+
}).map(schema => formSchemaToFormGraph(schema, pageUrl));
|
|
3649
|
+
}
|
|
3650
|
+
export function formSchemaToFormGraph(schema, pageUrl) {
|
|
3651
|
+
const sourceId = 'geometra-page';
|
|
3652
|
+
const formSlug = slugPathSegment(schema.name ?? schema.formId);
|
|
3653
|
+
const pathCounts = new Map();
|
|
3654
|
+
return {
|
|
3655
|
+
formgraph: '0.1',
|
|
3656
|
+
id: `geometra:${schema.formId}`,
|
|
3657
|
+
title: schema.name ?? `Geometra form ${schema.formId}`,
|
|
3658
|
+
description: 'FormGraph-compatible projection of a Geometra form schema.',
|
|
3659
|
+
sources: [
|
|
3660
|
+
{
|
|
3661
|
+
id: sourceId,
|
|
3662
|
+
kind: 'html',
|
|
3663
|
+
title: schema.name ?? 'Geometra-discovered web form',
|
|
3664
|
+
...(pageUrl ? { url: pageUrl } : {}),
|
|
3665
|
+
},
|
|
3666
|
+
],
|
|
3667
|
+
fields: schema.fields.map(field => formSchemaFieldToFormGraphField(field, {
|
|
3668
|
+
formSlug,
|
|
3669
|
+
sourceId,
|
|
3670
|
+
pathCounts,
|
|
3671
|
+
})),
|
|
3672
|
+
evidence: [],
|
|
3673
|
+
dependencies: [],
|
|
3674
|
+
review: {
|
|
3675
|
+
autoSubmitAllowed: false,
|
|
3676
|
+
requiredBeforeSubmit: true,
|
|
3677
|
+
},
|
|
3678
|
+
metadata: {
|
|
3679
|
+
producer: 'geometra',
|
|
3680
|
+
geometra: {
|
|
3681
|
+
formId: schema.formId,
|
|
3682
|
+
fieldCount: schema.fieldCount,
|
|
3683
|
+
requiredCount: schema.requiredCount,
|
|
3684
|
+
invalidCount: schema.invalidCount,
|
|
3685
|
+
...(schema.sections ? { sections: schema.sections } : {}),
|
|
3686
|
+
},
|
|
3687
|
+
},
|
|
3688
|
+
};
|
|
3689
|
+
}
|
|
3690
|
+
function formSchemaFieldToFormGraphField(field, opts) {
|
|
3691
|
+
const basePath = `web.forms.${opts.formSlug}.${slugPathSegment(field.label || field.id)}`;
|
|
3692
|
+
const path = uniquePath(basePath, opts.pathCounts);
|
|
3693
|
+
const aliases = fieldAliases(field);
|
|
3694
|
+
const options = field.optionDetails
|
|
3695
|
+
?.filter(option => !option.disabled)
|
|
3696
|
+
.map(option => ({ value: option.value, label: option.label }))
|
|
3697
|
+
?? field.options?.map(option => ({ value: option, label: option }));
|
|
3698
|
+
const inputType = field.format?.inputType?.toLowerCase();
|
|
3699
|
+
const metadata = {
|
|
3700
|
+
geometra: {
|
|
3701
|
+
fieldId: field.id,
|
|
3702
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
3703
|
+
kind: field.kind,
|
|
3704
|
+
...(field.choiceType ? { choiceType: field.choiceType } : {}),
|
|
3705
|
+
...(field.controlType ? { controlType: field.controlType } : {}),
|
|
3706
|
+
...(field.booleanChoice ? { booleanChoice: true } : {}),
|
|
3707
|
+
...(field.invalid ? { invalid: true } : {}),
|
|
3708
|
+
...(field.format ? { format: field.format } : {}),
|
|
3709
|
+
...(field.context ? { context: field.context } : {}),
|
|
3710
|
+
...(field.optionDetails ? { optionDetails: field.optionDetails } : {}),
|
|
3711
|
+
},
|
|
3712
|
+
};
|
|
3713
|
+
return {
|
|
3714
|
+
id: field.fieldKey ?? field.id,
|
|
3715
|
+
path,
|
|
3716
|
+
label: field.label,
|
|
3717
|
+
kind: formGraphFieldKind(field),
|
|
3718
|
+
...(field.required ? { required: true } : {}),
|
|
3719
|
+
...(field.invalid ? { reviewRequired: true } : {}),
|
|
3720
|
+
...(aliases.length > 0 ? { aliases } : {}),
|
|
3721
|
+
...(options && options.length > 0 ? { options } : {}),
|
|
3722
|
+
...(field.format?.pattern ? { constraints: { pattern: field.format.pattern } } : {}),
|
|
3723
|
+
sourceAnchors: [
|
|
3724
|
+
{
|
|
3725
|
+
sourceId: opts.sourceId,
|
|
3726
|
+
kind: 'html',
|
|
3727
|
+
fieldName: field.fieldKey ?? field.id,
|
|
3728
|
+
pointer: `geometra:${field.fieldKey ?? field.id}`,
|
|
3729
|
+
},
|
|
3730
|
+
],
|
|
3731
|
+
...(inputType ? { metadata: { ...metadata, inputType } } : { metadata }),
|
|
3732
|
+
};
|
|
3733
|
+
}
|
|
3734
|
+
function formGraphFieldKind(field) {
|
|
3735
|
+
if (field.kind === 'toggle' || field.booleanChoice)
|
|
3736
|
+
return 'boolean';
|
|
3737
|
+
if (field.kind === 'choice' || field.kind === 'multi_choice')
|
|
3738
|
+
return 'enum';
|
|
3739
|
+
const inputType = field.format?.inputType?.toLowerCase();
|
|
3740
|
+
if (inputType === 'email')
|
|
3741
|
+
return 'email';
|
|
3742
|
+
if (inputType === 'tel' || inputType === 'phone')
|
|
3743
|
+
return 'phone';
|
|
3744
|
+
if (inputType === 'date')
|
|
3745
|
+
return 'date';
|
|
3746
|
+
if (inputType === 'number')
|
|
3747
|
+
return 'number';
|
|
3748
|
+
if (field.valueLength !== undefined && field.valueLength > 120)
|
|
3749
|
+
return 'textarea';
|
|
3750
|
+
return 'text';
|
|
3751
|
+
}
|
|
3752
|
+
function fieldAliases(field) {
|
|
3753
|
+
const values = new Set();
|
|
3754
|
+
if (field.format?.placeholder)
|
|
3755
|
+
values.add(field.format.placeholder);
|
|
3756
|
+
if (field.context?.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
|
|
3757
|
+
values.add(field.context.prompt);
|
|
3758
|
+
}
|
|
3759
|
+
return [...values].filter(value => value.trim().length > 0);
|
|
3760
|
+
}
|
|
3761
|
+
function uniquePath(basePath, seen) {
|
|
3762
|
+
const count = seen.get(basePath) ?? 0;
|
|
3763
|
+
seen.set(basePath, count + 1);
|
|
3764
|
+
return count === 0 ? basePath : `${basePath}.${count + 1}`;
|
|
3765
|
+
}
|
|
3766
|
+
function slugPathSegment(value) {
|
|
3767
|
+
const normalized = normalizeUiText(value)
|
|
3768
|
+
.toLowerCase()
|
|
3769
|
+
.replace(/[^a-z0-9]+/g, '.')
|
|
3770
|
+
.replace(/^\.+|\.+$/g, '');
|
|
3771
|
+
return normalized || 'field';
|
|
3772
|
+
}
|
|
2764
3773
|
/**
|
|
2765
3774
|
* Required-field snapshot for automation: every required field in a form, including
|
|
2766
3775
|
* offscreen entries, annotated with visibility and scroll hints so agents do not
|
|
@@ -2779,7 +3788,10 @@ export function buildFormRequiredSnapshot(root, options) {
|
|
|
2779
3788
|
const formNode = parsedForm ? findNodeByPath(root, parsedForm.path) : null;
|
|
2780
3789
|
const fields = schema.fields
|
|
2781
3790
|
.map(field => {
|
|
2782
|
-
const
|
|
3791
|
+
const authoredMatches = field.fieldKey && formNode
|
|
3792
|
+
? collectDescendants(formNode, node => node.meta?.controlKey === field.fieldKey)
|
|
3793
|
+
: [];
|
|
3794
|
+
const fieldPath = authoredMatches.length === 1 ? authoredMatches[0].path : parseFormFieldId(field.id);
|
|
2783
3795
|
const target = fieldPath ? findNodeByPath(root, fieldPath) ?? formNode : formNode;
|
|
2784
3796
|
if (!target)
|
|
2785
3797
|
return null;
|
|
@@ -2846,7 +3858,7 @@ export function expandPageSection(root, id, options) {
|
|
|
2846
3858
|
const maxTextPreview = options?.maxTextPreview ?? 6;
|
|
2847
3859
|
const includeBounds = options?.includeBounds ?? false;
|
|
2848
3860
|
const headingsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
|
|
2849
|
-
const fieldsAll = sortByBounds(collectDescendants(node,
|
|
3861
|
+
const fieldsAll = sortByBounds(collectDescendants(node, isFormFieldNode));
|
|
2850
3862
|
const actionsAll = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
|
|
2851
3863
|
const nestedListsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'list' && pathKey(candidate.path) !== pathKey(node.path)));
|
|
2852
3864
|
const itemsAll = actualKind === 'list'
|
|
@@ -3183,6 +4195,32 @@ function normalizeCheckedState(value) {
|
|
|
3183
4195
|
return false;
|
|
3184
4196
|
return undefined;
|
|
3185
4197
|
}
|
|
4198
|
+
function normalizeSemanticOptions(value) {
|
|
4199
|
+
if (!Array.isArray(value))
|
|
4200
|
+
return undefined;
|
|
4201
|
+
const options = [];
|
|
4202
|
+
const seenIndices = new Set();
|
|
4203
|
+
for (const entry of value.slice(0, 500)) {
|
|
4204
|
+
if (!entry || typeof entry !== 'object')
|
|
4205
|
+
continue;
|
|
4206
|
+
const record = entry;
|
|
4207
|
+
if (typeof record.value !== 'string' || typeof record.label !== 'string')
|
|
4208
|
+
continue;
|
|
4209
|
+
if (typeof record.index !== 'number' || !Number.isInteger(record.index) || record.index < 0)
|
|
4210
|
+
continue;
|
|
4211
|
+
if (seenIndices.has(record.index))
|
|
4212
|
+
continue;
|
|
4213
|
+
seenIndices.add(record.index);
|
|
4214
|
+
options.push({
|
|
4215
|
+
value: record.value,
|
|
4216
|
+
label: record.label,
|
|
4217
|
+
disabled: record.disabled === true,
|
|
4218
|
+
selected: record.selected === true,
|
|
4219
|
+
index: record.index,
|
|
4220
|
+
});
|
|
4221
|
+
}
|
|
4222
|
+
return options;
|
|
4223
|
+
}
|
|
3186
4224
|
function normalizeA11yRoleHint(value) {
|
|
3187
4225
|
if (typeof value !== 'string')
|
|
3188
4226
|
return undefined;
|
|
@@ -3239,6 +4277,15 @@ function walkNode(element, layout, path) {
|
|
|
3239
4277
|
meta.scrollY = semantic.scrollY;
|
|
3240
4278
|
if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
|
|
3241
4279
|
meta.controlTag = semantic.tag;
|
|
4280
|
+
if (typeof semantic?.controlKey === 'string' && semantic.controlKey.trim().length > 0)
|
|
4281
|
+
meta.controlKey = semantic.controlKey.trim();
|
|
4282
|
+
if (typeof semantic?.controlId === 'string' && semantic.controlId.trim().length > 0)
|
|
4283
|
+
meta.controlId = semantic.controlId.trim();
|
|
4284
|
+
if (typeof semantic?.controlName === 'string' && semantic.controlName.trim().length > 0)
|
|
4285
|
+
meta.controlName = semantic.controlName.trim();
|
|
4286
|
+
const semanticOptions = normalizeSemanticOptions(semantic?.options);
|
|
4287
|
+
if (semanticOptions)
|
|
4288
|
+
meta.options = semanticOptions;
|
|
3242
4289
|
if (typeof semantic?.placeholder === 'string')
|
|
3243
4290
|
meta.placeholder = semantic.placeholder;
|
|
3244
4291
|
if (typeof semantic?.inputPattern === 'string')
|
|
@@ -3247,6 +4294,14 @@ function walkNode(element, layout, path) {
|
|
|
3247
4294
|
meta.inputType = semantic.inputType;
|
|
3248
4295
|
if (typeof semantic?.autocomplete === 'string')
|
|
3249
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;
|
|
3250
4305
|
if (semantic?.isAutocompleteCombobox === true)
|
|
3251
4306
|
meta.isAutocompleteCombobox = true;
|
|
3252
4307
|
const children = [];
|
|
@@ -3350,24 +4405,186 @@ function applyPatches(layout, patches) {
|
|
|
3350
4405
|
}
|
|
3351
4406
|
async function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
|
|
3352
4407
|
await ensureSessionConnected(session);
|
|
3353
|
-
const
|
|
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);
|
|
4421
|
+
const requiresExactFieldIdentity = typeof message.fieldKey === 'string' || (message.type === 'fillFields' &&
|
|
4422
|
+
Array.isArray(message.fields) &&
|
|
4423
|
+
message.fields.some(field => typeof field === 'object' && field !== null && typeof field.fieldKey === 'string'));
|
|
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, {
|
|
4454
|
+
...opts,
|
|
4455
|
+
...(requiresExactFieldIdentity ? { requiredProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION } : {}),
|
|
4456
|
+
});
|
|
4457
|
+
}
|
|
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
|
+
}
|
|
3354
4498
|
const startRevision = session.updateRevision;
|
|
3355
|
-
|
|
3356
|
-
|
|
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
|
+
}
|
|
3357
4548
|
}
|
|
3358
|
-
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS,
|
|
4549
|
+
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, operation, startRevision = session.updateRevision, opts, ignoreDuplicateRequestError = false, sendAfterSubscribe) {
|
|
3359
4550
|
return new Promise((resolve, reject) => {
|
|
4551
|
+
const transport = session.ws;
|
|
4552
|
+
const { requestId, actionId } = operation;
|
|
4553
|
+
const operationRequestIds = new Set(operation.requestIds);
|
|
3360
4554
|
let ackSeen = false;
|
|
3361
4555
|
let ackResult;
|
|
3362
|
-
const ackPayload = () => (
|
|
4556
|
+
const ackPayload = () => ({
|
|
4557
|
+
requestId,
|
|
4558
|
+
actionId,
|
|
4559
|
+
...(ackSeen && ackResult !== undefined ? { result: ackResult } : {}),
|
|
4560
|
+
});
|
|
3363
4561
|
const onMessage = (data) => {
|
|
4562
|
+
if (session.ws !== transport)
|
|
4563
|
+
return;
|
|
3364
4564
|
try {
|
|
3365
|
-
const msg =
|
|
4565
|
+
const msg = parseInboundServerMessage(data);
|
|
4566
|
+
updatePeerProtocol(session, msg);
|
|
3366
4567
|
const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
3367
4568
|
if (requestId) {
|
|
3368
|
-
|
|
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) {
|
|
3369
4580
|
cleanup();
|
|
3370
|
-
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));
|
|
3371
4588
|
return;
|
|
3372
4589
|
}
|
|
3373
4590
|
if ((msg.type === 'frame' || (msg.type === 'patch' && session.layout)) && ackSeen && session.updateRevision > startRevision) {
|
|
@@ -3380,6 +4597,20 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
3380
4597
|
return;
|
|
3381
4598
|
}
|
|
3382
4599
|
if (msg.type === 'ack' && messageRequestId === requestId) {
|
|
4600
|
+
const peerProtocolVersion = typeof msg.proxyActionProtocolVersion === 'number'
|
|
4601
|
+
? msg.proxyActionProtocolVersion
|
|
4602
|
+
: typeof msg.protocolVersion === 'number'
|
|
4603
|
+
? msg.protocolVersion
|
|
4604
|
+
: session.peerProxyActionProtocolVersion;
|
|
4605
|
+
if (opts?.requiredProtocolVersion !== undefined && (peerProtocolVersion === undefined || peerProtocolVersion < opts.requiredProtocolVersion)) {
|
|
4606
|
+
cleanup();
|
|
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);
|
|
4612
|
+
return;
|
|
4613
|
+
}
|
|
3383
4614
|
ackSeen = true;
|
|
3384
4615
|
ackResult = msg.result;
|
|
3385
4616
|
if (!opts?.requireUpdateOnAck || session.updateRevision > startRevision) {
|
|
@@ -3395,45 +4626,95 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
3395
4626
|
}
|
|
3396
4627
|
if (msg.type === 'error') {
|
|
3397
4628
|
cleanup();
|
|
3398
|
-
reject(
|
|
4629
|
+
reject(wireErrorFromMessage(msg, actionId));
|
|
3399
4630
|
return;
|
|
3400
4631
|
}
|
|
3401
4632
|
if (msg.type === 'frame') {
|
|
3402
4633
|
cleanup();
|
|
3403
|
-
resolve({ status: 'updated', timeoutMs });
|
|
4634
|
+
resolve({ status: 'updated', timeoutMs, ...ackPayload() });
|
|
3404
4635
|
}
|
|
3405
4636
|
else if (msg.type === 'patch' && session.layout) {
|
|
3406
4637
|
cleanup();
|
|
3407
|
-
resolve({ status: 'updated', timeoutMs });
|
|
4638
|
+
resolve({ status: 'updated', timeoutMs, ...ackPayload() });
|
|
3408
4639
|
}
|
|
3409
4640
|
else if (msg.type === 'ack') {
|
|
3410
4641
|
cleanup();
|
|
3411
4642
|
resolve({
|
|
3412
4643
|
status: 'acknowledged',
|
|
3413
4644
|
timeoutMs,
|
|
4645
|
+
requestId,
|
|
4646
|
+
actionId,
|
|
3414
4647
|
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
3415
4648
|
});
|
|
3416
4649
|
}
|
|
3417
4650
|
}
|
|
3418
|
-
catch {
|
|
4651
|
+
catch (err) {
|
|
4652
|
+
cleanup();
|
|
4653
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
4654
|
+
}
|
|
3419
4655
|
};
|
|
3420
4656
|
// Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
|
|
3421
4657
|
const timeout = setTimeout(() => {
|
|
3422
4658
|
cleanup();
|
|
3423
|
-
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) {
|
|
3424
4667
|
resolve({ status: 'updated', timeoutMs, ...ackPayload() });
|
|
3425
4668
|
return;
|
|
3426
4669
|
}
|
|
3427
|
-
if (requestId && ackSeen) {
|
|
4670
|
+
if (requestId && ackSeen && (!opts?.requireUpdateOnAck || session.updateRevision > startRevision)) {
|
|
3428
4671
|
resolve({ status: 'acknowledged', timeoutMs, ...ackPayload() });
|
|
3429
4672
|
return;
|
|
3430
4673
|
}
|
|
3431
|
-
|
|
4674
|
+
if (operation.mutating)
|
|
4675
|
+
rememberAmbiguousOperation(session, operation);
|
|
4676
|
+
resolve({ status: 'timed_out', timeoutMs, requestId, actionId });
|
|
3432
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
|
+
};
|
|
3433
4690
|
function cleanup() {
|
|
3434
4691
|
clearTimeout(timeout);
|
|
3435
|
-
|
|
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
|
+
}
|
|
3436
4718
|
}
|
|
3437
|
-
session.ws.on('message', onMessage);
|
|
3438
4719
|
});
|
|
3439
4720
|
}
|