@deeeed/metamask-harness 0.41.1 → 0.43.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +12 -0
  3. package/adapters/manifest.json +8 -0
  4. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +108 -10
  5. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +117 -16
  6. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  7. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +921 -0
  8. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  9. package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
  10. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
  11. package/adapters/mobile/reload-app.mjs +67 -0
  12. package/adapters/mobile/start-console-forwarder.sh +22 -3
  13. package/adapters/mobile/start-metro.sh +6 -11
  14. package/adapters/mobile/stop-metro.sh +10 -1
  15. package/adapters/shared/open-debug.mjs +172 -2
  16. package/dist/adapters/extension/browser-cdp.js +174 -0
  17. package/dist/adapters/extension/network-observer.js +209 -0
  18. package/dist/adapters/extension/performance-observer.js +75 -0
  19. package/dist/adapters/mobile/frame-metrics.js +45 -0
  20. package/dist/adapters/mobile/metro-env.js +0 -5
  21. package/dist/adapters/mobile/performance-observer.js +43 -0
  22. package/dist/adapters/mobile/prepare.js +1 -3
  23. package/dist/adapters/mobile/runtime-decision.js +6 -9
  24. package/dist/adapters/performance/cdp-trace.js +342 -0
  25. package/dist/adapters/performance/js-task-metrics.js +35 -0
  26. package/dist/adapters.js +29 -1
  27. package/dist/artifact-files.js +92 -0
  28. package/dist/async.js +19 -0
  29. package/dist/cli-commands.js +6 -3
  30. package/dist/cli.js +4 -0
  31. package/dist/command-contract.js +3 -0
  32. package/dist/commands/call.js +66 -20
  33. package/dist/commands/reload.js +80 -0
  34. package/dist/commands/run-engine.js +18 -0
  35. package/dist/commands/run.js +68 -22
  36. package/dist/mm-harness-cli.js +17 -1
  37. package/dist/network-observation.js +283 -0
  38. package/dist/performance-observation.js +465 -0
  39. package/docs/NETWORK-CAPTURE.md +98 -0
  40. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  41. package/docs/RECIPES.md +17 -0
  42. package/library/actions/mobile/app/network_assert.mjs +14 -0
  43. package/library/actions/mobile/app/network_capture.mjs +72 -0
  44. package/library/actions/mobile/platform/bridge.mjs +10 -2
  45. package/library/actions/shared/app/network-artifact.mjs +10 -0
  46. package/library/actions/shared/app/network-assert.mjs +154 -0
  47. package/library/manifests/extension.action-manifest.json +173 -0
  48. package/library/manifests/mobile.action-manifest.json +204 -0
  49. package/library/recipes/mobile/perps/performance.recipe.json +11 -11
  50. package/package.json +1 -1
  51. package/scripts/completions.sh +2 -1
  52. package/scripts/site-contrast.mjs +43 -27
@@ -0,0 +1,921 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { createHash, randomUUID } = require('node:crypto');
5
+ const net = require('node:net');
6
+ const path = require('node:path');
7
+
8
+ const DEFAULT_MAX_REQUESTS = 1000;
9
+ const DEFAULT_MAX_DURATION_MS = 5 * 60 * 1000;
10
+ const MAX_CAPTURE_DURATION_MS = 60 * 60 * 1000;
11
+ const MAX_ACTIVE_CAPTURES = 16;
12
+ const MAX_COMPLETED_CAPTURES = 16;
13
+ const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
14
+ const MAX_POST_DATA_BYTES = 64 * 1024;
15
+ const MAX_REQUEST_FRAME_BYTES = 8 * 1024 * 1024;
16
+ const MAX_RESPONSE_FRAME_BYTES = 16 * 1024 * 1024;
17
+ const MAX_RETAINED_STRING_LENGTH = 256;
18
+ const MAX_BROKER_TIMEOUT_MS = 60_000;
19
+ const SENSITIVE_FIELD =
20
+ /(?:address|authorization|cookie|key|password|secret|token|user|account)/iu;
21
+ const SENSITIVE_VALUE =
22
+ /(?:0x[a-f0-9]{40,}|[a-f0-9]{64,}|eyJ[a-z0-9_-]{20,}\.[a-z0-9_-]{20,})/iu;
23
+
24
+ function brokerSocketPath(runtimeDir, endpointIdentity) {
25
+ const runtimePath = path.resolve(
26
+ runtimeDir ||
27
+ process.env.RECIPE_RUNTIME_DIR ||
28
+ path.join('temp', 'recipe', 'runtime'),
29
+ );
30
+ const socketIdentity =
31
+ endpointIdentity === undefined || endpointIdentity === null
32
+ ? runtimePath
33
+ : `${runtimePath}\u0000${String(endpointIdentity)}`;
34
+ const identity = createHash('sha256')
35
+ .update(socketIdentity)
36
+ .digest('hex')
37
+ .slice(0, 20);
38
+ return path.join('/tmp', `mmh-cdp-${identity}.sock`);
39
+ }
40
+
41
+ function deviceIdFromUrl(wsUrl) {
42
+ const match = /[?&]device=([^&]+)/u.exec(wsUrl || '');
43
+ return match ? match[1] : wsUrl;
44
+ }
45
+
46
+ function safePrimitive(value) {
47
+ if (value === null || ['number', 'boolean'].includes(typeof value)) {
48
+ return value;
49
+ }
50
+ if (
51
+ typeof value !== 'string' ||
52
+ value.length > MAX_RETAINED_STRING_LENGTH ||
53
+ SENSITIVE_VALUE.test(value)
54
+ ) {
55
+ return undefined;
56
+ }
57
+ return value;
58
+ }
59
+
60
+ function sanitizePathname(pathname) {
61
+ const segments = String(pathname)
62
+ .split('/')
63
+ .map((segment) =>
64
+ segment.length > 64 || SENSITIVE_VALUE.test(segment)
65
+ ? '<redacted>'
66
+ : segment,
67
+ );
68
+ return segments.join('/').slice(0, 1024);
69
+ }
70
+
71
+ function boundedError(error) {
72
+ return String(error?.message || error || 'unknown error').slice(
73
+ 0,
74
+ MAX_RETAINED_STRING_LENGTH,
75
+ );
76
+ }
77
+
78
+ function boundedTimeout(value, fallback = 10_000) {
79
+ const timeout = Number(value);
80
+ return Number.isFinite(timeout) && timeout > 0
81
+ ? Math.min(MAX_BROKER_TIMEOUT_MS, timeout)
82
+ : fallback;
83
+ }
84
+
85
+ // State recovery is event-driven. Pick a fixed bound below the caller's RPC
86
+ // deadline so the broker always answers before the client gives up. Returning
87
+ // literals also keeps the timer independent from untrusted request values.
88
+ function stateWaitTimeout(value) {
89
+ const timeout = boundedTimeout(value);
90
+ if (timeout <= 500) return 1;
91
+ if (timeout <= 1_000) return 400;
92
+ if (timeout <= 2_000) return 900;
93
+ if (timeout <= 5_000) return 1_900;
94
+ if (timeout <= 10_000) return 4_500;
95
+ if (timeout <= 15_000) return 9_500;
96
+ if (timeout <= 30_000) return 14_500;
97
+ if (timeout <= 45_000) return 29_500;
98
+ if (timeout <= 55_000) return 44_500;
99
+ return 54_500;
100
+ }
101
+
102
+ function readField(value, field) {
103
+ let current = value;
104
+ for (const segment of String(field).split('.')) {
105
+ if (
106
+ !current ||
107
+ typeof current !== 'object' ||
108
+ !Object.hasOwn(current, segment)
109
+ ) {
110
+ return { found: false };
111
+ }
112
+ current = current[segment];
113
+ }
114
+ return { found: true, value: safePrimitive(current) };
115
+ }
116
+
117
+ function sanitizeNetworkRequest(request, capture, now) {
118
+ let url;
119
+ try {
120
+ url = new URL(request.url);
121
+ } catch {
122
+ return null;
123
+ }
124
+ if (
125
+ capture.urlIncludes.length > 0 &&
126
+ !capture.urlIncludes.some((value) => request.url.includes(value))
127
+ ) {
128
+ return null;
129
+ }
130
+ if (
131
+ capture.methods.length > 0 &&
132
+ !capture.methods.includes(String(request.method).toUpperCase())
133
+ ) {
134
+ return null;
135
+ }
136
+
137
+ let body = null;
138
+ let bodyInspectable = capture.bodyJsonFields.length === 0;
139
+ if (
140
+ capture.bodyJsonFields.length > 0 &&
141
+ request.postData &&
142
+ Buffer.byteLength(request.postData) <= MAX_POST_DATA_BYTES
143
+ ) {
144
+ try {
145
+ const parsed = JSON.parse(request.postData);
146
+ bodyInspectable = true;
147
+ const entries = [];
148
+ for (const field of capture.bodyJsonFields) {
149
+ const retained = readField(parsed, field);
150
+ if (!retained.found) continue;
151
+ if (retained.value === undefined) {
152
+ bodyInspectable = false;
153
+ continue;
154
+ }
155
+ entries.push([field, retained.value]);
156
+ }
157
+ body = Object.fromEntries(entries);
158
+ } catch {
159
+ body = null;
160
+ }
161
+ }
162
+
163
+ return {
164
+ record: {
165
+ elapsedMs: now - capture.startedAtEpochMs,
166
+ host: url.hostname,
167
+ path: sanitizePathname(url.pathname),
168
+ method: String(request.method || 'GET').toUpperCase(),
169
+ ...(body && Object.keys(body).length > 0 ? { body } : {}),
170
+ },
171
+ bodyInspectable,
172
+ };
173
+ }
174
+
175
+ function countsBy(records, selector) {
176
+ const result = Object.create(null);
177
+ for (const record of records) {
178
+ const key = String(selector(record) ?? 'unknown');
179
+ result[key] = (result[key] || 0) + 1;
180
+ }
181
+ return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b)));
182
+ }
183
+
184
+ function normalizeCapture(input) {
185
+ const id = String(input.id || '').trim();
186
+ if (!/^[a-z0-9._-]{1,100}$/iu.test(id)) {
187
+ throw new Error('Network capture id is invalid');
188
+ }
189
+ const normalizeList = (value, name) => {
190
+ const values = Array.isArray(value)
191
+ ? [...new Set(value.map(String).filter(Boolean))]
192
+ : [];
193
+ if (values.length > 20 || values.some((entry) => entry.length > 256)) {
194
+ throw new Error(`Network capture ${name} exceeds its bound`);
195
+ }
196
+ return values;
197
+ };
198
+ const bodyJsonFields = normalizeList(
199
+ input.bodyJsonFields,
200
+ 'bodyJsonFields',
201
+ );
202
+ if (bodyJsonFields.some((field) => SENSITIVE_FIELD.test(field))) {
203
+ throw new Error('Network capture body field is sensitive');
204
+ }
205
+ const maxRequests = Number(input.maxRequests ?? DEFAULT_MAX_REQUESTS);
206
+ if (!Number.isInteger(maxRequests) || maxRequests < 1 || maxRequests > 10_000) {
207
+ throw new Error('Network capture maxRequests must be between 1 and 10000');
208
+ }
209
+ const maxDurationMs = Number(
210
+ input.maxDurationMs ?? DEFAULT_MAX_DURATION_MS,
211
+ );
212
+ if (
213
+ !Number.isInteger(maxDurationMs) ||
214
+ maxDurationMs < 1 ||
215
+ maxDurationMs > MAX_CAPTURE_DURATION_MS
216
+ ) {
217
+ throw new Error('Network capture maxDurationMs is invalid');
218
+ }
219
+ return {
220
+ id,
221
+ startedAtEpochMs: Date.now(),
222
+ urlIncludes: normalizeList(input.urlIncludes, 'urlIncludes'),
223
+ methods: normalizeList(input.methods, 'methods').map((value) =>
224
+ value.toUpperCase(),
225
+ ),
226
+ bodyJsonFields,
227
+ maxRequests,
228
+ maxDurationMs,
229
+ requests: [],
230
+ retainedBytes: 0,
231
+ uninspectableBodyRequests: 0,
232
+ droppedRequests: 0,
233
+ reconnects: 0,
234
+ partial: false,
235
+ networkEnabled: false,
236
+ enableErrors: [],
237
+ endedAtEpochMs: null,
238
+ };
239
+ }
240
+
241
+ function brokerOwnerPath(socketPath) {
242
+ return `${socketPath}.owner`;
243
+ }
244
+
245
+ function readBrokerOwner(ownerPath) {
246
+ let source;
247
+ try {
248
+ source = fs.readFileSync(ownerPath, 'utf8');
249
+ } catch (error) {
250
+ if (error.code !== 'EISDIR') return null;
251
+ try {
252
+ source = fs.readFileSync(path.join(ownerPath, 'owner.json'), 'utf8');
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+ try {
258
+ const value = JSON.parse(source);
259
+ return Number.isInteger(value.pid) && typeof value.token === 'string'
260
+ ? value
261
+ : null;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ function processIsAlive(pid) {
268
+ if (!Number.isInteger(pid) || pid < 1) return false;
269
+ try {
270
+ process.kill(pid, 0);
271
+ return true;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ function claimBrokerSocket(socketPath) {
278
+ const ownerPath = brokerOwnerPath(socketPath);
279
+ const owner = { pid: process.pid, token: randomUUID() };
280
+ const candidatePath = `${ownerPath}.claim-${owner.pid}-${owner.token}`;
281
+ fs.mkdirSync(candidatePath, { mode: 0o700 });
282
+ fs.writeFileSync(
283
+ path.join(candidatePath, 'owner.json'),
284
+ `${JSON.stringify(owner)}\n`,
285
+ { flag: 'wx', mode: 0o600 },
286
+ );
287
+ let claimed = false;
288
+ for (let attempt = 0; attempt < 10 && !claimed; attempt += 1) {
289
+ try {
290
+ fs.renameSync(candidatePath, ownerPath);
291
+ claimed = true;
292
+ } catch (error) {
293
+ if (!['EEXIST', 'ENOTEMPTY', 'ENOTDIR', 'EISDIR'].includes(error.code)) {
294
+ fs.rmSync(candidatePath, { recursive: true, force: true });
295
+ throw error;
296
+ }
297
+ const previousOwner = readBrokerOwner(ownerPath);
298
+ if (previousOwner && processIsAlive(previousOwner.pid)) {
299
+ fs.rmSync(candidatePath, { recursive: true, force: true });
300
+ throw new Error(
301
+ `CDP broker socket is owned by process ${previousOwner.pid}`,
302
+ );
303
+ }
304
+ const stalePath = `${ownerPath}.stale-${randomUUID()}`;
305
+ try {
306
+ fs.renameSync(ownerPath, stalePath);
307
+ fs.rmSync(stalePath, { recursive: true, force: true });
308
+ } catch (staleError) {
309
+ if (staleError.code !== 'ENOENT') {
310
+ fs.rmSync(candidatePath, { recursive: true, force: true });
311
+ throw staleError;
312
+ }
313
+ }
314
+ }
315
+ }
316
+ if (!claimed) {
317
+ fs.rmSync(candidatePath, { recursive: true, force: true });
318
+ throw new Error('CDP broker socket ownership could not be claimed');
319
+ }
320
+ try {
321
+ fs.unlinkSync(socketPath);
322
+ } catch (error) {
323
+ if (error.code !== 'ENOENT') {
324
+ fs.rmSync(ownerPath, { recursive: true, force: true });
325
+ throw error;
326
+ }
327
+ }
328
+ return { ownerPath, owner };
329
+ }
330
+
331
+ function releaseBrokerSocket(socketPath, ownership) {
332
+ const currentOwner = readBrokerOwner(ownership.ownerPath);
333
+ if (
334
+ currentOwner?.pid !== ownership.owner.pid ||
335
+ currentOwner?.token !== ownership.owner.token
336
+ ) {
337
+ return;
338
+ }
339
+ try {
340
+ fs.unlinkSync(socketPath);
341
+ } catch {}
342
+ try {
343
+ fs.rmSync(ownership.ownerPath, { recursive: true, force: true });
344
+ } catch {}
345
+ }
346
+
347
+ function createCdpBroker({
348
+ socketPath,
349
+ sessions,
350
+ sendCommand,
351
+ requestDiscovery,
352
+ onClientActivity,
353
+ }) {
354
+ const captures = new Map();
355
+ const completedCaptures = new Map();
356
+ const clients = new Set();
357
+ const subscriptions = new Map();
358
+ const knownTargets = new Map(
359
+ [...sessions.entries()].flatMap(([deviceId, session]) =>
360
+ session?.brokerReady
361
+ ? [[deviceId, {
362
+ deviceId,
363
+ generation: 1,
364
+ name: String(session.name || ''),
365
+ ready: true,
366
+ }]]
367
+ : [],
368
+ ),
369
+ );
370
+ const sessionWaiters = new Map();
371
+ const targetWaiters = new Set();
372
+ const observedSessions = new Map(
373
+ [...sessions.entries()].flatMap(([deviceId, session]) =>
374
+ session?.brokerReady ? [[deviceId, session]] : [],
375
+ ),
376
+ );
377
+
378
+ const capturesFor = (deviceId) => {
379
+ if (!captures.has(deviceId)) captures.set(deviceId, new Map());
380
+ return captures.get(deviceId);
381
+ };
382
+
383
+ const completedCapturesFor = (deviceId) => {
384
+ if (!completedCaptures.has(deviceId)) {
385
+ completedCaptures.set(deviceId, new Map());
386
+ }
387
+ return completedCaptures.get(deviceId);
388
+ };
389
+
390
+ function targetList({ nameIncludes = '', readyOnly = false } = {}) {
391
+ const pin = String(nameIncludes).trim().toLowerCase();
392
+ return [...knownTargets.values()]
393
+ .filter((target) => !readyOnly || target.ready)
394
+ .filter((target) => !pin || target.name.toLowerCase().includes(pin))
395
+ .map((target) => ({ ...target }))
396
+ .sort((a, b) => a.name.localeCompare(b.name));
397
+ }
398
+
399
+ function settleTargetWaiters() {
400
+ for (const waiter of [...targetWaiters]) {
401
+ const targets = targetList({ nameIncludes: waiter.nameIncludes });
402
+ if (targets.length === 0) continue;
403
+ targetWaiters.delete(waiter);
404
+ clearTimeout(waiter.timer);
405
+ waiter.resolve(targets);
406
+ }
407
+ }
408
+
409
+ function resolveTargets(params, timeoutMs, socket) {
410
+ const existing = targetList({ nameIncludes: params.nameIncludes });
411
+ if (existing.length > 0) return Promise.resolve(existing);
412
+ requestDiscovery?.('');
413
+ return new Promise((resolve, reject) => {
414
+ const waiter = {
415
+ nameIncludes: String(params.nameIncludes || ''),
416
+ resolve,
417
+ reject,
418
+ socket,
419
+ timer: null,
420
+ };
421
+ waiter.timer = setTimeout(() => {
422
+ targetWaiters.delete(waiter);
423
+ reject(new Error('CDP broker never observed the requested target'));
424
+ }, stateWaitTimeout(timeoutMs));
425
+ targetWaiters.add(waiter);
426
+ settleTargetWaiters();
427
+ });
428
+ }
429
+
430
+ function waitForSession(deviceId, timeoutMs, socket) {
431
+ if (sessions.get(deviceId)?.brokerReady) return sessions.get(deviceId);
432
+ requestDiscovery?.(deviceId);
433
+ return new Promise((resolve, reject) => {
434
+ const waiters = sessionWaiters.get(deviceId) || new Set();
435
+ const waiter = {
436
+ resolve,
437
+ reject,
438
+ socket,
439
+ timer: setTimeout(() => {
440
+ waiters.delete(waiter);
441
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
442
+ reject(new Error(`CDP broker target unavailable: ${deviceId}`));
443
+ }, stateWaitTimeout(timeoutMs)),
444
+ };
445
+ waiters.add(waiter);
446
+ sessionWaiters.set(deviceId, waiters);
447
+ const session = sessions.get(deviceId);
448
+ if (session?.brokerReady) {
449
+ waiters.delete(waiter);
450
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
451
+ clearTimeout(waiter.timer);
452
+ resolve(session);
453
+ }
454
+ });
455
+ }
456
+
457
+ async function enableNetwork(
458
+ deviceId,
459
+ capture,
460
+ timeoutMs = 10_000,
461
+ socket,
462
+ ) {
463
+ try {
464
+ const budgetMs = boundedTimeout(timeoutMs);
465
+ const deadline = Date.now() + budgetMs;
466
+ const session = await waitForSession(deviceId, budgetMs, socket);
467
+ const remainingMs = Math.max(1, deadline - Date.now());
468
+ await sendCommand(session, 'Network.enable', {}, remainingMs);
469
+ if (capture.enableErrors.length > 0) capture.partial = true;
470
+ capture.networkEnabled = true;
471
+ } catch (error) {
472
+ if (capture.networkEnabled) capture.partial = true;
473
+ if (capture.enableErrors.length < 20) {
474
+ capture.enableErrors.push(boundedError(error));
475
+ }
476
+ }
477
+ }
478
+
479
+ function finishCapture(deviceId, capture, partial = false) {
480
+ capture.partial ||= partial;
481
+ capture.endedAtEpochMs ??= Date.now();
482
+ capturesFor(deviceId).delete(capture.id);
483
+ }
484
+
485
+ function expireCapture(deviceId, capture) {
486
+ if (capturesFor(deviceId).get(capture.id) !== capture) return;
487
+ finishCapture(deviceId, capture, true);
488
+ const completed = completedCapturesFor(deviceId);
489
+ completed.set(capture.id, capture);
490
+ while (completed.size > MAX_COMPLETED_CAPTURES) {
491
+ completed.delete(completed.keys().next().value);
492
+ }
493
+ }
494
+
495
+ function captureSummary(capture) {
496
+ const bodyType = (record) => record.body?.type ?? 'unknown';
497
+ const endedAtEpochMs = capture.endedAtEpochMs ?? Date.now();
498
+ const exceededMaxDuration =
499
+ endedAtEpochMs - capture.startedAtEpochMs > capture.maxDurationMs;
500
+ return {
501
+ schemaVersion: 1,
502
+ id: capture.id,
503
+ status:
504
+ !capture.networkEnabled
505
+ ? 'unavailable'
506
+ : capture.partial || exceededMaxDuration
507
+ ? 'partial'
508
+ : 'complete',
509
+ startedAtEpochMs: capture.startedAtEpochMs,
510
+ endedAtEpochMs,
511
+ maxDurationMs: capture.maxDurationMs,
512
+ reconnects: capture.reconnects,
513
+ droppedRequests: capture.droppedRequests,
514
+ unavailableReasons: capture.networkEnabled ? [] : capture.enableErrors,
515
+ coverageGapReasons: capture.networkEnabled ? capture.enableErrors : [],
516
+ uninspectableBodyRequests: capture.uninspectableBodyRequests,
517
+ projectedBodyFields: capture.bodyJsonFields,
518
+ totalRequests: capture.requests.length,
519
+ requestsByMethod: countsBy(capture.requests, (record) => record.method),
520
+ requestsByHost: countsBy(capture.requests, (record) => record.host),
521
+ requestsByType: countsBy(capture.requests, bodyType),
522
+ requests: capture.requests,
523
+ };
524
+ }
525
+
526
+ async function control(
527
+ deviceId,
528
+ action,
529
+ params,
530
+ timeoutMs = 10_000,
531
+ socket,
532
+ ) {
533
+ if (action === 'list-targets') {
534
+ return targetList({ readyOnly: true }).map(({ deviceId, name }) => ({
535
+ deviceId,
536
+ name,
537
+ }));
538
+ }
539
+ if (action === 'resolve-targets') {
540
+ return resolveTargets(params, timeoutMs, socket);
541
+ }
542
+ if (action === 'capture-start') {
543
+ const capture = normalizeCapture(params);
544
+ const deviceCaptures = capturesFor(deviceId);
545
+ if (deviceCaptures.size >= MAX_ACTIVE_CAPTURES) {
546
+ throw new Error('Too many active Network captures');
547
+ }
548
+ if (deviceCaptures.has(capture.id)) {
549
+ throw new Error(`Network capture already active: ${capture.id}`);
550
+ }
551
+ completedCapturesFor(deviceId).delete(capture.id);
552
+ deviceCaptures.set(capture.id, capture);
553
+ await enableNetwork(
554
+ deviceId,
555
+ capture,
556
+ Math.max(1, timeoutMs - 500),
557
+ socket,
558
+ );
559
+ return { id: capture.id, status: 'started' };
560
+ }
561
+ if (action === 'capture-end') {
562
+ const id = String(params.id || '').trim();
563
+ const deviceCaptures = capturesFor(deviceId);
564
+ const completed = completedCapturesFor(deviceId);
565
+ const capture = deviceCaptures.get(id) || completed.get(id);
566
+ if (!capture) throw new Error(`Network capture not active: ${id}`);
567
+ if (deviceCaptures.has(id)) finishCapture(deviceId, capture);
568
+ completed.delete(id);
569
+ return captureSummary(capture);
570
+ }
571
+ throw new Error(`Unknown CDP broker control: ${action}`);
572
+ }
573
+
574
+ function writeMessage(socket, message) {
575
+ if (!socket.destroyed) socket.write(`${JSON.stringify(message)}\n`);
576
+ }
577
+
578
+ async function handleRequest(socket, request) {
579
+ try {
580
+ onClientActivity?.();
581
+ if (request.type === 'command') {
582
+ const budgetMs = boundedTimeout(request.timeoutMs);
583
+ const deadline = Date.now() + budgetMs;
584
+ const session = await waitForSession(
585
+ request.deviceId,
586
+ budgetMs,
587
+ socket,
588
+ );
589
+ const result = await sendCommand(
590
+ session,
591
+ request.method,
592
+ request.params || {},
593
+ Math.max(1, deadline - Date.now()),
594
+ );
595
+ writeMessage(socket, { id: request.id, result });
596
+ return;
597
+ }
598
+ if (request.type === 'control') {
599
+ const result = await control(
600
+ request.deviceId,
601
+ request.action,
602
+ request.params || {},
603
+ boundedTimeout(request.timeoutMs),
604
+ socket,
605
+ );
606
+ writeMessage(socket, { id: request.id, result });
607
+ return;
608
+ }
609
+ if (request.type === 'subscribe') {
610
+ const entries = subscriptions.get(socket) || new Set();
611
+ entries.add(`${request.deviceId}\u0000${request.method}`);
612
+ subscriptions.set(socket, entries);
613
+ writeMessage(socket, { id: request.id, result: {} });
614
+ return;
615
+ }
616
+ if (request.type === 'unsubscribe') {
617
+ subscriptions
618
+ .get(socket)
619
+ ?.delete(`${request.deviceId}\u0000${request.method}`);
620
+ writeMessage(socket, { id: request.id, result: {} });
621
+ return;
622
+ }
623
+ throw new Error('Unknown CDP broker request');
624
+ } catch (error) {
625
+ writeMessage(socket, {
626
+ id: request.id,
627
+ error: String(error.message || error),
628
+ });
629
+ }
630
+ }
631
+
632
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true });
633
+ const ownership = claimBrokerSocket(socketPath);
634
+ const expirySweep = setInterval(() => {
635
+ const now = Date.now();
636
+ for (const [deviceId, deviceCaptures] of captures) {
637
+ for (const capture of deviceCaptures.values()) {
638
+ if (now - capture.startedAtEpochMs >= capture.maxDurationMs) {
639
+ expireCapture(deviceId, capture);
640
+ }
641
+ }
642
+ }
643
+ }, 100);
644
+ const server = net.createServer((socket) => {
645
+ clients.add(socket);
646
+ subscriptions.set(socket, new Set());
647
+ let buffer = '';
648
+ socket.setEncoding('utf8');
649
+ socket.on('data', (chunk) => {
650
+ buffer += chunk;
651
+ if (Buffer.byteLength(buffer) > MAX_REQUEST_FRAME_BYTES) {
652
+ writeMessage(socket, { error: 'CDP broker request exceeds its bound' });
653
+ socket.destroy();
654
+ return;
655
+ }
656
+ let newline;
657
+ while ((newline = buffer.indexOf('\n')) >= 0) {
658
+ const line = buffer.slice(0, newline);
659
+ buffer = buffer.slice(newline + 1);
660
+ if (!line.trim()) continue;
661
+ try {
662
+ const request = JSON.parse(line);
663
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
664
+ throw new Error('Invalid CDP broker request');
665
+ }
666
+ void handleRequest(socket, request);
667
+ } catch {
668
+ writeMessage(socket, { error: 'Invalid CDP broker request' });
669
+ }
670
+ }
671
+ });
672
+ const drop = () => {
673
+ clients.delete(socket);
674
+ subscriptions.delete(socket);
675
+ for (const waiter of [...targetWaiters]) {
676
+ if (waiter.socket !== socket) continue;
677
+ targetWaiters.delete(waiter);
678
+ clearTimeout(waiter.timer);
679
+ waiter.reject(new Error('CDP broker client closed'));
680
+ }
681
+ for (const [deviceId, waiters] of sessionWaiters) {
682
+ for (const waiter of [...waiters]) {
683
+ if (waiter.socket !== socket) continue;
684
+ waiters.delete(waiter);
685
+ clearTimeout(waiter.timer);
686
+ waiter.reject(new Error('CDP broker client closed'));
687
+ }
688
+ if (waiters.size === 0) sessionWaiters.delete(deviceId);
689
+ }
690
+ };
691
+ socket.on('close', drop);
692
+ socket.on('error', drop);
693
+ });
694
+ server.on('error', (error) => {
695
+ releaseBrokerSocket(socketPath, ownership);
696
+ process.stderr.write(`cdp-broker: ${boundedError(error)}\n`);
697
+ });
698
+ server.listen(socketPath, () => fs.chmodSync(socketPath, 0o600));
699
+
700
+ return {
701
+ onSessionOpen(deviceId) {
702
+ const session = sessions.get(deviceId);
703
+ if (!session?.brokerReady) return;
704
+ const previous = knownTargets.get(deviceId);
705
+ const sessionChanged = observedSessions.get(deviceId) !== session;
706
+ knownTargets.set(deviceId, {
707
+ deviceId,
708
+ generation:
709
+ previous?.generation === undefined
710
+ ? 1
711
+ : previous.generation + (!previous.ready || sessionChanged ? 1 : 0),
712
+ name: String(session.name || previous?.name || ''),
713
+ ready: true,
714
+ });
715
+ observedSessions.set(deviceId, session);
716
+ const waiters = sessionWaiters.get(deviceId);
717
+ if (waiters) {
718
+ sessionWaiters.delete(deviceId);
719
+ for (const waiter of waiters) {
720
+ clearTimeout(waiter.timer);
721
+ waiter.resolve(session);
722
+ }
723
+ }
724
+ settleTargetWaiters();
725
+ for (const capture of capturesFor(deviceId).values()) {
726
+ if (capture.reconnects > 0) capture.partial = true;
727
+ void enableNetwork(deviceId, capture);
728
+ }
729
+ },
730
+ onSessionClose(deviceId) {
731
+ const previous = knownTargets.get(deviceId);
732
+ if (previous) knownTargets.set(deviceId, { ...previous, ready: false });
733
+ observedSessions.delete(deviceId);
734
+ for (const capture of capturesFor(deviceId).values()) {
735
+ capture.reconnects += 1;
736
+ capture.partial = true;
737
+ }
738
+ },
739
+ onCdpEvent(deviceId, method, params) {
740
+ for (const socket of clients) {
741
+ if (subscriptions.get(socket)?.has(`${deviceId}\u0000${method}`)) {
742
+ writeMessage(socket, { type: 'event', deviceId, method, params });
743
+ }
744
+ }
745
+ if (method !== 'Network.requestWillBeSent') return;
746
+ const now = Date.now();
747
+ for (const capture of capturesFor(deviceId).values()) {
748
+ const sanitized = sanitizeNetworkRequest(
749
+ params.request || {},
750
+ capture,
751
+ now,
752
+ );
753
+ if (!sanitized) continue;
754
+ const { record } = sanitized;
755
+ if (!sanitized.bodyInspectable) {
756
+ capture.uninspectableBodyRequests += 1;
757
+ capture.partial = true;
758
+ }
759
+ if (now - capture.startedAtEpochMs > capture.maxDurationMs) {
760
+ capture.droppedRequests += 1;
761
+ expireCapture(deviceId, capture);
762
+ continue;
763
+ }
764
+ if (capture.requests.length >= capture.maxRequests) {
765
+ capture.droppedRequests += 1;
766
+ capture.partial = true;
767
+ continue;
768
+ }
769
+ const recordBytes = Buffer.byteLength(JSON.stringify(record));
770
+ if (capture.retainedBytes + recordBytes > MAX_CAPTURE_BYTES) {
771
+ capture.droppedRequests += 1;
772
+ capture.partial = true;
773
+ continue;
774
+ }
775
+ capture.retainedBytes += recordBytes;
776
+ capture.requests.push(record);
777
+ }
778
+ },
779
+ close() {
780
+ clearInterval(expirySweep);
781
+ for (const waiters of sessionWaiters.values()) {
782
+ for (const waiter of waiters) {
783
+ clearTimeout(waiter.timer);
784
+ waiter.reject(new Error('CDP broker closed'));
785
+ }
786
+ }
787
+ sessionWaiters.clear();
788
+ for (const waiter of targetWaiters) {
789
+ clearTimeout(waiter.timer);
790
+ waiter.reject(new Error('CDP broker closed'));
791
+ }
792
+ targetWaiters.clear();
793
+ for (const socket of clients) socket.destroy();
794
+ try {
795
+ server.close();
796
+ } catch {}
797
+ releaseBrokerSocket(socketPath, ownership);
798
+ },
799
+ };
800
+ }
801
+
802
+ function createBrokerClient(socketPath, deviceId, timeout) {
803
+ return new Promise((resolve, reject) => {
804
+ const clientTimeout = boundedTimeout(timeout);
805
+ const socket = net.createConnection(socketPath);
806
+ socket.setEncoding('utf8');
807
+ let nextId = 0;
808
+ let buffer = '';
809
+ const pending = new Map();
810
+ const eventHandlers = new Map();
811
+ const connectTimer = setTimeout(() => {
812
+ socket.destroy();
813
+ reject(new Error('CDP broker connection timeout'));
814
+ }, clientTimeout);
815
+
816
+ const request = (type, value, requestTimeout = clientTimeout) =>
817
+ new Promise((requestResolve, requestReject) => {
818
+ const timeoutMs = boundedTimeout(requestTimeout, clientTimeout);
819
+ const id = ++nextId;
820
+ const timer = setTimeout(() => {
821
+ pending.delete(id);
822
+ requestReject(new Error('CDP broker request timeout'));
823
+ }, timeoutMs);
824
+ pending.set(id, {
825
+ resolve: (result) => {
826
+ clearTimeout(timer);
827
+ requestResolve(result);
828
+ },
829
+ reject: (error) => {
830
+ clearTimeout(timer);
831
+ requestReject(error);
832
+ },
833
+ });
834
+ socket.write(
835
+ `${JSON.stringify({ id, deviceId, timeoutMs, type, ...value })}\n`,
836
+ );
837
+ });
838
+
839
+ socket.on('connect', () => {
840
+ clearTimeout(connectTimer);
841
+ resolve({
842
+ send(method, params = {}, requestTimeout = clientTimeout) {
843
+ return request('command', { method, params }, requestTimeout);
844
+ },
845
+ control(action, params = {}, requestTimeout = clientTimeout) {
846
+ return request('control', { action, params }, requestTimeout);
847
+ },
848
+ on(method, handler) {
849
+ const handlers = eventHandlers.get(method) || new Set();
850
+ handlers.add(handler);
851
+ eventHandlers.set(method, handlers);
852
+ void request('subscribe', { method });
853
+ return () => {
854
+ handlers.delete(handler);
855
+ if (handlers.size === 0) {
856
+ eventHandlers.delete(method);
857
+ void request('unsubscribe', { method }).catch(() => undefined);
858
+ }
859
+ };
860
+ },
861
+ close() {
862
+ socket.destroy();
863
+ },
864
+ });
865
+ });
866
+ socket.on('data', (chunk) => {
867
+ buffer += chunk;
868
+ if (Buffer.byteLength(buffer) > MAX_RESPONSE_FRAME_BYTES) {
869
+ socket.destroy();
870
+ for (const entry of pending.values()) {
871
+ entry.reject(new Error('CDP broker response exceeds its bound'));
872
+ }
873
+ pending.clear();
874
+ return;
875
+ }
876
+ let newline;
877
+ while ((newline = buffer.indexOf('\n')) >= 0) {
878
+ const line = buffer.slice(0, newline);
879
+ buffer = buffer.slice(newline + 1);
880
+ if (!line.trim()) continue;
881
+ let message;
882
+ try {
883
+ message = JSON.parse(line);
884
+ } catch {
885
+ continue;
886
+ }
887
+ if (message.id && pending.has(message.id)) {
888
+ const entry = pending.get(message.id);
889
+ pending.delete(message.id);
890
+ if (message.error) entry.reject(new Error(message.error));
891
+ else entry.resolve(message.result);
892
+ continue;
893
+ }
894
+ if (message.type !== 'event') continue;
895
+ for (const handler of eventHandlers.get(message.method) || []) {
896
+ try {
897
+ handler(message.params || {});
898
+ } catch {}
899
+ }
900
+ }
901
+ });
902
+ socket.on('error', (error) => {
903
+ clearTimeout(connectTimer);
904
+ reject(error);
905
+ });
906
+ socket.on('close', () => {
907
+ for (const entry of pending.values()) {
908
+ entry.reject(new Error('CDP broker closed'));
909
+ }
910
+ pending.clear();
911
+ eventHandlers.clear();
912
+ });
913
+ });
914
+ }
915
+
916
+ module.exports = {
917
+ brokerSocketPath,
918
+ createBrokerClient,
919
+ createCdpBroker,
920
+ deviceIdFromUrl,
921
+ };