@geometra/mcp 1.57.0 → 1.59.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/dist/index.js +11 -1
- package/dist/proxy-spawn.d.ts +14 -0
- package/dist/proxy-spawn.js +10 -0
- package/dist/server.js +31 -2
- package/dist/session-state.d.ts +35 -0
- package/dist/session-state.js +191 -0
- package/dist/session.d.ts +18 -2
- package/dist/session.js +268 -107
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { createServer } from './server.js';
|
|
4
|
-
import { disconnect } from './session.js';
|
|
4
|
+
import { disconnect, listSessions } from './session.js';
|
|
5
|
+
import { shutdownSessionLifecycleRegistry } from './session-state.js';
|
|
5
6
|
let cleanedUp = false;
|
|
6
7
|
function cleanupActiveSession() {
|
|
7
8
|
if (cleanedUp)
|
|
8
9
|
return;
|
|
9
10
|
cleanedUp = true;
|
|
10
11
|
try {
|
|
12
|
+
for (const session of listSessions()) {
|
|
13
|
+
disconnect({ sessionId: session.id, closeProxy: false });
|
|
14
|
+
}
|
|
11
15
|
disconnect({ closeProxy: true });
|
|
12
16
|
}
|
|
13
17
|
catch {
|
|
14
18
|
/* ignore */
|
|
15
19
|
}
|
|
20
|
+
try {
|
|
21
|
+
shutdownSessionLifecycleRegistry();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* ignore */
|
|
25
|
+
}
|
|
16
26
|
}
|
|
17
27
|
async function main() {
|
|
18
28
|
const server = createServer();
|
package/dist/proxy-spawn.d.ts
CHANGED
|
@@ -11,6 +11,19 @@ export declare function resolveProxyScriptPath(): string;
|
|
|
11
11
|
export declare function resolveProxyScriptPathWith(customRequire: NodeRequire, moduleDir?: string): string;
|
|
12
12
|
export declare function resolveProxyRuntimePath(): string;
|
|
13
13
|
export declare function resolveProxyRuntimePathWith(customRequire: NodeRequire, moduleDir?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* BYO outbound proxy for the spawned Chromium. JobForge sets this when the
|
|
16
|
+
* user configures a residential / mobile / SOCKS proxy in `profile.yml` to
|
|
17
|
+
* bypass datacenter-IP fingerprinting on apply portals (Ashby class B,
|
|
18
|
+
* Lever Mapbox geocoder, Cloudflare Bot Management, etc.). Geometra is the
|
|
19
|
+
* wire — the user supplies the proxy.
|
|
20
|
+
*/
|
|
21
|
+
export interface SpawnProxyConfig {
|
|
22
|
+
server: string;
|
|
23
|
+
username?: string;
|
|
24
|
+
password?: string;
|
|
25
|
+
bypass?: string;
|
|
26
|
+
}
|
|
14
27
|
export interface SpawnProxyParams {
|
|
15
28
|
pageUrl: string;
|
|
16
29
|
port: number;
|
|
@@ -19,6 +32,7 @@ export interface SpawnProxyParams {
|
|
|
19
32
|
height?: number;
|
|
20
33
|
slowMo?: number;
|
|
21
34
|
eagerInitialExtract?: boolean;
|
|
35
|
+
proxy?: SpawnProxyConfig;
|
|
22
36
|
}
|
|
23
37
|
export declare function startEmbeddedGeometraProxy(opts: SpawnProxyParams): Promise<{
|
|
24
38
|
runtime: EmbeddedProxyRuntime;
|
package/dist/proxy-spawn.js
CHANGED
|
@@ -139,6 +139,7 @@ export async function startEmbeddedGeometraProxy(opts) {
|
|
|
139
139
|
headed: opts.headless !== true,
|
|
140
140
|
slowMo: opts.slowMo,
|
|
141
141
|
eagerInitialExtract: opts.eagerInitialExtract,
|
|
142
|
+
...(opts.proxy && { proxy: opts.proxy }),
|
|
142
143
|
});
|
|
143
144
|
return { runtime, wsUrl: runtime.wsUrl };
|
|
144
145
|
}
|
|
@@ -192,6 +193,15 @@ export function spawnGeometraProxy(opts) {
|
|
|
192
193
|
args.push('--headed');
|
|
193
194
|
if (opts.eagerInitialExtract === false)
|
|
194
195
|
args.push('--lazy-initial-extract');
|
|
196
|
+
if (opts.proxy?.server) {
|
|
197
|
+
args.push('--proxy-server', opts.proxy.server);
|
|
198
|
+
if (opts.proxy.username !== undefined)
|
|
199
|
+
args.push('--proxy-username', opts.proxy.username);
|
|
200
|
+
if (opts.proxy.password !== undefined)
|
|
201
|
+
args.push('--proxy-password', opts.proxy.password);
|
|
202
|
+
if (opts.proxy.bypass !== undefined)
|
|
203
|
+
args.push('--proxy-bypass', opts.proxy.bypass);
|
|
204
|
+
}
|
|
195
205
|
return new Promise((resolve, reject) => {
|
|
196
206
|
const child = spawn(process.execPath, args, {
|
|
197
207
|
stdio: ['ignore', 'pipe', 'pipe'],
|
package/dist/server.js
CHANGED
|
@@ -376,6 +376,20 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
376
376
|
.optional()
|
|
377
377
|
.default(false)
|
|
378
378
|
.describe('When true, bypass the reusable proxy pool and spawn a brand-new Chromium for this session that is destroyed on disconnect. Required for safe parallel form submission — without this, two parallel sessions can land on the same pooled proxy and contaminate each other. Default false (use the pool for speed).'),
|
|
379
|
+
proxy: z
|
|
380
|
+
.object({
|
|
381
|
+
server: z
|
|
382
|
+
.string()
|
|
383
|
+
.describe('Proxy URL (http://host:port, https://host:port, or socks5://host:port).'),
|
|
384
|
+
username: z.string().optional().describe('Proxy auth username.'),
|
|
385
|
+
password: z.string().optional().describe('Proxy auth password.'),
|
|
386
|
+
bypass: z
|
|
387
|
+
.string()
|
|
388
|
+
.optional()
|
|
389
|
+
.describe('Comma-separated host patterns to bypass (e.g. "*.internal,localhost").'),
|
|
390
|
+
})
|
|
391
|
+
.optional()
|
|
392
|
+
.describe('BYO outbound proxy for the spawned Chromium. Routes all browser traffic through the supplied residential / mobile / SOCKS proxy — useful for apply portals (Ashby, Lever, Cloudflare-fronted ATSes) that fingerprint datacenter IPs and flag headless sessions as bots. The reusable proxy pool is partitioned by proxy identity so callers with different proxy configs never share a Chromium instance.'),
|
|
379
393
|
returnForms: z
|
|
380
394
|
.boolean()
|
|
381
395
|
.optional()
|
|
@@ -431,6 +445,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
431
445
|
height: input.height,
|
|
432
446
|
slowMo: input.slowMo,
|
|
433
447
|
isolated: input.isolated,
|
|
448
|
+
proxy: input.proxy,
|
|
434
449
|
awaitInitialFrame: deferInlinePageModel ? false : undefined,
|
|
435
450
|
eagerInitialExtract: deferInlinePageModel ? true : undefined,
|
|
436
451
|
});
|
|
@@ -501,9 +516,23 @@ Use this when you can prepare ahead of the user-facing task so the next \`geomet
|
|
|
501
516
|
.nonnegative()
|
|
502
517
|
.optional()
|
|
503
518
|
.describe('Playwright slowMo (ms) for the warmed browser.'),
|
|
504
|
-
|
|
519
|
+
proxy: z
|
|
520
|
+
.object({
|
|
521
|
+
server: z
|
|
522
|
+
.string()
|
|
523
|
+
.describe('Proxy URL (http://host:port, https://host:port, or socks5://host:port).'),
|
|
524
|
+
username: z.string().optional().describe('Proxy auth username.'),
|
|
525
|
+
password: z.string().optional().describe('Proxy auth password.'),
|
|
526
|
+
bypass: z
|
|
527
|
+
.string()
|
|
528
|
+
.optional()
|
|
529
|
+
.describe('Comma-separated host patterns to bypass.'),
|
|
530
|
+
})
|
|
531
|
+
.optional()
|
|
532
|
+
.describe('BYO outbound proxy for the warmed Chromium. The pool entry is partitioned by proxy identity, so a later geometra_connect with the same proxy config will reuse this warmed browser; a different proxy config (or no proxy) will not.'),
|
|
533
|
+
}, async ({ pageUrl, port, headless, width, height, slowMo, proxy }) => {
|
|
505
534
|
try {
|
|
506
|
-
const prepared = await prewarmProxy({ pageUrl, port, headless, width, height, slowMo });
|
|
535
|
+
const prepared = await prewarmProxy({ pageUrl, port, headless, width, height, slowMo, proxy });
|
|
507
536
|
return ok(JSON.stringify(prepared));
|
|
508
537
|
}
|
|
509
538
|
catch (e) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
interface SessionLifecycleTarget {
|
|
2
|
+
id: string;
|
|
3
|
+
url: string;
|
|
4
|
+
isolated?: boolean;
|
|
5
|
+
proxyReusable?: boolean;
|
|
6
|
+
updateRevision: number;
|
|
7
|
+
layout: Record<string, unknown> | null;
|
|
8
|
+
tree: Record<string, unknown> | null;
|
|
9
|
+
ws: {
|
|
10
|
+
readyState: number;
|
|
11
|
+
};
|
|
12
|
+
connectTrace?: {
|
|
13
|
+
mode?: string;
|
|
14
|
+
} | null;
|
|
15
|
+
cachedA11y?: {
|
|
16
|
+
meta?: {
|
|
17
|
+
pageUrl?: string;
|
|
18
|
+
};
|
|
19
|
+
} | null;
|
|
20
|
+
lifecycleTaskId?: string;
|
|
21
|
+
lifecycleTaskKind?: string;
|
|
22
|
+
lifecycleLeaseId?: string;
|
|
23
|
+
lifecycleWorkerId?: string;
|
|
24
|
+
lifecycleFinalized?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function initializeSessionLifecycle(target: SessionLifecycleTarget, options?: {
|
|
27
|
+
pageUrl?: string;
|
|
28
|
+
transportMode?: string;
|
|
29
|
+
}): void;
|
|
30
|
+
export declare function heartbeatSessionLifecycle(target: SessionLifecycleTarget): void;
|
|
31
|
+
export declare function recordSessionSnapshot(target: SessionLifecycleTarget, label: string, extra?: Record<string, unknown>): void;
|
|
32
|
+
export declare function completeSessionLifecycle(target: SessionLifecycleTarget, reason: string, extra?: Record<string, unknown>): void;
|
|
33
|
+
export declare function failSessionLifecycle(target: SessionLifecycleTarget, error: string, extra?: Record<string, unknown>): void;
|
|
34
|
+
export declare function shutdownSessionLifecycleRegistry(): void;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ParallelMcpOrchestrator, SqliteParallelMcpStore, } from '@razroo/parallel-mcp';
|
|
5
|
+
const SESSION_NAMESPACE = 'geometra-mcp-session';
|
|
6
|
+
const SESSION_TASK_KEY = 'session.live';
|
|
7
|
+
const SESSION_LEASE_MS = 60_000;
|
|
8
|
+
const SESSION_SWEEP_MS = 15_000;
|
|
9
|
+
const SESSION_WORKER_PREFIX = `geometra-mcp:${process.pid}`;
|
|
10
|
+
function resolveSessionStateFile() {
|
|
11
|
+
const raw = process.env.GEOMETRA_MCP_STATE_FILE?.trim();
|
|
12
|
+
if (raw) {
|
|
13
|
+
mkdirSync(path.dirname(raw), { recursive: true });
|
|
14
|
+
return raw;
|
|
15
|
+
}
|
|
16
|
+
const dir = path.join(homedir(), '.geometra-mcp');
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
return path.join(dir, `parallel-mcp-${process.pid}.sqlite`);
|
|
19
|
+
}
|
|
20
|
+
const orchestrator = new ParallelMcpOrchestrator(new SqliteParallelMcpStore({ filename: resolveSessionStateFile() }), { defaultLeaseMs: SESSION_LEASE_MS });
|
|
21
|
+
const leaseSweep = setInterval(() => {
|
|
22
|
+
try {
|
|
23
|
+
orchestrator.expireLeases();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* ignore background lease sweep failures */
|
|
27
|
+
}
|
|
28
|
+
}, SESSION_SWEEP_MS);
|
|
29
|
+
leaseSweep.unref();
|
|
30
|
+
function extractPageUrl(target) {
|
|
31
|
+
const cached = target.cachedA11y?.meta?.pageUrl;
|
|
32
|
+
if (typeof cached === 'string' && cached.length > 0)
|
|
33
|
+
return cached;
|
|
34
|
+
const semantic = target.tree?.semantic;
|
|
35
|
+
if (semantic && typeof semantic.pageUrl === 'string' && semantic.pageUrl.length > 0)
|
|
36
|
+
return semantic.pageUrl;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function toJsonValue(value) {
|
|
40
|
+
if (value === null)
|
|
41
|
+
return null;
|
|
42
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
if (value instanceof Date) {
|
|
46
|
+
return value.toISOString();
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
return value.map(item => toJsonValue(item));
|
|
50
|
+
}
|
|
51
|
+
if (typeof value === 'object') {
|
|
52
|
+
const object = {};
|
|
53
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
54
|
+
if (entry === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
object[key] = toJsonValue(entry);
|
|
57
|
+
}
|
|
58
|
+
return object;
|
|
59
|
+
}
|
|
60
|
+
return String(value);
|
|
61
|
+
}
|
|
62
|
+
function buildSessionContext(target, label, extra) {
|
|
63
|
+
return {
|
|
64
|
+
sessionId: target.id,
|
|
65
|
+
label,
|
|
66
|
+
transportUrl: target.url,
|
|
67
|
+
pageUrl: extractPageUrl(target),
|
|
68
|
+
isolated: target.isolated === true,
|
|
69
|
+
proxyReusable: target.proxyReusable === true,
|
|
70
|
+
wsReadyState: target.ws.readyState,
|
|
71
|
+
updateRevision: target.updateRevision,
|
|
72
|
+
hasLayout: target.layout !== null,
|
|
73
|
+
hasTree: target.tree !== null,
|
|
74
|
+
connectMode: target.connectTrace?.mode ?? null,
|
|
75
|
+
...(extra ? { extra: toJsonValue(extra) } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function liveTaskIdFor(sessionId) {
|
|
79
|
+
return `${sessionId}:live`;
|
|
80
|
+
}
|
|
81
|
+
function liveTaskKindFor(sessionId) {
|
|
82
|
+
return `session.live:${sessionId}`;
|
|
83
|
+
}
|
|
84
|
+
function workerIdFor(sessionId) {
|
|
85
|
+
return `${SESSION_WORKER_PREFIX}:${sessionId}`;
|
|
86
|
+
}
|
|
87
|
+
export function initializeSessionLifecycle(target, options) {
|
|
88
|
+
const sessionId = target.id;
|
|
89
|
+
const taskId = liveTaskIdFor(sessionId);
|
|
90
|
+
const taskKind = liveTaskKindFor(sessionId);
|
|
91
|
+
const workerId = workerIdFor(sessionId);
|
|
92
|
+
orchestrator.createRun({
|
|
93
|
+
id: sessionId,
|
|
94
|
+
namespace: SESSION_NAMESPACE,
|
|
95
|
+
metadata: toJsonValue({
|
|
96
|
+
transportMode: options?.transportMode ?? 'direct-ws',
|
|
97
|
+
isolated: target.isolated === true,
|
|
98
|
+
}),
|
|
99
|
+
context: buildSessionContext(target, 'session.initialized', {
|
|
100
|
+
requestedPageUrl: options?.pageUrl ?? null,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
orchestrator.enqueueTask({
|
|
104
|
+
id: taskId,
|
|
105
|
+
runId: sessionId,
|
|
106
|
+
key: SESSION_TASK_KEY,
|
|
107
|
+
kind: taskKind,
|
|
108
|
+
input: toJsonValue({
|
|
109
|
+
transportUrl: target.url,
|
|
110
|
+
requestedPageUrl: options?.pageUrl ?? null,
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
const claimed = orchestrator.claimNextTask({
|
|
114
|
+
workerId,
|
|
115
|
+
kinds: [taskKind],
|
|
116
|
+
leaseMs: SESSION_LEASE_MS,
|
|
117
|
+
});
|
|
118
|
+
if (!claimed || claimed.task.id !== taskId) {
|
|
119
|
+
throw new Error(`Failed to initialize durable session task for ${sessionId}`);
|
|
120
|
+
}
|
|
121
|
+
orchestrator.markTaskRunning({
|
|
122
|
+
taskId,
|
|
123
|
+
leaseId: claimed.lease.id,
|
|
124
|
+
workerId,
|
|
125
|
+
});
|
|
126
|
+
target.lifecycleTaskId = taskId;
|
|
127
|
+
target.lifecycleTaskKind = taskKind;
|
|
128
|
+
target.lifecycleLeaseId = claimed.lease.id;
|
|
129
|
+
target.lifecycleWorkerId = workerId;
|
|
130
|
+
target.lifecycleFinalized = false;
|
|
131
|
+
}
|
|
132
|
+
export function heartbeatSessionLifecycle(target) {
|
|
133
|
+
if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
|
|
134
|
+
return;
|
|
135
|
+
orchestrator.heartbeatLease({
|
|
136
|
+
taskId: target.lifecycleTaskId,
|
|
137
|
+
leaseId: target.lifecycleLeaseId,
|
|
138
|
+
workerId: target.lifecycleWorkerId,
|
|
139
|
+
leaseMs: SESSION_LEASE_MS,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export function recordSessionSnapshot(target, label, extra) {
|
|
143
|
+
if (!target.lifecycleTaskId)
|
|
144
|
+
return;
|
|
145
|
+
orchestrator.appendContextSnapshot({
|
|
146
|
+
runId: target.id,
|
|
147
|
+
taskId: target.lifecycleTaskId,
|
|
148
|
+
scope: 'run',
|
|
149
|
+
label,
|
|
150
|
+
payload: buildSessionContext(target, label, extra),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export function completeSessionLifecycle(target, reason, extra) {
|
|
154
|
+
if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
|
|
155
|
+
return;
|
|
156
|
+
orchestrator.completeTask({
|
|
157
|
+
taskId: target.lifecycleTaskId,
|
|
158
|
+
leaseId: target.lifecycleLeaseId,
|
|
159
|
+
workerId: target.lifecycleWorkerId,
|
|
160
|
+
output: toJsonValue({
|
|
161
|
+
reason,
|
|
162
|
+
...(extra ? { extra } : {}),
|
|
163
|
+
}),
|
|
164
|
+
nextContext: buildSessionContext(target, 'session.completed', {
|
|
165
|
+
reason,
|
|
166
|
+
...(extra ? { ...extra } : {}),
|
|
167
|
+
}),
|
|
168
|
+
nextContextLabel: 'session.completed',
|
|
169
|
+
});
|
|
170
|
+
target.lifecycleFinalized = true;
|
|
171
|
+
}
|
|
172
|
+
export function failSessionLifecycle(target, error, extra) {
|
|
173
|
+
if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
|
|
174
|
+
return;
|
|
175
|
+
recordSessionSnapshot(target, 'session.failed', {
|
|
176
|
+
error,
|
|
177
|
+
...(extra ? { ...extra } : {}),
|
|
178
|
+
});
|
|
179
|
+
orchestrator.failTask({
|
|
180
|
+
taskId: target.lifecycleTaskId,
|
|
181
|
+
leaseId: target.lifecycleLeaseId,
|
|
182
|
+
workerId: target.lifecycleWorkerId,
|
|
183
|
+
error,
|
|
184
|
+
metadata: extra ? toJsonValue(extra) : undefined,
|
|
185
|
+
});
|
|
186
|
+
target.lifecycleFinalized = true;
|
|
187
|
+
}
|
|
188
|
+
export function shutdownSessionLifecycleRegistry() {
|
|
189
|
+
clearInterval(leaseSweep);
|
|
190
|
+
orchestrator.close();
|
|
191
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChildProcess } from 'node:child_process';
|
|
2
2
|
import WebSocket from 'ws';
|
|
3
|
-
import { type EmbeddedProxyRuntime } from './proxy-spawn.js';
|
|
3
|
+
import { type EmbeddedProxyRuntime, type SpawnProxyConfig } from './proxy-spawn.js';
|
|
4
4
|
/**
|
|
5
5
|
* Parsed accessibility node from the UI tree + computed layout.
|
|
6
6
|
* Mirrors the shape of @geometra/core's AccessibilityNode without importing it
|
|
@@ -392,7 +392,7 @@ export interface WorkflowState {
|
|
|
392
392
|
startedAt: number;
|
|
393
393
|
}
|
|
394
394
|
export interface Session {
|
|
395
|
-
/**
|
|
395
|
+
/** Durable unique identifier returned by geometra_connect. */
|
|
396
396
|
id: string;
|
|
397
397
|
ws: WebSocket;
|
|
398
398
|
layout: Record<string, unknown> | null;
|
|
@@ -421,6 +421,14 @@ export interface Session {
|
|
|
421
421
|
}>;
|
|
422
422
|
workflowState?: WorkflowState;
|
|
423
423
|
reconnectInFlight?: Promise<boolean>;
|
|
424
|
+
lifecycleTaskId?: string;
|
|
425
|
+
lifecycleTaskKind?: string;
|
|
426
|
+
lifecycleLeaseId?: string;
|
|
427
|
+
lifecycleWorkerId?: string;
|
|
428
|
+
lifecycleFinalized?: boolean;
|
|
429
|
+
heartbeatInterval?: ReturnType<typeof setInterval> | null;
|
|
430
|
+
heartbeatLastMessageAt?: number;
|
|
431
|
+
heartbeatPendingPongBy?: number | null;
|
|
424
432
|
}
|
|
425
433
|
export interface SessionConnectTrace {
|
|
426
434
|
mode: 'direct-ws' | 'fresh-proxy' | 'reused-proxy';
|
|
@@ -484,6 +492,7 @@ export declare function prewarmProxy(options: {
|
|
|
484
492
|
width?: number;
|
|
485
493
|
height?: number;
|
|
486
494
|
slowMo?: number;
|
|
495
|
+
proxy?: SpawnProxyConfig;
|
|
487
496
|
}): Promise<{
|
|
488
497
|
prepared: true;
|
|
489
498
|
reused: boolean;
|
|
@@ -527,6 +536,13 @@ export declare function connectThroughProxy(options: {
|
|
|
527
536
|
* leak into another. Default false preserves the existing pool behavior.
|
|
528
537
|
*/
|
|
529
538
|
isolated?: boolean;
|
|
539
|
+
/**
|
|
540
|
+
* BYO outbound proxy for the Chromium. Routes all browser traffic through
|
|
541
|
+
* the supplied residential / mobile / SOCKS proxy. The reusable pool is
|
|
542
|
+
* partitioned by proxy identity so two callers with different proxy
|
|
543
|
+
* configs never share a Chromium instance.
|
|
544
|
+
*/
|
|
545
|
+
proxy?: SpawnProxyConfig;
|
|
530
546
|
}): Promise<Session>;
|
|
531
547
|
export declare function getSession(id?: string): Session | null;
|
|
532
548
|
export declare function pruneDisconnectedSessions(): string[];
|
package/dist/session.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { performance } from 'node:perf_hooks';
|
|
2
3
|
import WebSocket from 'ws';
|
|
3
4
|
import { spawnGeometraProxy, startEmbeddedGeometraProxy } from './proxy-spawn.js';
|
|
5
|
+
import { completeSessionLifecycle, failSessionLifecycle, heartbeatSessionLifecycle, initializeSessionLifecycle, recordSessionSnapshot, } from './session-state.js';
|
|
6
|
+
/**
|
|
7
|
+
* Stable identity for an outbound proxy config, used as the reusable-pool
|
|
8
|
+
* partition key. Two sessions with different proxy configs MUST NOT share a
|
|
9
|
+
* pooled Chromium — otherwise the first apply's IP leaks into subsequent
|
|
10
|
+
* applies even when the caller opted into a fresh proxy. Password is
|
|
11
|
+
* excluded from the key to keep logs safe; `server + username + bypass` is
|
|
12
|
+
* enough to distinguish every realistic multi-tenant config.
|
|
13
|
+
*/
|
|
14
|
+
function proxyKeyFor(proxy) {
|
|
15
|
+
if (!proxy?.server)
|
|
16
|
+
return '';
|
|
17
|
+
return `${proxy.server}|${proxy.username ?? ''}|${proxy.bypass ?? ''}`;
|
|
18
|
+
}
|
|
4
19
|
const activeSessions = new Map();
|
|
5
20
|
let defaultSessionId = null;
|
|
6
21
|
const MAX_ACTIVE_SESSIONS = 5;
|
|
7
|
-
|
|
8
|
-
function generateSessionId() { return `s${++nextSessionId}`; }
|
|
22
|
+
function generateSessionId() { return `s_${randomUUID()}`; }
|
|
9
23
|
let reusableProxies = [];
|
|
10
24
|
const REUSABLE_PROXY_POOL_LIMIT = 6;
|
|
11
25
|
/** Close idle reusable proxies after 5 minutes of inactivity. */
|
|
@@ -128,6 +142,7 @@ function enforceReusableProxyPoolLimit() {
|
|
|
128
142
|
function setReusableProxy(proxy, wsUrl, opts) {
|
|
129
143
|
clearReusableProxiesIfExited();
|
|
130
144
|
const now = Date.now();
|
|
145
|
+
const proxyKey = proxyKeyFor(opts.proxy);
|
|
131
146
|
const existing = reusableProxies.find(entry => sameReusableProxyEntry(entry, proxy));
|
|
132
147
|
if (existing) {
|
|
133
148
|
existing.wsUrl = wsUrl;
|
|
@@ -136,6 +151,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
136
151
|
existing.width = opts.width ?? 1280;
|
|
137
152
|
existing.height = opts.height ?? 720;
|
|
138
153
|
existing.pageUrl = opts.pageUrl;
|
|
154
|
+
existing.proxyKey = proxyKey;
|
|
139
155
|
existing.snapshotReady = opts.snapshotReady ?? existing.snapshotReady;
|
|
140
156
|
existing.lastUsedAt = now;
|
|
141
157
|
return;
|
|
@@ -150,6 +166,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
150
166
|
width: opts.width ?? 1280,
|
|
151
167
|
height: opts.height ?? 720,
|
|
152
168
|
pageUrl: opts.pageUrl,
|
|
169
|
+
proxyKey,
|
|
153
170
|
snapshotReady: opts.snapshotReady === true,
|
|
154
171
|
lastUsedAt: now,
|
|
155
172
|
};
|
|
@@ -175,6 +192,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
175
192
|
width: opts.width ?? 1280,
|
|
176
193
|
height: opts.height ?? 720,
|
|
177
194
|
pageUrl: opts.pageUrl,
|
|
195
|
+
proxyKey,
|
|
178
196
|
snapshotReady: opts.snapshotReady === true,
|
|
179
197
|
lastUsedAt: now,
|
|
180
198
|
});
|
|
@@ -229,11 +247,20 @@ function shutdownSession(id, opts) {
|
|
|
229
247
|
const prev = activeSessions.get(id);
|
|
230
248
|
if (!prev)
|
|
231
249
|
return;
|
|
250
|
+
const forceCloseProxy = prev.isolated === true;
|
|
251
|
+
safeCompleteSessionLifecycle(prev, opts?.reason ?? 'disconnect', {
|
|
252
|
+
closeProxy: opts?.closeProxy ?? false,
|
|
253
|
+
forceCloseProxy,
|
|
254
|
+
});
|
|
232
255
|
activeSessions.delete(id);
|
|
233
256
|
if (defaultSessionId === id)
|
|
234
257
|
promoteDefaultSession();
|
|
258
|
+
stopSessionHeartbeat(prev);
|
|
259
|
+
releaseSessionResources(prev, { closeProxy: opts?.closeProxy ?? false });
|
|
260
|
+
}
|
|
261
|
+
function releaseSessionResources(session, opts) {
|
|
235
262
|
try {
|
|
236
|
-
|
|
263
|
+
session.ws.close();
|
|
237
264
|
}
|
|
238
265
|
catch {
|
|
239
266
|
/* ignore */
|
|
@@ -243,44 +270,44 @@ function shutdownSession(id, opts) {
|
|
|
243
270
|
// the underlying browser would defeat the entire point of the
|
|
244
271
|
// isolation flag (the next non-isolated connect could attach to a
|
|
245
272
|
// proxy with stale storage from this session's job).
|
|
246
|
-
const forceCloseProxy =
|
|
247
|
-
if (
|
|
248
|
-
const shouldKeepProxy = !forceCloseProxy &&
|
|
249
|
-
rememberReusableProxyPageUrl(
|
|
273
|
+
const forceCloseProxy = session.isolated === true;
|
|
274
|
+
if (session.proxyChild) {
|
|
275
|
+
const shouldKeepProxy = !forceCloseProxy && session.proxyReusable && opts?.closeProxy === false;
|
|
276
|
+
rememberReusableProxyPageUrl(session);
|
|
250
277
|
if (shouldKeepProxy) {
|
|
251
|
-
const entry = reusableProxyEntryForSession(
|
|
278
|
+
const entry = reusableProxyEntryForSession(session);
|
|
252
279
|
if (entry)
|
|
253
280
|
touchReusableProxy(entry);
|
|
254
281
|
return;
|
|
255
282
|
}
|
|
256
|
-
const entry = reusableProxyEntryForSession(
|
|
283
|
+
const entry = reusableProxyEntryForSession(session);
|
|
257
284
|
if (entry) {
|
|
258
285
|
closeReusableProxy(entry);
|
|
259
286
|
return;
|
|
260
287
|
}
|
|
261
288
|
try {
|
|
262
|
-
|
|
289
|
+
session.proxyChild.kill('SIGTERM');
|
|
263
290
|
}
|
|
264
291
|
catch {
|
|
265
292
|
/* ignore */
|
|
266
293
|
}
|
|
267
294
|
return;
|
|
268
295
|
}
|
|
269
|
-
if (
|
|
270
|
-
const shouldKeepProxy = !forceCloseProxy &&
|
|
271
|
-
rememberReusableProxyPageUrl(
|
|
296
|
+
if (session.proxyRuntime) {
|
|
297
|
+
const shouldKeepProxy = !forceCloseProxy && session.proxyReusable && opts?.closeProxy === false;
|
|
298
|
+
rememberReusableProxyPageUrl(session);
|
|
272
299
|
if (shouldKeepProxy) {
|
|
273
|
-
const entry = reusableProxyEntryForSession(
|
|
300
|
+
const entry = reusableProxyEntryForSession(session);
|
|
274
301
|
if (entry)
|
|
275
302
|
touchReusableProxy(entry);
|
|
276
303
|
return;
|
|
277
304
|
}
|
|
278
|
-
const entry = reusableProxyEntryForSession(
|
|
305
|
+
const entry = reusableProxyEntryForSession(session);
|
|
279
306
|
if (entry) {
|
|
280
307
|
closeReusableProxy(entry);
|
|
281
308
|
return;
|
|
282
309
|
}
|
|
283
|
-
void
|
|
310
|
+
void session.proxyRuntime.close().catch(() => { });
|
|
284
311
|
}
|
|
285
312
|
}
|
|
286
313
|
/** Evict the oldest session when at capacity. */
|
|
@@ -288,17 +315,100 @@ function evictOldestSession() {
|
|
|
288
315
|
if (activeSessions.size < MAX_ACTIVE_SESSIONS)
|
|
289
316
|
return;
|
|
290
317
|
const oldestId = activeSessions.keys().next().value;
|
|
291
|
-
shutdownSession(oldestId, { closeProxy: false });
|
|
318
|
+
shutdownSession(oldestId, { closeProxy: false, reason: 'evicted' });
|
|
292
319
|
}
|
|
293
320
|
function formatUnknownError(err) {
|
|
294
321
|
return err instanceof Error ? err.message : String(err);
|
|
295
322
|
}
|
|
323
|
+
function warnSessionLifecycleError(action, session, err) {
|
|
324
|
+
console.warn(`geometra-mcp: failed to ${action} for session ${session.id}: ${formatUnknownError(err)}`);
|
|
325
|
+
}
|
|
326
|
+
function safeRecordSessionSnapshot(session, label, extra) {
|
|
327
|
+
try {
|
|
328
|
+
recordSessionSnapshot(session, label, extra);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
warnSessionLifecycleError(`record snapshot "${label}"`, session, err);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function safeHeartbeatSessionLifecycle(session) {
|
|
335
|
+
try {
|
|
336
|
+
heartbeatSessionLifecycle(session);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
warnSessionLifecycleError('heartbeat durable state', session, err);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function safeCompleteSessionLifecycle(session, reason, extra) {
|
|
343
|
+
try {
|
|
344
|
+
completeSessionLifecycle(session, reason, extra);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
warnSessionLifecycleError(`complete durable state as "${reason}"`, session, err);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function safeFailSessionLifecycle(session, error, extra) {
|
|
351
|
+
try {
|
|
352
|
+
failSessionLifecycle(session, error, extra);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
warnSessionLifecycleError(`fail durable state as "${error}"`, session, err);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function noteSessionSocketActivity(session, ws) {
|
|
359
|
+
if (session.ws !== ws)
|
|
360
|
+
return;
|
|
361
|
+
session.heartbeatLastMessageAt = Date.now();
|
|
362
|
+
session.heartbeatPendingPongBy = null;
|
|
363
|
+
}
|
|
364
|
+
// Keep the durable lease alive independently from a transient socket and only
|
|
365
|
+
// ping the WebSocket transport when a socket is actually open.
|
|
366
|
+
function startSessionHeartbeat(session) {
|
|
367
|
+
if (session.heartbeatInterval)
|
|
368
|
+
return;
|
|
369
|
+
session.heartbeatLastMessageAt ??= Date.now();
|
|
370
|
+
session.heartbeatPendingPongBy ??= null;
|
|
371
|
+
session.heartbeatInterval = setInterval(() => {
|
|
372
|
+
safeHeartbeatSessionLifecycle(session);
|
|
373
|
+
const ws = session.ws;
|
|
374
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
375
|
+
return;
|
|
376
|
+
const pendingPongBy = session.heartbeatPendingPongBy ?? null;
|
|
377
|
+
if (pendingPongBy !== null && Date.now() > pendingPongBy) {
|
|
378
|
+
try {
|
|
379
|
+
ws.close();
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
/* ignore */
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (Date.now() - (session.heartbeatLastMessageAt ?? 0) > 10_000) {
|
|
387
|
+
try {
|
|
388
|
+
ws.ping();
|
|
389
|
+
session.heartbeatPendingPongBy = Date.now() + 30_000;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
/* ignore */
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}, 15_000);
|
|
396
|
+
session.heartbeatInterval.unref();
|
|
397
|
+
}
|
|
398
|
+
function stopSessionHeartbeat(session) {
|
|
399
|
+
if (session.heartbeatInterval) {
|
|
400
|
+
clearInterval(session.heartbeatInterval);
|
|
401
|
+
session.heartbeatInterval = null;
|
|
402
|
+
}
|
|
403
|
+
session.heartbeatPendingPongBy = null;
|
|
404
|
+
}
|
|
296
405
|
function reusableProxyMatchesOptions(entry, options) {
|
|
297
406
|
return (entry.pageUrl === options.pageUrl &&
|
|
298
407
|
entry.headless === (options.headless === true) &&
|
|
299
408
|
entry.slowMo === (options.slowMo ?? 0) &&
|
|
300
409
|
entry.width === (options.width ?? 1280) &&
|
|
301
|
-
entry.height === (options.height ?? 720)
|
|
410
|
+
entry.height === (options.height ?? 720) &&
|
|
411
|
+
entry.proxyKey === proxyKeyFor(options.proxy));
|
|
302
412
|
}
|
|
303
413
|
function findExactReusableProxy(options) {
|
|
304
414
|
clearReusableProxiesIfExited();
|
|
@@ -315,8 +425,14 @@ function findReusableProxy(options) {
|
|
|
315
425
|
const desiredSlowMo = options.slowMo ?? 0;
|
|
316
426
|
const desiredWidth = options.width ?? 1280;
|
|
317
427
|
const desiredHeight = options.height ?? 720;
|
|
428
|
+
const desiredProxyKey = proxyKeyFor(options.proxy);
|
|
318
429
|
return reusableProxies
|
|
319
|
-
.filter(entry => entry.headless === desiredHeadless
|
|
430
|
+
.filter(entry => entry.headless === desiredHeadless
|
|
431
|
+
&& entry.slowMo === desiredSlowMo
|
|
432
|
+
// Proxy partition is hard — a session with residential proxy MUST NOT
|
|
433
|
+
// attach to a pooled direct-connection Chromium (and vice versa).
|
|
434
|
+
// Different proxy credentials also get separate pool entries.
|
|
435
|
+
&& entry.proxyKey === desiredProxyKey)
|
|
320
436
|
.sort((a, b) => {
|
|
321
437
|
const score = (entry) => {
|
|
322
438
|
let value = 0;
|
|
@@ -356,6 +472,7 @@ export async function prewarmProxy(options) {
|
|
|
356
472
|
width: options.width,
|
|
357
473
|
height: options.height,
|
|
358
474
|
slowMo: options.slowMo,
|
|
475
|
+
proxy: options.proxy,
|
|
359
476
|
});
|
|
360
477
|
try {
|
|
361
478
|
await runtime.ready;
|
|
@@ -371,6 +488,7 @@ export async function prewarmProxy(options) {
|
|
|
371
488
|
height: options.height,
|
|
372
489
|
pageUrl: options.pageUrl,
|
|
373
490
|
snapshotReady: true,
|
|
491
|
+
proxy: options.proxy,
|
|
374
492
|
});
|
|
375
493
|
return {
|
|
376
494
|
prepared: true,
|
|
@@ -394,6 +512,7 @@ export async function prewarmProxy(options) {
|
|
|
394
512
|
width: options.width,
|
|
395
513
|
height: options.height,
|
|
396
514
|
slowMo: options.slowMo,
|
|
515
|
+
proxy: options.proxy,
|
|
397
516
|
});
|
|
398
517
|
setReusableProxy({ child }, wsUrl, {
|
|
399
518
|
headless: options.headless,
|
|
@@ -401,6 +520,7 @@ export async function prewarmProxy(options) {
|
|
|
401
520
|
width: options.width,
|
|
402
521
|
height: options.height,
|
|
403
522
|
pageUrl: options.pageUrl,
|
|
523
|
+
proxy: options.proxy,
|
|
404
524
|
});
|
|
405
525
|
return {
|
|
406
526
|
prepared: true,
|
|
@@ -477,6 +597,11 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
477
597
|
totalMs: performance.now() - startedAt,
|
|
478
598
|
};
|
|
479
599
|
updateReusableProxySnapshotState(proxy, session);
|
|
600
|
+
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
601
|
+
transportMode: 'reused-proxy',
|
|
602
|
+
targetPageUrl: options.pageUrl,
|
|
603
|
+
reusedExistingSession: reusedExistingSession !== null,
|
|
604
|
+
});
|
|
480
605
|
return session;
|
|
481
606
|
}
|
|
482
607
|
async function startFreshProxySession(options) {
|
|
@@ -497,6 +622,7 @@ async function startFreshProxySession(options) {
|
|
|
497
622
|
height: options.height,
|
|
498
623
|
slowMo: options.slowMo,
|
|
499
624
|
eagerInitialExtract,
|
|
625
|
+
proxy: options.proxy,
|
|
500
626
|
});
|
|
501
627
|
pendingEmbeddedRuntime = runtime;
|
|
502
628
|
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
@@ -527,6 +653,7 @@ async function startFreshProxySession(options) {
|
|
|
527
653
|
height: options.height,
|
|
528
654
|
pageUrl: options.pageUrl,
|
|
529
655
|
snapshotReady: Boolean(session.tree && session.layout),
|
|
656
|
+
proxy: options.proxy,
|
|
530
657
|
});
|
|
531
658
|
}
|
|
532
659
|
const baseConnectTrace = session.connectTrace;
|
|
@@ -542,6 +669,12 @@ async function startFreshProxySession(options) {
|
|
|
542
669
|
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
543
670
|
totalMs: performance.now() - startedAt,
|
|
544
671
|
};
|
|
672
|
+
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
673
|
+
transportMode: 'fresh-proxy',
|
|
674
|
+
proxyStartMode: 'embedded',
|
|
675
|
+
requestedPageUrl: options.pageUrl,
|
|
676
|
+
isolated: options.isolated === true,
|
|
677
|
+
});
|
|
545
678
|
return session;
|
|
546
679
|
}
|
|
547
680
|
catch (e) {
|
|
@@ -564,6 +697,7 @@ async function startFreshProxySession(options) {
|
|
|
564
697
|
height: options.height,
|
|
565
698
|
slowMo: options.slowMo,
|
|
566
699
|
eagerInitialExtract,
|
|
700
|
+
proxy: options.proxy,
|
|
567
701
|
});
|
|
568
702
|
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
569
703
|
try {
|
|
@@ -588,6 +722,7 @@ async function startFreshProxySession(options) {
|
|
|
588
722
|
height: options.height,
|
|
589
723
|
pageUrl: options.pageUrl,
|
|
590
724
|
snapshotReady: Boolean(session.tree && session.layout),
|
|
725
|
+
proxy: options.proxy,
|
|
591
726
|
});
|
|
592
727
|
}
|
|
593
728
|
const baseConnectTrace = session.connectTrace;
|
|
@@ -603,6 +738,12 @@ async function startFreshProxySession(options) {
|
|
|
603
738
|
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
604
739
|
totalMs: performance.now() - startedAt,
|
|
605
740
|
};
|
|
741
|
+
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
742
|
+
transportMode: 'fresh-proxy',
|
|
743
|
+
proxyStartMode: 'child',
|
|
744
|
+
requestedPageUrl: options.pageUrl,
|
|
745
|
+
isolated: options.isolated === true,
|
|
746
|
+
});
|
|
606
747
|
return session;
|
|
607
748
|
}
|
|
608
749
|
catch (fallbackError) {
|
|
@@ -642,56 +783,40 @@ export function connect(url, opts) {
|
|
|
642
783
|
cachedA11y: null,
|
|
643
784
|
cachedA11yRevision: -1,
|
|
644
785
|
cachedFormSchemas: new Map(),
|
|
786
|
+
heartbeatInterval: null,
|
|
787
|
+
heartbeatLastMessageAt: Date.now(),
|
|
788
|
+
heartbeatPendingPongBy: null,
|
|
645
789
|
};
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
function startHeartbeat() {
|
|
659
|
-
if (heartbeatInterval)
|
|
660
|
-
return;
|
|
661
|
-
heartbeatInterval = setInterval(() => {
|
|
662
|
-
// If we're waiting on a pong and it's overdue, the peer is dead.
|
|
663
|
-
if (pendingPongBy !== null && Date.now() > pendingPongBy) {
|
|
664
|
-
try {
|
|
665
|
-
ws.close();
|
|
666
|
-
}
|
|
667
|
-
catch { /* ignore */ }
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
// Only send a new ping if we haven't heard anything for a while,
|
|
671
|
-
// to avoid spamming a chatty session.
|
|
672
|
-
if (Date.now() - lastMessageAt > 10_000) {
|
|
673
|
-
try {
|
|
674
|
-
ws.ping();
|
|
675
|
-
// Allow 30s for the pong before declaring the peer dead.
|
|
676
|
-
pendingPongBy = Date.now() + 30_000;
|
|
677
|
-
}
|
|
678
|
-
catch {
|
|
679
|
-
/* if ping throws, the socket is already gone — let 'close' handle */
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}, 15_000);
|
|
683
|
-
heartbeatInterval.unref();
|
|
790
|
+
try {
|
|
791
|
+
initializeSessionLifecycle(session, { transportMode: 'direct-ws' });
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
try {
|
|
795
|
+
ws.terminate();
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
/* ignore */
|
|
799
|
+
}
|
|
800
|
+
reject(new Error(`Failed to initialize durable session state for ${url}: ${formatUnknownError(err)}`));
|
|
801
|
+
return;
|
|
684
802
|
}
|
|
803
|
+
let resolved = false;
|
|
685
804
|
ws.on('pong', () => {
|
|
686
|
-
|
|
687
|
-
return;
|
|
688
|
-
lastMessageAt = Date.now();
|
|
689
|
-
pendingPongBy = null;
|
|
805
|
+
noteSessionSocketActivity(session, ws);
|
|
690
806
|
});
|
|
691
807
|
const timeout = setTimeout(() => {
|
|
692
808
|
if (!resolved) {
|
|
809
|
+
safeFailSessionLifecycle(session, 'connect_timeout', {
|
|
810
|
+
transportUrl: url,
|
|
811
|
+
timeoutMs: 10_000,
|
|
812
|
+
});
|
|
693
813
|
resolved = true;
|
|
694
|
-
|
|
814
|
+
try {
|
|
815
|
+
ws.close();
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
/* ignore */
|
|
819
|
+
}
|
|
695
820
|
reject(new Error(`Connection to ${url} timed out after 10s`));
|
|
696
821
|
}
|
|
697
822
|
}, 10_000);
|
|
@@ -715,14 +840,17 @@ export function connect(url, opts) {
|
|
|
715
840
|
}
|
|
716
841
|
activeSessions.set(session.id, session);
|
|
717
842
|
defaultSessionId = session.id;
|
|
718
|
-
|
|
843
|
+
startSessionHeartbeat(session);
|
|
844
|
+
safeRecordSessionSnapshot(session, 'session.open', {
|
|
845
|
+
awaitInitialFrame: false,
|
|
846
|
+
});
|
|
719
847
|
resolve(session);
|
|
720
848
|
}
|
|
721
849
|
});
|
|
722
850
|
ws.on('message', (data) => {
|
|
851
|
+
noteSessionSocketActivity(session, ws);
|
|
723
852
|
if (session.ws !== ws)
|
|
724
853
|
return;
|
|
725
|
-
lastMessageAt = Date.now();
|
|
726
854
|
try {
|
|
727
855
|
const msg = JSON.parse(String(data));
|
|
728
856
|
if (msg.type === 'frame') {
|
|
@@ -731,6 +859,7 @@ export function connect(url, opts) {
|
|
|
731
859
|
session.updateRevision++;
|
|
732
860
|
invalidateSessionCaches(session);
|
|
733
861
|
const connectTrace = session.connectTrace;
|
|
862
|
+
const firstFrame = connectTrace?.firstFrameMs === undefined;
|
|
734
863
|
if (connectTrace && connectTrace.firstFrameMs === undefined) {
|
|
735
864
|
connectTrace.firstFrameMs = performance.now() - startedAt;
|
|
736
865
|
}
|
|
@@ -742,9 +871,15 @@ export function connect(url, opts) {
|
|
|
742
871
|
}
|
|
743
872
|
activeSessions.set(session.id, session);
|
|
744
873
|
defaultSessionId = session.id;
|
|
745
|
-
|
|
874
|
+
startSessionHeartbeat(session);
|
|
875
|
+
safeRecordSessionSnapshot(session, 'session.connected');
|
|
746
876
|
resolve(session);
|
|
747
877
|
}
|
|
878
|
+
else if (firstFrame) {
|
|
879
|
+
safeRecordSessionSnapshot(session, 'session.connected', {
|
|
880
|
+
lateInitialFrame: session.connectTrace?.resolvedWithoutInitialFrame === true,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
748
883
|
}
|
|
749
884
|
else if (msg.type === 'patch' && session.layout) {
|
|
750
885
|
applyPatches(session.layout, msg.patches);
|
|
@@ -756,6 +891,10 @@ export function connect(url, opts) {
|
|
|
756
891
|
});
|
|
757
892
|
ws.on('error', (err) => {
|
|
758
893
|
if (!resolved) {
|
|
894
|
+
safeFailSessionLifecycle(session, 'websocket_error', {
|
|
895
|
+
transportUrl: url,
|
|
896
|
+
message: err.message,
|
|
897
|
+
});
|
|
759
898
|
resolved = true;
|
|
760
899
|
clearTimeout(timeout);
|
|
761
900
|
reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
|
|
@@ -764,30 +903,19 @@ export function connect(url, opts) {
|
|
|
764
903
|
ws.on('close', () => {
|
|
765
904
|
if (session.ws !== ws)
|
|
766
905
|
return;
|
|
767
|
-
if (heartbeatInterval) {
|
|
768
|
-
clearInterval(heartbeatInterval);
|
|
769
|
-
heartbeatInterval = null;
|
|
770
|
-
}
|
|
771
|
-
if (activeSessions.get(session.id) === session) {
|
|
772
|
-
activeSessions.delete(session.id);
|
|
773
|
-
if (defaultSessionId === session.id)
|
|
774
|
-
promoteDefaultSession();
|
|
775
|
-
if (session.proxyChild && !session.proxyReusable) {
|
|
776
|
-
try {
|
|
777
|
-
session.proxyChild.kill('SIGTERM');
|
|
778
|
-
}
|
|
779
|
-
catch {
|
|
780
|
-
/* ignore */
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
if (session.proxyRuntime && !session.proxyReusable) {
|
|
784
|
-
void session.proxyRuntime.close().catch(() => { });
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
906
|
if (!resolved) {
|
|
907
|
+
safeFailSessionLifecycle(session, 'websocket_closed_before_ready', {
|
|
908
|
+
transportUrl: url,
|
|
909
|
+
});
|
|
788
910
|
resolved = true;
|
|
789
911
|
clearTimeout(timeout);
|
|
790
912
|
reject(new Error(`Connection to ${url} closed before first frame`));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
|
|
916
|
+
safeRecordSessionSnapshot(session, 'session.transport_closed', {
|
|
917
|
+
reconnectable: reconnectUrlForSession(session) !== null,
|
|
918
|
+
});
|
|
791
919
|
}
|
|
792
920
|
});
|
|
793
921
|
});
|
|
@@ -858,7 +986,7 @@ export function getSession(id) {
|
|
|
858
986
|
export function pruneDisconnectedSessions() {
|
|
859
987
|
const removedIds = [];
|
|
860
988
|
for (const [id, session] of activeSessions.entries()) {
|
|
861
|
-
if (session.ws.readyState === WebSocket.OPEN)
|
|
989
|
+
if (session.ws.readyState === WebSocket.OPEN || session.reconnectInFlight || reconnectUrlForSession(session))
|
|
862
990
|
continue;
|
|
863
991
|
removedIds.push(id);
|
|
864
992
|
activeSessions.delete(id);
|
|
@@ -905,10 +1033,10 @@ export function getDefaultSessionId() {
|
|
|
905
1033
|
}
|
|
906
1034
|
export function disconnect(opts) {
|
|
907
1035
|
if (opts?.sessionId) {
|
|
908
|
-
shutdownSession(opts.sessionId, { closeProxy: opts
|
|
1036
|
+
shutdownSession(opts.sessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
|
|
909
1037
|
}
|
|
910
1038
|
else if (defaultSessionId) {
|
|
911
|
-
shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false });
|
|
1039
|
+
shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
|
|
912
1040
|
}
|
|
913
1041
|
if (opts?.closeProxy)
|
|
914
1042
|
closeReusableProxies();
|
|
@@ -1026,6 +1154,7 @@ async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
|
|
|
1026
1154
|
}
|
|
1027
1155
|
function bindReconnectedSocket(session, ws) {
|
|
1028
1156
|
ws.on('message', data => {
|
|
1157
|
+
noteSessionSocketActivity(session, ws);
|
|
1029
1158
|
if (session.ws !== ws)
|
|
1030
1159
|
return;
|
|
1031
1160
|
try {
|
|
@@ -1046,13 +1175,16 @@ function bindReconnectedSocket(session, ws) {
|
|
|
1046
1175
|
/* ignore malformed messages */
|
|
1047
1176
|
}
|
|
1048
1177
|
});
|
|
1178
|
+
ws.on('pong', () => {
|
|
1179
|
+
noteSessionSocketActivity(session, ws);
|
|
1180
|
+
});
|
|
1049
1181
|
ws.on('close', () => {
|
|
1050
1182
|
if (session.ws !== ws)
|
|
1051
1183
|
return;
|
|
1052
|
-
if (activeSessions.get(session.id) === session) {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1184
|
+
if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
|
|
1185
|
+
safeRecordSessionSnapshot(session, 'session.transport_closed', {
|
|
1186
|
+
reconnectable: reconnectUrlForSession(session) !== null,
|
|
1187
|
+
});
|
|
1056
1188
|
}
|
|
1057
1189
|
});
|
|
1058
1190
|
}
|
|
@@ -1071,20 +1203,41 @@ async function ensureSessionConnected(session) {
|
|
|
1071
1203
|
throw new Error('Not connected');
|
|
1072
1204
|
}
|
|
1073
1205
|
const reconnectPromise = (async () => {
|
|
1074
|
-
const nextWs = await openWebSocket(targetUrl);
|
|
1075
1206
|
try {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1207
|
+
const nextWs = await openWebSocket(targetUrl);
|
|
1208
|
+
try {
|
|
1209
|
+
session.ws.close();
|
|
1210
|
+
}
|
|
1211
|
+
catch {
|
|
1212
|
+
/* ignore */
|
|
1213
|
+
}
|
|
1214
|
+
session.ws = nextWs;
|
|
1215
|
+
noteSessionSocketActivity(session, nextWs);
|
|
1216
|
+
bindReconnectedSocket(session, nextWs);
|
|
1217
|
+
activeSessions.set(session.id, session);
|
|
1218
|
+
if (!session.isolated) {
|
|
1219
|
+
defaultSessionId = session.id;
|
|
1220
|
+
}
|
|
1221
|
+
startSessionHeartbeat(session);
|
|
1222
|
+
safeRecordSessionSnapshot(session, 'session.reconnected', {
|
|
1223
|
+
targetUrl,
|
|
1224
|
+
});
|
|
1225
|
+
return true;
|
|
1080
1226
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1227
|
+
catch (err) {
|
|
1228
|
+
safeFailSessionLifecycle(session, 'reconnect_failed', {
|
|
1229
|
+
targetUrl,
|
|
1230
|
+
message: formatUnknownError(err),
|
|
1231
|
+
});
|
|
1232
|
+
if (activeSessions.get(session.id) === session) {
|
|
1233
|
+
activeSessions.delete(session.id);
|
|
1234
|
+
if (defaultSessionId === session.id)
|
|
1235
|
+
promoteDefaultSession();
|
|
1236
|
+
}
|
|
1237
|
+
stopSessionHeartbeat(session);
|
|
1238
|
+
releaseSessionResources(session, { closeProxy: true });
|
|
1239
|
+
throw err;
|
|
1086
1240
|
}
|
|
1087
|
-
return true;
|
|
1088
1241
|
})();
|
|
1089
1242
|
session.reconnectInFlight = reconnectPromise;
|
|
1090
1243
|
let recovered = false;
|
|
@@ -1296,10 +1449,18 @@ export function sendPdfGenerate(session, options, timeoutMs = 30_000) {
|
|
|
1296
1449
|
}
|
|
1297
1450
|
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
1298
1451
|
export function sendNavigate(session, url, timeoutMs = 15_000) {
|
|
1299
|
-
return
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1452
|
+
return (async () => {
|
|
1453
|
+
const result = await sendAndWaitForUpdate(session, {
|
|
1454
|
+
type: 'navigate',
|
|
1455
|
+
url,
|
|
1456
|
+
}, timeoutMs, { requireUpdateOnAck: true });
|
|
1457
|
+
safeRecordSessionSnapshot(session, 'session.navigate', {
|
|
1458
|
+
requestedUrl: url,
|
|
1459
|
+
status: result.status,
|
|
1460
|
+
result: result.result ?? null,
|
|
1461
|
+
});
|
|
1462
|
+
return result;
|
|
1463
|
+
})();
|
|
1303
1464
|
}
|
|
1304
1465
|
/**
|
|
1305
1466
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.59.0",
|
|
4
4
|
"description": "MCP server for Geometra — interact with running Geometra apps via the geometry protocol, no browser needed",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@geometra/proxy": "^1.19.23",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
|
+
"@razroo/parallel-mcp": "^0.1.0",
|
|
35
36
|
"ws": "^8.18.0",
|
|
36
37
|
"zod": "^3.23.0"
|
|
37
38
|
},
|