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