@geometra/proxy 1.64.0 → 1.65.1
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 +10 -1
- package/dist/a11y-enrich.d.ts +4 -2
- package/dist/a11y-enrich.d.ts.map +1 -1
- package/dist/a11y-enrich.js +204 -35
- package/dist/a11y-enrich.js.map +1 -1
- package/dist/dom-actions.d.ts +3 -1
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +1483 -328
- package/dist/dom-actions.js.map +1 -1
- package/dist/extractor.d.ts +9 -0
- package/dist/extractor.d.ts.map +1 -1
- package/dist/extractor.js +877 -63
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts +34 -4
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +884 -91
- package/dist/geometry-ws.js.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +9 -1
- package/dist/runtime.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +120 -37
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
package/dist/geometry-ws.js
CHANGED
|
@@ -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 { clientMessageValidationError, 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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
attributes: true,
|
|
32
|
-
characterData: true,
|
|
243
|
+
Object.defineProperty(instrumentedAttachShadow, 'toString', {
|
|
244
|
+
value: nativeAttachShadow.toString.bind(nativeAttachShadow),
|
|
245
|
+
configurable: true,
|
|
33
246
|
});
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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.
|
|
398
|
+
}
|
|
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);
|
|
41
408
|
}
|
|
42
|
-
|
|
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,13 +667,15 @@ async function applyKeyPhase(page, msg) {
|
|
|
105
667
|
if (msg.shiftKey)
|
|
106
668
|
await page.keyboard.up('Shift');
|
|
107
669
|
}
|
|
108
|
-
export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitForBeforeInput, onViewportOrInput, onHandlerError) {
|
|
109
|
-
const
|
|
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) => {
|
|
110
673
|
ws.send(JSON.stringify({
|
|
111
674
|
type: 'error',
|
|
112
675
|
message,
|
|
676
|
+
...(code ? { code } : {}),
|
|
113
677
|
...(requestId ? { requestId } : {}),
|
|
114
|
-
|
|
678
|
+
...PROXY_ACTION_METADATA,
|
|
115
679
|
}));
|
|
116
680
|
};
|
|
117
681
|
let parsed;
|
|
@@ -131,9 +695,9 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
131
695
|
return;
|
|
132
696
|
}
|
|
133
697
|
const msg = parsed;
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
sendWireError(
|
|
698
|
+
const compatibilityError = protocolCompatibilityError(msg);
|
|
699
|
+
if (compatibilityError) {
|
|
700
|
+
sendWireError(compatibilityError, requestId);
|
|
137
701
|
return;
|
|
138
702
|
}
|
|
139
703
|
const validationError = clientMessageValidationError(msg);
|
|
@@ -141,18 +705,46 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
141
705
|
sendWireError(validationError, requestId);
|
|
142
706
|
return;
|
|
143
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
|
+
}
|
|
144
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);
|
|
145
732
|
const page = await waitForPage();
|
|
733
|
+
assertActionDeadline(msg, receivedAt);
|
|
146
734
|
if (isResizeMessage(msg)) {
|
|
147
735
|
const w = Math.max(1, Math.floor(msg.width));
|
|
148
736
|
const h = Math.max(1, Math.floor(msg.height));
|
|
737
|
+
assertActionDeadline(msg, receivedAt);
|
|
149
738
|
await page.setViewportSize({ width: w, height: h });
|
|
150
739
|
onViewportOrInput('resize', requestId);
|
|
151
740
|
return;
|
|
152
741
|
}
|
|
742
|
+
assertActionDeadline(msg, receivedAt);
|
|
153
743
|
await waitForBeforeInput();
|
|
744
|
+
assertActionDeadline(msg, receivedAt);
|
|
154
745
|
if (isNavigateMessage(msg)) {
|
|
155
746
|
clearFillLookupCache(fieldLookupCache);
|
|
747
|
+
assertActionDeadline(msg, receivedAt);
|
|
156
748
|
await page.goto(msg.url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
157
749
|
onViewportOrInput('input', requestId, { pageUrl: page.url() });
|
|
158
750
|
return;
|
|
@@ -169,6 +761,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
169
761
|
// "session crashed for unknown reason". Bug surfaced by JobForge
|
|
170
762
|
// round-2 marathon — Cloudflare FDE NYC #312 and Airtable PM AI #94.
|
|
171
763
|
const urlBefore = page.url();
|
|
764
|
+
assertActionDeadline(msg, receivedAt);
|
|
172
765
|
await page.mouse.click(x, y);
|
|
173
766
|
// Give the navigation a brief window to start. We don't await
|
|
174
767
|
// waitForNavigation here because most clicks DON'T navigate, and
|
|
@@ -199,26 +792,37 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
199
792
|
return;
|
|
200
793
|
}
|
|
201
794
|
if (isKeyMessage(msg)) {
|
|
795
|
+
assertActionDeadline(msg, receivedAt);
|
|
202
796
|
await applyKeyPhase(page, msg);
|
|
203
797
|
onViewportOrInput('input', requestId);
|
|
204
798
|
return;
|
|
205
799
|
}
|
|
800
|
+
if (isTypeTextMessage(msg)) {
|
|
801
|
+
assertActionDeadline(msg, receivedAt);
|
|
802
|
+
await page.keyboard.type(msg.text);
|
|
803
|
+
onViewportOrInput('input', requestId);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
206
806
|
if (isCompositionMessage(msg)) {
|
|
207
807
|
const data = typeof msg.data === 'string' ? msg.data : '';
|
|
208
808
|
if (msg.eventType === 'onCompositionUpdate' || msg.eventType === 'onCompositionEnd') {
|
|
809
|
+
assertActionDeadline(msg, receivedAt);
|
|
209
810
|
await page.keyboard.insertText(data);
|
|
210
811
|
onViewportOrInput('input', requestId);
|
|
211
812
|
}
|
|
212
813
|
return;
|
|
213
814
|
}
|
|
214
815
|
if (isFileMessage(msg)) {
|
|
215
|
-
const paths = resolveExistingFiles(msg.paths);
|
|
816
|
+
const paths = resolveExistingFiles(msg.paths, security?.allowedFileRoots);
|
|
817
|
+
assertActionDeadline(msg, receivedAt);
|
|
216
818
|
await attachFiles(page, paths, {
|
|
217
819
|
clickX: msg.x,
|
|
218
820
|
clickY: msg.y,
|
|
219
821
|
fieldId: msg.fieldId,
|
|
220
822
|
fieldKey: msg.fieldKey,
|
|
221
823
|
fieldLabel: msg.fieldLabel,
|
|
824
|
+
contextText: msg.contextText,
|
|
825
|
+
sectionText: msg.sectionText,
|
|
222
826
|
exact: msg.exact,
|
|
223
827
|
strategy: msg.strategy,
|
|
224
828
|
dropX: msg.dropX,
|
|
@@ -228,6 +832,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
228
832
|
return;
|
|
229
833
|
}
|
|
230
834
|
if (isSetFieldTextMessage(msg)) {
|
|
835
|
+
assertActionDeadline(msg, receivedAt);
|
|
231
836
|
await setFieldText(page, msg.fieldLabel, msg.value, {
|
|
232
837
|
fieldId: msg.fieldId,
|
|
233
838
|
fieldKey: msg.fieldKey,
|
|
@@ -240,6 +845,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
240
845
|
return;
|
|
241
846
|
}
|
|
242
847
|
if (isSetFieldChoiceMessage(msg)) {
|
|
848
|
+
assertActionDeadline(msg, receivedAt);
|
|
243
849
|
await setFieldChoice(page, msg.fieldLabel, msg.value, {
|
|
244
850
|
fieldId: msg.fieldId,
|
|
245
851
|
fieldKey: msg.fieldKey,
|
|
@@ -253,12 +859,17 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
253
859
|
return;
|
|
254
860
|
}
|
|
255
861
|
if (isFillFieldsMessage(msg)) {
|
|
256
|
-
|
|
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);
|
|
257
867
|
const result = await fillFieldsAckResult(page);
|
|
258
868
|
onViewportOrInput('input', requestId, result);
|
|
259
869
|
return;
|
|
260
870
|
}
|
|
261
871
|
if (isFillOtpMessage(msg)) {
|
|
872
|
+
assertActionDeadline(msg, receivedAt);
|
|
262
873
|
const result = await fillOtp(page, msg.value, {
|
|
263
874
|
fieldLabel: msg.fieldLabel,
|
|
264
875
|
perCharDelayMs: msg.perCharDelayMs,
|
|
@@ -271,6 +882,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
271
882
|
return;
|
|
272
883
|
}
|
|
273
884
|
if (isListboxPickMessage(msg)) {
|
|
885
|
+
assertActionDeadline(msg, receivedAt);
|
|
274
886
|
await pickListboxOption(page, msg.label, {
|
|
275
887
|
exact: msg.exact,
|
|
276
888
|
openX: msg.openX,
|
|
@@ -285,6 +897,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
285
897
|
return;
|
|
286
898
|
}
|
|
287
899
|
if (isSelectOptionMessage(msg)) {
|
|
900
|
+
assertActionDeadline(msg, receivedAt);
|
|
288
901
|
await selectNativeOption(page, msg.x, msg.y, {
|
|
289
902
|
value: msg.value,
|
|
290
903
|
label: msg.label,
|
|
@@ -294,6 +907,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
294
907
|
return;
|
|
295
908
|
}
|
|
296
909
|
if (isSetCheckedMessage(msg)) {
|
|
910
|
+
assertActionDeadline(msg, receivedAt);
|
|
297
911
|
await setCheckedControl(page, msg.label, {
|
|
298
912
|
fieldKey: msg.fieldKey,
|
|
299
913
|
checked: msg.checked,
|
|
@@ -310,6 +924,7 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
310
924
|
const dy = typeof msg.deltaY === 'number' && Number.isFinite(msg.deltaY) ? msg.deltaY : 0;
|
|
311
925
|
const x = typeof msg.x === 'number' && Number.isFinite(msg.x) ? msg.x : undefined;
|
|
312
926
|
const y = typeof msg.y === 'number' && Number.isFinite(msg.y) ? msg.y : undefined;
|
|
927
|
+
assertActionDeadline(msg, receivedAt);
|
|
313
928
|
await wheelAt(page, dx, dy, x, y);
|
|
314
929
|
onViewportOrInput('input', requestId);
|
|
315
930
|
return;
|
|
@@ -327,8 +942,10 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
327
942
|
const marginValue = msg.margin ?? '1cm';
|
|
328
943
|
const margin = { top: marginValue, right: marginValue, bottom: marginValue, left: marginValue };
|
|
329
944
|
if (msg.html) {
|
|
945
|
+
assertActionDeadline(msg, receivedAt);
|
|
330
946
|
await page.setContent(msg.html, { waitUntil: 'networkidle', timeout: 30_000 });
|
|
331
947
|
}
|
|
948
|
+
assertActionDeadline(msg, receivedAt);
|
|
332
949
|
const buffer = await page.pdf({
|
|
333
950
|
format,
|
|
334
951
|
landscape,
|
|
@@ -342,8 +959,17 @@ export async function handleClientMessage(waitForPage, ws, raw, fieldLookupCache
|
|
|
342
959
|
sendWireError(`Unsupported client message type "${msg.type}"`, requestId);
|
|
343
960
|
}
|
|
344
961
|
catch (err) {
|
|
345
|
-
|
|
346
|
-
|
|
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);
|
|
347
973
|
}
|
|
348
974
|
}
|
|
349
975
|
async function fillFieldsAckResult(page) {
|
|
@@ -355,9 +981,11 @@ async function fillFieldsAckResult(page) {
|
|
|
355
981
|
// or similar. Counting both gives fill_form a reliable signal for whether
|
|
356
982
|
// a custom dropdown commit actually landed, which is the authoritative
|
|
357
983
|
// check used throughout the listbox pick pipeline (see readAriaInvalid in
|
|
358
|
-
// dom-actions.ts).
|
|
359
|
-
//
|
|
360
|
-
|
|
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"]))';
|
|
361
989
|
const [invalidCount, alertCount, dialogCount, busyCount] = await Promise.all([
|
|
362
990
|
countAcrossFrames(frames, invalidSelector),
|
|
363
991
|
countAcrossFrames(frames, '[role="alert"], [role="alertdialog"]'),
|
|
@@ -455,12 +1083,53 @@ async function countAcrossFrames(frames, selector) {
|
|
|
455
1083
|
}
|
|
456
1084
|
export function startGeometryWebSocket(options) {
|
|
457
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 ?? []);
|
|
458
1089
|
const clients = new Set();
|
|
459
|
-
|
|
460
|
-
const
|
|
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
|
+
});
|
|
461
1124
|
let prevLayout = null;
|
|
462
1125
|
let prevTreeJson = null;
|
|
463
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;
|
|
464
1133
|
let extracting = false;
|
|
465
1134
|
let pendingExtract = false;
|
|
466
1135
|
let extractSequence = 0;
|
|
@@ -468,6 +1137,7 @@ export function startGeometryWebSocket(options) {
|
|
|
468
1137
|
let actionQueue = Promise.resolve();
|
|
469
1138
|
let pendingInputAcks = [];
|
|
470
1139
|
const fieldLookupCache = createFillLookupCache();
|
|
1140
|
+
const actionRequestLedger = createActionRequestLedger();
|
|
471
1141
|
const beforeInput = options.beforeInput?.then(() => undefined);
|
|
472
1142
|
const trace = { extractCount: 0 };
|
|
473
1143
|
const pagePromise = Promise.resolve(options.page);
|
|
@@ -500,14 +1170,20 @@ export function startGeometryWebSocket(options) {
|
|
|
500
1170
|
type: 'ack',
|
|
501
1171
|
...(requestId ? { requestId } : {}),
|
|
502
1172
|
...(result !== undefined ? { result } : {}),
|
|
503
|
-
|
|
1173
|
+
...PROXY_ACTION_METADATA,
|
|
504
1174
|
}));
|
|
505
1175
|
}
|
|
506
1176
|
}
|
|
1177
|
+
if (pendingInputAcks.length === 0) {
|
|
1178
|
+
clearInputAckFlushTimer();
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
armInputAckFlushTimer();
|
|
1182
|
+
}
|
|
507
1183
|
}
|
|
508
1184
|
function sendPendingInputErrors(message) {
|
|
509
1185
|
if (pendingInputAcks.length === 0) {
|
|
510
|
-
const errText = JSON.stringify({ type: 'error', message,
|
|
1186
|
+
const errText = JSON.stringify({ type: 'error', message, ...PROXY_ACTION_METADATA });
|
|
511
1187
|
for (const ws of clients) {
|
|
512
1188
|
if (ws.readyState === ws.OPEN)
|
|
513
1189
|
ws.send(errText);
|
|
@@ -516,13 +1192,15 @@ export function startGeometryWebSocket(options) {
|
|
|
516
1192
|
}
|
|
517
1193
|
const pending = pendingInputAcks;
|
|
518
1194
|
pendingInputAcks = [];
|
|
1195
|
+
clearInputAckFlushTimer();
|
|
519
1196
|
for (const { ws, requestId } of pending) {
|
|
520
1197
|
if (ws.readyState === ws.OPEN) {
|
|
521
1198
|
ws.send(JSON.stringify({
|
|
522
1199
|
type: 'error',
|
|
523
1200
|
message,
|
|
1201
|
+
code: 'ACTION_OUTCOME_AMBIGUOUS',
|
|
524
1202
|
...(requestId ? { requestId } : {}),
|
|
525
|
-
|
|
1203
|
+
...PROXY_ACTION_METADATA,
|
|
526
1204
|
}));
|
|
527
1205
|
}
|
|
528
1206
|
}
|
|
@@ -535,7 +1213,7 @@ export function startGeometryWebSocket(options) {
|
|
|
535
1213
|
type: 'frame',
|
|
536
1214
|
layout: snap.layout,
|
|
537
1215
|
tree: snap.tree,
|
|
538
|
-
|
|
1216
|
+
...PROXY_GEOMETRY_METADATA,
|
|
539
1217
|
};
|
|
540
1218
|
prevLayout = cloneLayout(snap.layout);
|
|
541
1219
|
prevTreeJson = snap.treeJson;
|
|
@@ -551,11 +1229,11 @@ export function startGeometryWebSocket(options) {
|
|
|
551
1229
|
type: 'frame',
|
|
552
1230
|
layout: snap.layout,
|
|
553
1231
|
tree: snap.tree,
|
|
554
|
-
|
|
1232
|
+
...PROXY_GEOMETRY_METADATA,
|
|
555
1233
|
};
|
|
556
1234
|
}
|
|
557
1235
|
else {
|
|
558
|
-
outbound = { type: 'patch', patches,
|
|
1236
|
+
outbound = { type: 'patch', patches, ...PROXY_GEOMETRY_METADATA };
|
|
559
1237
|
}
|
|
560
1238
|
prevLayout = cloneLayout(snap.layout);
|
|
561
1239
|
prevTreeJson = snap.treeJson;
|
|
@@ -583,7 +1261,7 @@ export function startGeometryWebSocket(options) {
|
|
|
583
1261
|
};
|
|
584
1262
|
const page = await waitForPage();
|
|
585
1263
|
const extractStartedAt = performance.now();
|
|
586
|
-
const snap = await extractGeometryWithRecovery(page, axSessionManager, extractorTrace, recoveryTrace);
|
|
1264
|
+
const snap = await extractGeometryWithRecovery(page, axSessionManager, extractorTrace, recoveryTrace, () => scheduleExtract(true), delayMs => scheduleAxRetry(delayMs));
|
|
587
1265
|
const extractMs = performance.now() - extractStartedAt;
|
|
588
1266
|
const broadcastStartedAt = performance.now();
|
|
589
1267
|
const changed = broadcastSnapshot(snap);
|
|
@@ -631,20 +1309,113 @@ export function startGeometryWebSocket(options) {
|
|
|
631
1309
|
}
|
|
632
1310
|
finally {
|
|
633
1311
|
extracting = false;
|
|
1312
|
+
lastExtractCompletedAtMs = performance.now();
|
|
1313
|
+
if (immediateExtractRequestedAfterActive) {
|
|
1314
|
+
immediateExtractRequestedAfterActive = false;
|
|
1315
|
+
armImmediateExtract();
|
|
1316
|
+
}
|
|
634
1317
|
}
|
|
635
1318
|
return changed;
|
|
636
1319
|
}
|
|
637
|
-
function
|
|
638
|
-
if (debounceTimer !== null)
|
|
1320
|
+
function clearDebounceTimer() {
|
|
1321
|
+
if (debounceTimer !== null) {
|
|
639
1322
|
clearTimeout(debounceTimer);
|
|
640
|
-
debounceTimer = setTimeout(() => {
|
|
641
1323
|
debounceTimer = null;
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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);
|
|
648
1419
|
}
|
|
649
1420
|
wss.on('listening', () => {
|
|
650
1421
|
const addr = wss.address();
|
|
@@ -656,6 +1427,13 @@ export function startGeometryWebSocket(options) {
|
|
|
656
1427
|
});
|
|
657
1428
|
wss.on('connection', (ws) => {
|
|
658
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
|
+
}
|
|
659
1437
|
if (prevLayout && prevTreeJson !== null) {
|
|
660
1438
|
const snap = {
|
|
661
1439
|
layout: prevLayout,
|
|
@@ -666,16 +1444,29 @@ export function startGeometryWebSocket(options) {
|
|
|
666
1444
|
type: 'frame',
|
|
667
1445
|
layout: snap.layout,
|
|
668
1446
|
tree: snap.tree,
|
|
669
|
-
|
|
1447
|
+
...PROXY_GEOMETRY_METADATA,
|
|
670
1448
|
});
|
|
671
1449
|
if (ws.readyState === ws.OPEN)
|
|
672
1450
|
ws.send(text);
|
|
673
1451
|
}
|
|
674
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();
|
|
675
1457
|
actionQueue = actionQueue
|
|
676
1458
|
.then(() => handleClientMessage(waitForPage, ws, raw, fieldLookupCache, waitForBeforeInput, (kind, requestId, result) => {
|
|
677
1459
|
if (kind === 'resize') {
|
|
678
|
-
|
|
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));
|
|
679
1470
|
}
|
|
680
1471
|
else {
|
|
681
1472
|
pendingInputAcks.push({
|
|
@@ -685,36 +1476,48 @@ export function startGeometryWebSocket(options) {
|
|
|
685
1476
|
...(result !== undefined ? { result } : {}),
|
|
686
1477
|
});
|
|
687
1478
|
scheduleExtract();
|
|
1479
|
+
armInputAckFlushTimer();
|
|
688
1480
|
}
|
|
689
|
-
}, err => options.onError?.(err)))
|
|
1481
|
+
}, err => options.onError?.(err), { allowedFileRoots: options.allowedFileRoots, actionRequestLedger, receivedAt }))
|
|
690
1482
|
.catch(err => options.onError?.(err));
|
|
691
1483
|
});
|
|
692
1484
|
ws.on('close', () => {
|
|
693
1485
|
clients.delete(ws);
|
|
1486
|
+
controllerClaimed = clients.size > 0;
|
|
694
1487
|
pendingInputAcks = pendingInputAcks.filter(entry => entry.ws !== ws);
|
|
1488
|
+
if (pendingInputAcks.length === 0)
|
|
1489
|
+
clearInputAckFlushTimer();
|
|
695
1490
|
});
|
|
696
1491
|
});
|
|
697
1492
|
return {
|
|
1493
|
+
authToken,
|
|
1494
|
+
host,
|
|
698
1495
|
scheduleExtract,
|
|
699
1496
|
flushExtract: async () => {
|
|
700
1497
|
await actionQueue.catch(() => { });
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
1498
|
+
clearDebounceTimer();
|
|
1499
|
+
clearInputAckFlushTimer();
|
|
1500
|
+
clearImmediateExtractTimer();
|
|
705
1501
|
await runExtractQueued();
|
|
706
1502
|
sendPendingInputAcks();
|
|
707
1503
|
},
|
|
708
1504
|
getTrace: () => structuredClone(trace),
|
|
709
|
-
close: () =>
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
+
});
|
|
716
1519
|
});
|
|
717
|
-
}
|
|
1520
|
+
},
|
|
718
1521
|
};
|
|
719
1522
|
}
|
|
720
1523
|
export async function primeDomObserver(page, scheduleExtract) {
|
|
@@ -722,27 +1525,12 @@ export async function primeDomObserver(page, scheduleExtract) {
|
|
|
722
1525
|
}
|
|
723
1526
|
export async function installDomObserver(page, scheduleExtract) {
|
|
724
1527
|
await bindDomObserverBridge(page, scheduleExtract);
|
|
725
|
-
await page.evaluate(() => {
|
|
726
|
-
const w = window;
|
|
727
|
-
if (w.__geometraProxyObserverInstalled)
|
|
728
|
-
return;
|
|
729
|
-
const observer = new MutationObserver(() => {
|
|
730
|
-
void w.__geometraProxyNotify?.();
|
|
731
|
-
});
|
|
732
|
-
observer.observe(document.documentElement, {
|
|
733
|
-
subtree: true,
|
|
734
|
-
childList: true,
|
|
735
|
-
attributes: true,
|
|
736
|
-
characterData: true,
|
|
737
|
-
});
|
|
738
|
-
w.__geometraProxyObserverInstalled = true;
|
|
739
|
-
});
|
|
740
1528
|
}
|
|
741
1529
|
function isNavigationTransitionError(err) {
|
|
742
1530
|
const message = err instanceof Error ? err.message : String(err);
|
|
743
1531
|
return /Execution context was destroyed|Cannot find context with specified id|Frame was detached|navigation/i.test(message);
|
|
744
1532
|
}
|
|
745
|
-
async function extractGeometryWithRecovery(page, axSessionManager, extractTrace, recoveryTrace) {
|
|
1533
|
+
async function extractGeometryWithRecovery(page, axSessionManager, extractTrace, recoveryTrace, onAxReady, onAxRetryDue) {
|
|
746
1534
|
let lastNavigationError = null;
|
|
747
1535
|
let domContentLoadedWaitMs = 0;
|
|
748
1536
|
let loadWaitMs = 0;
|
|
@@ -751,7 +1539,12 @@ async function extractGeometryWithRecovery(page, axSessionManager, extractTrace,
|
|
|
751
1539
|
if (recoveryTrace) {
|
|
752
1540
|
recoveryTrace.attemptCount = attempt + 1;
|
|
753
1541
|
}
|
|
754
|
-
return await extractGeometry(page, {
|
|
1542
|
+
return await extractGeometry(page, {
|
|
1543
|
+
axSessionManager,
|
|
1544
|
+
trace: extractTrace,
|
|
1545
|
+
onAxReady,
|
|
1546
|
+
onAxRetryDue,
|
|
1547
|
+
});
|
|
755
1548
|
}
|
|
756
1549
|
catch (err) {
|
|
757
1550
|
if (page.isClosed() || !isNavigationTransitionError(err))
|