rdapper 0.8.0 → 0.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/README.md CHANGED
@@ -2,12 +2,17 @@
2
2
 
3
3
  RDAP‑first domain registration lookups with WHOIS fallback. Produces a single, normalized record shape regardless of source.
4
4
 
5
- - RDAP discovery via IANA bootstrap (`https://data.iana.org/rdap/dns.json`)
6
- - WHOIS TCP 43 client with TLD discovery, registrar referral follow, and curated exceptions
7
- - Normalized output: registrar, contacts, nameservers, statuses, dates, DNSSEC, privacy flag, source metadata
8
- - TypeScript types included; ESM‑only; no external HTTP client (uses global `fetch`)
5
+ - RDAP‑first lookup via [IANA bootstrap](https://data.iana.org/rdap/dns.json) with automatic WHOIS fallback when needed
6
+ - Smart WHOIS client (TCP 43): authoritative TLD discovery, registrar referral follow, and curated exceptions
7
+ - Rich, normalized results: registrar, contacts, nameservers, EPP statuses, key dates, DNSSEC, privacy flag, source metadata
8
+ - RDAP enrichment: follows related/entity/registrar links (bounded) to fill in missing details
9
+ - TypeScript‑first: shipped types, ESM‑only, zero external HTTP client (uses global `fetch`)
9
10
 
10
- ### [🦉 See it in action on hoot.sh!](https://hoot.sh)
11
+ > [!IMPORTANT]
12
+ > Edge runtimes (e.g., Vercel Edge, Cloudflare Workers) do not support WHOIS (TCP 43 via `node:net`). Use RDAP‑only mode by setting `{ rdapOnly: true }`.
13
+
14
+ > [!TIP]
15
+ > See `rdapper` in action on [**Domainstack**](https://domainstack.io)!
11
16
 
12
17
  ## Install
13
18
 
@@ -18,23 +23,14 @@ npm install rdapper
18
23
  ## Quick Start
19
24
 
20
25
  ```ts
21
- import { lookupDomain } from "rdapper";
26
+ import { lookup } from "rdapper";
22
27
 
23
- const { ok, record, error } = await lookupDomain("example.com");
28
+ const { ok, record, error } = await lookup("example.com");
24
29
 
25
30
  if (!ok) throw new Error(error);
26
31
  console.log(record); // normalized DomainRecord
27
32
  ```
28
33
 
29
- Also available:
30
-
31
- ```ts
32
- import { isRegistered, isAvailable } from "rdapper";
33
-
34
- await isRegistered("example.com"); // => true
35
- await isAvailable("likely-unregistered-thing-320485230458.com"); // => false
36
- ```
37
-
38
34
  Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1):
39
35
 
40
36
  ```ts
@@ -45,11 +41,25 @@ toRegistrableDomain("spark-public.s3.amazonaws.com"); // => "amazonaws.com" (I
45
41
  toRegistrableDomain("192.168.0.1"); // => null
46
42
  ```
47
43
 
44
+ Convenience helpers to quickly check availability:
45
+
46
+ ```ts
47
+ import { isRegistered, isAvailable } from "rdapper";
48
+
49
+ await isRegistered("example.com"); // => true
50
+ await isRegistered("likely-unregistered-thing-320485230458.com"); // => false
51
+ await isAvailable("example.com"); // => false
52
+ await isAvailable("likely-unregistered-thing-320485230458.com"); // => true
53
+ ```
54
+
48
55
  ## API
49
56
 
50
- - `lookupDomain(domain, options?) => Promise<LookupResult>`
57
+ - `lookup(domain, options?) => Promise<LookupResult>`
51
58
  - Tries RDAP first if supported by the domain’s TLD; if unavailable or fails, falls back to WHOIS (unless toggled off).
52
59
  - Result is `{ ok: boolean, record?: DomainRecord, error?: string }`.
60
+ - `toRegistrableDomain(input, options?) => string | null`
61
+ - Normalizes a domain or URL to its registrable domain (eTLD+1).
62
+ - Returns the registrable domain string, or `null` for IPs/invalid input; [options](https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts) are forwarded to `tldts` (e.g., `allowPrivateDomains`).
53
63
  - `isRegistered(domain, options?) => Promise<boolean>`
54
64
  - `isAvailable(domain, options?) => Promise<boolean>`
55
65
 
@@ -64,17 +74,17 @@ echo "example.com" | npx rdapper
64
74
 
65
75
  ### Edge runtimes (e.g., Vercel Edge)
66
76
 
67
- WHOIS requires a raw TCP connection over port 43 via `node:net`, which is not available on edge runtimes. This package lazily loads `node:net` only when the WHOIS code path runs. To use rdapper safely on edge:
77
+ WHOIS requires a raw TCP connection over port 43 via `node:net`, which is not available on edge runtimes. rdapper lazily loads `node:net` only when the WHOIS path is taken.
68
78
 
69
- - Prefer RDAP only:
79
+ - Prefer RDAP only on edge:
70
80
 
71
81
  ```ts
72
- import { lookupDomain } from "rdapper";
82
+ import { lookup } from "rdapper";
73
83
 
74
- const res = await lookupDomain("example.com", { rdapOnly: true });
84
+ const res = await lookup("example.com", { rdapOnly: true });
75
85
  ```
76
86
 
77
- - If `rdapOnly` is omitted and the code path reaches WHOIS on edge, rdapper throws a clear runtime error indicating WHOIS is unsupported on edge and to run in Node or set `rdapOnly: true`.
87
+ - If `rdapOnly` is omitted and the code path reaches WHOIS on edge, rdapper throws a clear runtime error advising to run in Node or set `{ rdapOnly: true }`.
78
88
 
79
89
  ### Options
80
90
 
@@ -98,7 +108,7 @@ The exact presence of fields depends on registry/registrar data and whether RDAP
98
108
  ```ts
99
109
  interface DomainRecord {
100
110
  domain: string; // normalized name (unicode when available)
101
- tld: string; // terminal TLD label (e.g., "com")
111
+ tld: string; // public suffix (can be multi-label, e.g., "com", "co.uk")
102
112
  isRegistered: boolean; // availability heuristic (WHOIS) or true (RDAP)
103
113
  isIDN?: boolean; // uses punycode labels (xn--)
104
114
  unicodeName?: string; // RDAP unicodeName when provided
@@ -152,7 +162,7 @@ interface DomainRecord {
152
162
  }>;
153
163
  privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics
154
164
  whoisServer?: string; // authoritative WHOIS queried (if any)
155
- rdapServers?: string[]; // RDAP base URLs tried
165
+ rdapServers?: string[]; // RDAP URLs tried (bootstrap bases and related/entity links)
156
166
  rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
157
167
  rawWhois?: string; // raw WHOIS text (only when options.includeRaw)
158
168
  source: "rdap" | "whois"; // which path produced data
@@ -191,13 +201,13 @@ Timeouts are enforced per request using a simple race against `timeoutMs` (defau
191
201
 
192
202
  ## Development
193
203
 
194
- - Build: `npm run build`
195
- - Test: `npm test` (Vitest)
204
+ - Build: `npm run build` ([tsdown](https://tsdown.dev/))
205
+ - Test: `npm test` ([Vitest](https://vitest.dev/))
196
206
  - By default, tests are offline/deterministic.
197
- - Watch mode: `npm run test:watch`
198
- - Coverage: `npm run test:coverage`
199
- - Smoke tests that hit the network are gated by `SMOKE=1`, e.g. `SMOKE=1 npm run test:smoke`.
200
- - Lint/format: `npm run lint` (Biome)
207
+ - Watch mode: `npm run dev`
208
+ - Coverage: `npm run test:run -- --coverage`
209
+ - Smoke tests that hit the network are gated by `SMOKE=1`, e.g. `SMOKE=1 npm test`.
210
+ - Lint/format: `npm run lint` ([Biome](https://biomejs.dev/))
201
211
 
202
212
  Project layout:
203
213
 
@@ -214,7 +224,7 @@ Project layout:
214
224
  - Some TLDs provide no RDAP service; `rdapOnly: true` will fail for them.
215
225
  - Registries may throttle or block WHOIS; respect rate limits and usage policies.
216
226
  - Field presence depends on source and privacy policies (e.g., redaction/withholding).
217
- - Public suffix detection uses `tldts` with ICANN‑only defaults (Private section is ignored). If you need behavior closer to `psl` that considers private suffixes, see the `allowPrivateDomains` option in the `tldts` docs (rdapper currently sticks to ICANN‑only by default). See: [tldts migration notes](https://github.com/remusao/tldts#migrating-from-other-libraries).
227
+ - Public suffix detection uses `tldts` with ICANN‑only defaults (Private section is ignored). You can pass options through to `tldts` via `toRegistrableDomain`/`getDomainParts`/`getDomainTld` (e.g., `allowPrivateDomains`) to customize behavior. See: [tldts migration notes](https://github.com/remusao/tldts#migrating-from-other-libraries).
218
228
 
219
229
  ## License
220
230
 
package/bin/cli.js CHANGED
@@ -6,14 +6,14 @@
6
6
  // echo "example.com" | npx rdapper
7
7
 
8
8
  import { createInterface } from "node:readline";
9
- import { lookupDomain } from "../dist/index.js";
9
+ import { lookup } from "../dist/index.js";
10
10
 
11
11
  async function main() {
12
12
  if (process.argv.length > 2) {
13
13
  // URL(s) specified in the command arguments
14
14
  console.log(
15
15
  JSON.stringify(
16
- await lookupDomain(process.argv[process.argv.length - 1]),
16
+ await lookup(process.argv[process.argv.length - 1]),
17
17
  null,
18
18
  2,
19
19
  ),
@@ -24,7 +24,7 @@ async function main() {
24
24
  input: process.stdin,
25
25
  });
26
26
  rlInterface.on("line", async (line) => {
27
- console.log(JSON.stringify(await lookupDomain(line), null, 2));
27
+ console.log(JSON.stringify(await lookup(line), null, 2));
28
28
  });
29
29
  }
30
30
  }
package/dist/index.d.ts CHANGED
@@ -129,11 +129,14 @@ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respo
129
129
  //#region src/lib/domain.d.ts
130
130
  type ParseOptions = Parameters<typeof parse>[1];
131
131
  /**
132
- * Parse a domain into its parts. Accepts options which are passed to tldts.parse().
132
+ * Parse a domain into its parts. Passes options to `tldts.parse()`.
133
133
  * @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
134
134
  */
135
135
  declare function getDomainParts(domain: string, opts?: ParseOptions): ReturnType<typeof parse>;
136
- /** Get the TLD (ICANN-only public suffix) of a domain. */
136
+ /**
137
+ * Get the TLD (ICANN-only public suffix) of a domain. Passes options to `tldts.parse()`.
138
+ * @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
139
+ */
137
140
  declare function getDomainTld(domain: string, opts?: ParseOptions): string | null;
138
141
  /**
139
142
  * Basic domain validity check (hostname-like), not performing DNS or RDAP.
@@ -141,7 +144,9 @@ declare function getDomainTld(domain: string, opts?: ParseOptions): string | nul
141
144
  declare function isLikelyDomain(value: string): boolean;
142
145
  /**
143
146
  * Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1).
144
- * Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs).
147
+ * Passes options to `tldts.parse()`.
148
+ * Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs)
149
+ * @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
145
150
  */
146
151
  declare function toRegistrableDomain(input: string, opts?: ParseOptions): string | null;
147
152
  //#endregion
@@ -150,12 +155,20 @@ declare function toRegistrableDomain(input: string, opts?: ParseOptions): string
150
155
  * High-level lookup that prefers RDAP and falls back to WHOIS.
151
156
  * Ensures a standardized DomainRecord, independent of the source.
152
157
  */
153
- declare function lookupDomain(domain: string, opts?: LookupOptions): Promise<LookupResult>;
154
- /** Determine if a domain appears available (not registered).
155
- * Performs a lookup and resolves to a boolean. Rejects on lookup error. */
158
+ declare function lookup(domain: string, opts?: LookupOptions): Promise<LookupResult>;
159
+ /**
160
+ * Determine if a domain appears available (not registered).
161
+ * Performs a lookup and resolves to a boolean. Rejects on lookup error.
162
+ */
156
163
  declare function isAvailable(domain: string, opts?: LookupOptions): Promise<boolean>;
157
- /** Determine if a domain appears registered.
158
- * Performs a lookup and resolves to a boolean. Rejects on lookup error. */
164
+ /**
165
+ * Determine if a domain appears registered.
166
+ * Performs a lookup and resolves to a boolean. Rejects on lookup error.
167
+ */
159
168
  declare function isRegistered(domain: string, opts?: LookupOptions): Promise<boolean>;
169
+ /**
170
+ * @deprecated Use `lookup` instead.
171
+ */
172
+ declare const lookupDomain: typeof lookup;
160
173
  //#endregion
161
- export { Contact, DomainRecord, FetchLike, LookupOptions, LookupResult, LookupSource, Nameserver, RegistrarInfo, StatusEvent, getDomainParts, getDomainTld, isAvailable, isLikelyDomain, isRegistered, lookupDomain, toRegistrableDomain };
174
+ export { Contact, DomainRecord, FetchLike, LookupOptions, LookupResult, LookupSource, Nameserver, RegistrarInfo, StatusEvent, getDomainParts, getDomainTld, isAvailable, isLikelyDomain, isRegistered, lookup, lookupDomain, toRegistrableDomain };
package/dist/index.js CHANGED
@@ -2,13 +2,16 @@ import { parse } from "tldts";
2
2
 
3
3
  //#region src/lib/domain.ts
4
4
  /**
5
- * Parse a domain into its parts. Accepts options which are passed to tldts.parse().
5
+ * Parse a domain into its parts. Passes options to `tldts.parse()`.
6
6
  * @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
7
7
  */
8
8
  function getDomainParts(domain, opts) {
9
9
  return parse(domain, { ...opts });
10
10
  }
11
- /** Get the TLD (ICANN-only public suffix) of a domain. */
11
+ /**
12
+ * Get the TLD (ICANN-only public suffix) of a domain. Passes options to `tldts.parse()`.
13
+ * @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
14
+ */
12
15
  function getDomainTld(domain, opts) {
13
16
  return getDomainParts(domain, {
14
17
  allowPrivateDomains: false,
@@ -24,7 +27,9 @@ function isLikelyDomain(value) {
24
27
  }
25
28
  /**
26
29
  * Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1).
27
- * Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs).
30
+ * Passes options to `tldts.parse()`.
31
+ * Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs)
32
+ * @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
28
33
  */
29
34
  function toRegistrableDomain(input, opts) {
30
35
  const raw = (input ?? "").trim();
@@ -39,21 +44,6 @@ function toRegistrableDomain(input, opts) {
39
44
  if (domain === "") return null;
40
45
  return domain.toLowerCase();
41
46
  }
42
- const WHOIS_AVAILABLE_PATTERNS = [
43
- /\bno match\b/i,
44
- /\bnot found\b/i,
45
- /\bno entries found\b/i,
46
- /\bno data found\b/i,
47
- /\bavailable for registration\b/i,
48
- /\bdomain\s+available\b/i,
49
- /\bdomain status[:\s]+available\b/i,
50
- /\bobject does not exist\b/i,
51
- /\bthe queried object does not exist\b/i
52
- ];
53
- function isWhoisAvailable(text) {
54
- if (!text) return false;
55
- return WHOIS_AVAILABLE_PATTERNS.some((re) => re.test(text));
56
- }
57
47
 
58
48
  //#endregion
59
49
  //#region src/lib/async.ts
@@ -90,9 +80,9 @@ async function getRdapBaseUrlsForTld(tld, options) {
90
80
  const target = tld.toLowerCase();
91
81
  const bases = [];
92
82
  for (const svc of data.services) {
93
- const tlds = svc[0];
83
+ const tlds = svc[0].map((x) => x.toLowerCase());
94
84
  const urls = svc[1];
95
- if (tlds.map((x) => x.toLowerCase()).includes(target)) for (const u of urls) {
85
+ if (tlds.includes(target)) for (const u of urls) {
96
86
  const base = u.endsWith("/") ? u : `${u}/`;
97
87
  bases.push(base);
98
88
  }
@@ -117,10 +107,9 @@ async function fetchRdapDomain(domain, baseUrl, options) {
117
107
  const bodyText = await res.text();
118
108
  throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
119
109
  }
120
- const json = await res.json();
121
110
  return {
122
111
  url,
123
- json
112
+ json: await res.json()
124
113
  };
125
114
  }
126
115
 
@@ -221,10 +210,9 @@ async function fetchRdapUrl(url, options) {
221
210
  const bodyText = await res.text();
222
211
  throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
223
212
  }
224
- const json = await res.json();
225
213
  return {
226
214
  url,
227
- json
215
+ json: await res.json()
228
216
  };
229
217
  }
230
218
  function toArray(val) {
@@ -564,12 +552,9 @@ function parseVcard(vcardArray) {
564
552
  */
565
553
  async function whoisQuery(server, query, options) {
566
554
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
567
- const port = 43;
568
- const host = server.replace(/^whois:\/\//i, "");
569
- const text = await withTimeout(queryTcp(host, port, query, options), timeoutMs, "WHOIS timeout");
570
555
  return {
571
556
  serverQueried: server,
572
- text
557
+ text: await withTimeout(queryTcp(server.replace(/^whois:\/\//i, ""), 43, query, options), timeoutMs, "WHOIS timeout")
573
558
  };
574
559
  }
575
560
  async function queryTcp(host, port, query, options) {
@@ -754,8 +739,114 @@ function normalizeServer(server) {
754
739
  return server.replace(/^whois:\/\//i, "").replace(/\/$/, "");
755
740
  }
756
741
 
742
+ //#endregion
743
+ //#region src/whois/merge.ts
744
+ function dedupeStatuses(a, b) {
745
+ const list = [...a || [], ...b || []];
746
+ const seen = /* @__PURE__ */ new Set();
747
+ const out = [];
748
+ for (const s of list) {
749
+ const key = (s?.status || "").toLowerCase();
750
+ if (!key || seen.has(key)) continue;
751
+ seen.add(key);
752
+ out.push(s);
753
+ }
754
+ return out.length ? out : void 0;
755
+ }
756
+ function dedupeNameservers(a, b) {
757
+ const map = /* @__PURE__ */ new Map();
758
+ for (const ns of [...a || [], ...b || []]) {
759
+ const host = ns.host.toLowerCase();
760
+ const prev = map.get(host);
761
+ if (!prev) {
762
+ map.set(host, {
763
+ ...ns,
764
+ host
765
+ });
766
+ continue;
767
+ }
768
+ const ipv4 = uniq([...prev.ipv4 || [], ...ns.ipv4 || []]);
769
+ const ipv6 = uniq([...prev.ipv6 || [], ...ns.ipv6 || []]);
770
+ map.set(host, {
771
+ host,
772
+ ipv4,
773
+ ipv6
774
+ });
775
+ }
776
+ const out = Array.from(map.values());
777
+ return out.length ? out : void 0;
778
+ }
779
+ function dedupeContacts(a, b) {
780
+ const list = [...a || [], ...b || []];
781
+ const seen = /* @__PURE__ */ new Set();
782
+ const out = [];
783
+ for (const c of list) {
784
+ const key = `${c.type}|${(c.organization || c.name || c.email || "").toString().toLowerCase()}`;
785
+ if (seen.has(key)) continue;
786
+ seen.add(key);
787
+ out.push(c);
788
+ }
789
+ return out.length ? out : void 0;
790
+ }
791
+ /** Conservative merge: start with base; fill missing scalars; union arrays; prefer more informative dates. */
792
+ function mergeWhoisRecords(base, others) {
793
+ const merged = { ...base };
794
+ for (const cur of others) {
795
+ merged.isRegistered = merged.isRegistered || cur.isRegistered;
796
+ merged.registry = merged.registry ?? cur.registry;
797
+ merged.registrar = merged.registrar ?? cur.registrar;
798
+ merged.reseller = merged.reseller ?? cur.reseller;
799
+ merged.statuses = dedupeStatuses(merged.statuses, cur.statuses);
800
+ merged.creationDate = preferEarliestIso(merged.creationDate, cur.creationDate);
801
+ merged.updatedDate = preferLatestIso(merged.updatedDate, cur.updatedDate);
802
+ merged.expirationDate = preferLatestIso(merged.expirationDate, cur.expirationDate);
803
+ merged.deletionDate = merged.deletionDate ?? cur.deletionDate;
804
+ merged.transferLock = Boolean(merged.transferLock || cur.transferLock);
805
+ merged.dnssec = merged.dnssec ?? cur.dnssec;
806
+ merged.nameservers = dedupeNameservers(merged.nameservers, cur.nameservers);
807
+ merged.contacts = dedupeContacts(merged.contacts, cur.contacts);
808
+ merged.privacyEnabled = merged.privacyEnabled ?? cur.privacyEnabled;
809
+ merged.whoisServer = cur.whoisServer ?? merged.whoisServer;
810
+ merged.rawWhois = cur.rawWhois ?? merged.rawWhois;
811
+ }
812
+ return merged;
813
+ }
814
+ function preferEarliestIso(a, b) {
815
+ if (!a) return b;
816
+ if (!b) return a;
817
+ return new Date(a) <= new Date(b) ? a : b;
818
+ }
819
+ function preferLatestIso(a, b) {
820
+ if (!a) return b;
821
+ if (!b) return a;
822
+ return new Date(a) >= new Date(b) ? a : b;
823
+ }
824
+
757
825
  //#endregion
758
826
  //#region src/whois/normalize.ts
827
+ const WHOIS_AVAILABLE_PATTERNS = [
828
+ /\bno match\b/i,
829
+ /\bnot found\b/i,
830
+ /\bno entries found\b/i,
831
+ /\bno data found\b/i,
832
+ /\bavailable for registration\b/i,
833
+ /\bdomain\s+available\b/i,
834
+ /\bdomain status[:\s]+available\b/i,
835
+ /\bobject does not exist\b/i,
836
+ /\bthe queried object does not exist\b/i,
837
+ /\bstatus:\s*free\b/i,
838
+ /\bstatus:\s*available\b/i,
839
+ /\bno object found\b/i,
840
+ /\bnicht gefunden\b/i,
841
+ /\bpending release\b/i
842
+ ];
843
+ /**
844
+ * Best-effort heuristic to determine if a WHOIS response indicates the domain is available.
845
+ */
846
+ function isAvailableByWhois(text) {
847
+ if (!text) return false;
848
+ return WHOIS_AVAILABLE_PATTERNS.some((re) => re.test(text));
849
+ }
759
850
  /**
760
851
  * Convert raw WHOIS text into our normalized DomainRecord.
761
852
  * Heuristics cover many gTLD and ccTLD formats; exact fields vary per registry.
@@ -770,11 +861,13 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, includeRaw = false)
770
861
  "domain create date",
771
862
  "created",
772
863
  "registered",
864
+ "registration time",
773
865
  "domain record activated"
774
866
  ]);
775
867
  const updatedDate = anyValue(map, [
776
868
  "updated date",
777
869
  "last updated",
870
+ "last update",
778
871
  "last modified",
779
872
  "modified",
780
873
  "domain record last updated"
@@ -784,6 +877,8 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, includeRaw = false)
784
877
  "registry expiration date",
785
878
  "expiry date",
786
879
  "expiration date",
880
+ "expire date",
881
+ "expiration time",
787
882
  "registrar registration expiration date",
788
883
  "registrar registration expiry date",
789
884
  "registrar expiration date",
@@ -791,6 +886,7 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, includeRaw = false)
791
886
  "domain expires",
792
887
  "paid-till",
793
888
  "expires on",
889
+ "expires",
794
890
  "renewal date"
795
891
  ]);
796
892
  const registrar = (() => {
@@ -849,7 +945,7 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, includeRaw = false)
849
945
  return {
850
946
  domain,
851
947
  tld,
852
- isRegistered: !isWhoisAvailable(whoisText),
948
+ isRegistered: !isAvailableByWhois(whoisText),
853
949
  isIDN: /(^|\.)xn--/i.test(domain),
854
950
  unicodeName: void 0,
855
951
  punycodeName: void 0,
@@ -978,8 +1074,8 @@ async function followWhoisReferrals(initialServer, domain, opts) {
978
1074
  visited.add(normalized);
979
1075
  try {
980
1076
  const res = await whoisQuery(next, domain, opts);
981
- const registeredBefore = !isWhoisAvailable(current.text);
982
- const registeredAfter = !isWhoisAvailable(res.text);
1077
+ const registeredBefore = !isAvailableByWhois(current.text);
1078
+ const registeredAfter = !isAvailableByWhois(res.text);
983
1079
  if (registeredBefore && !registeredAfter) break;
984
1080
  current = res;
985
1081
  } catch {
@@ -989,6 +1085,40 @@ async function followWhoisReferrals(initialServer, domain, opts) {
989
1085
  }
990
1086
  return current;
991
1087
  }
1088
+ /**
1089
+ * Collect the WHOIS referral chain starting from the TLD server.
1090
+ * Always includes the initial TLD response; may include one or more registrar responses.
1091
+ * Stops on contradiction (registrar claims availability) or failures.
1092
+ */
1093
+ async function collectWhoisReferralChain(initialServer, domain, opts) {
1094
+ const results = [];
1095
+ const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
1096
+ const first = await whoisQuery(initialServer, domain, opts);
1097
+ results.push(first);
1098
+ if (opts?.followWhoisReferral === false || maxHops === 0) return results;
1099
+ const visited = new Set([normalize(first.serverQueried)]);
1100
+ let current = first;
1101
+ let hops = 0;
1102
+ while (hops < maxHops) {
1103
+ const next = extractWhoisReferral(current.text);
1104
+ if (!next) break;
1105
+ const normalized = normalize(next);
1106
+ if (visited.has(normalized)) break;
1107
+ visited.add(normalized);
1108
+ try {
1109
+ const res = await whoisQuery(next, domain, opts);
1110
+ const registeredBefore = !isAvailableByWhois(current.text);
1111
+ const registeredAfter = !isAvailableByWhois(res.text);
1112
+ if (registeredBefore && !registeredAfter) break;
1113
+ results.push(res);
1114
+ current = res;
1115
+ } catch {
1116
+ break;
1117
+ }
1118
+ hops += 1;
1119
+ }
1120
+ return results;
1121
+ }
992
1122
  function normalize(server) {
993
1123
  return server.replace(/^whois:\/\//i, "").toLowerCase();
994
1124
  }
@@ -999,7 +1129,7 @@ function normalize(server) {
999
1129
  * High-level lookup that prefers RDAP and falls back to WHOIS.
1000
1130
  * Ensures a standardized DomainRecord, independent of the source.
1001
1131
  */
1002
- async function lookupDomain(domain, opts) {
1132
+ async function lookup(domain, opts) {
1003
1133
  try {
1004
1134
  if (!isLikelyDomain(domain)) return {
1005
1135
  ok: false,
@@ -1011,7 +1141,8 @@ async function lookupDomain(domain, opts) {
1011
1141
  error: "Invalid TLD"
1012
1142
  };
1013
1143
  if (!opts?.whoisOnly) {
1014
- const bases = await getRdapBaseUrlsForTld(tld, opts);
1144
+ let bases = await getRdapBaseUrlsForTld(tld, opts);
1145
+ if (bases.length === 0 && tld.includes(".")) bases = await getRdapBaseUrlsForTld(tld.split(".").pop() ?? tld, opts);
1015
1146
  const tried = [];
1016
1147
  for (const base of bases) {
1017
1148
  tried.push(base);
@@ -1033,16 +1164,23 @@ async function lookupDomain(domain, opts) {
1033
1164
  if (!whoisServer) {
1034
1165
  const ianaText = await getIanaWhoisTextForTld(tld, opts);
1035
1166
  const regUrl = ianaText ? parseIanaRegistrationInfoUrl(ianaText) : void 0;
1036
- const hint = regUrl ? ` See registration info at ${regUrl}.` : "";
1037
1167
  return {
1038
1168
  ok: false,
1039
- error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`
1169
+ error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${regUrl ? ` See registration info at ${regUrl}.` : ""}`
1040
1170
  };
1041
1171
  }
1042
- const res = await followWhoisReferrals(whoisServer, domain, opts);
1172
+ const chain = await collectWhoisReferralChain(whoisServer, domain, opts);
1173
+ if (chain.length === 0) {
1174
+ const res = await followWhoisReferrals(whoisServer, domain, opts);
1175
+ return {
1176
+ ok: true,
1177
+ record: normalizeWhois(domain, tld, res.text, res.serverQueried, !!opts?.includeRaw)
1178
+ };
1179
+ }
1180
+ const [first, ...rest] = chain.map((r) => normalizeWhois(domain, tld, r.text, r.serverQueried, !!opts?.includeRaw));
1043
1181
  return {
1044
1182
  ok: true,
1045
- record: normalizeWhois(domain, tld, res.text, res.serverQueried, !!opts?.includeRaw)
1183
+ record: rest.length ? mergeWhoisRecords(first, rest) : first
1046
1184
  };
1047
1185
  } catch (err) {
1048
1186
  return {
@@ -1051,20 +1189,28 @@ async function lookupDomain(domain, opts) {
1051
1189
  };
1052
1190
  }
1053
1191
  }
1054
- /** Determine if a domain appears available (not registered).
1055
- * Performs a lookup and resolves to a boolean. Rejects on lookup error. */
1192
+ /**
1193
+ * Determine if a domain appears available (not registered).
1194
+ * Performs a lookup and resolves to a boolean. Rejects on lookup error.
1195
+ */
1056
1196
  async function isAvailable(domain, opts) {
1057
- const res = await lookupDomain(domain, opts);
1197
+ const res = await lookup(domain, opts);
1058
1198
  if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
1059
1199
  return res.record.isRegistered === false;
1060
1200
  }
1061
- /** Determine if a domain appears registered.
1062
- * Performs a lookup and resolves to a boolean. Rejects on lookup error. */
1201
+ /**
1202
+ * Determine if a domain appears registered.
1203
+ * Performs a lookup and resolves to a boolean. Rejects on lookup error.
1204
+ */
1063
1205
  async function isRegistered(domain, opts) {
1064
- const res = await lookupDomain(domain, opts);
1206
+ const res = await lookup(domain, opts);
1065
1207
  if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
1066
1208
  return res.record.isRegistered === true;
1067
1209
  }
1210
+ /**
1211
+ * @deprecated Use `lookup` instead.
1212
+ */
1213
+ const lookupDomain = lookup;
1068
1214
 
1069
1215
  //#endregion
1070
- export { getDomainParts, getDomainTld, isAvailable, isLikelyDomain, isRegistered, lookupDomain, toRegistrableDomain };
1216
+ export { getDomainParts, getDomainTld, isAvailable, isLikelyDomain, isRegistered, lookup, lookupDomain, toRegistrableDomain };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdapper",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "description": "🎩 RDAP/WHOIS fetcher, parser, and normalizer for Node",
6
6
  "repository": {
@@ -22,12 +22,14 @@
22
22
  "main": "./dist/index.js",
23
23
  "module": "./dist/index.js",
24
24
  "types": "./dist/index.d.ts",
25
- "bin": "./bin/cli.js",
25
+ "bin": {
26
+ "rdapper": "bin/cli.js"
27
+ },
26
28
  "exports": {
27
29
  ".": {
28
- "default": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
29
31
  "import": "./dist/index.js",
30
- "types": "./dist/index.d.ts"
32
+ "default": "./dist/index.js"
31
33
  }
32
34
  },
33
35
  "files": [
@@ -48,8 +50,8 @@
48
50
  },
49
51
  "devDependencies": {
50
52
  "@biomejs/biome": "2.2.6",
51
- "@types/node": "24.8.1",
52
- "tsdown": "0.15.7",
53
+ "@types/node": "24.9.0",
54
+ "tsdown": "0.15.9",
53
55
  "typescript": "5.9.3",
54
56
  "vitest": "^3.2.4"
55
57
  },