@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.
- 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 +12 -1
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +1949 -615
- 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 +939 -62
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts +36 -3
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +940 -114
- 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 +78 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +372 -31
- 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 { 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.
|
|
41
398
|
}
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 (
|
|
123
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
onViewportOrInput('input', requestId, {
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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).
|
|
344
|
-
//
|
|
345
|
-
|
|
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
|
-
|
|
445
|
-
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
|
+
});
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1232
|
+
...PROXY_GEOMETRY_METADATA,
|
|
535
1233
|
};
|
|
536
1234
|
}
|
|
537
1235
|
else {
|
|
538
|
-
outbound = { type: 'patch', patches,
|
|
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
|
|
610
|
-
if (debounceTimer !== null)
|
|
1320
|
+
function clearDebounceTimer() {
|
|
1321
|
+
if (debounceTimer !== null) {
|
|
611
1322
|
clearTimeout(debounceTimer);
|
|
612
|
-
debounceTimer = setTimeout(() => {
|
|
613
1323
|
debounceTimer = null;
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
1498
|
+
clearDebounceTimer();
|
|
1499
|
+
clearInputAckFlushTimer();
|
|
1500
|
+
clearImmediateExtractTimer();
|
|
672
1501
|
await runExtractQueued();
|
|
673
1502
|
sendPendingInputAcks();
|
|
674
1503
|
},
|
|
675
1504
|
getTrace: () => structuredClone(trace),
|
|
676
|
-
close: () =>
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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, {
|
|
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))
|