@geometra/mcp 1.57.0 → 1.58.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/session-state.d.ts +35 -0
- package/dist/session-state.js +191 -0
- package/dist/session.d.ts +9 -1
- package/dist/session.js +234 -105
- 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();
|
|
@@ -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
|
@@ -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';
|
package/dist/session.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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';
|
|
4
6
|
const activeSessions = new Map();
|
|
5
7
|
let defaultSessionId = null;
|
|
6
8
|
const MAX_ACTIVE_SESSIONS = 5;
|
|
7
|
-
|
|
8
|
-
function generateSessionId() { return `s${++nextSessionId}`; }
|
|
9
|
+
function generateSessionId() { return `s_${randomUUID()}`; }
|
|
9
10
|
let reusableProxies = [];
|
|
10
11
|
const REUSABLE_PROXY_POOL_LIMIT = 6;
|
|
11
12
|
/** Close idle reusable proxies after 5 minutes of inactivity. */
|
|
@@ -229,11 +230,20 @@ function shutdownSession(id, opts) {
|
|
|
229
230
|
const prev = activeSessions.get(id);
|
|
230
231
|
if (!prev)
|
|
231
232
|
return;
|
|
233
|
+
const forceCloseProxy = prev.isolated === true;
|
|
234
|
+
safeCompleteSessionLifecycle(prev, opts?.reason ?? 'disconnect', {
|
|
235
|
+
closeProxy: opts?.closeProxy ?? false,
|
|
236
|
+
forceCloseProxy,
|
|
237
|
+
});
|
|
232
238
|
activeSessions.delete(id);
|
|
233
239
|
if (defaultSessionId === id)
|
|
234
240
|
promoteDefaultSession();
|
|
241
|
+
stopSessionHeartbeat(prev);
|
|
242
|
+
releaseSessionResources(prev, { closeProxy: opts?.closeProxy ?? false });
|
|
243
|
+
}
|
|
244
|
+
function releaseSessionResources(session, opts) {
|
|
235
245
|
try {
|
|
236
|
-
|
|
246
|
+
session.ws.close();
|
|
237
247
|
}
|
|
238
248
|
catch {
|
|
239
249
|
/* ignore */
|
|
@@ -243,44 +253,44 @@ function shutdownSession(id, opts) {
|
|
|
243
253
|
// the underlying browser would defeat the entire point of the
|
|
244
254
|
// isolation flag (the next non-isolated connect could attach to a
|
|
245
255
|
// proxy with stale storage from this session's job).
|
|
246
|
-
const forceCloseProxy =
|
|
247
|
-
if (
|
|
248
|
-
const shouldKeepProxy = !forceCloseProxy &&
|
|
249
|
-
rememberReusableProxyPageUrl(
|
|
256
|
+
const forceCloseProxy = session.isolated === true;
|
|
257
|
+
if (session.proxyChild) {
|
|
258
|
+
const shouldKeepProxy = !forceCloseProxy && session.proxyReusable && opts?.closeProxy === false;
|
|
259
|
+
rememberReusableProxyPageUrl(session);
|
|
250
260
|
if (shouldKeepProxy) {
|
|
251
|
-
const entry = reusableProxyEntryForSession(
|
|
261
|
+
const entry = reusableProxyEntryForSession(session);
|
|
252
262
|
if (entry)
|
|
253
263
|
touchReusableProxy(entry);
|
|
254
264
|
return;
|
|
255
265
|
}
|
|
256
|
-
const entry = reusableProxyEntryForSession(
|
|
266
|
+
const entry = reusableProxyEntryForSession(session);
|
|
257
267
|
if (entry) {
|
|
258
268
|
closeReusableProxy(entry);
|
|
259
269
|
return;
|
|
260
270
|
}
|
|
261
271
|
try {
|
|
262
|
-
|
|
272
|
+
session.proxyChild.kill('SIGTERM');
|
|
263
273
|
}
|
|
264
274
|
catch {
|
|
265
275
|
/* ignore */
|
|
266
276
|
}
|
|
267
277
|
return;
|
|
268
278
|
}
|
|
269
|
-
if (
|
|
270
|
-
const shouldKeepProxy = !forceCloseProxy &&
|
|
271
|
-
rememberReusableProxyPageUrl(
|
|
279
|
+
if (session.proxyRuntime) {
|
|
280
|
+
const shouldKeepProxy = !forceCloseProxy && session.proxyReusable && opts?.closeProxy === false;
|
|
281
|
+
rememberReusableProxyPageUrl(session);
|
|
272
282
|
if (shouldKeepProxy) {
|
|
273
|
-
const entry = reusableProxyEntryForSession(
|
|
283
|
+
const entry = reusableProxyEntryForSession(session);
|
|
274
284
|
if (entry)
|
|
275
285
|
touchReusableProxy(entry);
|
|
276
286
|
return;
|
|
277
287
|
}
|
|
278
|
-
const entry = reusableProxyEntryForSession(
|
|
288
|
+
const entry = reusableProxyEntryForSession(session);
|
|
279
289
|
if (entry) {
|
|
280
290
|
closeReusableProxy(entry);
|
|
281
291
|
return;
|
|
282
292
|
}
|
|
283
|
-
void
|
|
293
|
+
void session.proxyRuntime.close().catch(() => { });
|
|
284
294
|
}
|
|
285
295
|
}
|
|
286
296
|
/** Evict the oldest session when at capacity. */
|
|
@@ -288,11 +298,93 @@ function evictOldestSession() {
|
|
|
288
298
|
if (activeSessions.size < MAX_ACTIVE_SESSIONS)
|
|
289
299
|
return;
|
|
290
300
|
const oldestId = activeSessions.keys().next().value;
|
|
291
|
-
shutdownSession(oldestId, { closeProxy: false });
|
|
301
|
+
shutdownSession(oldestId, { closeProxy: false, reason: 'evicted' });
|
|
292
302
|
}
|
|
293
303
|
function formatUnknownError(err) {
|
|
294
304
|
return err instanceof Error ? err.message : String(err);
|
|
295
305
|
}
|
|
306
|
+
function warnSessionLifecycleError(action, session, err) {
|
|
307
|
+
console.warn(`geometra-mcp: failed to ${action} for session ${session.id}: ${formatUnknownError(err)}`);
|
|
308
|
+
}
|
|
309
|
+
function safeRecordSessionSnapshot(session, label, extra) {
|
|
310
|
+
try {
|
|
311
|
+
recordSessionSnapshot(session, label, extra);
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
warnSessionLifecycleError(`record snapshot "${label}"`, session, err);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function safeHeartbeatSessionLifecycle(session) {
|
|
318
|
+
try {
|
|
319
|
+
heartbeatSessionLifecycle(session);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
warnSessionLifecycleError('heartbeat durable state', session, err);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function safeCompleteSessionLifecycle(session, reason, extra) {
|
|
326
|
+
try {
|
|
327
|
+
completeSessionLifecycle(session, reason, extra);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
warnSessionLifecycleError(`complete durable state as "${reason}"`, session, err);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function safeFailSessionLifecycle(session, error, extra) {
|
|
334
|
+
try {
|
|
335
|
+
failSessionLifecycle(session, error, extra);
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
warnSessionLifecycleError(`fail durable state as "${error}"`, session, err);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function noteSessionSocketActivity(session, ws) {
|
|
342
|
+
if (session.ws !== ws)
|
|
343
|
+
return;
|
|
344
|
+
session.heartbeatLastMessageAt = Date.now();
|
|
345
|
+
session.heartbeatPendingPongBy = null;
|
|
346
|
+
}
|
|
347
|
+
// Keep the durable lease alive independently from a transient socket and only
|
|
348
|
+
// ping the WebSocket transport when a socket is actually open.
|
|
349
|
+
function startSessionHeartbeat(session) {
|
|
350
|
+
if (session.heartbeatInterval)
|
|
351
|
+
return;
|
|
352
|
+
session.heartbeatLastMessageAt ??= Date.now();
|
|
353
|
+
session.heartbeatPendingPongBy ??= null;
|
|
354
|
+
session.heartbeatInterval = setInterval(() => {
|
|
355
|
+
safeHeartbeatSessionLifecycle(session);
|
|
356
|
+
const ws = session.ws;
|
|
357
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
358
|
+
return;
|
|
359
|
+
const pendingPongBy = session.heartbeatPendingPongBy ?? null;
|
|
360
|
+
if (pendingPongBy !== null && Date.now() > pendingPongBy) {
|
|
361
|
+
try {
|
|
362
|
+
ws.close();
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
/* ignore */
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (Date.now() - (session.heartbeatLastMessageAt ?? 0) > 10_000) {
|
|
370
|
+
try {
|
|
371
|
+
ws.ping();
|
|
372
|
+
session.heartbeatPendingPongBy = Date.now() + 30_000;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
/* ignore */
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}, 15_000);
|
|
379
|
+
session.heartbeatInterval.unref();
|
|
380
|
+
}
|
|
381
|
+
function stopSessionHeartbeat(session) {
|
|
382
|
+
if (session.heartbeatInterval) {
|
|
383
|
+
clearInterval(session.heartbeatInterval);
|
|
384
|
+
session.heartbeatInterval = null;
|
|
385
|
+
}
|
|
386
|
+
session.heartbeatPendingPongBy = null;
|
|
387
|
+
}
|
|
296
388
|
function reusableProxyMatchesOptions(entry, options) {
|
|
297
389
|
return (entry.pageUrl === options.pageUrl &&
|
|
298
390
|
entry.headless === (options.headless === true) &&
|
|
@@ -477,6 +569,11 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
477
569
|
totalMs: performance.now() - startedAt,
|
|
478
570
|
};
|
|
479
571
|
updateReusableProxySnapshotState(proxy, session);
|
|
572
|
+
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
573
|
+
transportMode: 'reused-proxy',
|
|
574
|
+
targetPageUrl: options.pageUrl,
|
|
575
|
+
reusedExistingSession: reusedExistingSession !== null,
|
|
576
|
+
});
|
|
480
577
|
return session;
|
|
481
578
|
}
|
|
482
579
|
async function startFreshProxySession(options) {
|
|
@@ -542,6 +639,12 @@ async function startFreshProxySession(options) {
|
|
|
542
639
|
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
543
640
|
totalMs: performance.now() - startedAt,
|
|
544
641
|
};
|
|
642
|
+
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
643
|
+
transportMode: 'fresh-proxy',
|
|
644
|
+
proxyStartMode: 'embedded',
|
|
645
|
+
requestedPageUrl: options.pageUrl,
|
|
646
|
+
isolated: options.isolated === true,
|
|
647
|
+
});
|
|
545
648
|
return session;
|
|
546
649
|
}
|
|
547
650
|
catch (e) {
|
|
@@ -603,6 +706,12 @@ async function startFreshProxySession(options) {
|
|
|
603
706
|
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
604
707
|
totalMs: performance.now() - startedAt,
|
|
605
708
|
};
|
|
709
|
+
safeRecordSessionSnapshot(session, 'session.proxy_attached', {
|
|
710
|
+
transportMode: 'fresh-proxy',
|
|
711
|
+
proxyStartMode: 'child',
|
|
712
|
+
requestedPageUrl: options.pageUrl,
|
|
713
|
+
isolated: options.isolated === true,
|
|
714
|
+
});
|
|
606
715
|
return session;
|
|
607
716
|
}
|
|
608
717
|
catch (fallbackError) {
|
|
@@ -642,56 +751,40 @@ export function connect(url, opts) {
|
|
|
642
751
|
cachedA11y: null,
|
|
643
752
|
cachedA11yRevision: -1,
|
|
644
753
|
cachedFormSchemas: new Map(),
|
|
754
|
+
heartbeatInterval: null,
|
|
755
|
+
heartbeatLastMessageAt: Date.now(),
|
|
756
|
+
heartbeatPendingPongBy: null,
|
|
645
757
|
};
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
let heartbeatInterval = null;
|
|
649
|
-
let pendingPongBy = null;
|
|
650
|
-
// Heartbeat: send a real WS-level ping every 15s and only tear the socket
|
|
651
|
-
// down if the peer fails to respond to two consecutive pings (i.e. ~45s of
|
|
652
|
-
// true unresponsiveness). Previous versions used a dumb idle timer that
|
|
653
|
-
// closed the socket after 30s of no inbound frames — which killed sessions
|
|
654
|
-
// during normal form-submission flows where the DOM is legitimately idle
|
|
655
|
-
// for 20-30+ seconds while the backend processes (Greenhouse submit →
|
|
656
|
-
// security-code dialog is the canonical repro). A real ping/pong cycle
|
|
657
|
-
// distinguishes a silent-but-healthy session from a dead one.
|
|
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();
|
|
758
|
+
try {
|
|
759
|
+
initializeSessionLifecycle(session, { transportMode: 'direct-ws' });
|
|
684
760
|
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
try {
|
|
763
|
+
ws.terminate();
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
/* ignore */
|
|
767
|
+
}
|
|
768
|
+
reject(new Error(`Failed to initialize durable session state for ${url}: ${formatUnknownError(err)}`));
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
let resolved = false;
|
|
685
772
|
ws.on('pong', () => {
|
|
686
|
-
|
|
687
|
-
return;
|
|
688
|
-
lastMessageAt = Date.now();
|
|
689
|
-
pendingPongBy = null;
|
|
773
|
+
noteSessionSocketActivity(session, ws);
|
|
690
774
|
});
|
|
691
775
|
const timeout = setTimeout(() => {
|
|
692
776
|
if (!resolved) {
|
|
777
|
+
safeFailSessionLifecycle(session, 'connect_timeout', {
|
|
778
|
+
transportUrl: url,
|
|
779
|
+
timeoutMs: 10_000,
|
|
780
|
+
});
|
|
693
781
|
resolved = true;
|
|
694
|
-
|
|
782
|
+
try {
|
|
783
|
+
ws.close();
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
/* ignore */
|
|
787
|
+
}
|
|
695
788
|
reject(new Error(`Connection to ${url} timed out after 10s`));
|
|
696
789
|
}
|
|
697
790
|
}, 10_000);
|
|
@@ -715,14 +808,17 @@ export function connect(url, opts) {
|
|
|
715
808
|
}
|
|
716
809
|
activeSessions.set(session.id, session);
|
|
717
810
|
defaultSessionId = session.id;
|
|
718
|
-
|
|
811
|
+
startSessionHeartbeat(session);
|
|
812
|
+
safeRecordSessionSnapshot(session, 'session.open', {
|
|
813
|
+
awaitInitialFrame: false,
|
|
814
|
+
});
|
|
719
815
|
resolve(session);
|
|
720
816
|
}
|
|
721
817
|
});
|
|
722
818
|
ws.on('message', (data) => {
|
|
819
|
+
noteSessionSocketActivity(session, ws);
|
|
723
820
|
if (session.ws !== ws)
|
|
724
821
|
return;
|
|
725
|
-
lastMessageAt = Date.now();
|
|
726
822
|
try {
|
|
727
823
|
const msg = JSON.parse(String(data));
|
|
728
824
|
if (msg.type === 'frame') {
|
|
@@ -731,6 +827,7 @@ export function connect(url, opts) {
|
|
|
731
827
|
session.updateRevision++;
|
|
732
828
|
invalidateSessionCaches(session);
|
|
733
829
|
const connectTrace = session.connectTrace;
|
|
830
|
+
const firstFrame = connectTrace?.firstFrameMs === undefined;
|
|
734
831
|
if (connectTrace && connectTrace.firstFrameMs === undefined) {
|
|
735
832
|
connectTrace.firstFrameMs = performance.now() - startedAt;
|
|
736
833
|
}
|
|
@@ -742,9 +839,15 @@ export function connect(url, opts) {
|
|
|
742
839
|
}
|
|
743
840
|
activeSessions.set(session.id, session);
|
|
744
841
|
defaultSessionId = session.id;
|
|
745
|
-
|
|
842
|
+
startSessionHeartbeat(session);
|
|
843
|
+
safeRecordSessionSnapshot(session, 'session.connected');
|
|
746
844
|
resolve(session);
|
|
747
845
|
}
|
|
846
|
+
else if (firstFrame) {
|
|
847
|
+
safeRecordSessionSnapshot(session, 'session.connected', {
|
|
848
|
+
lateInitialFrame: session.connectTrace?.resolvedWithoutInitialFrame === true,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
748
851
|
}
|
|
749
852
|
else if (msg.type === 'patch' && session.layout) {
|
|
750
853
|
applyPatches(session.layout, msg.patches);
|
|
@@ -756,6 +859,10 @@ export function connect(url, opts) {
|
|
|
756
859
|
});
|
|
757
860
|
ws.on('error', (err) => {
|
|
758
861
|
if (!resolved) {
|
|
862
|
+
safeFailSessionLifecycle(session, 'websocket_error', {
|
|
863
|
+
transportUrl: url,
|
|
864
|
+
message: err.message,
|
|
865
|
+
});
|
|
759
866
|
resolved = true;
|
|
760
867
|
clearTimeout(timeout);
|
|
761
868
|
reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
|
|
@@ -764,30 +871,19 @@ export function connect(url, opts) {
|
|
|
764
871
|
ws.on('close', () => {
|
|
765
872
|
if (session.ws !== ws)
|
|
766
873
|
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
874
|
if (!resolved) {
|
|
875
|
+
safeFailSessionLifecycle(session, 'websocket_closed_before_ready', {
|
|
876
|
+
transportUrl: url,
|
|
877
|
+
});
|
|
788
878
|
resolved = true;
|
|
789
879
|
clearTimeout(timeout);
|
|
790
880
|
reject(new Error(`Connection to ${url} closed before first frame`));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
|
|
884
|
+
safeRecordSessionSnapshot(session, 'session.transport_closed', {
|
|
885
|
+
reconnectable: reconnectUrlForSession(session) !== null,
|
|
886
|
+
});
|
|
791
887
|
}
|
|
792
888
|
});
|
|
793
889
|
});
|
|
@@ -858,7 +954,7 @@ export function getSession(id) {
|
|
|
858
954
|
export function pruneDisconnectedSessions() {
|
|
859
955
|
const removedIds = [];
|
|
860
956
|
for (const [id, session] of activeSessions.entries()) {
|
|
861
|
-
if (session.ws.readyState === WebSocket.OPEN)
|
|
957
|
+
if (session.ws.readyState === WebSocket.OPEN || session.reconnectInFlight || reconnectUrlForSession(session))
|
|
862
958
|
continue;
|
|
863
959
|
removedIds.push(id);
|
|
864
960
|
activeSessions.delete(id);
|
|
@@ -905,10 +1001,10 @@ export function getDefaultSessionId() {
|
|
|
905
1001
|
}
|
|
906
1002
|
export function disconnect(opts) {
|
|
907
1003
|
if (opts?.sessionId) {
|
|
908
|
-
shutdownSession(opts.sessionId, { closeProxy: opts
|
|
1004
|
+
shutdownSession(opts.sessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
|
|
909
1005
|
}
|
|
910
1006
|
else if (defaultSessionId) {
|
|
911
|
-
shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false });
|
|
1007
|
+
shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
|
|
912
1008
|
}
|
|
913
1009
|
if (opts?.closeProxy)
|
|
914
1010
|
closeReusableProxies();
|
|
@@ -1026,6 +1122,7 @@ async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
|
|
|
1026
1122
|
}
|
|
1027
1123
|
function bindReconnectedSocket(session, ws) {
|
|
1028
1124
|
ws.on('message', data => {
|
|
1125
|
+
noteSessionSocketActivity(session, ws);
|
|
1029
1126
|
if (session.ws !== ws)
|
|
1030
1127
|
return;
|
|
1031
1128
|
try {
|
|
@@ -1046,13 +1143,16 @@ function bindReconnectedSocket(session, ws) {
|
|
|
1046
1143
|
/* ignore malformed messages */
|
|
1047
1144
|
}
|
|
1048
1145
|
});
|
|
1146
|
+
ws.on('pong', () => {
|
|
1147
|
+
noteSessionSocketActivity(session, ws);
|
|
1148
|
+
});
|
|
1049
1149
|
ws.on('close', () => {
|
|
1050
1150
|
if (session.ws !== ws)
|
|
1051
1151
|
return;
|
|
1052
|
-
if (activeSessions.get(session.id) === session) {
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1152
|
+
if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
|
|
1153
|
+
safeRecordSessionSnapshot(session, 'session.transport_closed', {
|
|
1154
|
+
reconnectable: reconnectUrlForSession(session) !== null,
|
|
1155
|
+
});
|
|
1056
1156
|
}
|
|
1057
1157
|
});
|
|
1058
1158
|
}
|
|
@@ -1071,20 +1171,41 @@ async function ensureSessionConnected(session) {
|
|
|
1071
1171
|
throw new Error('Not connected');
|
|
1072
1172
|
}
|
|
1073
1173
|
const reconnectPromise = (async () => {
|
|
1074
|
-
const nextWs = await openWebSocket(targetUrl);
|
|
1075
1174
|
try {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1175
|
+
const nextWs = await openWebSocket(targetUrl);
|
|
1176
|
+
try {
|
|
1177
|
+
session.ws.close();
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
/* ignore */
|
|
1181
|
+
}
|
|
1182
|
+
session.ws = nextWs;
|
|
1183
|
+
noteSessionSocketActivity(session, nextWs);
|
|
1184
|
+
bindReconnectedSocket(session, nextWs);
|
|
1185
|
+
activeSessions.set(session.id, session);
|
|
1186
|
+
if (!session.isolated) {
|
|
1187
|
+
defaultSessionId = session.id;
|
|
1188
|
+
}
|
|
1189
|
+
startSessionHeartbeat(session);
|
|
1190
|
+
safeRecordSessionSnapshot(session, 'session.reconnected', {
|
|
1191
|
+
targetUrl,
|
|
1192
|
+
});
|
|
1193
|
+
return true;
|
|
1080
1194
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1195
|
+
catch (err) {
|
|
1196
|
+
safeFailSessionLifecycle(session, 'reconnect_failed', {
|
|
1197
|
+
targetUrl,
|
|
1198
|
+
message: formatUnknownError(err),
|
|
1199
|
+
});
|
|
1200
|
+
if (activeSessions.get(session.id) === session) {
|
|
1201
|
+
activeSessions.delete(session.id);
|
|
1202
|
+
if (defaultSessionId === session.id)
|
|
1203
|
+
promoteDefaultSession();
|
|
1204
|
+
}
|
|
1205
|
+
stopSessionHeartbeat(session);
|
|
1206
|
+
releaseSessionResources(session, { closeProxy: true });
|
|
1207
|
+
throw err;
|
|
1086
1208
|
}
|
|
1087
|
-
return true;
|
|
1088
1209
|
})();
|
|
1089
1210
|
session.reconnectInFlight = reconnectPromise;
|
|
1090
1211
|
let recovered = false;
|
|
@@ -1296,10 +1417,18 @@ export function sendPdfGenerate(session, options, timeoutMs = 30_000) {
|
|
|
1296
1417
|
}
|
|
1297
1418
|
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
1298
1419
|
export function sendNavigate(session, url, timeoutMs = 15_000) {
|
|
1299
|
-
return
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1420
|
+
return (async () => {
|
|
1421
|
+
const result = await sendAndWaitForUpdate(session, {
|
|
1422
|
+
type: 'navigate',
|
|
1423
|
+
url,
|
|
1424
|
+
}, timeoutMs, { requireUpdateOnAck: true });
|
|
1425
|
+
safeRecordSessionSnapshot(session, 'session.navigate', {
|
|
1426
|
+
requestedUrl: url,
|
|
1427
|
+
status: result.status,
|
|
1428
|
+
result: result.result ?? null,
|
|
1429
|
+
});
|
|
1430
|
+
return result;
|
|
1431
|
+
})();
|
|
1303
1432
|
}
|
|
1304
1433
|
/**
|
|
1305
1434
|
* 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.58.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
|
},
|