@geometra/proxy 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.
@@ -1,48 +1,578 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
1
2
  import { performance } from 'node:perf_hooks';
2
3
  import { WebSocketServer } from 'ws';
3
4
  import { attachFiles, clearFillLookupCache, createFillLookupCache, delay, fillFields, fillOtp, pickListboxOption, resolveExistingFiles, setFieldChoice, setFieldText, selectNativeOption, setCheckedControl, wheelAt, } from './dom-actions.js';
4
5
  import { createCdpAxSessionManager } from './a11y-enrich.js';
5
6
  import { coalescePatches, diffLayout } from './diff-layout.js';
6
7
  import { extractGeometry } from './extractor.js';
7
- import { isClickEventMessage, isCompositionMessage, isFillFieldsMessage, isFillOtpMessage, isFileMessage, isKeyMessage, isListboxPickMessage, isNavigateMessage, isResizeMessage, isPdfGenerateMessage, isScreenshotMessage, isSetFieldChoiceMessage, isSetFieldTextMessage, isSetCheckedMessage, isSelectOptionMessage, isWheelMessage, PROXY_PROTOCOL_VERSION, } from './types.js';
8
+ import { clientMessageValidationError, isClickEventMessage, isCompositionMessage, isFillFieldsMessage, isFillOtpMessage, isFileMessage, isKeyMessage, isTypeTextMessage, isListboxPickMessage, isNavigateMessage, isResizeMessage, isPdfGenerateMessage, isScreenshotMessage, isSetFieldChoiceMessage, isSetFieldTextMessage, isSetCheckedMessage, isSelectOptionMessage, isWheelMessage, GEOMETRY_PROTOCOL_VERSION, PROXY_ACTION_PROTOCOL_VERSION, PROXY_PROTOCOL_VERSION, } from './types.js';
8
9
  const DOM_OBSERVER_BINDINGS = new WeakSet();
9
- async function bindDomObserverBridge(page, scheduleExtract) {
10
- if (DOM_OBSERVER_BINDINGS.has(page))
10
+ const DEFAULT_PROXY_HOST = '127.0.0.1';
11
+ const DEFAULT_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024;
12
+ const DEFAULT_ACTION_REQUEST_LEDGER_ENTRIES = 65_536;
13
+ /** A completed action must not be starved by a page that mutates forever. */
14
+ const MAX_INPUT_ACK_LATENCY_MS = 250;
15
+ // The page-side bridge lives in the untrusted main world. Even if application
16
+ // code calls the exposed "settled" hook directly, it must not be able to turn
17
+ // that hint into an unbounded extraction loop on the proxy host.
18
+ const MIN_IMMEDIATE_EXTRACT_INTERVAL_MS = 250;
19
+ const PROXY_PROTOCOL_CAPABILITIES = {
20
+ transport: 'proxy',
21
+ authenticatedController: true,
22
+ requestScopedAcks: true,
23
+ actionDeadlines: true,
24
+ idempotentRequestIds: true,
25
+ atomicTypeText: true,
26
+ proxyActions: true,
27
+ exactFieldIdentity: true,
28
+ verifiedFileUploads: true,
29
+ };
30
+ const PROXY_GEOMETRY_METADATA = {
31
+ protocolVersion: GEOMETRY_PROTOCOL_VERSION,
32
+ geometryProtocolVersion: GEOMETRY_PROTOCOL_VERSION,
33
+ proxyActionProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION,
34
+ protocolCapabilities: PROXY_PROTOCOL_CAPABILITIES,
35
+ };
36
+ const PROXY_ACTION_METADATA = {
37
+ // Keep the legacy field at v2 so @geometra/mcp@1.64.x clients installed
38
+ // through their caret proxy dependency continue to accept exact-field acks.
39
+ protocolVersion: PROXY_PROTOCOL_VERSION,
40
+ geometryProtocolVersion: GEOMETRY_PROTOCOL_VERSION,
41
+ proxyActionProtocolVersion: PROXY_ACTION_PROTOCOL_VERSION,
42
+ protocolCapabilities: PROXY_PROTOCOL_CAPABILITIES,
43
+ };
44
+ /**
45
+ * Runs in every page/frame realm before application code.
46
+ *
47
+ * Keep this function closure-free: Playwright serializes it for addInitScript.
48
+ * Closed shadow roots are deliberately held in a WeakMap rather than exposed
49
+ * through DOM attributes. The extractor consumes the same Symbol.for key in
50
+ * its in-page evaluation.
51
+ */
52
+ function installPageFreshnessInstrumentation(config) {
53
+ const { installToken, notifyBindingName } = config;
54
+ const CLOSED_ROOTS_KEY = Symbol.for('geometra.closedShadowRoots');
55
+ // This key is minted in the trusted host process and injected immediately
56
+ // before installation. A page can preseed a public Symbol.for name, but it
57
+ // cannot predict this per-binding capability before an init script runs.
58
+ const INSTALL_STATE_KEY = `__geometraProxyFreshnessInstalled_${installToken}`;
59
+ const global = globalThis;
60
+ const globalRecord = global;
61
+ if (globalRecord[INSTALL_STATE_KEY] === installToken)
11
62
  return;
12
- await page.exposeFunction('__geometraProxyNotify', () => {
13
- scheduleExtract();
14
- });
15
- await page.addInitScript(() => {
16
- const w = window;
17
- if (w.__geometraProxyObserverBootstrapped)
63
+ let existingRegistry;
64
+ try {
65
+ existingRegistry = global[CLOSED_ROOTS_KEY];
66
+ }
67
+ catch {
68
+ // A hostile getter must not disable the independent freshness signals.
69
+ }
70
+ let closedRoots = existingRegistry instanceof WeakMap
71
+ ? existingRegistry
72
+ : undefined;
73
+ if (!closedRoots) {
74
+ const candidate = new WeakMap();
75
+ try {
76
+ const existingDescriptor = Object.getOwnPropertyDescriptor(global, CLOSED_ROOTS_KEY);
77
+ if (existingDescriptor) {
78
+ // Supplying only value preserves the flags of a non-configurable but
79
+ // writable data property. Configurable collisions are replaced too.
80
+ Object.defineProperty(global, CLOSED_ROOTS_KEY, { value: candidate });
81
+ }
82
+ else {
83
+ Object.defineProperty(global, CLOSED_ROOTS_KEY, {
84
+ value: candidate,
85
+ configurable: false,
86
+ enumerable: false,
87
+ writable: false,
88
+ });
89
+ }
90
+ if (global[CLOSED_ROOTS_KEY] === candidate)
91
+ closedRoots = candidate;
92
+ }
93
+ catch {
94
+ // Fail closed for semantic access to closed roots. attachShadow remains
95
+ // usable and the mutation/resize/CSS freshness machinery still boots.
96
+ }
97
+ }
98
+ // Capture the Playwright bridge before application code can replace or
99
+ // delete its writable main-world property. All instrumentation signals use
100
+ // this private closure reference from here onward.
101
+ const bindingCandidate = globalRecord[notifyBindingName];
102
+ const notifyBridge = typeof bindingCandidate === 'function'
103
+ ? bindingCandidate.bind(global)
104
+ : undefined;
105
+ const NOTIFY_MIN_INTERVAL_MS = 25;
106
+ let notifyInFlight = false;
107
+ let notifyPending = false;
108
+ let settledNotifyPending = false;
109
+ let notifyTimer;
110
+ let lastNotifyStartedAt = Number.NEGATIVE_INFINITY;
111
+ const drainNotify = () => {
112
+ if (!notifyBridge || notifyInFlight || notifyTimer !== undefined || !notifyPending)
18
113
  return;
19
- const install = () => {
20
- if (w.__geometraProxyObserverInstalled)
21
- return;
22
- const root = document.documentElement;
23
- if (!root)
24
- return;
25
- const observer = new MutationObserver(() => {
26
- void w.__geometraProxyNotify?.();
114
+ const elapsed = global.performance.now() - lastNotifyStartedAt;
115
+ const remaining = Math.max(0, NOTIFY_MIN_INTERVAL_MS - elapsed);
116
+ if (remaining > 0) {
117
+ // Anchor the cadence to the prior dispatch instead of resetting this
118
+ // timer for every mutation. A page that changes forever therefore keeps
119
+ // making bounded progress and still emits its final trailing signal.
120
+ notifyTimer = global.setTimeout(() => {
121
+ notifyTimer = undefined;
122
+ drainNotify();
123
+ }, Math.ceil(remaining));
124
+ return;
125
+ }
126
+ const urgency = settledNotifyPending ? 'settled' : undefined;
127
+ notifyPending = false;
128
+ settledNotifyPending = false;
129
+ notifyInFlight = true;
130
+ lastNotifyStartedAt = global.performance.now();
131
+ let bridgeCall;
132
+ try {
133
+ bridgeCall = Promise.resolve(notifyBridge(urgency));
134
+ }
135
+ catch {
136
+ notifyInFlight = false;
137
+ drainNotify();
138
+ return;
139
+ }
140
+ void bridgeCall.catch(() => { }).finally(() => {
141
+ notifyInFlight = false;
142
+ drainNotify();
143
+ });
144
+ };
145
+ const notify = (urgency) => {
146
+ if (!notifyBridge)
147
+ return;
148
+ notifyPending = true;
149
+ // A final settle hint must survive coalescing with any number of ordinary
150
+ // mutation signals so the host can bypass its trailing debounce once.
151
+ if (urgency === 'settled')
152
+ settledNotifyPending = true;
153
+ drainNotify();
154
+ };
155
+ // A sampler is started only by a signal that can imply layout is actively
156
+ // changing. It has a hard per-signal horizon and never polls an idle page.
157
+ const MAX_SETTLE_MS = 500;
158
+ let settleUntil = 0;
159
+ let settleAnimationFrame;
160
+ const sampleUntilSettled = () => {
161
+ if (global.performance.now() < settleUntil) {
162
+ notify();
163
+ settleAnimationFrame = global.requestAnimationFrame(sampleUntilSettled);
164
+ }
165
+ else {
166
+ settleAnimationFrame = undefined;
167
+ // Bypass the trailing debounce for the final sample. This makes 500ms
168
+ // a hard scheduling bound for a finite layout activity window instead
169
+ // of silently turning it into 500ms + debounceMs.
170
+ notify('settled');
171
+ }
172
+ };
173
+ const markLayoutActivity = () => {
174
+ settleUntil = Math.max(settleUntil, global.performance.now() + MAX_SETTLE_MS);
175
+ notify();
176
+ if (settleAnimationFrame === undefined) {
177
+ settleAnimationFrame = global.requestAnimationFrame(sampleUntilSettled);
178
+ }
179
+ };
180
+ let resizeObserver;
181
+ const resizeTargets = new WeakSet();
182
+ // Bounding the target count avoids turning observation into an unbounded
183
+ // mirror of very large application DOMs. CSSOM/event sampling remains the
184
+ // fallback for nodes beyond this cap.
185
+ const MAX_RESIZE_TARGETS = 4_096;
186
+ let resizeTargetCount = 0;
187
+ const observeResizeTarget = (element) => {
188
+ if (!resizeObserver || resizeTargets.has(element) || resizeTargetCount >= MAX_RESIZE_TARGETS)
189
+ return;
190
+ resizeTargets.add(element);
191
+ resizeTargetCount += 1;
192
+ resizeObserver.observe(element);
193
+ };
194
+ const observeResizeCandidates = (root) => {
195
+ if (root instanceof Element)
196
+ observeResizeTarget(root);
197
+ for (const element of root.querySelectorAll('a,button,input,select,textarea,iframe,img,canvas,svg,video,[role],[contenteditable],[tabindex]')) {
198
+ observeResizeTarget(element);
199
+ if (resizeTargetCount >= MAX_RESIZE_TARGETS)
200
+ break;
201
+ }
202
+ };
203
+ if (typeof ResizeObserver === 'function') {
204
+ resizeObserver = new ResizeObserver(() => notify());
205
+ }
206
+ const mutationObserver = new MutationObserver(records => {
207
+ for (const record of records) {
208
+ for (const node of record.addedNodes) {
209
+ if (node instanceof Element || node instanceof ShadowRoot) {
210
+ observeResizeCandidates(node);
211
+ }
212
+ }
213
+ }
214
+ notify();
215
+ });
216
+ const observeRoot = (root) => {
217
+ mutationObserver.observe(root, {
218
+ subtree: true,
219
+ childList: true,
220
+ attributes: true,
221
+ characterData: true,
222
+ });
223
+ observeResizeCandidates(root);
224
+ };
225
+ const attachShadowDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'attachShadow');
226
+ const nativeAttachShadow = attachShadowDescriptor?.value;
227
+ if (nativeAttachShadow) {
228
+ const instrumentedAttachShadow = function attachShadow(init) {
229
+ const root = Reflect.apply(nativeAttachShadow, this, [init]);
230
+ if (init?.mode === 'closed')
231
+ closedRoots?.set(this, root);
232
+ observeRoot(root);
233
+ markLayoutActivity();
234
+ return root;
235
+ };
236
+ // Preserve the original method's own surface where the platform permits
237
+ // it. The wrapper already has the native one-argument call signature.
238
+ try {
239
+ Object.defineProperty(instrumentedAttachShadow, 'name', {
240
+ value: nativeAttachShadow.name,
241
+ configurable: true,
27
242
  });
28
- observer.observe(root, {
29
- subtree: true,
30
- childList: true,
31
- attributes: true,
32
- characterData: true,
243
+ Object.defineProperty(instrumentedAttachShadow, 'toString', {
244
+ value: nativeAttachShadow.toString.bind(nativeAttachShadow),
245
+ configurable: true,
33
246
  });
34
- w.__geometraProxyObserverInstalled = true;
247
+ }
248
+ catch {
249
+ // Function metadata is cosmetic; never make attachShadow unavailable.
250
+ }
251
+ try {
252
+ Object.defineProperty(Element.prototype, 'attachShadow', {
253
+ ...attachShadowDescriptor,
254
+ value: instrumentedAttachShadow,
255
+ });
256
+ }
257
+ catch {
258
+ // Hardened realms may lock platform prototypes. Keep the rest of the
259
+ // freshness instrumentation available even when closed-root capture is
260
+ // impossible in that realm.
261
+ }
262
+ }
263
+ const wrapLayoutMutator = (prototype, property) => {
264
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, property);
265
+ const native = descriptor?.value;
266
+ if (!descriptor || typeof native !== 'function')
267
+ return;
268
+ const wrapped = function (...args) {
269
+ const result = Reflect.apply(native, this, args);
270
+ markLayoutActivity();
271
+ if (result && typeof result.then === 'function') {
272
+ void Promise.resolve(result).then(markLayoutActivity, markLayoutActivity);
273
+ }
274
+ return result;
35
275
  };
36
- if (document.readyState === 'loading') {
37
- document.addEventListener('DOMContentLoaded', install, { once: true });
276
+ try {
277
+ Object.defineProperty(wrapped, 'name', { value: native.name, configurable: true });
278
+ Object.defineProperty(wrapped, 'length', { value: native.length, configurable: true });
38
279
  }
39
- else {
40
- install();
280
+ catch {
281
+ // Function metadata is best-effort only.
282
+ }
283
+ try {
284
+ Object.defineProperty(prototype, property, { ...descriptor, value: wrapped });
285
+ }
286
+ catch {
287
+ // A locked CSSOM prototype still has ResizeObserver/event fallbacks.
288
+ }
289
+ };
290
+ const wrapLayoutSetter = (prototype, property) => {
291
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, property);
292
+ const nativeSet = descriptor?.set;
293
+ if (!descriptor || !nativeSet)
294
+ return;
295
+ const wrappedSet = function (value) {
296
+ Reflect.apply(nativeSet, this, [value]);
297
+ markLayoutActivity();
298
+ };
299
+ try {
300
+ Object.defineProperty(prototype, property, { ...descriptor, set: wrappedSet });
301
+ }
302
+ catch {
303
+ // A locked CSSOM prototype still has ResizeObserver/event fallbacks.
304
+ }
305
+ };
306
+ const instrumentedDeclarations = new WeakSet();
307
+ const nativeGetPropertyValue = CSSStyleDeclaration.prototype.getPropertyValue;
308
+ const nativeSetProperty = CSSStyleDeclaration.prototype.setProperty;
309
+ // CSSStyleDeclaration exposes CSS properties as own WebIDL named
310
+ // properties. Assignments such as rule.style.transform = 'translateX(...)'
311
+ // do not call the JavaScript-visible setProperty method and do not produce a
312
+ // DOM mutation. Instrument geometry-bearing declarations when a CSS rule's
313
+ // style getter first exposes them so cached declarations remain observable.
314
+ const GEOMETRY_DECLARATION_PROPERTIES = [
315
+ 'alignContent', 'alignItems', 'alignSelf', 'alignmentBaseline',
316
+ 'animation', 'animationDelay', 'animationDuration', 'animationName', 'animationPlayState',
317
+ 'aspectRatio',
318
+ 'blockSize', 'inlineSize', 'minBlockSize', 'maxBlockSize', 'minInlineSize', 'maxInlineSize',
319
+ 'border', 'borderBlock', 'borderInline', 'borderWidth', 'borderTopWidth', 'borderRightWidth',
320
+ 'borderBottomWidth', 'borderLeftWidth', 'boxSizing',
321
+ 'clear', 'clip', 'clipPath', 'columnCount', 'columnGap', 'columnWidth', 'columns',
322
+ 'contain', 'content', 'contentVisibility',
323
+ 'direction', 'display',
324
+ 'flex', 'flexBasis', 'flexDirection', 'flexFlow', 'flexGrow', 'flexShrink', 'flexWrap',
325
+ 'float', 'cssFloat',
326
+ 'font', 'fontFamily', 'fontSize', 'fontStretch', 'fontStyle', 'fontWeight',
327
+ 'gap', 'grid', 'gridArea', 'gridAutoColumns', 'gridAutoFlow', 'gridAutoRows',
328
+ 'gridColumn', 'gridColumnEnd', 'gridColumnStart', 'gridRow', 'gridRowEnd', 'gridRowStart',
329
+ 'gridTemplate', 'gridTemplateAreas', 'gridTemplateColumns', 'gridTemplateRows',
330
+ 'height', 'minHeight', 'maxHeight',
331
+ 'inset', 'insetBlock', 'insetBlockEnd', 'insetBlockStart', 'insetInline', 'insetInlineEnd',
332
+ 'insetInlineStart', 'top', 'right', 'bottom', 'left',
333
+ 'justifyContent', 'justifyItems', 'justifySelf',
334
+ 'letterSpacing', 'lineHeight',
335
+ 'margin', 'marginBlock', 'marginBlockEnd', 'marginBlockStart', 'marginInline', 'marginInlineEnd',
336
+ 'marginInlineStart', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
337
+ 'objectFit', 'objectPosition', 'opacity', 'order', 'overflow', 'overflowX', 'overflowY',
338
+ 'padding', 'paddingBlock', 'paddingBlockEnd', 'paddingBlockStart', 'paddingInline',
339
+ 'paddingInlineEnd', 'paddingInlineStart', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
340
+ 'perspective', 'perspectiveOrigin', 'position',
341
+ 'rotate', 'rowGap', 'scale',
342
+ 'textIndent', 'transform', 'transformBox', 'transformOrigin', 'transition', 'translate',
343
+ 'verticalAlign', 'visibility', 'whiteSpace',
344
+ 'width', 'minWidth', 'maxWidth', 'wordSpacing', 'writingMode', 'zoom',
345
+ ];
346
+ const cssPropertyName = (property) => {
347
+ if (property === 'cssFloat')
348
+ return 'float';
349
+ const dashed = property.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`);
350
+ return /^(webkit|moz|ms|o)-/.test(dashed) ? `-${dashed}` : dashed;
351
+ };
352
+ const instrumentDeclaration = (declaration) => {
353
+ if (instrumentedDeclarations.has(declaration))
354
+ return;
355
+ instrumentedDeclarations.add(declaration);
356
+ for (const property of GEOMETRY_DECLARATION_PROPERTIES) {
357
+ const descriptor = Object.getOwnPropertyDescriptor(declaration, property);
358
+ if (!descriptor?.configurable)
359
+ continue;
360
+ const cssName = cssPropertyName(property);
361
+ try {
362
+ Object.defineProperty(declaration, property, {
363
+ configurable: true,
364
+ enumerable: descriptor.enumerable,
365
+ get() {
366
+ return Reflect.apply(nativeGetPropertyValue, declaration, [cssName]);
367
+ },
368
+ set(value) {
369
+ Reflect.apply(nativeSetProperty, declaration, [cssName, value == null ? '' : String(value), '']);
370
+ markLayoutActivity();
371
+ },
372
+ });
373
+ }
374
+ catch {
375
+ // The rule-style getter sampler below remains a bounded fallback.
376
+ }
377
+ }
378
+ };
379
+ const wrapRuleStyleGetter = (prototype) => {
380
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'style');
381
+ const nativeGet = descriptor?.get;
382
+ if (!descriptor || !nativeGet)
383
+ return;
384
+ const wrappedGet = function () {
385
+ const declaration = Reflect.apply(nativeGet, this, []);
386
+ instrumentDeclaration(declaration);
387
+ // Also cover newly introduced CSS properties that are not yet in the
388
+ // explicit geometry list. Access starts one bounded settle window; it
389
+ // never creates persistent polling.
390
+ markLayoutActivity();
391
+ return declaration;
392
+ };
393
+ try {
394
+ Object.defineProperty(prototype, 'style', { ...descriptor, get: wrappedGet });
395
+ }
396
+ catch {
397
+ // Locked rule prototypes retain ResizeObserver/event fallbacks.
41
398
  }
42
- w.__geometraProxyObserverBootstrapped = true;
399
+ };
400
+ if (typeof CSSStyleSheet !== 'undefined') {
401
+ for (const method of ['insertRule', 'deleteRule', 'replace', 'replaceSync']) {
402
+ wrapLayoutMutator(CSSStyleSheet.prototype, method);
403
+ }
404
+ }
405
+ if (typeof CSSStyleDeclaration !== 'undefined') {
406
+ for (const method of ['setProperty', 'removeProperty']) {
407
+ wrapLayoutMutator(CSSStyleDeclaration.prototype, method);
408
+ }
409
+ wrapLayoutSetter(CSSStyleDeclaration.prototype, 'cssText');
410
+ }
411
+ for (const constructorName of ['CSSStyleRule', 'CSSKeyframeRule', 'CSSFontFaceRule', 'CSSPageRule']) {
412
+ const constructor = global[constructorName];
413
+ if (constructor?.prototype)
414
+ wrapRuleStyleGetter(constructor.prototype);
415
+ }
416
+ const notifyEvent = () => notify();
417
+ const activityEvent = () => markLayoutActivity();
418
+ global.addEventListener('scroll', notifyEvent, { capture: true, passive: true });
419
+ global.addEventListener('resize', notifyEvent, { passive: true });
420
+ document.addEventListener('transitionrun', activityEvent, true);
421
+ document.addEventListener('transitionstart', activityEvent, true);
422
+ document.addEventListener('transitionend', activityEvent, true);
423
+ document.addEventListener('transitioncancel', activityEvent, true);
424
+ document.addEventListener('animationstart', activityEvent, true);
425
+ document.addEventListener('animationiteration', activityEvent, true);
426
+ document.addEventListener('animationend', activityEvent, true);
427
+ document.addEventListener('animationcancel', activityEvent, true);
428
+ document.addEventListener('load', event => {
429
+ const target = event.target;
430
+ if (target instanceof HTMLLinkElement && target.relList.contains('stylesheet')) {
431
+ markLayoutActivity();
432
+ }
433
+ }, true);
434
+ const fonts = document.fonts;
435
+ if (fonts) {
436
+ fonts.addEventListener('loading', activityEvent);
437
+ fonts.addEventListener('loadingdone', activityEvent);
438
+ fonts.addEventListener('loadingerror', activityEvent);
439
+ void fonts.ready.then(markLayoutActivity, notify);
440
+ }
441
+ // A Document can be observed before the parser creates documentElement, so
442
+ // install the mutation signal immediately and add resize targets once they
443
+ // exist.
444
+ observeRoot(document);
445
+ const installDocumentResizeTargets = () => {
446
+ if (document.documentElement)
447
+ observeResizeTarget(document.documentElement);
448
+ if (document.body)
449
+ observeResizeTarget(document.body);
450
+ };
451
+ if (document.documentElement) {
452
+ installDocumentResizeTargets();
453
+ }
454
+ else {
455
+ document.addEventListener('DOMContentLoaded', installDocumentResizeTargets, { once: true });
456
+ }
457
+ Object.defineProperty(global, INSTALL_STATE_KEY, {
458
+ value: installToken,
459
+ configurable: false,
460
+ enumerable: false,
461
+ writable: false,
462
+ });
463
+ }
464
+ async function bindDomObserverBridge(page, scheduleExtract) {
465
+ if (DOM_OBSERVER_BINDINGS.has(page))
466
+ return;
467
+ const installToken = randomBytes(32).toString('base64url');
468
+ const notifyBindingName = `__geometraProxyNotify_${installToken}`;
469
+ const installConfig = { installToken, notifyBindingName };
470
+ await page.exposeFunction(notifyBindingName, (urgency) => {
471
+ scheduleExtract(urgency === 'settled');
43
472
  });
473
+ // addInitScript is the critical path: it runs before application scripts in
474
+ // every subsequent main-frame and child-frame document.
475
+ await page.addInitScript(installPageFreshnessInstrumentation, installConfig);
476
+ // Also instrument a document that was already loaded before the bridge was
477
+ // bound. Closed roots created before this point cannot be recovered, which
478
+ // is why the runtime primes the bridge before its first navigation.
479
+ await Promise.all(page.frames().map(async (frame) => {
480
+ await frame.evaluate(installPageFreshnessInstrumentation, installConfig).catch(() => { });
481
+ }));
44
482
  DOM_OBSERVER_BINDINGS.add(page);
45
483
  }
484
+ function canonicalJson(value) {
485
+ if (Array.isArray(value)) {
486
+ return `[${value.map(canonicalJson).join(',')}]`;
487
+ }
488
+ if (value !== null && typeof value === 'object') {
489
+ const record = value;
490
+ return `{${Object.keys(record)
491
+ .sort()
492
+ .map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
493
+ .join(',')}}`;
494
+ }
495
+ return JSON.stringify(value) ?? 'null';
496
+ }
497
+ function sha256(value) {
498
+ return createHash('sha256').update(value).digest('hex');
499
+ }
500
+ const ACTION_FINGERPRINT_TRANSPORT_KEYS = new Set([
501
+ 'requestId',
502
+ 'actionTimeoutMs',
503
+ 'protocolVersion',
504
+ 'geometryProtocolVersion',
505
+ 'proxyActionProtocolVersion',
506
+ ]);
507
+ function actionPayloadFingerprint(payload) {
508
+ const semanticPayload = Object.fromEntries(Object.entries(payload).filter(([key]) => !ACTION_FINGERPRINT_TRANSPORT_KEYS.has(key)));
509
+ return sha256(canonicalJson(semanticPayload));
510
+ }
511
+ export function createActionRequestLedger(maxEntries = DEFAULT_ACTION_REQUEST_LEDGER_ENTRIES) {
512
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
513
+ throw new Error('Action request ledger size must be a positive safe integer');
514
+ }
515
+ const entries = new Map();
516
+ return {
517
+ remember(requestId, payload) {
518
+ // Hash request IDs as well as payloads so every retained entry has a
519
+ // fixed upper memory bound even when a peer sends an unusually long ID.
520
+ const requestKey = sha256(requestId);
521
+ const payloadFingerprint = actionPayloadFingerprint(payload);
522
+ const remembered = entries.get(requestKey);
523
+ if (remembered !== undefined) {
524
+ return remembered.fingerprint === payloadFingerprint ? 'duplicate' : 'conflict';
525
+ }
526
+ if (entries.size >= maxEntries) {
527
+ // Terminal means only that the handler returned; it does not prove
528
+ // the controller received the correlated ACK. Never evict a request
529
+ // ID within a runtime, or an ambiguous retry could mutate twice.
530
+ return 'capacity';
531
+ }
532
+ entries.set(requestKey, { fingerprint: payloadFingerprint, state: 'live' });
533
+ return 'accepted';
534
+ },
535
+ complete(requestId) {
536
+ const requestKey = sha256(requestId);
537
+ const remembered = entries.get(requestKey);
538
+ if (!remembered || remembered.state === 'terminal')
539
+ return;
540
+ entries.delete(requestKey);
541
+ entries.set(requestKey, { ...remembered, state: 'terminal' });
542
+ },
543
+ };
544
+ }
545
+ const MUTATING_CLIENT_MESSAGE_TYPES = new Set([
546
+ 'event',
547
+ 'key',
548
+ 'typeText',
549
+ 'resize',
550
+ 'navigate',
551
+ 'composition',
552
+ 'file',
553
+ 'setFieldText',
554
+ 'setFieldChoice',
555
+ 'fillFields',
556
+ 'fillOtp',
557
+ 'listboxPick',
558
+ 'selectOption',
559
+ 'setChecked',
560
+ 'wheel',
561
+ 'pdfGenerate',
562
+ ]);
563
+ function isMutatingClientMessage(msg) {
564
+ return MUTATING_CLIENT_MESSAGE_TYPES.has(msg.type);
565
+ }
566
+ class ActionDeadlineExpiredError extends Error {
567
+ }
568
+ function assertActionDeadline(msg, receivedAt) {
569
+ if (!isMutatingClientMessage(msg))
570
+ return;
571
+ const actionTimeoutMs = msg.actionTimeoutMs;
572
+ if (actionTimeoutMs !== undefined && performance.now() - receivedAt >= actionTimeoutMs) {
573
+ throw new ActionDeadlineExpiredError('Action deadline expired; request was not executed');
574
+ }
575
+ }
46
576
  function isProtocolCompatible(peerVersion) {
47
577
  if (peerVersion === undefined)
48
578
  return true;
@@ -50,6 +580,38 @@ function isProtocolCompatible(peerVersion) {
50
580
  return false;
51
581
  return peerVersion <= PROXY_PROTOCOL_VERSION;
52
582
  }
583
+ function generatedAuthToken() {
584
+ return randomBytes(32).toString('base64url');
585
+ }
586
+ function normalizedAuthToken(value) {
587
+ const token = value?.trim() || generatedAuthToken();
588
+ if (token.length < 32) {
589
+ throw new Error('geometra-proxy authToken must contain at least 32 characters');
590
+ }
591
+ return token;
592
+ }
593
+ function bearerTokenMatches(header, expected) {
594
+ if (!header?.startsWith('Bearer '))
595
+ return false;
596
+ const received = header.slice('Bearer '.length);
597
+ const receivedBytes = Buffer.from(received);
598
+ const expectedBytes = Buffer.from(expected);
599
+ return receivedBytes.length === expectedBytes.length && timingSafeEqual(receivedBytes, expectedBytes);
600
+ }
601
+ function protocolCompatibilityError(msg) {
602
+ const record = msg;
603
+ const geometryVersion = record.geometryProtocolVersion;
604
+ if (geometryVersion !== undefined && (typeof geometryVersion !== 'number' ||
605
+ !Number.isFinite(geometryVersion) ||
606
+ geometryVersion > GEOMETRY_PROTOCOL_VERSION)) {
607
+ return `Client geometry protocol ${String(geometryVersion)} is newer than proxy geometry protocol ${GEOMETRY_PROTOCOL_VERSION}`;
608
+ }
609
+ const actionVersion = record.proxyActionProtocolVersion ?? record.protocolVersion;
610
+ if (!isProtocolCompatible(actionVersion)) {
611
+ return `Client proxy-action protocol ${String(actionVersion)} is newer than proxy action protocol ${PROXY_ACTION_PROTOCOL_VERSION}`;
612
+ }
613
+ return null;
614
+ }
53
615
  function cloneLayout(layout) {
54
616
  return structuredClone(layout);
55
617
  }
@@ -105,49 +667,84 @@ async function applyKeyPhase(page, msg) {
105
667
  if (msg.shiftKey)
106
668
  await page.keyboard.up('Shift');
107
669
  }
108
- async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitForBeforeInput, onViewportOrInput, onHandlerError) {
109
- let msg;
670
+ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitForBeforeInput, onViewportOrInput, onHandlerError, security) {
671
+ const receivedAt = security?.receivedAt ?? performance.now();
672
+ const sendWireError = (message, requestId, code) => {
673
+ ws.send(JSON.stringify({
674
+ type: 'error',
675
+ message,
676
+ ...(code ? { code } : {}),
677
+ ...(requestId ? { requestId } : {}),
678
+ ...PROXY_ACTION_METADATA,
679
+ }));
680
+ };
681
+ let parsed;
110
682
  try {
111
- msg = JSON.parse(String(raw));
683
+ parsed = JSON.parse(String(raw));
112
684
  }
113
685
  catch {
686
+ sendWireError('Invalid client message: expected valid JSON');
114
687
  return;
115
688
  }
116
- if (typeof msg !== 'object' || msg === null || typeof msg.type !== 'string')
117
- return;
118
- const pv = 'protocolVersion' in msg ? msg.protocolVersion : undefined;
119
- const requestId = typeof msg.requestId === 'string'
120
- ? msg.requestId
689
+ const requestId = typeof parsed === 'object' && parsed !== null &&
690
+ typeof parsed.requestId === 'string'
691
+ ? parsed.requestId
121
692
  : undefined;
122
- if (!isProtocolCompatible(pv)) {
123
- ws.send(JSON.stringify({
124
- type: 'error',
125
- message: `Client protocol ${String(pv)} is newer than proxy protocol ${PROXY_PROTOCOL_VERSION}`,
126
- ...(requestId ? { requestId } : {}),
127
- protocolVersion: PROXY_PROTOCOL_VERSION,
128
- }));
693
+ if (typeof parsed !== 'object' || parsed === null || typeof parsed.type !== 'string') {
694
+ sendWireError('Invalid client message: expected an object with a string type', requestId);
129
695
  return;
130
696
  }
131
- const wireError = (message) => {
132
- ws.send(JSON.stringify({
133
- type: 'error',
134
- message,
135
- ...(requestId ? { requestId } : {}),
136
- protocolVersion: PROXY_PROTOCOL_VERSION,
137
- }));
138
- };
697
+ const msg = parsed;
698
+ const compatibilityError = protocolCompatibilityError(msg);
699
+ if (compatibilityError) {
700
+ sendWireError(compatibilityError, requestId);
701
+ return;
702
+ }
703
+ const validationError = clientMessageValidationError(msg);
704
+ if (validationError) {
705
+ sendWireError(validationError, requestId);
706
+ return;
707
+ }
708
+ // Reserve the request ID before consulting its deadline. An expired replay
709
+ // must be reported as a duplicate/conflict and must never become eligible
710
+ // to mutate merely because the original deadline elapsed.
711
+ let acceptedRequestId;
712
+ if (requestId && isMutatingClientMessage(msg) && security?.actionRequestLedger) {
713
+ const reservation = security.actionRequestLedger.remember(requestId, msg);
714
+ if (reservation === 'duplicate') {
715
+ sendWireError('Duplicate requestId; action was not repeated', requestId, 'DUPLICATE_REQUEST');
716
+ return;
717
+ }
718
+ if (reservation === 'conflict') {
719
+ sendWireError('requestId was already used with a different payload; action was not executed', requestId, 'REQUEST_ID_CONFLICT');
720
+ return;
721
+ }
722
+ if (reservation === 'capacity') {
723
+ sendWireError('Action request ledger is at capacity; action was not executed. Close and reconnect the proxy runtime before sending more actions.', requestId, 'REQUEST_LEDGER_CAPACITY');
724
+ return;
725
+ }
726
+ acceptedRequestId = requestId;
727
+ }
139
728
  try {
729
+ // These guards do not cancel an action that already started. They prevent
730
+ // queued work from beginning after the receipt-relative timeout.
731
+ assertActionDeadline(msg, receivedAt);
140
732
  const page = await waitForPage();
733
+ assertActionDeadline(msg, receivedAt);
141
734
  if (isResizeMessage(msg)) {
142
735
  const w = Math.max(1, Math.floor(msg.width));
143
736
  const h = Math.max(1, Math.floor(msg.height));
737
+ assertActionDeadline(msg, receivedAt);
144
738
  await page.setViewportSize({ width: w, height: h });
145
739
  onViewportOrInput('resize', requestId);
146
740
  return;
147
741
  }
742
+ assertActionDeadline(msg, receivedAt);
148
743
  await waitForBeforeInput();
744
+ assertActionDeadline(msg, receivedAt);
149
745
  if (isNavigateMessage(msg)) {
150
746
  clearFillLookupCache(fieldLookupCache);
747
+ assertActionDeadline(msg, receivedAt);
151
748
  await page.goto(msg.url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
152
749
  onViewportOrInput('input', requestId, { pageUrl: page.url() });
153
750
  return;
@@ -164,6 +761,7 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
164
761
  // "session crashed for unknown reason". Bug surfaced by JobForge
165
762
  // round-2 marathon — Cloudflare FDE NYC #312 and Airtable PM AI #94.
166
763
  const urlBefore = page.url();
764
+ assertActionDeadline(msg, receivedAt);
167
765
  await page.mouse.click(x, y);
168
766
  // Give the navigation a brief window to start. We don't await
169
767
  // waitForNavigation here because most clicks DON'T navigate, and
@@ -176,9 +774,9 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
176
774
  urlAfter = page.url();
177
775
  }
178
776
  catch {
179
- // If page.url() throws, the page is gone emit a minimal nav
180
- // signal so the caller knows the click did *something*.
181
- onViewportOrInput('input', requestId, { navigated: true, pageUrl: undefined });
777
+ // A disappeared page may have navigated, crashed, or been closed.
778
+ // Do not promote that ambiguity to a successful submission.
779
+ onViewportOrInput('input', requestId, { pageUnavailable: true, urlBefore });
182
780
  return;
183
781
  }
184
782
  if (urlAfter !== urlBefore) {
@@ -194,25 +792,37 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
194
792
  return;
195
793
  }
196
794
  if (isKeyMessage(msg)) {
795
+ assertActionDeadline(msg, receivedAt);
197
796
  await applyKeyPhase(page, msg);
198
797
  onViewportOrInput('input', requestId);
199
798
  return;
200
799
  }
800
+ if (isTypeTextMessage(msg)) {
801
+ assertActionDeadline(msg, receivedAt);
802
+ await page.keyboard.type(msg.text);
803
+ onViewportOrInput('input', requestId);
804
+ return;
805
+ }
201
806
  if (isCompositionMessage(msg)) {
202
807
  const data = typeof msg.data === 'string' ? msg.data : '';
203
808
  if (msg.eventType === 'onCompositionUpdate' || msg.eventType === 'onCompositionEnd') {
809
+ assertActionDeadline(msg, receivedAt);
204
810
  await page.keyboard.insertText(data);
205
811
  onViewportOrInput('input', requestId);
206
812
  }
207
813
  return;
208
814
  }
209
815
  if (isFileMessage(msg)) {
210
- const paths = resolveExistingFiles(msg.paths);
816
+ const paths = resolveExistingFiles(msg.paths, security?.allowedFileRoots);
817
+ assertActionDeadline(msg, receivedAt);
211
818
  await attachFiles(page, paths, {
212
819
  clickX: msg.x,
213
820
  clickY: msg.y,
214
821
  fieldId: msg.fieldId,
822
+ fieldKey: msg.fieldKey,
215
823
  fieldLabel: msg.fieldLabel,
824
+ contextText: msg.contextText,
825
+ sectionText: msg.sectionText,
216
826
  exact: msg.exact,
217
827
  strategy: msg.strategy,
218
828
  dropX: msg.dropX,
@@ -222,8 +832,10 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
222
832
  return;
223
833
  }
224
834
  if (isSetFieldTextMessage(msg)) {
835
+ assertActionDeadline(msg, receivedAt);
225
836
  await setFieldText(page, msg.fieldLabel, msg.value, {
226
837
  fieldId: msg.fieldId,
838
+ fieldKey: msg.fieldKey,
227
839
  exact: msg.exact,
228
840
  cache: fieldLookupCache,
229
841
  typingDelayMs: msg.typingDelayMs,
@@ -233,9 +845,12 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
233
845
  return;
234
846
  }
235
847
  if (isSetFieldChoiceMessage(msg)) {
848
+ assertActionDeadline(msg, receivedAt);
236
849
  await setFieldChoice(page, msg.fieldLabel, msg.value, {
237
850
  fieldId: msg.fieldId,
851
+ fieldKey: msg.fieldKey,
238
852
  exact: msg.exact,
853
+ optionIndex: msg.optionIndex,
239
854
  query: msg.query,
240
855
  choiceType: msg.choiceType,
241
856
  cache: fieldLookupCache,
@@ -244,12 +859,17 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
244
859
  return;
245
860
  }
246
861
  if (isFillFieldsMessage(msg)) {
247
- await fillFields(page, msg.fields, fieldLookupCache);
862
+ const authorizedFields = msg.fields.map(field => field.kind === 'file'
863
+ ? { ...field, paths: resolveExistingFiles(field.paths, security?.allowedFileRoots) }
864
+ : field);
865
+ assertActionDeadline(msg, receivedAt);
866
+ await fillFields(page, authorizedFields, fieldLookupCache);
248
867
  const result = await fillFieldsAckResult(page);
249
868
  onViewportOrInput('input', requestId, result);
250
869
  return;
251
870
  }
252
871
  if (isFillOtpMessage(msg)) {
872
+ assertActionDeadline(msg, receivedAt);
253
873
  const result = await fillOtp(page, msg.value, {
254
874
  fieldLabel: msg.fieldLabel,
255
875
  perCharDelayMs: msg.perCharDelayMs,
@@ -262,11 +882,13 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
262
882
  return;
263
883
  }
264
884
  if (isListboxPickMessage(msg)) {
885
+ assertActionDeadline(msg, receivedAt);
265
886
  await pickListboxOption(page, msg.label, {
266
887
  exact: msg.exact,
267
888
  openX: msg.openX,
268
889
  openY: msg.openY,
269
890
  fieldId: msg.fieldId,
891
+ fieldKey: msg.fieldKey,
270
892
  fieldLabel: msg.fieldLabel,
271
893
  query: msg.query,
272
894
  cache: fieldLookupCache,
@@ -275,6 +897,7 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
275
897
  return;
276
898
  }
277
899
  if (isSelectOptionMessage(msg)) {
900
+ assertActionDeadline(msg, receivedAt);
278
901
  await selectNativeOption(page, msg.x, msg.y, {
279
902
  value: msg.value,
280
903
  label: msg.label,
@@ -284,10 +907,14 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
284
907
  return;
285
908
  }
286
909
  if (isSetCheckedMessage(msg)) {
910
+ assertActionDeadline(msg, receivedAt);
287
911
  await setCheckedControl(page, msg.label, {
912
+ fieldKey: msg.fieldKey,
288
913
  checked: msg.checked,
289
914
  exact: msg.exact,
290
915
  controlType: msg.controlType,
916
+ contextText: msg.contextText,
917
+ sectionText: msg.sectionText,
291
918
  });
292
919
  onViewportOrInput('input', requestId);
293
920
  return;
@@ -297,6 +924,7 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
297
924
  const dy = typeof msg.deltaY === 'number' && Number.isFinite(msg.deltaY) ? msg.deltaY : 0;
298
925
  const x = typeof msg.x === 'number' && Number.isFinite(msg.x) ? msg.x : undefined;
299
926
  const y = typeof msg.y === 'number' && Number.isFinite(msg.y) ? msg.y : undefined;
927
+ assertActionDeadline(msg, receivedAt);
300
928
  await wheelAt(page, dx, dy, x, y);
301
929
  onViewportOrInput('input', requestId);
302
930
  return;
@@ -314,8 +942,10 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
314
942
  const marginValue = msg.margin ?? '1cm';
315
943
  const margin = { top: marginValue, right: marginValue, bottom: marginValue, left: marginValue };
316
944
  if (msg.html) {
945
+ assertActionDeadline(msg, receivedAt);
317
946
  await page.setContent(msg.html, { waitUntil: 'networkidle', timeout: 30_000 });
318
947
  }
948
+ assertActionDeadline(msg, receivedAt);
319
949
  const buffer = await page.pdf({
320
950
  format,
321
951
  landscape,
@@ -324,11 +954,22 @@ async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitF
324
954
  });
325
955
  const base64 = buffer.toString('base64');
326
956
  onViewportOrInput('input', requestId, { pdf: base64, pageUrl: page.url() });
957
+ return;
327
958
  }
959
+ sendWireError(`Unsupported client message type "${msg.type}"`, requestId);
328
960
  }
329
961
  catch (err) {
330
- onHandlerError(err);
331
- wireError(err instanceof Error ? err.message : String(err));
962
+ if (!(err instanceof ActionDeadlineExpiredError))
963
+ onHandlerError(err);
964
+ sendWireError(err instanceof Error ? err.message : String(err), requestId, err instanceof ActionDeadlineExpiredError
965
+ ? 'ACTION_EXPIRED'
966
+ : acceptedRequestId
967
+ ? 'ACTION_OUTCOME_AMBIGUOUS'
968
+ : undefined);
969
+ }
970
+ finally {
971
+ if (acceptedRequestId)
972
+ security?.actionRequestLedger?.complete(acceptedRequestId);
332
973
  }
333
974
  }
334
975
  async function fillFieldsAckResult(page) {
@@ -340,9 +981,11 @@ async function fillFieldsAckResult(page) {
340
981
  // or similar. Counting both gives fill_form a reliable signal for whether
341
982
  // a custom dropdown commit actually landed, which is the authoritative
342
983
  // check used throughout the listbox pick pipeline (see readAriaInvalid in
343
- // dom-actions.ts). The `:is()` selector de-duplicates the two passes so
344
- // elements that match both just count once.
345
- const invalidSelector = ':is(:invalid, [aria-invalid="true"]:is(input, textarea, select, [role="combobox"], [role="listbox"], [role="spinbutton"], [role="searchbox"], [role="textbox"]))';
984
+ // dom-actions.ts). Restrict native validity to actual controls because
985
+ // `:invalid` also matches ancestor <form> and <fieldset> elements whenever
986
+ // one descendant is invalid. The outer `:is()` de-duplicates controls that
987
+ // also advertise aria-invalid.
988
+ const invalidSelector = ':is(:is(input, textarea, select):invalid, [aria-invalid="true"]:is(input, textarea, select, [role="combobox"], [role="listbox"], [role="spinbutton"], [role="searchbox"], [role="textbox"]))';
346
989
  const [invalidCount, alertCount, dialogCount, busyCount] = await Promise.all([
347
990
  countAcrossFrames(frames, invalidSelector),
348
991
  countAcrossFrames(frames, '[role="alert"], [role="alertdialog"]'),
@@ -440,17 +1083,61 @@ async function countAcrossFrames(frames, selector) {
440
1083
  }
441
1084
  export function startGeometryWebSocket(options) {
442
1085
  const debounceMs = options.debounceMs ?? 50;
1086
+ const host = options.host?.trim() || DEFAULT_PROXY_HOST;
1087
+ const authToken = normalizedAuthToken(options.authToken);
1088
+ const allowedOrigins = new Set(options.allowedOrigins ?? []);
443
1089
  const clients = new Set();
444
- const wss = new WebSocketServer({ port: options.port });
445
- const axSessionManager = createCdpAxSessionManager();
1090
+ let controllerClaimed = false;
1091
+ const wss = new WebSocketServer({
1092
+ port: options.port,
1093
+ host,
1094
+ maxPayload: Math.max(1024, options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES),
1095
+ verifyClient(info, done) {
1096
+ const origin = typeof info.req.headers.origin === 'string' ? info.req.headers.origin : undefined;
1097
+ if (origin !== undefined && !allowedOrigins.has(origin)) {
1098
+ done(false, 403, 'WebSocket Origin is not allowed');
1099
+ return;
1100
+ }
1101
+ const authorization = typeof info.req.headers.authorization === 'string'
1102
+ ? info.req.headers.authorization
1103
+ : undefined;
1104
+ if (!bearerTokenMatches(authorization, authToken)) {
1105
+ done(false, 401, 'Bearer capability required');
1106
+ return;
1107
+ }
1108
+ const liveController = Array.from(clients).some(client => client.readyState === client.OPEN || client.readyState === client.CONNECTING);
1109
+ // Permit a clean handover while the prior controller is already
1110
+ // CLOSING; reject both a live controller and a concurrent handshake.
1111
+ if (controllerClaimed && (clients.size === 0 || liveController)) {
1112
+ done(false, 409, 'A Geometra controller is already connected');
1113
+ return;
1114
+ }
1115
+ controllerClaimed = true;
1116
+ done(true);
1117
+ },
1118
+ });
1119
+ let closing = false;
1120
+ const axSessionManager = createCdpAxSessionManager(() => {
1121
+ if (!closing)
1122
+ scheduleExtract(true);
1123
+ });
446
1124
  let prevLayout = null;
447
1125
  let prevTreeJson = null;
448
1126
  let debounceTimer = null;
1127
+ let inputAckFlushTimer = null;
1128
+ let immediateExtractTimer = null;
1129
+ let axRetryTimer = null;
1130
+ let axRetryDueAtMs = Number.POSITIVE_INFINITY;
1131
+ let immediateExtractRequestedAfterActive = false;
1132
+ let lastExtractCompletedAtMs = Number.NEGATIVE_INFINITY;
449
1133
  let extracting = false;
450
1134
  let pendingExtract = false;
1135
+ let extractSequence = 0;
1136
+ let completedExtractSequence = 0;
451
1137
  let actionQueue = Promise.resolve();
452
1138
  let pendingInputAcks = [];
453
1139
  const fieldLookupCache = createFillLookupCache();
1140
+ const actionRequestLedger = createActionRequestLedger();
454
1141
  const beforeInput = options.beforeInput?.then(() => undefined);
455
1142
  const trace = { extractCount: 0 };
456
1143
  const pagePromise = Promise.resolve(options.page);
@@ -472,22 +1159,31 @@ export function startGeometryWebSocket(options) {
472
1159
  function sendPendingInputAcks() {
473
1160
  if (pendingInputAcks.length === 0)
474
1161
  return;
475
- const pending = pendingInputAcks;
476
- pendingInputAcks = [];
1162
+ const pending = pendingInputAcks.filter(entry => completedExtractSequence > entry.afterExtractSequence);
1163
+ if (pending.length === 0)
1164
+ return;
1165
+ const ready = new Set(pending);
1166
+ pendingInputAcks = pendingInputAcks.filter(entry => !ready.has(entry));
477
1167
  for (const { ws, requestId, result } of pending) {
478
1168
  if (ws.readyState === ws.OPEN) {
479
1169
  ws.send(JSON.stringify({
480
1170
  type: 'ack',
481
1171
  ...(requestId ? { requestId } : {}),
482
1172
  ...(result !== undefined ? { result } : {}),
483
- protocolVersion: PROXY_PROTOCOL_VERSION,
1173
+ ...PROXY_ACTION_METADATA,
484
1174
  }));
485
1175
  }
486
1176
  }
1177
+ if (pendingInputAcks.length === 0) {
1178
+ clearInputAckFlushTimer();
1179
+ }
1180
+ else {
1181
+ armInputAckFlushTimer();
1182
+ }
487
1183
  }
488
1184
  function sendPendingInputErrors(message) {
489
1185
  if (pendingInputAcks.length === 0) {
490
- const errText = JSON.stringify({ type: 'error', message, protocolVersion: PROXY_PROTOCOL_VERSION });
1186
+ const errText = JSON.stringify({ type: 'error', message, ...PROXY_ACTION_METADATA });
491
1187
  for (const ws of clients) {
492
1188
  if (ws.readyState === ws.OPEN)
493
1189
  ws.send(errText);
@@ -496,13 +1192,15 @@ export function startGeometryWebSocket(options) {
496
1192
  }
497
1193
  const pending = pendingInputAcks;
498
1194
  pendingInputAcks = [];
1195
+ clearInputAckFlushTimer();
499
1196
  for (const { ws, requestId } of pending) {
500
1197
  if (ws.readyState === ws.OPEN) {
501
1198
  ws.send(JSON.stringify({
502
1199
  type: 'error',
503
1200
  message,
1201
+ code: 'ACTION_OUTCOME_AMBIGUOUS',
504
1202
  ...(requestId ? { requestId } : {}),
505
- protocolVersion: PROXY_PROTOCOL_VERSION,
1203
+ ...PROXY_ACTION_METADATA,
506
1204
  }));
507
1205
  }
508
1206
  }
@@ -515,7 +1213,7 @@ export function startGeometryWebSocket(options) {
515
1213
  type: 'frame',
516
1214
  layout: snap.layout,
517
1215
  tree: snap.tree,
518
- protocolVersion: PROXY_PROTOCOL_VERSION,
1216
+ ...PROXY_GEOMETRY_METADATA,
519
1217
  };
520
1218
  prevLayout = cloneLayout(snap.layout);
521
1219
  prevTreeJson = snap.treeJson;
@@ -531,11 +1229,11 @@ export function startGeometryWebSocket(options) {
531
1229
  type: 'frame',
532
1230
  layout: snap.layout,
533
1231
  tree: snap.tree,
534
- protocolVersion: PROXY_PROTOCOL_VERSION,
1232
+ ...PROXY_GEOMETRY_METADATA,
535
1233
  };
536
1234
  }
537
1235
  else {
538
- outbound = { type: 'patch', patches, protocolVersion: PROXY_PROTOCOL_VERSION };
1236
+ outbound = { type: 'patch', patches, ...PROXY_GEOMETRY_METADATA };
539
1237
  }
540
1238
  prevLayout = cloneLayout(snap.layout);
541
1239
  prevTreeJson = snap.treeJson;
@@ -549,6 +1247,7 @@ export function startGeometryWebSocket(options) {
549
1247
  return true;
550
1248
  }
551
1249
  async function runExtract() {
1250
+ const sequence = ++extractSequence;
552
1251
  const runStartedAt = performance.now();
553
1252
  const beforeInputStartedAt = performance.now();
554
1253
  try {
@@ -562,10 +1261,17 @@ export function startGeometryWebSocket(options) {
562
1261
  };
563
1262
  const page = await waitForPage();
564
1263
  const extractStartedAt = performance.now();
565
- const snap = await extractGeometryWithRecovery(page, axSessionManager, extractorTrace, recoveryTrace);
1264
+ const snap = await extractGeometryWithRecovery(page, axSessionManager, extractorTrace, recoveryTrace, () => scheduleExtract(true), delayMs => scheduleAxRetry(delayMs));
566
1265
  const extractMs = performance.now() - extractStartedAt;
567
1266
  const broadcastStartedAt = performance.now();
568
1267
  const changed = broadcastSnapshot(snap);
1268
+ completedExtractSequence = sequence;
1269
+ // An input ack only proves freshness after an extraction that started
1270
+ // after the action completed. Release eligible acks immediately after
1271
+ // that snapshot instead of waiting for the entire trailing-edge
1272
+ // mutation drain, which can be indefinitely extended by animated or
1273
+ // highly reactive controls such as react-select.
1274
+ sendPendingInputAcks();
569
1275
  const broadcastMs = performance.now() - broadcastStartedAt;
570
1276
  trace.extractCount += 1;
571
1277
  if (!trace.firstExtract) {
@@ -603,20 +1309,113 @@ export function startGeometryWebSocket(options) {
603
1309
  }
604
1310
  finally {
605
1311
  extracting = false;
1312
+ lastExtractCompletedAtMs = performance.now();
1313
+ if (immediateExtractRequestedAfterActive) {
1314
+ immediateExtractRequestedAfterActive = false;
1315
+ armImmediateExtract();
1316
+ }
606
1317
  }
607
1318
  return changed;
608
1319
  }
609
- function scheduleExtract() {
610
- if (debounceTimer !== null)
1320
+ function clearDebounceTimer() {
1321
+ if (debounceTimer !== null) {
611
1322
  clearTimeout(debounceTimer);
612
- debounceTimer = setTimeout(() => {
613
1323
  debounceTimer = null;
614
- void runExtractQueued()
615
- .then(() => {
616
- sendPendingInputAcks();
617
- })
618
- .catch(err => options.onError?.(err));
619
- }, debounceMs);
1324
+ }
1325
+ }
1326
+ function clearInputAckFlushTimer() {
1327
+ if (inputAckFlushTimer !== null) {
1328
+ clearTimeout(inputAckFlushTimer);
1329
+ inputAckFlushTimer = null;
1330
+ }
1331
+ }
1332
+ function clearImmediateExtractTimer() {
1333
+ if (immediateExtractTimer !== null) {
1334
+ clearTimeout(immediateExtractTimer);
1335
+ immediateExtractTimer = null;
1336
+ }
1337
+ immediateExtractRequestedAfterActive = false;
1338
+ }
1339
+ function clearAxRetryTimer() {
1340
+ if (axRetryTimer !== null) {
1341
+ clearTimeout(axRetryTimer);
1342
+ axRetryTimer = null;
1343
+ }
1344
+ axRetryDueAtMs = Number.POSITIVE_INFINITY;
1345
+ }
1346
+ function scheduleAxRetry(delayMs) {
1347
+ const boundedDelayMs = Math.max(1, Math.ceil(delayMs));
1348
+ const dueAtMs = performance.now() + boundedDelayMs;
1349
+ if (axRetryTimer !== null && dueAtMs >= axRetryDueAtMs)
1350
+ return;
1351
+ clearAxRetryTimer();
1352
+ axRetryDueAtMs = dueAtMs;
1353
+ axRetryTimer = setTimeout(() => {
1354
+ axRetryTimer = null;
1355
+ axRetryDueAtMs = Number.POSITIVE_INFINITY;
1356
+ scheduleExtract(true);
1357
+ }, boundedDelayMs);
1358
+ }
1359
+ function runScheduledExtract() {
1360
+ void runExtractQueued()
1361
+ .then(() => {
1362
+ sendPendingInputAcks();
1363
+ })
1364
+ .catch(err => options.onError?.(err));
1365
+ }
1366
+ function flushDebouncedExtract() {
1367
+ clearDebounceTimer();
1368
+ runScheduledExtract();
1369
+ }
1370
+ function armInputAckFlushTimer() {
1371
+ if (pendingInputAcks.length === 0 || inputAckFlushTimer !== null)
1372
+ return;
1373
+ // This timer is anchored to the first pending action ack and is never
1374
+ // reset by page mutations. Mutation-only traffic does not arm it, so an
1375
+ // animated page retains the normal trailing-edge debounce behavior.
1376
+ inputAckFlushTimer = setTimeout(() => {
1377
+ inputAckFlushTimer = null;
1378
+ if (pendingInputAcks.length === 0)
1379
+ return;
1380
+ clearDebounceTimer();
1381
+ runScheduledExtract();
1382
+ }, MAX_INPUT_ACK_LATENCY_MS);
1383
+ }
1384
+ function armImmediateExtract() {
1385
+ if (extracting) {
1386
+ // Do not turn a page hint into runExtractQueued's pending loop. One
1387
+ // coalesced refresh becomes eligible only after the active extraction
1388
+ // completes and the host has had a cooldown window.
1389
+ immediateExtractRequestedAfterActive = true;
1390
+ return;
1391
+ }
1392
+ if (immediateExtractTimer !== null)
1393
+ return;
1394
+ const elapsed = performance.now() - lastExtractCompletedAtMs;
1395
+ const remaining = Math.max(0, MIN_IMMEDIATE_EXTRACT_INTERVAL_MS - elapsed);
1396
+ if (remaining === 0) {
1397
+ runScheduledExtract();
1398
+ return;
1399
+ }
1400
+ immediateExtractTimer = setTimeout(() => {
1401
+ immediateExtractTimer = null;
1402
+ if (extracting) {
1403
+ immediateExtractRequestedAfterActive = true;
1404
+ }
1405
+ else {
1406
+ runScheduledExtract();
1407
+ }
1408
+ }, remaining);
1409
+ }
1410
+ function scheduleExtract(immediate = false) {
1411
+ if (immediate) {
1412
+ clearDebounceTimer();
1413
+ armImmediateExtract();
1414
+ return;
1415
+ }
1416
+ if (debounceTimer !== null)
1417
+ clearTimeout(debounceTimer);
1418
+ debounceTimer = setTimeout(flushDebouncedExtract, debounceMs);
620
1419
  }
621
1420
  wss.on('listening', () => {
622
1421
  const addr = wss.address();
@@ -628,6 +1427,13 @@ export function startGeometryWebSocket(options) {
628
1427
  });
629
1428
  wss.on('connection', (ws) => {
630
1429
  clients.add(ws);
1430
+ // Authentication happens during the HTTP upgrade, but MCP still needs a
1431
+ // protocol-level attestation before it can trust a spawned proxy. Send it
1432
+ // immediately so lazy extraction can remain lazy without weakening the
1433
+ // capability handshake.
1434
+ if (ws.readyState === ws.OPEN) {
1435
+ ws.send(JSON.stringify({ type: 'hello', ...PROXY_GEOMETRY_METADATA }));
1436
+ }
631
1437
  if (prevLayout && prevTreeJson !== null) {
632
1438
  const snap = {
633
1439
  layout: prevLayout,
@@ -638,50 +1444,80 @@ export function startGeometryWebSocket(options) {
638
1444
  type: 'frame',
639
1445
  layout: snap.layout,
640
1446
  tree: snap.tree,
641
- protocolVersion: PROXY_PROTOCOL_VERSION,
1447
+ ...PROXY_GEOMETRY_METADATA,
642
1448
  });
643
1449
  if (ws.readyState === ws.OPEN)
644
1450
  ws.send(text);
645
1451
  }
646
1452
  ws.on('message', (raw) => {
1453
+ // Capture receipt before queueing so actionTimeoutMs includes time spent
1454
+ // waiting behind earlier Playwright work and uses this host's monotonic
1455
+ // clock rather than trusting the controller's wall clock.
1456
+ const receivedAt = performance.now();
647
1457
  actionQueue = actionQueue
648
1458
  .then(() => handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitForBeforeInput, (kind, requestId, result) => {
649
1459
  if (kind === 'resize') {
650
- void runExtractQueued();
1460
+ if (requestId) {
1461
+ pendingInputAcks.push({
1462
+ ws,
1463
+ requestId,
1464
+ afterExtractSequence: extractSequence,
1465
+ });
1466
+ }
1467
+ void runExtractQueued()
1468
+ .then(() => sendPendingInputAcks())
1469
+ .catch(err => options.onError?.(err));
651
1470
  }
652
1471
  else {
653
- pendingInputAcks.push({ ws, ...(requestId ? { requestId } : {}), ...(result !== undefined ? { result } : {}) });
1472
+ pendingInputAcks.push({
1473
+ ws,
1474
+ afterExtractSequence: extractSequence,
1475
+ ...(requestId ? { requestId } : {}),
1476
+ ...(result !== undefined ? { result } : {}),
1477
+ });
654
1478
  scheduleExtract();
1479
+ armInputAckFlushTimer();
655
1480
  }
656
- }, err => options.onError?.(err)))
1481
+ }, err => options.onError?.(err), { allowedFileRoots: options.allowedFileRoots, actionRequestLedger, receivedAt }))
657
1482
  .catch(err => options.onError?.(err));
658
1483
  });
659
1484
  ws.on('close', () => {
660
1485
  clients.delete(ws);
1486
+ controllerClaimed = clients.size > 0;
661
1487
  pendingInputAcks = pendingInputAcks.filter(entry => entry.ws !== ws);
1488
+ if (pendingInputAcks.length === 0)
1489
+ clearInputAckFlushTimer();
662
1490
  });
663
1491
  });
664
1492
  return {
1493
+ authToken,
1494
+ host,
665
1495
  scheduleExtract,
666
1496
  flushExtract: async () => {
667
1497
  await actionQueue.catch(() => { });
668
- if (debounceTimer !== null) {
669
- clearTimeout(debounceTimer);
670
- debounceTimer = null;
671
- }
1498
+ clearDebounceTimer();
1499
+ clearInputAckFlushTimer();
1500
+ clearImmediateExtractTimer();
672
1501
  await runExtractQueued();
673
1502
  sendPendingInputAcks();
674
1503
  },
675
1504
  getTrace: () => structuredClone(trace),
676
- close: () => new Promise((resolve, reject) => {
677
- void axSessionManager.close().finally(() => {
678
- for (const ws of clients) {
679
- ws.close();
680
- }
681
- clients.clear();
682
- wss.close(err => (err ? reject(err) : resolve()));
1505
+ close: () => {
1506
+ closing = true;
1507
+ return new Promise((resolve, reject) => {
1508
+ void axSessionManager.close().finally(() => {
1509
+ clearDebounceTimer();
1510
+ clearInputAckFlushTimer();
1511
+ clearImmediateExtractTimer();
1512
+ clearAxRetryTimer();
1513
+ for (const ws of clients) {
1514
+ ws.close();
1515
+ }
1516
+ clients.clear();
1517
+ wss.close(err => (err ? reject(err) : resolve()));
1518
+ });
683
1519
  });
684
- }),
1520
+ },
685
1521
  };
686
1522
  }
687
1523
  export async function primeDomObserver(page, scheduleExtract) {
@@ -689,27 +1525,12 @@ export async function primeDomObserver(page, scheduleExtract) {
689
1525
  }
690
1526
  export async function installDomObserver(page, scheduleExtract) {
691
1527
  await bindDomObserverBridge(page, scheduleExtract);
692
- await page.evaluate(() => {
693
- const w = window;
694
- if (w.__geometraProxyObserverInstalled)
695
- return;
696
- const observer = new MutationObserver(() => {
697
- void w.__geometraProxyNotify?.();
698
- });
699
- observer.observe(document.documentElement, {
700
- subtree: true,
701
- childList: true,
702
- attributes: true,
703
- characterData: true,
704
- });
705
- w.__geometraProxyObserverInstalled = true;
706
- });
707
1528
  }
708
1529
  function isNavigationTransitionError(err) {
709
1530
  const message = err instanceof Error ? err.message : String(err);
710
1531
  return /Execution context was destroyed|Cannot find context with specified id|Frame was detached|navigation/i.test(message);
711
1532
  }
712
- async function extractGeometryWithRecovery(page, axSessionManager, extractTrace, recoveryTrace) {
1533
+ async function extractGeometryWithRecovery(page, axSessionManager, extractTrace, recoveryTrace, onAxReady, onAxRetryDue) {
713
1534
  let lastNavigationError = null;
714
1535
  let domContentLoadedWaitMs = 0;
715
1536
  let loadWaitMs = 0;
@@ -718,7 +1539,12 @@ async function extractGeometryWithRecovery(page, axSessionManager, extractTrace,
718
1539
  if (recoveryTrace) {
719
1540
  recoveryTrace.attemptCount = attempt + 1;
720
1541
  }
721
- return await extractGeometry(page, { axSessionManager, trace: extractTrace });
1542
+ return await extractGeometry(page, {
1543
+ axSessionManager,
1544
+ trace: extractTrace,
1545
+ onAxReady,
1546
+ onAxRetryDue,
1547
+ });
722
1548
  }
723
1549
  catch (err) {
724
1550
  if (page.isClosed() || !isNavigationTransitionError(err))