@push.rocks/smartnetwork 4.10.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.
@@ -63,9 +63,9 @@ export interface IDomainIntelligenceOptions {
63
63
  timeout?: number;
64
64
  /**
65
65
  * Optional injected smartdns client. When provided, DomainIntelligence
66
- * will not create or destroy its own client (the owner — typically
66
+ * will not create or terminate its own client (the owner — typically
67
67
  * SmartNetwork — manages lifecycle). When omitted, a short-lived client
68
- * is created per DNS-layer query and destroyed in finally.
68
+ * is created per DNS-layer query and terminated in finally.
69
69
  */
70
70
  dnsClient?: TSmartdnsClient;
71
71
  }
@@ -114,6 +114,8 @@ export class DomainIntelligence {
114
114
 
115
115
  // Optional injected smartdns client (shared by SmartNetwork)
116
116
  private readonly sharedDnsClient: TSmartdnsClient | null;
117
+ private ownedDnsClient: TSmartdnsClient | null = null;
118
+ private dnsLifecycleQueue: Promise<void> = Promise.resolve();
117
119
 
118
120
  constructor(options?: IDomainIntelligenceOptions) {
119
121
  this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
@@ -169,9 +171,19 @@ export class DomainIntelligence {
169
171
  }
170
172
  }
171
173
 
174
+ if (dnsSettled.status === 'rejected') {
175
+ throw dnsSettled.reason;
176
+ }
177
+
172
178
  return result;
173
179
  }
174
180
 
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());
185
+ }
186
+
175
187
  // ─── RDAP Layer (delegated to @push.rocks/smartwhois) ───────────────
176
188
 
177
189
  /**
@@ -252,8 +264,8 @@ export class DomainIntelligence {
252
264
  * field either populated or null.
253
265
  *
254
266
  * If a shared dnsClient was injected via constructor options, it is
255
- * reused and NOT destroyed (ownership stays with the injector). Otherwise
256
- * a short-lived client is created and destroyed in finally.
267
+ * reused and NOT terminated (ownership stays with the injector). Otherwise
268
+ * a short-lived client is created and terminated in finally.
257
269
  */
258
270
  private async queryDnsLayer(domain: string): Promise<{
259
271
  nameservers: string[] | null;
@@ -263,45 +275,66 @@ export class DomainIntelligence {
263
275
  txtRecords: string[] | null;
264
276
  soaRecord: string | null;
265
277
  } | null> {
266
- const external = this.sharedDnsClient !== null;
267
- let dnsClient: TSmartdnsClient | null = this.sharedDnsClient;
268
- try {
269
- if (!dnsClient) {
270
- dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
271
- strategy: 'prefer-system',
272
- allowDohFallback: true,
273
- timeoutMs: this.timeout,
274
- });
278
+ const query = async (dnsClientArg: TSmartdnsClient) => {
279
+ try {
280
+ const [nsRes, aRes, aaaaRes, mxRes, txtRes, soaRes] = await Promise.allSettled([
281
+ dnsClientArg.getNameServers(domain),
282
+ dnsClientArg.getRecordsA(domain),
283
+ dnsClientArg.getRecordsAAAA(domain),
284
+ dnsClientArg.getRecords(domain, 'MX'),
285
+ dnsClientArg.getRecordsTxt(domain),
286
+ // 'SOA' is in smartdns's runtime dnsTypeMap but missing from the
287
+ // TDnsRecordType union in @tsclass/tsclass — cast to bypass the
288
+ // stale type definition.
289
+ dnsClientArg.getRecords(domain, 'SOA' as any),
290
+ ]);
291
+
292
+ return {
293
+ nameservers: this.parseNsResult(nsRes),
294
+ resolvedIpv4: this.dnsValuesOrNull(aRes),
295
+ resolvedIpv6: this.dnsValuesOrNull(aaaaRes),
296
+ mxRecords: this.parseMxRecords(mxRes),
297
+ txtRecords: this.dnsValuesOrNull(txtRes),
298
+ soaRecord: this.dnsFirstValueOrNull(soaRes),
299
+ };
300
+ } catch (err: any) {
301
+ this.logger.debug?.(`DNS layer failed for ${domain}: ${err.message}`);
302
+ return null;
275
303
  }
304
+ };
276
305
 
277
- const [nsRes, aRes, aaaaRes, mxRes, txtRes, soaRes] = await Promise.allSettled([
278
- dnsClient.getNameServers(domain),
279
- dnsClient.getRecordsA(domain),
280
- dnsClient.getRecordsAAAA(domain),
281
- dnsClient.getRecords(domain, 'MX'),
282
- dnsClient.getRecordsTxt(domain),
283
- // 'SOA' is in smartdns's runtime dnsTypeMap but missing from the
284
- // TDnsRecordType union in @tsclass/tsclass — cast to bypass the
285
- // stale type definition.
286
- dnsClient.getRecords(domain, 'SOA' as any),
287
- ]);
288
-
289
- return {
290
- nameservers: this.parseNsResult(nsRes),
291
- resolvedIpv4: this.dnsValuesOrNull(aRes),
292
- resolvedIpv6: this.dnsValuesOrNull(aaaaRes),
293
- mxRecords: this.parseMxRecords(mxRes),
294
- txtRecords: this.dnsValuesOrNull(txtRes),
295
- soaRecord: this.dnsFirstValueOrNull(soaRes),
296
- };
297
- } catch (err: any) {
298
- this.logger.debug?.(`DNS layer failed for ${domain}: ${err.message}`);
299
- return null;
300
- } finally {
301
- // Only destroy clients we created ourselves; leave injected ones alone.
302
- if (!external && dnsClient) {
303
- dnsClient.destroy();
306
+ if (this.sharedDnsClient) {
307
+ return query(this.sharedDnsClient);
308
+ }
309
+
310
+ return this.enqueueDnsLifecycle(async () => {
311
+ await this.terminateOwnedDnsClient();
312
+ const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
313
+ strategy: 'prefer-system',
314
+ allowDohFallback: true,
315
+ timeoutMs: this.timeout,
316
+ });
317
+ this.ownedDnsClient = dnsClient;
318
+ try {
319
+ return await query(dnsClient);
320
+ } finally {
321
+ await this.terminateOwnedDnsClient();
304
322
  }
323
+ });
324
+ }
325
+
326
+ private enqueueDnsLifecycle<T>(operationArg: () => Promise<T>): Promise<T> {
327
+ const result = this.dnsLifecycleQueue.then(operationArg);
328
+ this.dnsLifecycleQueue = result.then(() => {}, () => {});
329
+ return result;
330
+ }
331
+
332
+ private async terminateOwnedDnsClient(): Promise<void> {
333
+ const dnsClient = this.ownedDnsClient;
334
+ if (!dnsClient) return;
335
+ await dnsClient.terminate();
336
+ if (this.ownedDnsClient === dnsClient) {
337
+ this.ownedDnsClient = null;
305
338
  }
306
339
  }
307
340
 
@@ -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
  }
@@ -89,6 +89,8 @@ export class IpIntelligence {
89
89
 
90
90
  // Optional injected smartdns client (shared by SmartNetwork)
91
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();
92
94
 
93
95
  constructor(options?: IIpIntelligenceOptions) {
94
96
  this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
@@ -130,6 +132,10 @@ export class IpIntelligence {
130
132
  this.queryMaxMind(ip),
131
133
  ]);
132
134
 
135
+ if (cymruResult.status === 'rejected') {
136
+ throw cymruResult.reason;
137
+ }
138
+
133
139
  // Merge RDAP results
134
140
  if (rdapResult.status === 'fulfilled' && rdapResult.value) {
135
141
  const rdap = rdapResult.value;
@@ -172,6 +178,12 @@ export class IpIntelligence {
172
178
  return result;
173
179
  }
174
180
 
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());
185
+ }
186
+
175
187
  // ─── RDAP Subsystem (delegated to @push.rocks/smartwhois) ───────────
176
188
 
177
189
  /**
@@ -208,46 +220,68 @@ export class IpIntelligence {
208
220
  * Response: "ASN | prefix | CC | rir | date"
209
221
  */
210
222
  private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
211
- const external = this.sharedDnsClient !== null;
212
- let dnsClient = this.sharedDnsClient;
213
- try {
214
- const reversed = ip.split('.').reverse().join('.');
215
- const queryName = `${reversed}.origin.asn.cymru.com`;
216
-
217
- if (!dnsClient) {
218
- dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
219
- strategy: 'prefer-system',
220
- allowDohFallback: true,
221
- timeoutMs: this.timeout,
222
- });
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;
223
250
  }
251
+ };
224
252
 
225
- const records = await dnsClient.getRecordsTxt(queryName);
226
- if (!records || records.length === 0) return null;
227
-
228
- // Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
229
- const txt = records[0].value || (records[0] as any).data;
230
- if (!txt) return null;
253
+ if (this.sharedDnsClient) {
254
+ return query(this.sharedDnsClient);
255
+ }
231
256
 
232
- const parts = txt.split('|').map((s: string) => s.trim());
233
- 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
+ }
234
272
 
235
- const asn = parseInt(parts[0], 10);
236
- 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
+ }
237
278
 
238
- return {
239
- asn,
240
- prefix: parts[1] || '',
241
- country: parts[2] || '',
242
- };
243
- } catch (err: any) {
244
- this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
245
- return null;
246
- } finally {
247
- // Only destroy clients we created ourselves; leave injected ones alone.
248
- if (!external && dnsClient) {
249
- dnsClient.destroy();
250
- }
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;
251
285
  }
252
286
  }
253
287
 
@@ -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 =====