@browserbridge/bbx 1.9.0 → 1.10.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/package.json +1 -1
- package/packages/agent-client/src/cli-args.js +35 -6
- package/packages/agent-client/src/cli-batch.js +10 -5
- package/packages/agent-client/src/cli-helpers.js +8 -0
- package/packages/agent-client/src/cli-setup-commands.js +3 -1
- package/packages/agent-client/src/cli.js +35 -15
- package/packages/agent-client/src/command-registry.js +7 -3
- package/packages/agent-client/src/mcp-config.js +8 -8
- package/packages/agent-client/src/runtime.js +2 -2
- package/packages/mcp-server/src/guidance.js +37 -6
- package/packages/mcp-server/src/handlers-capture.js +1 -1
- package/packages/mcp-server/src/handlers-page.js +162 -9
- package/packages/mcp-server/src/handlers-utils.js +126 -167
- package/packages/mcp-server/src/handlers.js +7 -1
- package/packages/mcp-server/src/server.js +195 -46
- package/packages/mcp-server/src/toolset.js +48 -0
- package/packages/native-host/src/daemon.js +20 -106
- package/packages/native-host/src/dom-baseline-owners.js +210 -0
- package/packages/native-host/src/native-host.js +14 -28
- package/packages/protocol/src/budget-presets.js +182 -0
- package/packages/protocol/src/har.js +34 -0
- package/packages/protocol/src/index.js +2 -0
- package/packages/protocol/src/json-lines.js +3 -2
- package/packages/protocol/src/registry.js +83 -0
- package/packages/protocol/src/types.ts +1 -0
- package/skills/browser-bridge/SKILL.md +4 -0
- package/skills/browser-bridge/references/protocol.md +11 -5
|
@@ -24,12 +24,10 @@ import {
|
|
|
24
24
|
DAEMON_RECENT_LOG_LIMIT,
|
|
25
25
|
DEFAULT_DAEMON_PENDING_TIMEOUT_MS,
|
|
26
26
|
DEFAULT_LOG_TAIL_LIMIT,
|
|
27
|
-
DOM_BASELINE_TTL_MS,
|
|
28
27
|
ERROR_CODES,
|
|
29
28
|
getProtocolVersion,
|
|
30
29
|
getSupportedProtocolVersions,
|
|
31
30
|
MAX_DAEMON_PENDING_TIMEOUT_MS,
|
|
32
|
-
MAX_DOM_BASELINES_GLOBAL,
|
|
33
31
|
parseJsonLines,
|
|
34
32
|
RecoveryTelemetryCollector,
|
|
35
33
|
normalizeRecoveryTelemetrySummary,
|
|
@@ -48,6 +46,7 @@ import {
|
|
|
48
46
|
import { ArtifactStore } from './artifact-store.js';
|
|
49
47
|
import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
|
|
50
48
|
import { normalizeDaemonLogger } from './daemon-logger.js';
|
|
49
|
+
import { DomBaselineOwnerRegistry, isValidBaselineId } from './dom-baseline-owners.js';
|
|
51
50
|
import { writeJsonLine } from './framing.js';
|
|
52
51
|
|
|
53
52
|
const DAEMON_VERSION = loadDaemonVersion();
|
|
@@ -440,10 +439,10 @@ export class BridgeDaemon {
|
|
|
440
439
|
this.authToken = authToken;
|
|
441
440
|
this.artifactStore = artifactStore;
|
|
442
441
|
this.artifactStoreInitialized = false;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
442
|
+
this.domBaselines = new DomBaselineOwnerRegistry({
|
|
443
|
+
isCurrentExtensionSocket: (socket) =>
|
|
444
|
+
this.extensionSockets.get(socket.__extensionId ?? '') === socket,
|
|
445
|
+
});
|
|
447
446
|
this.recoveryTelemetry = new RecoveryTelemetryCollector();
|
|
448
447
|
}
|
|
449
448
|
|
|
@@ -765,8 +764,7 @@ export class BridgeDaemon {
|
|
|
765
764
|
}
|
|
766
765
|
this.extensionSockets.clear();
|
|
767
766
|
this.invalidateConnectedExtensionsCache();
|
|
768
|
-
this.
|
|
769
|
-
this.abandonedDomBaselineCreates.clear();
|
|
767
|
+
this.domBaselines.clear();
|
|
770
768
|
if (this.artifactStoreInitialized) {
|
|
771
769
|
this.artifactStore.reset();
|
|
772
770
|
this.artifactStoreInitialized = false;
|
|
@@ -998,7 +996,7 @@ export class BridgeDaemon {
|
|
|
998
996
|
}
|
|
999
997
|
|
|
1000
998
|
if (request.method === 'daemon.metrics') {
|
|
1001
|
-
this.
|
|
999
|
+
this.domBaselines.prune();
|
|
1002
1000
|
const now = Date.now();
|
|
1003
1001
|
const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
|
|
1004
1002
|
const avgResponseTimeMs =
|
|
@@ -1013,7 +1011,7 @@ export class BridgeDaemon {
|
|
|
1013
1011
|
requestsProcessed: this.requestsProcessed,
|
|
1014
1012
|
requestsFailed: this.requestsFailed,
|
|
1015
1013
|
avgResponseTimeMs,
|
|
1016
|
-
domBaselineOwners: this.
|
|
1014
|
+
domBaselineOwners: this.domBaselines.size,
|
|
1017
1015
|
recovery: this.recoveryTelemetry.snapshot('daemon'),
|
|
1018
1016
|
});
|
|
1019
1017
|
await writeJsonLine(socket, { type: 'agent.response', response });
|
|
@@ -1123,7 +1121,7 @@ export class BridgeDaemon {
|
|
|
1123
1121
|
request.method === 'dom.baseline.compare' ||
|
|
1124
1122
|
request.method === 'dom.baseline.describe' ||
|
|
1125
1123
|
request.method === 'dom.baseline.release';
|
|
1126
|
-
const baselineOwner = usesBaselineOwner ? this.
|
|
1124
|
+
const baselineOwner = usesBaselineOwner ? this.domBaselines.get(baselineId ?? '') : null;
|
|
1127
1125
|
const target = usesBaselineOwner
|
|
1128
1126
|
? baselineOwner &&
|
|
1129
1127
|
(!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
|
|
@@ -1173,7 +1171,7 @@ export class BridgeDaemon {
|
|
|
1173
1171
|
timeoutId: setTimeout(() => {
|
|
1174
1172
|
const pending = this.pendingRequests.get(request.id);
|
|
1175
1173
|
if (!pending) return;
|
|
1176
|
-
this.
|
|
1174
|
+
this.domBaselines.markAbandonedCreate(request.id, pending);
|
|
1177
1175
|
this.clearPendingRequest(request.id, pending);
|
|
1178
1176
|
this.recordRequestCompletion(request.id, false, pending);
|
|
1179
1177
|
const response = createFailure(
|
|
@@ -1272,85 +1270,6 @@ export class BridgeDaemon {
|
|
|
1272
1270
|
return candidates[0]?.[1] ?? null;
|
|
1273
1271
|
}
|
|
1274
1272
|
|
|
1275
|
-
/** @param {string} baselineId */
|
|
1276
|
-
getDomBaselineOwner(baselineId) {
|
|
1277
|
-
this.pruneDomBaselineOwners();
|
|
1278
|
-
const owner = this.domBaselineOwners.get(baselineId);
|
|
1279
|
-
if (!owner || !owner.socket.__extensionId || !owner.socket.__accessEnabled) return null;
|
|
1280
|
-
if (this.extensionSockets.get(owner.socket.__extensionId) !== owner.socket) {
|
|
1281
|
-
this.domBaselineOwners.delete(baselineId);
|
|
1282
|
-
return null;
|
|
1283
|
-
}
|
|
1284
|
-
return owner.socket;
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
/** @param {string} baselineId @param {ClientSocket} socket @param {unknown} expiresAt */
|
|
1288
|
-
registerDomBaselineOwner(baselineId, socket, expiresAt) {
|
|
1289
|
-
this.pruneDomBaselineOwners();
|
|
1290
|
-
const parsedExpiry = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
|
|
1291
|
-
const now = Date.now();
|
|
1292
|
-
if (
|
|
1293
|
-
!/^baseline_[A-Za-z0-9_-]{32,64}$/u.test(baselineId) ||
|
|
1294
|
-
!socket.__extensionId ||
|
|
1295
|
-
!Number.isFinite(parsedExpiry) ||
|
|
1296
|
-
parsedExpiry <= now ||
|
|
1297
|
-
parsedExpiry > now + DOM_BASELINE_TTL_MS + 10_000
|
|
1298
|
-
) {
|
|
1299
|
-
return false;
|
|
1300
|
-
}
|
|
1301
|
-
const existing = this.domBaselineOwners.get(baselineId);
|
|
1302
|
-
if (existing && existing.socket !== socket) return false;
|
|
1303
|
-
const maxOwnerMappings = MAX_DOM_BASELINES_GLOBAL * 4;
|
|
1304
|
-
while (
|
|
1305
|
-
!existing &&
|
|
1306
|
-
[...this.domBaselineOwners.values()].filter((owner) => owner.socket === socket).length >=
|
|
1307
|
-
maxOwnerMappings
|
|
1308
|
-
) {
|
|
1309
|
-
const oldest = [...this.domBaselineOwners].find(([, owner]) => owner.socket === socket);
|
|
1310
|
-
if (!oldest) break;
|
|
1311
|
-
this.domBaselineOwners.delete(oldest[0]);
|
|
1312
|
-
}
|
|
1313
|
-
this.domBaselineOwners.set(baselineId, { socket, expiresAt: parsedExpiry });
|
|
1314
|
-
return true;
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
pruneDomBaselineOwners() {
|
|
1318
|
-
const now = Date.now();
|
|
1319
|
-
for (const [baselineId, owner] of this.domBaselineOwners) {
|
|
1320
|
-
if (owner.expiresAt <= now) this.domBaselineOwners.delete(baselineId);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
/** @param {ClientSocket} socket */
|
|
1325
|
-
clearDomBaselineOwnersForSocket(socket) {
|
|
1326
|
-
for (const [baselineId, owner] of this.domBaselineOwners) {
|
|
1327
|
-
if (owner.socket === socket) this.domBaselineOwners.delete(baselineId);
|
|
1328
|
-
}
|
|
1329
|
-
for (const [requestId, abandoned] of this.abandonedDomBaselineCreates) {
|
|
1330
|
-
if (abandoned.socket === socket) this.abandonedDomBaselineCreates.delete(requestId);
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
/** @param {string} requestId @param {PendingEntry} pending */
|
|
1335
|
-
markAbandonedDomBaselineCreate(requestId, pending) {
|
|
1336
|
-
if (pending.method !== 'dom.baseline.create') return;
|
|
1337
|
-
const target = pending.targets.values().next().value;
|
|
1338
|
-
if (!target) return;
|
|
1339
|
-
const now = Date.now();
|
|
1340
|
-
for (const [id, entry] of this.abandonedDomBaselineCreates) {
|
|
1341
|
-
if (entry.expiresAt <= now) this.abandonedDomBaselineCreates.delete(id);
|
|
1342
|
-
}
|
|
1343
|
-
this.abandonedDomBaselineCreates.set(requestId, {
|
|
1344
|
-
socket: target,
|
|
1345
|
-
expiresAt: now + DOM_BASELINE_TTL_MS,
|
|
1346
|
-
});
|
|
1347
|
-
while (this.abandonedDomBaselineCreates.size > MAX_DOM_BASELINES_GLOBAL * 4) {
|
|
1348
|
-
const oldestId = this.abandonedDomBaselineCreates.keys().next().value;
|
|
1349
|
-
if (typeof oldestId !== 'string') break;
|
|
1350
|
-
this.abandonedDomBaselineCreates.delete(oldestId);
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
1273
|
/**
|
|
1355
1274
|
* @param {ClientSocket} socket
|
|
1356
1275
|
* @param {import('../../protocol/src/types.js').BridgeResponse} response
|
|
@@ -1361,7 +1280,7 @@ export class BridgeDaemon {
|
|
|
1361
1280
|
? /** @type {Record<string, unknown>} */ (response.result)
|
|
1362
1281
|
: {};
|
|
1363
1282
|
const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
|
|
1364
|
-
if (
|
|
1283
|
+
if (!isValidBaselineId(baselineId)) return;
|
|
1365
1284
|
const scope =
|
|
1366
1285
|
result.scope && typeof result.scope === 'object'
|
|
1367
1286
|
? /** @type {Record<string, unknown>} */ (result.scope)
|
|
@@ -1455,7 +1374,7 @@ export class BridgeDaemon {
|
|
|
1455
1374
|
const accessEnabled = Boolean(message.accessEnabled);
|
|
1456
1375
|
if (!accessEnabled && socket.__extensionId) {
|
|
1457
1376
|
this.artifactStore.deleteByExtension(socket.__extensionId);
|
|
1458
|
-
this.
|
|
1377
|
+
this.domBaselines.clearForSocket(socket);
|
|
1459
1378
|
}
|
|
1460
1379
|
if (socket.__accessEnabled !== accessEnabled) {
|
|
1461
1380
|
socket.__accessEnabled = accessEnabled;
|
|
@@ -1558,9 +1477,7 @@ export class BridgeDaemon {
|
|
|
1558
1477
|
|
|
1559
1478
|
const pending = this.pendingRequests.get(responseMessage.id);
|
|
1560
1479
|
if (!pending) {
|
|
1561
|
-
|
|
1562
|
-
if (abandoned?.socket === socket) {
|
|
1563
|
-
this.abandonedDomBaselineCreates.delete(responseMessage.id);
|
|
1480
|
+
if (this.domBaselines.takeAbandonedCreate(responseMessage.id, socket)) {
|
|
1564
1481
|
if (responseMessage.ok) {
|
|
1565
1482
|
await this.releaseOrphanDomBaseline(socket, responseMessage).catch((error) => {
|
|
1566
1483
|
this.logger.error('orphan DOM baseline release failed', {
|
|
@@ -1618,13 +1535,10 @@ export class BridgeDaemon {
|
|
|
1618
1535
|
const evicted = Array.isArray(result.evicted) ? result.evicted : [];
|
|
1619
1536
|
for (const item of evicted) {
|
|
1620
1537
|
if (item && typeof item === 'object' && 'baselineId' in item) {
|
|
1621
|
-
|
|
1622
|
-
if (this.domBaselineOwners.get(evictedId)?.socket === socket) {
|
|
1623
|
-
this.domBaselineOwners.delete(evictedId);
|
|
1624
|
-
}
|
|
1538
|
+
this.domBaselines.deleteIfOwnedBy(String(item.baselineId), socket);
|
|
1625
1539
|
}
|
|
1626
1540
|
}
|
|
1627
|
-
if (!this.
|
|
1541
|
+
if (!this.domBaselines.register(baselineId, socket, result.expiresAt)) {
|
|
1628
1542
|
await this.releaseOrphanDomBaseline(socket, responseMessage);
|
|
1629
1543
|
pending.lastErrorResponse = createFailure(
|
|
1630
1544
|
responseMessage.id,
|
|
@@ -1637,7 +1551,7 @@ export class BridgeDaemon {
|
|
|
1637
1551
|
return;
|
|
1638
1552
|
}
|
|
1639
1553
|
} else if (pending.method === 'dom.baseline.release' && pending.baselineId) {
|
|
1640
|
-
this.
|
|
1554
|
+
this.domBaselines.delete(pending.baselineId);
|
|
1641
1555
|
}
|
|
1642
1556
|
const response =
|
|
1643
1557
|
pending.method === 'health.ping'
|
|
@@ -1674,7 +1588,7 @@ export class BridgeDaemon {
|
|
|
1674
1588
|
if (pending.method === 'dom.baseline.create') {
|
|
1675
1589
|
const baselineId =
|
|
1676
1590
|
result && typeof result.baselineId === 'string' ? result.baselineId : null;
|
|
1677
|
-
if (baselineId) this.
|
|
1591
|
+
if (baselineId) this.domBaselines.delete(baselineId);
|
|
1678
1592
|
await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
|
|
1679
1593
|
}
|
|
1680
1594
|
this.clearPendingRequest(responseMessage.id, pending);
|
|
@@ -1695,7 +1609,7 @@ export class BridgeDaemon {
|
|
|
1695
1609
|
|
|
1696
1610
|
// A routed request has one target, so its error is final.
|
|
1697
1611
|
if (pending.baselineId && responseMessage.error?.code === ERROR_CODES.DOM_BASELINE_NOT_FOUND) {
|
|
1698
|
-
this.
|
|
1612
|
+
this.domBaselines.delete(pending.baselineId);
|
|
1699
1613
|
}
|
|
1700
1614
|
pending.lastErrorResponse = responseMessage;
|
|
1701
1615
|
|
|
@@ -1709,7 +1623,7 @@ export class BridgeDaemon {
|
|
|
1709
1623
|
handleSocketClose(socket) {
|
|
1710
1624
|
if (socket.__extensionId) {
|
|
1711
1625
|
this.artifactStore.deleteByExtension(socket.__extensionId);
|
|
1712
|
-
this.
|
|
1626
|
+
this.domBaselines.clearForSocket(socket);
|
|
1713
1627
|
this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
|
|
1714
1628
|
if (this.extensionSockets.get(socket.__extensionId) === socket) {
|
|
1715
1629
|
this.extensionSockets.delete(socket.__extensionId);
|
|
@@ -1731,7 +1645,7 @@ export class BridgeDaemon {
|
|
|
1731
1645
|
if (!pending) {
|
|
1732
1646
|
continue;
|
|
1733
1647
|
}
|
|
1734
|
-
this.
|
|
1648
|
+
this.domBaselines.markAbandonedCreate(id, pending);
|
|
1735
1649
|
this.clearPendingRequest(id, pending);
|
|
1736
1650
|
this.recordRequestCompletion(id, false, pending);
|
|
1737
1651
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { DOM_BASELINE_TTL_MS, MAX_DOM_BASELINES_GLOBAL } from '../../protocol/src/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ownership bookkeeping for retained semantic DOM baselines.
|
|
7
|
+
*
|
|
8
|
+
* A baseline lives in the extension's memory, but the daemon has to know which
|
|
9
|
+
* extension socket owns it so a later `compare`/`describe`/`release` is routed
|
|
10
|
+
* back to the same document, and so the state is dropped when that socket goes
|
|
11
|
+
* away. This module owns only that mapping and its expiry rules; sending the
|
|
12
|
+
* release request over the wire stays with the daemon, which owns transport.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {import('./daemon.js').ClientSocket} ClientSocket
|
|
15
|
+
* @typedef {{ socket: ClientSocket, expiresAt: number }} BaselineOwner
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Baseline IDs are minted by the extension. The daemon re-validates the shape
|
|
20
|
+
* before trusting one as a map key so a malformed response cannot grow state.
|
|
21
|
+
*/
|
|
22
|
+
const BASELINE_ID_PATTERN = /^baseline_[A-Za-z0-9_-]{32,64}$/u;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Expiry timestamps are allowed a small skew over the nominal TTL to absorb
|
|
26
|
+
* clock jitter between the extension and the daemon.
|
|
27
|
+
*/
|
|
28
|
+
const EXPIRY_SKEW_MS = 10_000;
|
|
29
|
+
|
|
30
|
+
/** Cap on tracked mappings per socket, and on abandoned-create entries. */
|
|
31
|
+
const MAX_OWNER_MAPPINGS = MAX_DOM_BASELINES_GLOBAL * 4;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {unknown} baselineId
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function isValidBaselineId(baselineId) {
|
|
38
|
+
return typeof baselineId === 'string' && BASELINE_ID_PATTERN.test(baselineId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class DomBaselineOwnerRegistry {
|
|
42
|
+
/**
|
|
43
|
+
* @param {{ isCurrentExtensionSocket: (socket: ClientSocket) => boolean }} options
|
|
44
|
+
*/
|
|
45
|
+
constructor({ isCurrentExtensionSocket }) {
|
|
46
|
+
/** @type {Map<string, BaselineOwner>} */
|
|
47
|
+
this.owners = new Map();
|
|
48
|
+
/**
|
|
49
|
+
* Creates whose agent went away before the extension replied. The response
|
|
50
|
+
* still arrives later, and the baseline it reports has to be released.
|
|
51
|
+
* @type {Map<string, BaselineOwner>}
|
|
52
|
+
*/
|
|
53
|
+
this.abandonedCreates = new Map();
|
|
54
|
+
this.isCurrentExtensionSocket = isCurrentExtensionSocket;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns {number} */
|
|
58
|
+
get size() {
|
|
59
|
+
return this.owners.size;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Drop every mapping whose TTL has passed. */
|
|
63
|
+
prune() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const [baselineId, owner] of this.owners) {
|
|
66
|
+
if (owner.expiresAt <= now) this.owners.delete(baselineId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the socket that owns a baseline, or null when the owner is gone,
|
|
72
|
+
* has lost access, or has been replaced by a newer socket for the same
|
|
73
|
+
* extension.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} baselineId
|
|
76
|
+
* @returns {ClientSocket | null}
|
|
77
|
+
*/
|
|
78
|
+
get(baselineId) {
|
|
79
|
+
this.prune();
|
|
80
|
+
const owner = this.owners.get(baselineId);
|
|
81
|
+
if (!owner || !owner.socket.__extensionId || !owner.socket.__accessEnabled) return null;
|
|
82
|
+
if (!this.isCurrentExtensionSocket(owner.socket)) {
|
|
83
|
+
this.owners.delete(baselineId);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return owner.socket;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Record ownership for a freshly created baseline.
|
|
91
|
+
*
|
|
92
|
+
* Returns false when the reported ID or expiry is unusable, or when another
|
|
93
|
+
* socket already claims the ID; the caller releases the orphan in that case.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} baselineId
|
|
96
|
+
* @param {ClientSocket} socket
|
|
97
|
+
* @param {unknown} expiresAt
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
register(baselineId, socket, expiresAt) {
|
|
101
|
+
this.prune();
|
|
102
|
+
const parsedExpiry = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
if (
|
|
105
|
+
!isValidBaselineId(baselineId) ||
|
|
106
|
+
!socket.__extensionId ||
|
|
107
|
+
!Number.isFinite(parsedExpiry) ||
|
|
108
|
+
parsedExpiry <= now ||
|
|
109
|
+
parsedExpiry > now + DOM_BASELINE_TTL_MS + EXPIRY_SKEW_MS
|
|
110
|
+
) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const existing = this.owners.get(baselineId);
|
|
114
|
+
if (existing && existing.socket !== socket) return false;
|
|
115
|
+
while (
|
|
116
|
+
!existing &&
|
|
117
|
+
[...this.owners.values()].filter((owner) => owner.socket === socket).length >=
|
|
118
|
+
MAX_OWNER_MAPPINGS
|
|
119
|
+
) {
|
|
120
|
+
const oldest = [...this.owners].find(([, owner]) => owner.socket === socket);
|
|
121
|
+
if (!oldest) break;
|
|
122
|
+
this.owners.delete(oldest[0]);
|
|
123
|
+
}
|
|
124
|
+
this.owners.set(baselineId, { socket, expiresAt: parsedExpiry });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} baselineId
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
delete(baselineId) {
|
|
133
|
+
return this.owners.delete(baselineId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Drop a mapping only when the given socket still owns it, so one extension
|
|
138
|
+
* cannot evict another's baseline by reporting its ID.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} baselineId
|
|
141
|
+
* @param {ClientSocket} socket
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
deleteIfOwnedBy(baselineId, socket) {
|
|
145
|
+
if (this.owners.get(baselineId)?.socket !== socket) return false;
|
|
146
|
+
return this.owners.delete(baselineId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Forget every baseline and abandoned create belonging to a socket.
|
|
151
|
+
*
|
|
152
|
+
* @param {ClientSocket} socket
|
|
153
|
+
*/
|
|
154
|
+
clearForSocket(socket) {
|
|
155
|
+
for (const [baselineId, owner] of this.owners) {
|
|
156
|
+
if (owner.socket === socket) this.owners.delete(baselineId);
|
|
157
|
+
}
|
|
158
|
+
for (const [requestId, abandoned] of this.abandonedCreates) {
|
|
159
|
+
if (abandoned.socket === socket) this.abandonedCreates.delete(requestId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Remember a `dom.baseline.create` whose agent disconnected before the
|
|
165
|
+
* extension replied, so the eventual response can be released rather than
|
|
166
|
+
* leaking a retained snapshot in the page.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} requestId
|
|
169
|
+
* @param {{ method?: string, targets: Set<ClientSocket> }} pending
|
|
170
|
+
*/
|
|
171
|
+
markAbandonedCreate(requestId, pending) {
|
|
172
|
+
if (pending.method !== 'dom.baseline.create') return;
|
|
173
|
+
const target = pending.targets.values().next().value;
|
|
174
|
+
if (!target) return;
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
for (const [id, entry] of this.abandonedCreates) {
|
|
177
|
+
if (entry.expiresAt <= now) this.abandonedCreates.delete(id);
|
|
178
|
+
}
|
|
179
|
+
this.abandonedCreates.set(requestId, {
|
|
180
|
+
socket: target,
|
|
181
|
+
expiresAt: now + DOM_BASELINE_TTL_MS,
|
|
182
|
+
});
|
|
183
|
+
while (this.abandonedCreates.size > MAX_OWNER_MAPPINGS) {
|
|
184
|
+
const oldestId = this.abandonedCreates.keys().next().value;
|
|
185
|
+
if (typeof oldestId !== 'string') break;
|
|
186
|
+
this.abandonedCreates.delete(oldestId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Claim an abandoned create for the socket that is answering it. Returns
|
|
192
|
+
* false when the request was not abandoned or belongs to another socket.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} requestId
|
|
195
|
+
* @param {ClientSocket} socket
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
takeAbandonedCreate(requestId, socket) {
|
|
199
|
+
const abandoned = this.abandonedCreates.get(requestId);
|
|
200
|
+
if (abandoned?.socket !== socket) return false;
|
|
201
|
+
this.abandonedCreates.delete(requestId);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Drop all tracked state, used on daemon shutdown. */
|
|
206
|
+
clear() {
|
|
207
|
+
this.owners.clear();
|
|
208
|
+
this.abandonedCreates.clear();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -5,7 +5,7 @@ import net from 'node:net';
|
|
|
5
5
|
import {
|
|
6
6
|
createFailure,
|
|
7
7
|
ERROR_CODES,
|
|
8
|
-
|
|
8
|
+
parseJsonLines,
|
|
9
9
|
sanitizeIncidentalText,
|
|
10
10
|
} from '../../protocol/src/index.js';
|
|
11
11
|
import { readBridgeAuthToken } from './auth-token.js';
|
|
@@ -170,32 +170,10 @@ export async function runNativeHost({
|
|
|
170
170
|
...(authToken ? { authToken } : {}),
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
|
|
178
|
-
socket.destroy();
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
while (lineBuffer.includes('\n')) {
|
|
182
|
-
const index = lineBuffer.indexOf('\n');
|
|
183
|
-
const line = lineBuffer.slice(0, index).trim();
|
|
184
|
-
lineBuffer = lineBuffer.slice(index + 1);
|
|
185
|
-
if (!line) {
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (Buffer.byteLength(line, 'utf8') > MAX_JSON_LINE_BYTES) {
|
|
189
|
-
console.error(`native-host: daemon JSON line exceeded ${MAX_JSON_LINE_BYTES} bytes`);
|
|
190
|
-
socket.destroy();
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
let message;
|
|
194
|
-
try {
|
|
195
|
-
message = JSON.parse(line);
|
|
196
|
-
} catch {
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
173
|
+
parseJsonLines(
|
|
174
|
+
socket,
|
|
175
|
+
(raw) => {
|
|
176
|
+
const message = /** @type {Record<string, unknown>} */ (raw);
|
|
199
177
|
void (async () => {
|
|
200
178
|
if (message.type === 'extension.request') {
|
|
201
179
|
await writeNativeMessageQueued(message.request);
|
|
@@ -228,8 +206,16 @@ export async function runNativeHost({
|
|
|
228
206
|
err instanceof Error ? err.message : err
|
|
229
207
|
);
|
|
230
208
|
});
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
onProtocolError: (error) => {
|
|
212
|
+
console.error(`native-host: ${error.message}`);
|
|
213
|
+
},
|
|
214
|
+
onInvalidLine: (error) => {
|
|
215
|
+
console.error(`native-host: ignoring malformed daemon JSON line: ${error.message}`);
|
|
216
|
+
},
|
|
231
217
|
}
|
|
232
|
-
|
|
218
|
+
);
|
|
233
219
|
|
|
234
220
|
createNativeMessageReader(
|
|
235
221
|
process.stdin,
|