@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.
@@ -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
- /** @type {Map<string, { socket: ClientSocket, expiresAt: number }>} */
444
- this.domBaselineOwners = new Map();
445
- /** @type {Map<string, { socket: ClientSocket, expiresAt: number }>} */
446
- this.abandonedDomBaselineCreates = new Map();
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.domBaselineOwners.clear();
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.pruneDomBaselineOwners();
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.domBaselineOwners.size,
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.getDomBaselineOwner(baselineId ?? '') : null;
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.markAbandonedDomBaselineCreate(request.id, pending);
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 (!/^baseline_[A-Za-z0-9_-]{32,64}$/u.test(baselineId)) return;
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.clearDomBaselineOwnersForSocket(socket);
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
- const abandoned = this.abandonedDomBaselineCreates.get(responseMessage.id);
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
- const evictedId = String(item.baselineId);
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.registerDomBaselineOwner(baselineId, socket, result.expiresAt)) {
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.domBaselineOwners.delete(pending.baselineId);
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.domBaselineOwners.delete(baselineId);
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.domBaselineOwners.delete(pending.baselineId);
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.clearDomBaselineOwnersForSocket(socket);
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.markAbandonedDomBaselineCreate(id, pending);
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
- MAX_JSON_LINE_BYTES,
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
- let lineBuffer = '';
174
- socket.on('data', (chunk) => {
175
- lineBuffer += chunk;
176
- if (!lineBuffer.includes('\n') && Buffer.byteLength(lineBuffer, 'utf8') > MAX_JSON_LINE_BYTES) {
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,