@remcp/runtime 0.2.153 → 0.2.155

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.
@@ -0,0 +1,413 @@
1
+ import process from 'node:process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { isIP } from 'node:net';
5
+ import { lookup } from 'node:dns/promises';
6
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
7
+ import { resolveSafePath } from '../util.mjs';
8
+ import { preserveInterruptedBrowserSession } from './browser-session-backup.mjs';
9
+ import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
10
+
11
+
12
+ import { MAX_CDP_MESSAGE_BYTES, PRODUCTION_SESSION_IDLE_MS, assertResolvedBrowserUrl, assertResolvedPageUrl, chooseTarget, endpointUrl, ensureBrowserEndpoint, productionBrowserSessions, requestJson, validatedTargetWebSocketUrl } from './browser-endpoint.mjs';
13
+
14
+ export class CdpSession {
15
+ constructor(url, timeoutMs) {
16
+ this.url = url;
17
+ this.timeoutMs = timeoutMs;
18
+ this.socket = null;
19
+ this.nextId = 1;
20
+ this.pending = new Map();
21
+ this.waiters = new Map();
22
+ this.observers = new Map();
23
+ this.closed = true;
24
+ }
25
+
26
+ async open() {
27
+ if (typeof WebSocket !== 'function') throw new Error('This Node.js runtime does not provide WebSocket support');
28
+ await new Promise((resolve, reject) => {
29
+ const socket = new WebSocket(this.url);
30
+ const timer = setTimeout(() => {
31
+ try { socket.close(); } catch {}
32
+ reject(new Error('CDP websocket connection timed out'));
33
+ }, this.timeoutMs);
34
+ socket.addEventListener('open', () => { clearTimeout(timer); this.socket = socket; this.closed = false; resolve(); }, { once: true });
35
+ socket.addEventListener('error', event => { clearTimeout(timer); try { socket.close(); } catch {}; reject(new Error(event?.message || 'CDP websocket error')); }, { once: true });
36
+ socket.addEventListener('message', event => this.onMessage(event));
37
+ socket.addEventListener('close', () => this.onClose());
38
+ });
39
+ return this;
40
+ }
41
+
42
+ onMessage(event) {
43
+ const raw = String(event.data);
44
+ if (Buffer.byteLength(raw, 'utf8') > MAX_CDP_MESSAGE_BYTES) {
45
+ this.close();
46
+ return;
47
+ }
48
+ let message;
49
+ try { message = JSON.parse(raw); } catch { return; }
50
+ const sessionId = String(message.sessionId || '');
51
+ const pendingKey = message.id ? `${sessionId}:${message.id}` : '';
52
+ if (message.id && this.pending.has(pendingKey)) {
53
+ const pending = this.pending.get(pendingKey);
54
+ this.pending.delete(pendingKey);
55
+ clearTimeout(pending.timer);
56
+ if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
57
+ else pending.resolve(message.result || {});
58
+ return;
59
+ }
60
+ const observers = this.observers.get(message.method);
61
+ if (observers) {
62
+ for (const observer of [...observers]) {
63
+ try { observer(message.params || {}, sessionId); } catch {}
64
+ }
65
+ }
66
+ const listeners = this.waiters.get(message.method);
67
+ if (!listeners) return;
68
+ this.waiters.delete(message.method);
69
+ for (const listener of listeners) {
70
+ clearTimeout(listener.timer);
71
+ listener.resolve(message.params || {});
72
+ }
73
+ }
74
+
75
+ onClose() {
76
+ this.closed = true;
77
+ for (const pending of this.pending.values()) {
78
+ clearTimeout(pending.timer);
79
+ pending.reject(new Error('CDP websocket closed'));
80
+ }
81
+ this.pending.clear();
82
+ for (const listeners of this.waiters.values()) {
83
+ for (const listener of listeners) {
84
+ clearTimeout(listener.timer);
85
+ listener.reject(new Error('CDP websocket closed'));
86
+ }
87
+ }
88
+ this.waiters.clear();
89
+ this.observers.clear();
90
+ }
91
+
92
+ observe(method, callback) {
93
+ const listeners = this.observers.get(method) || new Set();
94
+ listeners.add(callback);
95
+ this.observers.set(method, listeners);
96
+ return () => {
97
+ const current = this.observers.get(method);
98
+ if (!current) return;
99
+ current.delete(callback);
100
+ if (!current.size) this.observers.delete(method);
101
+ };
102
+ }
103
+
104
+ send(method, params = {}, sessionId = '', timeoutMs = this.timeoutMs) {
105
+ if (this.closed || !this.socket || this.socket.readyState !== 1) {
106
+ return Promise.reject(new Error('CDP websocket is not open'));
107
+ }
108
+ const id = this.nextId++;
109
+ const key = `${sessionId}:${id}`;
110
+ return new Promise((resolve, reject) => {
111
+ const timer = setTimeout(() => {
112
+ this.pending.delete(key);
113
+ reject(new Error(`${method} timed out`));
114
+ }, timeoutMs);
115
+ this.pending.set(key, { resolve, reject, timer });
116
+ this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
117
+ });
118
+ }
119
+
120
+ waitFor(method, timeoutMs = this.timeoutMs) {
121
+ return new Promise((resolve, reject) => {
122
+ const listeners = this.waiters.get(method) || [];
123
+ const listener = { resolve, reject, timer: null };
124
+ listener.timer = setTimeout(() => {
125
+ const current = this.waiters.get(method) || [];
126
+ const next = current.filter(item => item !== listener);
127
+ if (next.length) this.waiters.set(method, next); else this.waiters.delete(method);
128
+ reject(new Error(`${method} timed out`));
129
+ }, timeoutMs);
130
+ listeners.push(listener);
131
+ this.waiters.set(method, listeners);
132
+ });
133
+ }
134
+
135
+ close() {
136
+ if (this.closed) return;
137
+ try { this.socket?.close(); } catch {}
138
+ this.onClose();
139
+ }
140
+ }
141
+
142
+ export async function installBrowserRequestGuard(session, { closeRootTarget = async () => {} } = {}) {
143
+ const guardedSessions = new Set();
144
+ const childSetup = new Map();
145
+ const enableSession = async (sessionId, { autoAttach = false } = {}) => {
146
+ if (guardedSessions.has(sessionId)) return;
147
+ try {
148
+ await session.send('Network.enable', {}, sessionId, 2000);
149
+ await session.send('Network.setBlockedURLs', { urls:['ws://*', 'wss://*'] }, sessionId, 2000);
150
+ await session.send('Fetch.enable', { patterns:[{ urlPattern:'*' }] }, sessionId, 2000);
151
+ if (autoAttach) await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, sessionId, 2000);
152
+ guardedSessions.add(sessionId);
153
+ } catch (error) {
154
+ await session.send('Fetch.disable', {}, sessionId, 1000).catch(() => {});
155
+ throw error;
156
+ }
157
+ };
158
+ const guardChild = params => {
159
+ const childSessionId = String(params?.sessionId || '');
160
+ if (!childSessionId) return Promise.resolve();
161
+ if (childSetup.has(childSessionId)) return childSetup.get(childSessionId);
162
+ const setup = (async () => {
163
+ try {
164
+ await enableSession(childSessionId, { autoAttach:process.env.NODE_ENV === 'production' });
165
+ await session.send('Runtime.runIfWaitingForDebugger', {}, childSessionId, 2000);
166
+ } catch (error) {
167
+ const targetId = params?.targetInfo?.targetId;
168
+ let closed = false;
169
+ if (targetId) {
170
+ try {
171
+ await session.send('Target.closeTarget', { targetId }, '', 2000);
172
+ closed = true;
173
+ } catch {}
174
+ }
175
+ if (!closed) throw new Error(`Browser child guard failed: ${error instanceof Error ? error.message : String(error)}`);
176
+ }
177
+ })();
178
+ childSetup.set(childSessionId, setup);
179
+ return setup;
180
+ };
181
+ const removePaused = session.observe('Fetch.requestPaused', (params, eventSessionId) => {
182
+ const requestId = params?.requestId;
183
+ const requestUrl = params?.request?.url || '';
184
+ void (async () => {
185
+ let allowed = requestUrl === 'about:blank';
186
+ if (!allowed) {
187
+ try { await assertResolvedBrowserUrl(requestUrl, 'browser request'); allowed = true; }
188
+ catch {}
189
+ }
190
+ if (!requestId) return;
191
+ try {
192
+ if (allowed) await session.send('Fetch.continueRequest', { requestId }, eventSessionId);
193
+ else await session.send('Fetch.failRequest', { requestId, errorReason:'BlockedByClient' }, eventSessionId);
194
+ } catch {}
195
+ })();
196
+ });
197
+ const removeAttached = session.observe('Target.attachedToTarget', params => {
198
+ void guardChild(params).catch(async () => {
199
+ await closeRootTarget().catch(() => {});
200
+ session.close();
201
+ });
202
+ });
203
+ await enableSession('');
204
+ if (process.env.NODE_ENV === 'production') {
205
+ await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, '', 2000);
206
+ let existing;
207
+ try {
208
+ existing = await session.send('Target.getTargets', {}, '', 2000);
209
+ } catch (error) {
210
+ throw new Error(`Could not enumerate browser child targets safely: ${error instanceof Error ? error.message : String(error)}`);
211
+ }
212
+ for (const targetInfo of existing.targetInfos || []) {
213
+ if (!['worker', 'service_worker', 'shared_worker', 'iframe', 'webview'].includes(targetInfo.type)) continue;
214
+ const attached = await session.send('Target.attachToTarget', { targetId:targetInfo.targetId, flatten:true }, '', 2000).catch(async error => {
215
+ await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
216
+ throw new Error(`Could not attach browser child target safely: ${error instanceof Error ? error.message : String(error)}`);
217
+ });
218
+ if (attached?.sessionId) await guardChild({ sessionId:attached.sessionId, targetInfo });
219
+ else {
220
+ await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
221
+ throw new Error('Browser child target did not expose a guard session');
222
+ }
223
+ }
224
+ }
225
+ return () => {
226
+ childSetup.clear();
227
+ removePaused();
228
+ removeAttached();
229
+ };
230
+ }
231
+
232
+ export async function closeBrowserTarget(endpoint, targetId) {
233
+ if (!targetId) return false;
234
+ try {
235
+ const base = endpoint instanceof URL ? endpoint.href : endpoint;
236
+ await requestJson(`/json/close/${encodeURIComponent(String(targetId))}`, base, { timeout_ms:2000 });
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ export function productionSessionKey(endpoint, target) {
244
+ return `${endpoint.origin}|${String(target.id)}`;
245
+ }
246
+
247
+ export function closeProductionSession(key, record) {
248
+ if (!record) return;
249
+ if (record.timer) clearTimeout(record.timer);
250
+ record.removeGuard();
251
+ if (record.ownedTarget && record.targetId) {
252
+ record.session.send('Target.closeTarget', { targetId:record.targetId }, '', 2000).catch(() => {});
253
+ if (record.endpoint) void closeBrowserTarget(record.endpoint, record.targetId);
254
+ }
255
+ record.session.send('Fetch.disable').catch(() => {});
256
+ record.session.close();
257
+ if (productionBrowserSessions.get(key) === record) productionBrowserSessions.delete(key);
258
+ }
259
+
260
+ export function retainProductionSession(key, session, removeGuard, targetId, endpoint, ownedTarget = false) {
261
+ const existing = productionBrowserSessions.get(key);
262
+ if (existing) closeProductionSession(key, existing);
263
+ const record = { session, removeGuard, targetId, endpoint, ownedTarget, timer:null };
264
+ productionBrowserSessions.set(key, record);
265
+ const schedule = () => {
266
+ if (record.timer) clearTimeout(record.timer);
267
+ record.timer = setTimeout(() => closeProductionSession(key, record), PRODUCTION_SESSION_IDLE_MS);
268
+ record.timer.unref?.();
269
+ };
270
+ record.schedule = schedule;
271
+ schedule();
272
+ return record;
273
+ }
274
+
275
+ export async function createGuardedNewTab(url, args) {
276
+ await ensureBrowserEndpoint(args.endpoint);
277
+ const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
278
+ if (!created?.id) throw new Error('Browser did not return a new page target');
279
+ const endpoint = endpointUrl(args.endpoint);
280
+ let target;
281
+ try {
282
+ target = { ...created, webSocketDebuggerUrl: validatedTargetWebSocketUrl(created, endpoint) };
283
+ } catch (error) {
284
+ await closeBrowserTarget(endpoint, created.id);
285
+ throw error;
286
+ }
287
+ let session;
288
+ try {
289
+ session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
290
+ } catch (error) {
291
+ await closeBrowserTarget(endpoint, target.id);
292
+ throw error;
293
+ }
294
+ let removeGuard = () => {};
295
+ let productionRecord = null;
296
+ let productionKey = '';
297
+ let operationFailed = false;
298
+ try {
299
+ removeGuard = await installBrowserRequestGuard(session, { closeRootTarget:() => closeBrowserTarget(endpoint, target.id) });
300
+ if (process.env.NODE_ENV === 'production') {
301
+ productionKey = productionSessionKey(endpoint, target);
302
+ productionRecord = retainProductionSession(productionKey, session, removeGuard, target.id, endpoint, true);
303
+ }
304
+ await session.send('Page.enable');
305
+ const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
306
+ const navigation = await session.send('Page.navigate', { url });
307
+ if (loaded) await loaded;
308
+ const finalUrl = await evaluate(session, 'location.href');
309
+ await assertResolvedPageUrl(finalUrl);
310
+ return { target, navigation, finalUrl };
311
+ } catch (error) {
312
+ operationFailed = true;
313
+ if (!productionRecord) await closeBrowserTarget(endpoint, target.id);
314
+ throw error;
315
+ } finally {
316
+ if (productionRecord) {
317
+ if (operationFailed) closeProductionSession(productionKey, productionRecord);
318
+ else productionRecord.schedule();
319
+ }
320
+ else {
321
+ removeGuard();
322
+ await session.send('Fetch.disable').catch(() => {});
323
+ session.close();
324
+ }
325
+ }
326
+ }
327
+
328
+ export async function withTarget(args, callback) {
329
+ const target = await chooseTarget(args);
330
+ await assertResolvedPageUrl(target.url);
331
+ const endpoint = endpointUrl(args.endpoint);
332
+ const production = process.env.NODE_ENV === 'production';
333
+ const key = production ? productionSessionKey(endpoint, target) : '';
334
+ let record = production ? productionBrowserSessions.get(key) : null;
335
+ if (record?.session?.closed || (record?.session?.socket && record.session.socket.readyState !== 1)) {
336
+ closeProductionSession(key, record);
337
+ record = null;
338
+ }
339
+ let session = record?.session;
340
+ let removeGuard = record?.removeGuard || (() => {});
341
+ if (!session) {
342
+ try {
343
+ session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
344
+ removeGuard = await installBrowserRequestGuard(session);
345
+ if (production) record = retainProductionSession(key, session, removeGuard, target.id, endpoint);
346
+ } catch (error) {
347
+ removeGuard();
348
+ if (session) {
349
+ await session.send('Fetch.disable').catch(() => {});
350
+ session.close();
351
+ }
352
+ throw error;
353
+ }
354
+ }
355
+ let operationFailed = false;
356
+ try {
357
+ const currentUrl = await evaluate(session, 'location.href');
358
+ await assertResolvedPageUrl(currentUrl);
359
+ const result = await callback(session, target);
360
+ const finalUrl = await evaluate(session, 'location.href').catch(() => { throw new Error('Browser page URL could not be verified after the operation'); });
361
+ await assertResolvedPageUrl(finalUrl);
362
+ return result;
363
+ } catch (error) {
364
+ operationFailed = true;
365
+ throw error;
366
+ } finally {
367
+ if (production && record) {
368
+ if (operationFailed) closeProductionSession(key, record);
369
+ else record.schedule();
370
+ }
371
+ else {
372
+ removeGuard();
373
+ await session.send('Fetch.disable').catch(() => {});
374
+ session.close();
375
+ }
376
+ }
377
+ }
378
+
379
+ export async function evaluate(session, expression, awaitPromise = true) {
380
+ const result = await session.send('Runtime.evaluate', {
381
+ expression,
382
+ returnByValue: true,
383
+ awaitPromise,
384
+ userGesture: true,
385
+ });
386
+ if (result.exceptionDetails) {
387
+ throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'JavaScript evaluation failed');
388
+ }
389
+ return result.result?.value;
390
+ }
391
+
392
+ export async function callPageFunction(session, functionDeclaration, args = [], awaitPromise = true) {
393
+ const global = await session.send('Runtime.evaluate', {
394
+ expression: 'globalThis',
395
+ returnByValue: false,
396
+ awaitPromise: false,
397
+ });
398
+ if (global.exceptionDetails || !global.result?.objectId) {
399
+ throw new Error(global.exceptionDetails?.exception?.description || global.exceptionDetails?.text || 'Could not access the page execution context');
400
+ }
401
+ const result = await session.send('Runtime.callFunctionOn', {
402
+ objectId: global.result.objectId,
403
+ functionDeclaration,
404
+ arguments: args.map(value => ({ value })),
405
+ returnByValue: true,
406
+ awaitPromise,
407
+ userGesture: true,
408
+ });
409
+ if (result.exceptionDetails) {
410
+ throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'JavaScript function call failed');
411
+ }
412
+ return result.result?.value;
413
+ }