@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.
package/readme.md CHANGED
@@ -16,7 +16,7 @@ pnpm add @push.rocks/smartnetwork
16
16
 
17
17
  **@push.rocks/smartnetwork** is your Swiss Army knife for network diagnostics in Node.js. Whether you're building monitoring dashboards, investigating IP/domain ownership, or debugging connectivity — this library has you covered with a clean, async API and zero-config setup.
18
18
 
19
- Under the hood, system-level operations (ICMP ping, traceroute, raw-socket port checks, gateway detection) are powered by a bundled **Rust binary** for maximum performance and cross-platform reliability. Everything else — speed tests, DNS, public IP discovery, IP & domain intelligence, HTTP health checks — runs in pure TypeScript.
19
+ Under the hood, system-level operations (ICMP ping, traceroute, raw-socket port checks, gateway detection) are powered by a bundled **Rust binary** for maximum performance and cross-platform reliability. The remaining orchestration runs in TypeScript, while DNS and intelligence lookups can invoke smartdns's bundled Rust client for UDP or DoH fallback.
20
20
 
21
21
  ### ✨ Key Features
22
22
 
@@ -47,7 +47,7 @@ await network.start();
47
47
 
48
48
  // ... use the network instance ...
49
49
 
50
- // Clean up when done — tears down both the Rust bridge and the smartdns backend
50
+ // Clean up when done. This confirms DNS cleanup and releases the shared Rust bridge.
51
51
  await network.stop();
52
52
  ```
53
53
 
@@ -61,9 +61,9 @@ const network = new SmartNetwork({ cacheTtl: 60000 }); // 60s TTL
61
61
 
62
62
  ### 🕵️ IP Intelligence
63
63
 
64
- Get ASN, organization, geolocation, and RDAP registration data for any IPv4 address. Combines three free public data sources in parallel:
64
+ Get ASN, organization, geolocation, and RDAP registration data for any IPv4 address (RDAP registration data also works for IPv6). Combines three free public data sources in parallel:
65
65
 
66
- - **RDAP** — direct queries to RIRs (RIPE, ARIN, APNIC, LACNIC, AFRINIC) for authoritative registration data
66
+ - **RDAP** — direct queries to RIRs (RIPE, ARIN, APNIC, LACNIC, AFRINIC) for authoritative registration data, via [`@push.rocks/smartwhois`](https://code.foss.global/push.rocks/smartwhois)
67
67
  - **Team Cymru DNS** — fast ASN resolution via DNS TXT records
68
68
  - **MaxMind GeoLite2** — in-memory MMDB databases (auto-downloaded from CDN, periodically refreshed)
69
69
 
@@ -133,7 +133,7 @@ interface IIpIntelligenceResult {
133
133
 
134
134
  Get comprehensive domain registration and DNS data. Runs an **RDAP layer** and a **DNS layer** in parallel, then merges results:
135
135
 
136
- - **RDAP** — queries the correct registry RDAP server (discovered via the [IANA DNS bootstrap](https://data.iana.org/rdap/dns.json)) for registrar, registrant, events, status flags, DNSSEC, and abuse contacts
136
+ - **RDAP** — queries the correct registry RDAP server (discovered via the [IANA DNS bootstrap](https://data.iana.org/rdap/dns.json)) for registrar, registrant, events, status flags, DNSSEC, and abuse contacts, via [`@push.rocks/smartwhois`](https://code.foss.global/push.rocks/smartwhois)
137
137
  - **DNS** — queries A, AAAA, NS, MX, TXT, and SOA records via `@push.rocks/smartdns` (system resolver first, DoH fallback)
138
138
 
139
139
  For gTLDs (`.com`, `.org`, `.net`, etc.) you get the full picture from both layers. For ccTLDs without RDAP support (`.de`, `.fr`, `.nl`, etc.), the DNS layer fills in nameservers, resolved IPs, MX, TXT, and SOA — so you still get useful intelligence even when RDAP isn't available.
@@ -225,6 +225,20 @@ interface IDomainIntelligenceResult {
225
225
 
226
226
  > 💡 **RDAP vs DNS nameservers**: When RDAP is available (most gTLDs), `nameservers` comes from the registry's authoritative parent-zone delegation (`nameserversSource: 'rdap'`). When RDAP is unavailable (many ccTLDs), the DNS layer fills in nameservers via NS record resolution (`nameserversSource: 'dns'`). The `nameserversSource` field tells you which source won.
227
227
 
228
+ When using `IpIntelligence` or `DomainIntelligence` directly without injecting a smartdns client, await `stop()` after the final lookup. A failed confirmed shutdown rejects and retains the client, so call `stop()` again to retry cleanup:
229
+
230
+ ```typescript
231
+ import { DomainIntelligence } from '@push.rocks/smartnetwork';
232
+
233
+ const intelligence = new DomainIntelligence();
234
+ try {
235
+ const result = await intelligence.getIntelligence('example.com');
236
+ console.log(result.registrarName);
237
+ } finally {
238
+ await intelligence.stop();
239
+ }
240
+ ```
241
+
228
242
  ---
229
243
 
230
244
  ### 🏎️ Speed Testing
@@ -474,7 +488,7 @@ const monitor = async () => {
474
488
 
475
489
  | Method | Description | Requires Rust |
476
490
  |--------|-------------|:---:|
477
- | `start()` / `stop()` | Start/stop the Rust bridge and smartdns backend | — |
491
+ | `start()` / `stop()` | Acquire/release the shared Rust bridge and confirm smartdns cleanup. Retry `stop()` if cleanup rejects | Lifecycle |
478
492
  | `getSpeed(opts?)` | Cloudflare speed test (download + upload) | No |
479
493
  | `ping(host, opts?)` | ICMP ping with optional multi-ping stats | Yes |
480
494
  | `traceroute(host, opts?)` | Hop-by-hop network path analysis | Yes |
@@ -484,10 +498,10 @@ const monitor = async () => {
484
498
  | `getGateways()` | List all network interfaces | No |
485
499
  | `getDefaultGateway()` | Get default gateway info | Yes |
486
500
  | `getPublicIps()` | Discover public IPv4/IPv6 (4 fallback services) | No |
487
- | `resolveDns(host)` | Resolve A, AAAA, MX records | No |
501
+ | `resolveDns(host)` | Resolve A, AAAA, MX records | SmartDNS fallback |
488
502
  | `checkEndpoint(url, opts?)` | HTTP/HTTPS health check with RTT | No |
489
- | `getIpIntelligence(ip)` | ASN + org + geo + RDAP registration | No |
490
- | `getDomainIntelligence(domain)` | Registrar, nameservers, events, DNS records, DNSSEC | No |
503
+ | `getIpIntelligence(ip)` | ASN + org + geo + RDAP registration | SmartDNS fallback |
504
+ | `getDomainIntelligence(domain)` | Registrar, nameservers, events, DNS records, DNSSEC | SmartDNS fallback |
491
505
 
492
506
  ### Standalone helpers
493
507
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnetwork',
6
- version: '4.9.0',
6
+ version: '4.10.1',
7
7
  description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
8
8
  }
@@ -63,15 +63,13 @@ 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
  }
72
72
 
73
- // IANA bootstrap for domain RDAP
74
- const IANA_BOOTSTRAP_DNS_URL = 'https://data.iana.org/rdap/dns.json';
75
73
  const DEFAULT_TIMEOUT = 5000;
76
74
 
77
75
  /**
@@ -103,23 +101,30 @@ function emptyResult(domain: string | null = null): IDomainIntelligenceResult {
103
101
  }
104
102
 
105
103
  /**
106
- * DomainIntelligence performs RDAP lookups for domain names using the
107
- * IANA DNS bootstrap to discover the correct registry RDAP endpoint per TLD.
104
+ * DomainIntelligence performs RDAP lookups for domain names via
105
+ * @push.rocks/smartwhois, which resolves the correct registry RDAP endpoint
106
+ * per TLD through the IANA DNS bootstrap.
108
107
  */
109
108
  export class DomainIntelligence {
110
109
  private readonly logger = getLogger();
111
110
  private readonly timeout: number;
112
111
 
113
- // Bootstrap cache: tld (lowercased) -> RDAP base URL (without trailing slash)
114
- private bootstrapEntries: Map<string, string> | null = null;
115
- private bootstrapPromise: Promise<void> | null = null;
112
+ // RDAP client from smartwhois (owns bootstrap caching and registry failover)
113
+ private readonly rdapClient: plugins.smartwhois.RdapClient;
116
114
 
117
115
  // Optional injected smartdns client (shared by SmartNetwork)
118
116
  private readonly sharedDnsClient: TSmartdnsClient | null;
117
+ private ownedDnsClient: TSmartdnsClient | null = null;
118
+ private dnsLifecycleQueue: Promise<void> = Promise.resolve();
119
119
 
120
120
  constructor(options?: IDomainIntelligenceOptions) {
121
121
  this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
122
122
  this.sharedDnsClient = options?.dnsClient ?? null;
123
+ this.rdapClient = new plugins.smartwhois.RdapClient({
124
+ requestTimeoutMs: this.timeout,
125
+ totalTimeoutMs: this.timeout,
126
+ userAgent: '@push.rocks/smartnetwork',
127
+ });
123
128
  }
124
129
 
125
130
  /**
@@ -166,29 +171,62 @@ export class DomainIntelligence {
166
171
  }
167
172
  }
168
173
 
174
+ if (dnsSettled.status === 'rejected') {
175
+ throw dnsSettled.reason;
176
+ }
177
+
169
178
  return result;
170
179
  }
171
180
 
172
- // ─── RDAP Layer (existing logic, wrapped) ───────────────────────────
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
+
187
+ // ─── RDAP Layer (delegated to @push.rocks/smartwhois) ───────────────
173
188
 
174
189
  /**
175
- * Run the full RDAP lookup flow for a pre-normalized domain: extract TLD,
176
- * load bootstrap, match registry, query, and parse. Returns the parsed
177
- * RDAP fields (as a full IDomainIntelligenceResult) or null if any step
178
- * fails or the TLD has no RDAP support.
190
+ * Run the full RDAP lookup flow for a pre-normalized domain via the
191
+ * smartwhois RdapClient. Returns the parsed RDAP fields (as a full
192
+ * IDomainIntelligenceResult) or null if any step fails, the TLD has no
193
+ * RDAP support, or the registry reports the domain as not registered.
179
194
  */
180
195
  private async queryRdapLayer(domain: string): Promise<IDomainIntelligenceResult | null> {
181
- const tld = this.extractTld(domain);
182
- if (!tld) return null;
183
-
184
- await this.ensureBootstrap();
185
- const baseUrl = this.matchTld(tld);
186
- if (!baseUrl) return null;
187
-
188
- const rdapData = await this.queryRdap(domain, baseUrl);
189
- if (!rdapData) return null;
196
+ try {
197
+ const rdapRecord = await this.rdapClient.lookupDomain(domain);
198
+ if (!rdapRecord || !rdapRecord.isRegistered) return null;
199
+ return this.mapRdapRecord(domain, rdapRecord);
200
+ } catch (err: any) {
201
+ this.logger.debug?.(`RDAP query failed for ${domain}: ${err.message}`);
202
+ return null;
203
+ }
204
+ }
190
205
 
191
- return this.parseRdapResponse(domain, rdapData);
206
+ /**
207
+ * Map a smartwhois RDAP domain record onto the IDomainIntelligenceResult
208
+ * shape (RDAP fields only; DNS enrichment fields stay null here).
209
+ */
210
+ private mapRdapRecord(
211
+ domain: string,
212
+ record: plugins.smartwhois.IRdapDomainRecord,
213
+ ): IDomainIntelligenceResult {
214
+ const result = emptyResult(domain);
215
+ result.handle = record.handle ?? null;
216
+ result.status = record.statuses && record.statuses.length > 0 ? record.statuses : null;
217
+ result.registrationDate = record.createdDate ?? null;
218
+ result.expirationDate = record.expiryDate ?? null;
219
+ result.lastChangedDate = record.updatedDate ?? null;
220
+ result.registrarName = record.registrar ?? null;
221
+ result.registrarIanaId = record.registrarIanaId ?? null;
222
+ result.registrantOrg = record.registrantOrg ?? null;
223
+ result.registrantCountry = record.registrantCountry ?? null;
224
+ result.abuseEmail = record.abuseEmail ?? null;
225
+ result.abusePhone = record.abusePhone ?? null;
226
+ result.nameservers =
227
+ record.nameservers && record.nameservers.length > 0 ? record.nameservers : null;
228
+ result.dnssec = record.dnssec ?? null;
229
+ return result;
192
230
  }
193
231
 
194
232
  // ─── Normalization & TLD extraction ─────────────────────────────────
@@ -217,244 +255,6 @@ export class DomainIntelligence {
217
255
  return ascii;
218
256
  }
219
257
 
220
- /**
221
- * Extract the TLD as the last dot-separated label.
222
- */
223
- private extractTld(domain: string): string | null {
224
- const idx = domain.lastIndexOf('.');
225
- if (idx < 0 || idx === domain.length - 1) return null;
226
- return domain.slice(idx + 1);
227
- }
228
-
229
- // ─── Bootstrap Subsystem ────────────────────────────────────────────
230
-
231
- /**
232
- * Load and cache the IANA DNS bootstrap file.
233
- */
234
- private async ensureBootstrap(): Promise<void> {
235
- if (this.bootstrapEntries) return;
236
- if (this.bootstrapPromise) {
237
- await this.bootstrapPromise;
238
- return;
239
- }
240
-
241
- this.bootstrapPromise = (async () => {
242
- try {
243
- const controller = new AbortController();
244
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
245
- try {
246
- const response = await fetch(IANA_BOOTSTRAP_DNS_URL, {
247
- signal: controller.signal,
248
- headers: { 'User-Agent': '@push.rocks/smartnetwork' },
249
- });
250
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
251
- const data = (await response.json()) as { services: [string[], string[]][] };
252
-
253
- const entries = new Map<string, string>();
254
- for (const [tlds, urls] of data.services) {
255
- const baseUrl = urls[0]; // first URL is preferred
256
- if (!baseUrl) continue;
257
- const cleanBase = baseUrl.replace(/\/$/, ''); // strip trailing slash
258
- for (const tld of tlds) {
259
- entries.set(tld.toLowerCase(), cleanBase);
260
- }
261
- }
262
- this.bootstrapEntries = entries;
263
- } finally {
264
- clearTimeout(timeoutId);
265
- }
266
- } catch (err: any) {
267
- this.logger.debug?.(`Failed to load DNS RDAP bootstrap: ${err.message}`);
268
- this.bootstrapEntries = new Map(); // empty = all RDAP lookups will skip
269
- }
270
- })();
271
-
272
- await this.bootstrapPromise;
273
- this.bootstrapPromise = null;
274
- }
275
-
276
- /**
277
- * Find the RDAP base URL for a given TLD via direct lookup.
278
- */
279
- private matchTld(tld: string): string | null {
280
- if (!this.bootstrapEntries || this.bootstrapEntries.size === 0) return null;
281
- return this.bootstrapEntries.get(tld.toLowerCase()) ?? null;
282
- }
283
-
284
- // ─── RDAP Query ─────────────────────────────────────────────────────
285
-
286
- /**
287
- * Perform the RDAP HTTP query for a domain.
288
- */
289
- private async queryRdap(domain: string, baseUrl: string): Promise<any | null> {
290
- const controller = new AbortController();
291
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
292
- try {
293
- const response = await fetch(`${baseUrl}/domain/${encodeURIComponent(domain)}`, {
294
- signal: controller.signal,
295
- headers: {
296
- 'Accept': 'application/rdap+json',
297
- 'User-Agent': '@push.rocks/smartnetwork',
298
- },
299
- });
300
- if (!response.ok) return null;
301
- return await response.json();
302
- } catch (err: any) {
303
- this.logger.debug?.(`RDAP query failed for ${domain}: ${err.message}`);
304
- return null;
305
- } finally {
306
- clearTimeout(timeoutId);
307
- }
308
- }
309
-
310
- // ─── RDAP Response Parsing ──────────────────────────────────────────
311
-
312
- private parseRdapResponse(domain: string, data: any): IDomainIntelligenceResult {
313
- const result = emptyResult(domain);
314
-
315
- if (typeof data.handle === 'string') result.handle = data.handle;
316
-
317
- if (Array.isArray(data.status) && data.status.length > 0) {
318
- result.status = data.status.filter((s: any): s is string => typeof s === 'string');
319
- }
320
-
321
- // Events: registration / expiration / last changed
322
- if (Array.isArray(data.events)) {
323
- const events = this.extractEvents(data.events);
324
- result.registrationDate = events.registration;
325
- result.expirationDate = events.expiration;
326
- result.lastChangedDate = events.lastChanged;
327
- }
328
-
329
- // Registrar (sponsor) and registrant from entities
330
- if (Array.isArray(data.entities)) {
331
- const registrar = this.extractRegistrar(data.entities);
332
- result.registrarName = registrar.name;
333
- result.registrarIanaId = registrar.ianaId;
334
- result.abuseEmail = registrar.abuseEmail;
335
- result.abusePhone = registrar.abusePhone;
336
-
337
- const registrant = this.extractRegistrant(data.entities);
338
- result.registrantOrg = registrant.org;
339
- result.registrantCountry = registrant.country;
340
- }
341
-
342
- // Nameservers
343
- if (Array.isArray(data.nameservers)) {
344
- result.nameservers = this.extractNameservers(data.nameservers);
345
- }
346
-
347
- // DNSSEC: secureDNS.delegationSigned
348
- if (data.secureDNS && typeof data.secureDNS.delegationSigned === 'boolean') {
349
- result.dnssec = data.secureDNS.delegationSigned;
350
- }
351
-
352
- return result;
353
- }
354
-
355
- /**
356
- * Pull registration / expiration / last changed timestamps from an
357
- * RDAP `events` array.
358
- */
359
- private extractEvents(events: any[]): {
360
- registration: string | null;
361
- expiration: string | null;
362
- lastChanged: string | null;
363
- } {
364
- let registration: string | null = null;
365
- let expiration: string | null = null;
366
- let lastChanged: string | null = null;
367
-
368
- for (const ev of events) {
369
- const action = typeof ev?.eventAction === 'string' ? ev.eventAction.toLowerCase() : '';
370
- const date = typeof ev?.eventDate === 'string' ? ev.eventDate : null;
371
- if (!date) continue;
372
- if (action === 'registration') registration = date;
373
- else if (action === 'expiration') expiration = date;
374
- else if (action === 'last changed') lastChanged = date;
375
- }
376
-
377
- return { registration, expiration, lastChanged };
378
- }
379
-
380
- /**
381
- * Extract registrar identity (name, IANA ID) and a nested abuse contact.
382
- */
383
- private extractRegistrar(entities: any[]): {
384
- name: string | null;
385
- ianaId: number | null;
386
- abuseEmail: string | null;
387
- abusePhone: string | null;
388
- } {
389
- let name: string | null = null;
390
- let ianaId: number | null = null;
391
- let abuseEmail: string | null = null;
392
- let abusePhone: string | null = null;
393
-
394
- for (const entity of entities) {
395
- const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : [];
396
- if (!roles.includes('registrar')) continue;
397
-
398
- name = this.extractVcardFn(entity);
399
-
400
- // IANA Registrar ID lives in publicIds[]
401
- if (Array.isArray(entity.publicIds)) {
402
- for (const pid of entity.publicIds) {
403
- if (pid && typeof pid === 'object' && pid.type === 'IANA Registrar ID') {
404
- const parsed = parseInt(String(pid.identifier), 10);
405
- if (!isNaN(parsed)) ianaId = parsed;
406
- }
407
- }
408
- }
409
-
410
- // Abuse contact: nested entity with role "abuse"
411
- if (Array.isArray(entity.entities)) {
412
- for (const sub of entity.entities) {
413
- const subRoles: string[] = Array.isArray(sub?.roles) ? sub.roles : [];
414
- if (subRoles.includes('abuse')) {
415
- if (!abuseEmail) abuseEmail = this.extractVcardEmail(sub);
416
- if (!abusePhone) abusePhone = this.extractVcardTel(sub);
417
- }
418
- }
419
- }
420
-
421
- break; // first registrar wins
422
- }
423
-
424
- return { name, ianaId, abuseEmail, abusePhone };
425
- }
426
-
427
- /**
428
- * Extract registrant org/country from entities array.
429
- */
430
- private extractRegistrant(entities: any[]): {
431
- org: string | null;
432
- country: string | null;
433
- } {
434
- for (const entity of entities) {
435
- const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : [];
436
- if (!roles.includes('registrant')) continue;
437
- const org = this.extractVcardFn(entity);
438
- const country = this.extractVcardCountry(entity);
439
- return { org, country };
440
- }
441
- return { org: null, country: null };
442
- }
443
-
444
- /**
445
- * Map an RDAP nameservers[] array to a list of lowercased ldhName strings.
446
- */
447
- private extractNameservers(nameservers: any[]): string[] | null {
448
- const out: string[] = [];
449
- for (const ns of nameservers) {
450
- const ldh = ns?.ldhName;
451
- if (typeof ldh === 'string' && ldh.length > 0) {
452
- out.push(ldh.toLowerCase());
453
- }
454
- }
455
- return out.length > 0 ? out : null;
456
- }
457
-
458
258
  // ─── DNS Layer ──────────────────────────────────────────────────────
459
259
 
460
260
  /**
@@ -464,8 +264,8 @@ export class DomainIntelligence {
464
264
  * field either populated or null.
465
265
  *
466
266
  * If a shared dnsClient was injected via constructor options, it is
467
- * reused and NOT destroyed (ownership stays with the injector). Otherwise
468
- * 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.
469
269
  */
470
270
  private async queryDnsLayer(domain: string): Promise<{
471
271
  nameservers: string[] | null;
@@ -475,45 +275,66 @@ export class DomainIntelligence {
475
275
  txtRecords: string[] | null;
476
276
  soaRecord: string | null;
477
277
  } | null> {
478
- const external = this.sharedDnsClient !== null;
479
- let dnsClient: TSmartdnsClient | null = this.sharedDnsClient;
480
- try {
481
- if (!dnsClient) {
482
- dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
483
- strategy: 'prefer-system',
484
- allowDohFallback: true,
485
- timeoutMs: this.timeout,
486
- });
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;
487
303
  }
304
+ };
488
305
 
489
- const [nsRes, aRes, aaaaRes, mxRes, txtRes, soaRes] = await Promise.allSettled([
490
- dnsClient.getNameServers(domain),
491
- dnsClient.getRecordsA(domain),
492
- dnsClient.getRecordsAAAA(domain),
493
- dnsClient.getRecords(domain, 'MX'),
494
- dnsClient.getRecordsTxt(domain),
495
- // 'SOA' is in smartdns's runtime dnsTypeMap but missing from the
496
- // TDnsRecordType union in @tsclass/tsclass — cast to bypass the
497
- // stale type definition.
498
- dnsClient.getRecords(domain, 'SOA' as any),
499
- ]);
500
-
501
- return {
502
- nameservers: this.parseNsResult(nsRes),
503
- resolvedIpv4: this.dnsValuesOrNull(aRes),
504
- resolvedIpv6: this.dnsValuesOrNull(aaaaRes),
505
- mxRecords: this.parseMxRecords(mxRes),
506
- txtRecords: this.dnsValuesOrNull(txtRes),
507
- soaRecord: this.dnsFirstValueOrNull(soaRes),
508
- };
509
- } catch (err: any) {
510
- this.logger.debug?.(`DNS layer failed for ${domain}: ${err.message}`);
511
- return null;
512
- } finally {
513
- // Only destroy clients we created ourselves; leave injected ones alone.
514
- if (!external && dnsClient) {
515
- 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();
516
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;
517
338
  }
518
339
  }
519
340
 
@@ -575,85 +396,4 @@ export class DomainIntelligence {
575
396
  }
576
397
  return out.length > 0 ? out : null;
577
398
  }
578
-
579
- // ─── vCard helpers (duplicated from IpIntelligence) ─────────────────
580
-
581
- /**
582
- * Extract the 'fn' (formatted name) from an entity's vcardArray
583
- */
584
- private extractVcardFn(entity: any): string | null {
585
- if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
586
- const properties = entity.vcardArray[1];
587
- if (!Array.isArray(properties)) return null;
588
-
589
- for (const prop of properties) {
590
- if (Array.isArray(prop) && prop[0] === 'fn') {
591
- return prop[3] || null;
592
- }
593
- }
594
- return null;
595
- }
596
-
597
- /**
598
- * Extract email from an entity's vcardArray
599
- */
600
- private extractVcardEmail(entity: any): string | null {
601
- if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
602
- const properties = entity.vcardArray[1];
603
- if (!Array.isArray(properties)) return null;
604
-
605
- for (const prop of properties) {
606
- if (Array.isArray(prop) && prop[0] === 'email') {
607
- return prop[3] || null;
608
- }
609
- }
610
- return null;
611
- }
612
-
613
- /**
614
- * Extract telephone number from an entity's vcardArray
615
- */
616
- private extractVcardTel(entity: any): string | null {
617
- if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
618
- const properties = entity.vcardArray[1];
619
- if (!Array.isArray(properties)) return null;
620
-
621
- for (const prop of properties) {
622
- if (Array.isArray(prop) && prop[0] === 'tel') {
623
- // tel value can be a string or a uri like "tel:+1.5555555555"
624
- const value = prop[3];
625
- if (typeof value === 'string') {
626
- return value.startsWith('tel:') ? value.slice(4) : value;
627
- }
628
- }
629
- }
630
- return null;
631
- }
632
-
633
- /**
634
- * Extract country from an entity's vcardArray address field
635
- */
636
- private extractVcardCountry(entity: any): string | null {
637
- if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
638
- const properties = entity.vcardArray[1];
639
- if (!Array.isArray(properties)) return null;
640
-
641
- for (const prop of properties) {
642
- if (Array.isArray(prop) && prop[0] === 'adr') {
643
- // The label parameter often contains the full address with country at the end
644
- const label = prop[1]?.label;
645
- if (typeof label === 'string') {
646
- const lines = label.split('\n');
647
- const lastLine = lines[lines.length - 1]?.trim();
648
- if (lastLine && lastLine.length > 1) return lastLine;
649
- }
650
- // Also check the structured value (7-element array, last element is country)
651
- const value = prop[3];
652
- if (Array.isArray(value) && value.length >= 7 && value[6]) {
653
- return value[6];
654
- }
655
- }
656
- }
657
- return null;
658
- }
659
399
  }