@push.rocks/smartnetwork 4.9.0 → 4.10.1

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.
@@ -53,9 +53,9 @@ export interface IIpIntelligenceOptions {
53
53
  timeout?: number;
54
54
  /**
55
55
  * Optional injected smartdns client. When provided, IpIntelligence will
56
- * not create or destroy its own client (the owner — typically SmartNetwork —
56
+ * not create or terminate its own client (the owner — typically SmartNetwork —
57
57
  * manages lifecycle). When omitted, a short-lived client is created per
58
- * Team Cymru lookup and destroyed in finally.
58
+ * Team Cymru lookup and terminated in finally.
59
59
  */
60
60
  dnsClient?: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>;
61
61
  }
@@ -64,30 +64,12 @@ export interface IIpIntelligenceOptions {
64
64
  const CITY_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv4.mmdb';
65
65
  const ASN_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-asn-mmdb/geolite2-asn-ipv4.mmdb';
66
66
 
67
- // IANA bootstrap for RDAP
68
- const IANA_BOOTSTRAP_IPV4_URL = 'https://data.iana.org/rdap/ipv4.json';
69
-
70
67
  const DEFAULT_DB_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
71
68
  const DEFAULT_TIMEOUT = 5000;
72
69
 
73
- /**
74
- * Parsed IANA bootstrap entry: a CIDR prefix mapped to an RDAP base URL
75
- */
76
- interface IBootstrapEntry {
77
- prefix: string;
78
- prefixNum: number; // numeric representation of the network address
79
- maskBits: number;
80
- baseUrl: string;
81
- }
82
-
83
- interface IRdapNetworkInfo {
84
- networkRange: string | null;
85
- networkCidrs: string[] | null;
86
- }
87
-
88
70
  /**
89
71
  * IpIntelligence provides IP address intelligence by combining three data sources:
90
- * - RDAP (direct to RIRs) for registration/org data
72
+ * - RDAP (direct to RIRs, via @push.rocks/smartwhois) for registration/org data
91
73
  * - Team Cymru DNS for ASN
92
74
  * - MaxMind GeoLite2 (in-memory MMDB) for geolocation
93
75
  */
@@ -102,17 +84,23 @@ export class IpIntelligence {
102
84
  private lastFetchTime = 0;
103
85
  private refreshPromise: Promise<void> | null = null;
104
86
 
105
- // RDAP bootstrap cache
106
- private bootstrapEntries: IBootstrapEntry[] | null = null;
107
- private bootstrapPromise: Promise<void> | null = null;
87
+ // RDAP client from smartwhois (owns bootstrap caching and RIR matching)
88
+ private readonly rdapClient: plugins.smartwhois.RdapClient;
108
89
 
109
90
  // Optional injected smartdns client (shared by SmartNetwork)
110
91
  private readonly sharedDnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null;
92
+ private ownedDnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null = null;
93
+ private dnsLifecycleQueue: Promise<void> = Promise.resolve();
111
94
 
112
95
  constructor(options?: IIpIntelligenceOptions) {
113
96
  this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
114
97
  this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
115
98
  this.sharedDnsClient = options?.dnsClient ?? null;
99
+ this.rdapClient = new plugins.smartwhois.RdapClient({
100
+ requestTimeoutMs: this.timeout,
101
+ totalTimeoutMs: this.timeout,
102
+ userAgent: '@push.rocks/smartnetwork',
103
+ });
116
104
  }
117
105
 
118
106
  /**
@@ -144,6 +132,10 @@ export class IpIntelligence {
144
132
  this.queryMaxMind(ip),
145
133
  ]);
146
134
 
135
+ if (cymruResult.status === 'rejected') {
136
+ throw cymruResult.reason;
137
+ }
138
+
147
139
  // Merge RDAP results
148
140
  if (rdapResult.status === 'fulfilled' && rdapResult.value) {
149
141
  const rdap = rdapResult.value;
@@ -186,80 +178,16 @@ export class IpIntelligence {
186
178
  return result;
187
179
  }
188
180
 
189
- // ─── RDAP Subsystem ─────────────────────────────────────────────────
190
-
191
- /**
192
- * Load and cache the IANA RDAP bootstrap file
193
- */
194
- private async ensureBootstrap(): Promise<void> {
195
- if (this.bootstrapEntries) return;
196
- if (this.bootstrapPromise) {
197
- await this.bootstrapPromise;
198
- return;
199
- }
200
-
201
- this.bootstrapPromise = (async () => {
202
- try {
203
- const controller = new AbortController();
204
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
205
- try {
206
- const response = await fetch(IANA_BOOTSTRAP_IPV4_URL, {
207
- signal: controller.signal,
208
- headers: { 'User-Agent': '@push.rocks/smartnetwork' },
209
- });
210
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
211
- const data = await response.json() as { services: [string[], string[]][] };
212
-
213
- const entries: IBootstrapEntry[] = [];
214
- for (const [prefixes, urls] of data.services) {
215
- const baseUrl = urls[0]; // first URL is preferred
216
- for (const prefix of prefixes) {
217
- const [network, bits] = prefix.split('/');
218
- entries.push({
219
- prefix,
220
- prefixNum: this.ipToNumber(network),
221
- maskBits: parseInt(bits, 10),
222
- baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash
223
- });
224
- }
225
- }
226
-
227
- // Sort by mask bits descending for longest-prefix match
228
- entries.sort((a, b) => b.maskBits - a.maskBits);
229
- this.bootstrapEntries = entries;
230
- } finally {
231
- clearTimeout(timeoutId);
232
- }
233
- } catch (err: any) {
234
- this.logger.debug?.(`Failed to load RDAP bootstrap: ${err.message}`);
235
- this.bootstrapEntries = []; // empty = all RDAP lookups will skip
236
- }
237
- })();
238
-
239
- await this.bootstrapPromise;
240
- this.bootstrapPromise = null;
181
+ /** Retry and confirm cleanup of a standalone DNS client, if one is retained. */
182
+ public stop(): Promise<void> {
183
+ if (this.sharedDnsClient) return Promise.resolve();
184
+ return this.enqueueDnsLifecycle(() => this.terminateOwnedDnsClient());
241
185
  }
242
186
 
243
- /**
244
- * Find the RDAP base URL for a given IP via longest-prefix match
245
- */
246
- private matchRir(ip: string): string | null {
247
- if (!this.bootstrapEntries || this.bootstrapEntries.length === 0) return null;
248
-
249
- const ipNum = this.ipToNumber(ip);
250
-
251
- for (const entry of this.bootstrapEntries) {
252
- const mask = (0xFFFFFFFF << (32 - entry.maskBits)) >>> 0;
253
- if ((ipNum & mask) === (entry.prefixNum & mask)) {
254
- return entry.baseUrl;
255
- }
256
- }
257
-
258
- return null;
259
- }
187
+ // ─── RDAP Subsystem (delegated to @push.rocks/smartwhois) ───────────
260
188
 
261
189
  /**
262
- * Query RDAP for registration data
190
+ * Query RDAP for registration data via the smartwhois RdapClient (IPv4 and IPv6).
263
191
  */
264
192
  private async queryRdap(ip: string): Promise<{
265
193
  registrantOrg: string | null;
@@ -268,160 +196,20 @@ export class IpIntelligence {
268
196
  networkCidrs: string[] | null;
269
197
  abuseContact: string | null;
270
198
  } | null> {
271
- await this.ensureBootstrap();
272
- const baseUrl = this.matchRir(ip);
273
- if (!baseUrl) return null;
274
-
275
- const controller = new AbortController();
276
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
277
199
  try {
278
- const response = await fetch(`${baseUrl}/ip/${ip}`, {
279
- signal: controller.signal,
280
- headers: {
281
- 'Accept': 'application/rdap+json',
282
- 'User-Agent': '@push.rocks/smartnetwork',
283
- },
284
- });
285
- if (!response.ok) return null;
286
- const data = await response.json() as any;
287
-
288
- let registrantOrg: string | null = null;
289
- let registrantCountry: string | null = data.country || null;
290
- let abuseContact: string | null = null;
291
-
292
- const { networkRange, networkCidrs } = this.parseRdapNetworkInfo(data);
293
-
294
- // Parse entities
295
- if (data.entities && Array.isArray(data.entities)) {
296
- for (const entity of data.entities) {
297
- const roles: string[] = entity.roles || [];
298
-
299
- if (roles.includes('registrant') || roles.includes('administrative')) {
300
- const orgName = this.extractVcardFn(entity);
301
- if (orgName) registrantOrg = orgName;
302
-
303
- // Try to get country from registrant address if not at top level
304
- if (!registrantCountry) {
305
- registrantCountry = this.extractVcardCountry(entity);
306
- }
307
- }
308
-
309
- if (roles.includes('abuse')) {
310
- abuseContact = this.extractVcardEmail(entity);
311
- // Check nested entities for abuse contact
312
- if (!abuseContact && entity.entities) {
313
- for (const subEntity of entity.entities) {
314
- const subRoles: string[] = subEntity.roles || [];
315
- if (subRoles.includes('abuse')) {
316
- abuseContact = this.extractVcardEmail(subEntity);
317
- if (abuseContact) break;
318
- }
319
- }
320
- }
321
- }
322
- }
323
- }
324
-
325
- return { registrantOrg, registrantCountry, networkRange, networkCidrs, abuseContact };
200
+ const rdapRecord = await this.rdapClient.lookupIp(ip);
201
+ if (!rdapRecord) return null;
202
+ return {
203
+ registrantOrg: rdapRecord.registrantOrg ?? null,
204
+ registrantCountry: rdapRecord.registrantCountry ?? null,
205
+ networkRange: rdapRecord.networkRange ?? null,
206
+ networkCidrs: rdapRecord.networkCidrs ?? null,
207
+ abuseContact: rdapRecord.abuseEmail ?? null,
208
+ };
326
209
  } catch (err: any) {
327
210
  this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`);
328
211
  return null;
329
- } finally {
330
- clearTimeout(timeoutId);
331
- }
332
- }
333
-
334
- private parseRdapNetworkInfo(data: any): IRdapNetworkInfo {
335
- const cidrs = this.extractRdapCidrs(data);
336
- if (cidrs.length > 0) {
337
- return {
338
- networkRange: cidrs[0],
339
- networkCidrs: cidrs,
340
- };
341
- }
342
-
343
- if (typeof data.startAddress === 'string' && typeof data.endAddress === 'string') {
344
- const rangeCidrs = this.ipv4RangeToCidrs(data.startAddress, data.endAddress);
345
- return {
346
- networkRange: rangeCidrs.length === 1
347
- ? rangeCidrs[0]
348
- : `${data.startAddress} - ${data.endAddress}`,
349
- networkCidrs: rangeCidrs.length > 0 ? rangeCidrs : null,
350
- };
351
- }
352
-
353
- return { networkRange: null, networkCidrs: null };
354
- }
355
-
356
- private extractRdapCidrs(data: any): string[] {
357
- if (!Array.isArray(data.cidr0_cidrs)) return [];
358
- return data.cidr0_cidrs
359
- .map((cidr: any) => {
360
- const prefix = cidr?.v4prefix || cidr?.v6prefix;
361
- const length = Number(cidr?.length);
362
- if (typeof prefix !== 'string' || !Number.isInteger(length)) return null;
363
- return `${prefix}/${length}`;
364
- })
365
- .filter(Boolean) as string[];
366
- }
367
-
368
- /**
369
- * Extract the 'fn' (formatted name) from an entity's vcardArray
370
- */
371
- private extractVcardFn(entity: any): string | null {
372
- if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
373
- const properties = entity.vcardArray[1];
374
- if (!Array.isArray(properties)) return null;
375
-
376
- for (const prop of properties) {
377
- if (Array.isArray(prop) && prop[0] === 'fn') {
378
- return prop[3] || null;
379
- }
380
- }
381
- return null;
382
- }
383
-
384
- /**
385
- * Extract email from an entity's vcardArray
386
- */
387
- private extractVcardEmail(entity: any): string | null {
388
- if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
389
- const properties = entity.vcardArray[1];
390
- if (!Array.isArray(properties)) return null;
391
-
392
- for (const prop of properties) {
393
- if (Array.isArray(prop) && prop[0] === 'email') {
394
- return prop[3] || null;
395
- }
396
212
  }
397
- return null;
398
- }
399
-
400
- /**
401
- * Extract country from an entity's vcardArray address field
402
- */
403
- private extractVcardCountry(entity: any): string | null {
404
- if (!entity.vcardArray || !Array.isArray(entity.vcardArray)) return null;
405
- const properties = entity.vcardArray[1];
406
- if (!Array.isArray(properties)) return null;
407
-
408
- for (const prop of properties) {
409
- if (Array.isArray(prop) && prop[0] === 'adr') {
410
- // The label parameter often contains the full address with country at the end
411
- const label = prop[1]?.label;
412
- if (typeof label === 'string') {
413
- const lines = label.split('\n');
414
- const lastLine = lines[lines.length - 1]?.trim();
415
- if (lastLine && lastLine.length > 1) return lastLine;
416
- }
417
- // Also check the structured value (7-element array, last element is country)
418
- const value = prop[3];
419
- if (Array.isArray(value) && value.length >= 7 && value[6]) {
420
- return value[6];
421
- }
422
- }
423
- }
424
- return null;
425
213
  }
426
214
 
427
215
  // ─── Team Cymru DNS Subsystem ───────────────────────────────────────
@@ -432,46 +220,68 @@ export class IpIntelligence {
432
220
  * Response: "ASN | prefix | CC | rir | date"
433
221
  */
434
222
  private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
435
- const external = this.sharedDnsClient !== null;
436
- let dnsClient = this.sharedDnsClient;
437
- try {
438
- const reversed = ip.split('.').reverse().join('.');
439
- const queryName = `${reversed}.origin.asn.cymru.com`;
440
-
441
- if (!dnsClient) {
442
- dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
443
- strategy: 'prefer-system',
444
- allowDohFallback: true,
445
- timeoutMs: this.timeout,
446
- });
223
+ const query = async (
224
+ dnsClientArg: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>,
225
+ ) => {
226
+ try {
227
+ const reversed = ip.split('.').reverse().join('.');
228
+ const queryName = `${reversed}.origin.asn.cymru.com`;
229
+ const records = await dnsClientArg.getRecordsTxt(queryName);
230
+ if (!records || records.length === 0) return null;
231
+
232
+ // Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
233
+ const txt = records[0].value || (records[0] as any).data;
234
+ if (!txt) return null;
235
+
236
+ const parts = txt.split('|').map((s: string) => s.trim());
237
+ if (parts.length < 3) return null;
238
+
239
+ const asn = parseInt(parts[0], 10);
240
+ if (isNaN(asn)) return null;
241
+
242
+ return {
243
+ asn,
244
+ prefix: parts[1] || '',
245
+ country: parts[2] || '',
246
+ };
247
+ } catch (err: any) {
248
+ this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
249
+ return null;
447
250
  }
251
+ };
448
252
 
449
- const records = await dnsClient.getRecordsTxt(queryName);
450
- if (!records || records.length === 0) return null;
451
-
452
- // Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
453
- const txt = records[0].value || (records[0] as any).data;
454
- if (!txt) return null;
253
+ if (this.sharedDnsClient) {
254
+ return query(this.sharedDnsClient);
255
+ }
455
256
 
456
- const parts = txt.split('|').map((s: string) => s.trim());
457
- if (parts.length < 3) return null;
257
+ return this.enqueueDnsLifecycle(async () => {
258
+ await this.terminateOwnedDnsClient();
259
+ const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
260
+ strategy: 'prefer-system',
261
+ allowDohFallback: true,
262
+ timeoutMs: this.timeout,
263
+ });
264
+ this.ownedDnsClient = dnsClient;
265
+ try {
266
+ return await query(dnsClient);
267
+ } finally {
268
+ await this.terminateOwnedDnsClient();
269
+ }
270
+ });
271
+ }
458
272
 
459
- const asn = parseInt(parts[0], 10);
460
- if (isNaN(asn)) return null;
273
+ private enqueueDnsLifecycle<T>(operationArg: () => Promise<T>): Promise<T> {
274
+ const result = this.dnsLifecycleQueue.then(operationArg);
275
+ this.dnsLifecycleQueue = result.then(() => {}, () => {});
276
+ return result;
277
+ }
461
278
 
462
- return {
463
- asn,
464
- prefix: parts[1] || '',
465
- country: parts[2] || '',
466
- };
467
- } catch (err: any) {
468
- this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
469
- return null;
470
- } finally {
471
- // Only destroy clients we created ourselves; leave injected ones alone.
472
- if (!external && dnsClient) {
473
- dnsClient.destroy();
474
- }
279
+ private async terminateOwnedDnsClient(): Promise<void> {
280
+ const dnsClient = this.ownedDnsClient;
281
+ if (!dnsClient) return;
282
+ await dnsClient.terminate();
283
+ if (this.ownedDnsClient === dnsClient) {
284
+ this.ownedDnsClient = null;
475
285
  }
476
286
  }
477
287
 
@@ -601,71 +411,4 @@ export class IpIntelligence {
601
411
 
602
412
  return { country, countryCode, city, latitude, longitude, accuracyRadius, timezone, asn, asnOrg };
603
413
  }
604
-
605
- // ─── Utilities ──────────────────────────────────────────────────────
606
-
607
- /**
608
- * Convert an IPv4 address string to a 32-bit unsigned number
609
- */
610
- private ipToNumber(ip: string): number {
611
- const parts = ip.split('.');
612
- return (
613
- ((parseInt(parts[0], 10) << 24) |
614
- (parseInt(parts[1], 10) << 16) |
615
- (parseInt(parts[2], 10) << 8) |
616
- parseInt(parts[3], 10)) >>> 0
617
- );
618
- }
619
-
620
- private ipv4RangeToCidrs(startIp: string, endIp: string): string[] {
621
- const start = this.ipv4ToBigInt(startIp);
622
- const end = this.ipv4ToBigInt(endIp);
623
- if (start === undefined || end === undefined || start > end) return [];
624
-
625
- const cidrs: string[] = [];
626
- let current = start;
627
- while (current <= end) {
628
- let maxBlockSize = current === 0n ? 1n << 32n : current & -current;
629
- const remaining = end - current + 1n;
630
- while (maxBlockSize > remaining) {
631
- maxBlockSize = maxBlockSize / 2n;
632
- }
633
- const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize);
634
- cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`);
635
- current += maxBlockSize;
636
- }
637
- return cidrs;
638
- }
639
-
640
- private ipv4ToBigInt(ip: string): bigint | undefined {
641
- const parts = ip.trim().split('.');
642
- if (parts.length !== 4) return undefined;
643
- let result = 0n;
644
- for (const part of parts) {
645
- if (!/^\d+$/.test(part)) return undefined;
646
- const number = Number(part);
647
- if (!Number.isInteger(number) || number < 0 || number > 255) return undefined;
648
- result = (result * 256n) + BigInt(number);
649
- }
650
- return result;
651
- }
652
-
653
- private numberToIpv4(value: bigint): string {
654
- return [
655
- Number((value >> 24n) & 255n),
656
- Number((value >> 16n) & 255n),
657
- Number((value >> 8n) & 255n),
658
- Number(value & 255n),
659
- ].join('.');
660
- }
661
-
662
- private powerOfTwoExponent(value: bigint): number {
663
- let exponent = 0;
664
- let remaining = value;
665
- while (remaining > 1n) {
666
- remaining >>= 1n;
667
- exponent++;
668
- }
669
- return exponent;
670
- }
671
414
  }
@@ -46,6 +46,17 @@ type TNetworkCommands = {
46
46
  };
47
47
  };
48
48
 
49
+ /** Internal marker for an activation failure whose cleanup still needs an owner. */
50
+ export class RustNetworkBridgeActivationCleanupError extends AggregateError {
51
+ constructor(activationErrorArg: unknown, cleanupErrorArg: unknown) {
52
+ super(
53
+ [activationErrorArg, cleanupErrorArg],
54
+ 'RustNetworkBridge activation failed and cleanup could not be confirmed',
55
+ );
56
+ this.name = 'RustNetworkBridgeActivationCleanupError';
57
+ }
58
+ }
59
+
49
60
  function getPlatformSuffix(): string | null {
50
61
  const platform = process.platform;
51
62
  const arch = process.arch;
@@ -85,6 +96,9 @@ export class RustNetworkBridge {
85
96
  }
86
97
 
87
98
  private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TNetworkCommands>>;
99
+ private readonly owners = new Set<object>();
100
+ private cleanupRequired = false;
101
+ private lifecycleQueue: Promise<void> = Promise.resolve();
88
102
 
89
103
  private constructor() {
90
104
  const packageDir = path.resolve(
@@ -115,28 +129,72 @@ export class RustNetworkBridge {
115
129
  });
116
130
  }
117
131
 
118
- /**
119
- * Spawn the Rust binary and wait for it to be ready.
120
- */
121
- public async start(): Promise<void> {
122
- const ok = await this.bridge.spawn();
123
- if (!ok) {
124
- throw new Error('Failed to spawn rustnetwork binary');
125
- }
132
+ private enqueueLifecycle(operationArg: () => Promise<void>): Promise<void> {
133
+ const result = this.lifecycleQueue.then(operationArg);
134
+ this.lifecycleQueue = result.catch(() => {});
135
+ return result;
126
136
  }
127
137
 
128
138
  /**
129
- * Kill the Rust binary.
139
+ * Acquire the shared Rust binary and wait for it to be ready.
130
140
  */
131
- public async stop(): Promise<void> {
132
- this.bridge.kill();
141
+ public start(ownerArg: object = this): Promise<void> {
142
+ return this.enqueueLifecycle(async () => {
143
+ if (this.cleanupRequired) {
144
+ throw new Error('RustNetworkBridge cleanup must succeed before it can start again');
145
+ }
146
+ if (this.owners.size > 0 && !this.bridge.running) {
147
+ throw new Error('RustNetworkBridge stopped while it still had active owners');
148
+ }
149
+ if (this.owners.has(ownerArg)) return;
150
+ if (this.owners.size === 0) {
151
+ let activationError: unknown;
152
+ try {
153
+ const ok = await this.bridge.spawn();
154
+ if (ok) {
155
+ this.owners.add(ownerArg);
156
+ return;
157
+ }
158
+ activationError = new Error('Failed to spawn rustnetwork binary');
159
+ } catch (error) {
160
+ activationError = error;
161
+ }
162
+
163
+ try {
164
+ await this.bridge.terminate();
165
+ } catch (cleanupError) {
166
+ this.owners.add(ownerArg);
167
+ this.cleanupRequired = true;
168
+ throw new RustNetworkBridgeActivationCleanupError(
169
+ activationError,
170
+ cleanupError,
171
+ );
172
+ }
173
+ throw activationError;
174
+ }
175
+ this.owners.add(ownerArg);
176
+ });
133
177
  }
134
178
 
135
179
  /**
136
- * Ensure the bridge is running before sending a command.
180
+ * Release one owner and terminate the Rust binary after the last release.
137
181
  */
138
- private async ensureRunning(): Promise<void> {
139
- // The bridge will throw if not spawned — we just call start() if not yet running
182
+ public stop(ownerArg: object = this): Promise<void> {
183
+ return this.enqueueLifecycle(async () => {
184
+ if (!this.owners.has(ownerArg)) return;
185
+ if (this.owners.size > 1) {
186
+ this.owners.delete(ownerArg);
187
+ return;
188
+ }
189
+ try {
190
+ await this.bridge.terminate();
191
+ } catch (error) {
192
+ this.cleanupRequired = true;
193
+ throw error;
194
+ }
195
+ this.cleanupRequired = false;
196
+ this.owners.delete(ownerArg);
197
+ });
140
198
  }
141
199
 
142
200
  // ===== Command wrappers =====