rdapper 0.7.0 → 0.9.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/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { parse } from "tldts";
2
+
1
3
  //#region src/types.d.ts
2
4
  type LookupSource = "rdap" | "whois";
3
5
  interface RegistrarInfo {
@@ -125,11 +127,23 @@ interface LookupResult {
125
127
  type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
126
128
  //#endregion
127
129
  //#region src/lib/domain.d.ts
130
+ type ParseOptions = Parameters<typeof parse>[1];
131
+ /**
132
+ * Parse a domain into its parts. Accepts options which are passed to tldts.parse().
133
+ * @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
134
+ */
135
+ declare function getDomainParts(domain: string, opts?: ParseOptions): ReturnType<typeof parse>;
136
+ /** Get the TLD (ICANN-only public suffix) of a domain. */
137
+ declare function getDomainTld(domain: string, opts?: ParseOptions): string | null;
138
+ /**
139
+ * Basic domain validity check (hostname-like), not performing DNS or RDAP.
140
+ */
141
+ declare function isLikelyDomain(value: string): boolean;
128
142
  /**
129
143
  * Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1).
130
144
  * Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs).
131
145
  */
132
- declare function toRegistrableDomain(input: string): string | null;
146
+ declare function toRegistrableDomain(input: string, opts?: ParseOptions): string | null;
133
147
  //#endregion
134
148
  //#region src/index.d.ts
135
149
  /**
@@ -144,4 +158,4 @@ declare function isAvailable(domain: string, opts?: LookupOptions): Promise<bool
144
158
  * Performs a lookup and resolves to a boolean. Rejects on lookup error. */
145
159
  declare function isRegistered(domain: string, opts?: LookupOptions): Promise<boolean>;
146
160
  //#endregion
147
- export { Contact, DomainRecord, FetchLike, LookupOptions, LookupResult, LookupSource, Nameserver, RegistrarInfo, StatusEvent, isAvailable, isRegistered, lookupDomain, toRegistrableDomain };
161
+ export { Contact, DomainRecord, FetchLike, LookupOptions, LookupResult, LookupSource, Nameserver, RegistrarInfo, StatusEvent, getDomainParts, getDomainTld, isAvailable, isLikelyDomain, isRegistered, lookupDomain, toRegistrableDomain };
package/dist/index.js CHANGED
@@ -2,10 +2,18 @@ import { parse } from "tldts";
2
2
 
3
3
  //#region src/lib/domain.ts
4
4
  /**
5
- * Parse a domain into its parts.
5
+ * Parse a domain into its parts. Accepts options which are passed to tldts.parse().
6
+ * @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
6
7
  */
7
- function getDomainParts(domain) {
8
- return parse(domain);
8
+ function getDomainParts(domain, opts) {
9
+ return parse(domain, { ...opts });
10
+ }
11
+ /** Get the TLD (ICANN-only public suffix) of a domain. */
12
+ function getDomainTld(domain, opts) {
13
+ return getDomainParts(domain, {
14
+ allowPrivateDomains: false,
15
+ ...opts
16
+ }).publicSuffix ?? null;
9
17
  }
10
18
  /**
11
19
  * Basic domain validity check (hostname-like), not performing DNS or RDAP.
@@ -18,10 +26,13 @@ function isLikelyDomain(value) {
18
26
  * Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1).
19
27
  * Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs).
20
28
  */
21
- function toRegistrableDomain(input) {
29
+ function toRegistrableDomain(input, opts) {
22
30
  const raw = (input ?? "").trim();
23
31
  if (raw === "") return null;
24
- const result = parse(raw);
32
+ const result = getDomainParts(raw, {
33
+ allowPrivateDomains: false,
34
+ ...opts
35
+ });
25
36
  if (result.isIp) return null;
26
37
  if (!result.isIcann) return null;
27
38
  const domain = result.domain ?? "";
@@ -37,7 +48,12 @@ const WHOIS_AVAILABLE_PATTERNS = [
37
48
  /\bdomain\s+available\b/i,
38
49
  /\bdomain status[:\s]+available\b/i,
39
50
  /\bobject does not exist\b/i,
40
- /\bthe queried object does not exist\b/i
51
+ /\bthe queried object does not exist\b/i,
52
+ /\bstatus:\s*free\b/i,
53
+ /\bstatus:\s*available\b/i,
54
+ /\bno object found\b/i,
55
+ /\bnicht gefunden\b/i,
56
+ /\bpending release\b/i
41
57
  ];
42
58
  function isWhoisAvailable(text) {
43
59
  if (!text) return false;
@@ -79,9 +95,9 @@ async function getRdapBaseUrlsForTld(tld, options) {
79
95
  const target = tld.toLowerCase();
80
96
  const bases = [];
81
97
  for (const svc of data.services) {
82
- const tlds = svc[0];
98
+ const tlds = svc[0].map((x) => x.toLowerCase());
83
99
  const urls = svc[1];
84
- if (tlds.map((x) => x.toLowerCase()).includes(target)) for (const u of urls) {
100
+ if (tlds.includes(target)) for (const u of urls) {
85
101
  const base = u.endsWith("/") ? u : `${u}/`;
86
102
  bases.push(base);
87
103
  }
@@ -106,10 +122,9 @@ async function fetchRdapDomain(domain, baseUrl, options) {
106
122
  const bodyText = await res.text();
107
123
  throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
108
124
  }
109
- const json = await res.json();
110
125
  return {
111
126
  url,
112
- json
127
+ json: await res.json()
113
128
  };
114
129
  }
115
130
 
@@ -210,10 +225,9 @@ async function fetchRdapUrl(url, options) {
210
225
  const bodyText = await res.text();
211
226
  throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
212
227
  }
213
- const json = await res.json();
214
228
  return {
215
229
  url,
216
- json
230
+ json: await res.json()
217
231
  };
218
232
  }
219
233
  function toArray(val) {
@@ -553,12 +567,9 @@ function parseVcard(vcardArray) {
553
567
  */
554
568
  async function whoisQuery(server, query, options) {
555
569
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
556
- const port = 43;
557
- const host = server.replace(/^whois:\/\//i, "");
558
- const text = await withTimeout(queryTcp(host, port, query, options), timeoutMs, "WHOIS timeout");
559
570
  return {
560
571
  serverQueried: server,
561
- text
572
+ text: await withTimeout(queryTcp(server.replace(/^whois:\/\//i, ""), 43, query, options), timeoutMs, "WHOIS timeout")
562
573
  };
563
574
  }
564
575
  async function queryTcp(host, port, query, options) {
@@ -743,6 +754,89 @@ function normalizeServer(server) {
743
754
  return server.replace(/^whois:\/\//i, "").replace(/\/$/, "");
744
755
  }
745
756
 
757
+ //#endregion
758
+ //#region src/whois/merge.ts
759
+ function dedupeStatuses(a, b) {
760
+ const list = [...a || [], ...b || []];
761
+ const seen = /* @__PURE__ */ new Set();
762
+ const out = [];
763
+ for (const s of list) {
764
+ const key = (s?.status || "").toLowerCase();
765
+ if (!key || seen.has(key)) continue;
766
+ seen.add(key);
767
+ out.push(s);
768
+ }
769
+ return out.length ? out : void 0;
770
+ }
771
+ function dedupeNameservers(a, b) {
772
+ const map = /* @__PURE__ */ new Map();
773
+ for (const ns of [...a || [], ...b || []]) {
774
+ const host = ns.host.toLowerCase();
775
+ const prev = map.get(host);
776
+ if (!prev) {
777
+ map.set(host, {
778
+ ...ns,
779
+ host
780
+ });
781
+ continue;
782
+ }
783
+ const ipv4 = uniq([...prev.ipv4 || [], ...ns.ipv4 || []]);
784
+ const ipv6 = uniq([...prev.ipv6 || [], ...ns.ipv6 || []]);
785
+ map.set(host, {
786
+ host,
787
+ ipv4,
788
+ ipv6
789
+ });
790
+ }
791
+ const out = Array.from(map.values());
792
+ return out.length ? out : void 0;
793
+ }
794
+ function dedupeContacts(a, b) {
795
+ const list = [...a || [], ...b || []];
796
+ const seen = /* @__PURE__ */ new Set();
797
+ const out = [];
798
+ for (const c of list) {
799
+ const key = `${c.type}|${(c.organization || c.name || c.email || "").toString().toLowerCase()}`;
800
+ if (seen.has(key)) continue;
801
+ seen.add(key);
802
+ out.push(c);
803
+ }
804
+ return out.length ? out : void 0;
805
+ }
806
+ /** Conservative merge: start with base; fill missing scalars; union arrays; prefer more informative dates. */
807
+ function mergeWhoisRecords(base, others) {
808
+ const merged = { ...base };
809
+ for (const cur of others) {
810
+ merged.isRegistered = merged.isRegistered || cur.isRegistered;
811
+ merged.registry = merged.registry ?? cur.registry;
812
+ merged.registrar = merged.registrar ?? cur.registrar;
813
+ merged.reseller = merged.reseller ?? cur.reseller;
814
+ merged.statuses = dedupeStatuses(merged.statuses, cur.statuses);
815
+ merged.creationDate = preferEarliestIso(merged.creationDate, cur.creationDate);
816
+ merged.updatedDate = preferLatestIso(merged.updatedDate, cur.updatedDate);
817
+ merged.expirationDate = preferLatestIso(merged.expirationDate, cur.expirationDate);
818
+ merged.deletionDate = merged.deletionDate ?? cur.deletionDate;
819
+ merged.transferLock = Boolean(merged.transferLock || cur.transferLock);
820
+ merged.dnssec = merged.dnssec ?? cur.dnssec;
821
+ merged.nameservers = dedupeNameservers(merged.nameservers, cur.nameservers);
822
+ merged.contacts = dedupeContacts(merged.contacts, cur.contacts);
823
+ merged.privacyEnabled = merged.privacyEnabled ?? cur.privacyEnabled;
824
+ merged.whoisServer = cur.whoisServer ?? merged.whoisServer;
825
+ merged.rawWhois = cur.rawWhois ?? merged.rawWhois;
826
+ }
827
+ return merged;
828
+ }
829
+ function preferEarliestIso(a, b) {
830
+ if (!a) return b;
831
+ if (!b) return a;
832
+ return new Date(a) <= new Date(b) ? a : b;
833
+ }
834
+ function preferLatestIso(a, b) {
835
+ if (!a) return b;
836
+ if (!b) return a;
837
+ return new Date(a) >= new Date(b) ? a : b;
838
+ }
839
+
746
840
  //#endregion
747
841
  //#region src/whois/normalize.ts
748
842
  /**
@@ -759,11 +853,13 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, includeRaw = false)
759
853
  "domain create date",
760
854
  "created",
761
855
  "registered",
856
+ "registration time",
762
857
  "domain record activated"
763
858
  ]);
764
859
  const updatedDate = anyValue(map, [
765
860
  "updated date",
766
861
  "last updated",
862
+ "last update",
767
863
  "last modified",
768
864
  "modified",
769
865
  "domain record last updated"
@@ -773,6 +869,8 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, includeRaw = false)
773
869
  "registry expiration date",
774
870
  "expiry date",
775
871
  "expiration date",
872
+ "expire date",
873
+ "expiration time",
776
874
  "registrar registration expiration date",
777
875
  "registrar registration expiry date",
778
876
  "registrar expiration date",
@@ -780,6 +878,7 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, includeRaw = false)
780
878
  "domain expires",
781
879
  "paid-till",
782
880
  "expires on",
881
+ "expires",
783
882
  "renewal date"
784
883
  ]);
785
884
  const registrar = (() => {
@@ -978,6 +1077,40 @@ async function followWhoisReferrals(initialServer, domain, opts) {
978
1077
  }
979
1078
  return current;
980
1079
  }
1080
+ /**
1081
+ * Collect the WHOIS referral chain starting from the TLD server.
1082
+ * Always includes the initial TLD response; may include one or more registrar responses.
1083
+ * Stops on contradiction (registrar claims availability) or failures.
1084
+ */
1085
+ async function collectWhoisReferralChain(initialServer, domain, opts) {
1086
+ const results = [];
1087
+ const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
1088
+ const first = await whoisQuery(initialServer, domain, opts);
1089
+ results.push(first);
1090
+ if (opts?.followWhoisReferral === false || maxHops === 0) return results;
1091
+ const visited = new Set([normalize(first.serverQueried)]);
1092
+ let current = first;
1093
+ let hops = 0;
1094
+ while (hops < maxHops) {
1095
+ const next = extractWhoisReferral(current.text);
1096
+ if (!next) break;
1097
+ const normalized = normalize(next);
1098
+ if (visited.has(normalized)) break;
1099
+ visited.add(normalized);
1100
+ try {
1101
+ const res = await whoisQuery(next, domain, opts);
1102
+ const registeredBefore = !isWhoisAvailable(current.text);
1103
+ const registeredAfter = !isWhoisAvailable(res.text);
1104
+ if (registeredBefore && !registeredAfter) break;
1105
+ results.push(res);
1106
+ current = res;
1107
+ } catch {
1108
+ break;
1109
+ }
1110
+ hops += 1;
1111
+ }
1112
+ return results;
1113
+ }
981
1114
  function normalize(server) {
982
1115
  return server.replace(/^whois:\/\//i, "").toLowerCase();
983
1116
  }
@@ -1000,7 +1133,8 @@ async function lookupDomain(domain, opts) {
1000
1133
  error: "Invalid TLD"
1001
1134
  };
1002
1135
  if (!opts?.whoisOnly) {
1003
- const bases = await getRdapBaseUrlsForTld(tld, opts);
1136
+ let bases = await getRdapBaseUrlsForTld(tld, opts);
1137
+ if (bases.length === 0 && tld.includes(".")) bases = await getRdapBaseUrlsForTld(tld.split(".").pop() ?? tld, opts);
1004
1138
  const tried = [];
1005
1139
  for (const base of bases) {
1006
1140
  tried.push(base);
@@ -1022,16 +1156,23 @@ async function lookupDomain(domain, opts) {
1022
1156
  if (!whoisServer) {
1023
1157
  const ianaText = await getIanaWhoisTextForTld(tld, opts);
1024
1158
  const regUrl = ianaText ? parseIanaRegistrationInfoUrl(ianaText) : void 0;
1025
- const hint = regUrl ? ` See registration info at ${regUrl}.` : "";
1026
1159
  return {
1027
1160
  ok: false,
1028
- error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`
1161
+ error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${regUrl ? ` See registration info at ${regUrl}.` : ""}`
1162
+ };
1163
+ }
1164
+ const chain = await collectWhoisReferralChain(whoisServer, domain, opts);
1165
+ if (chain.length === 0) {
1166
+ const res = await followWhoisReferrals(whoisServer, domain, opts);
1167
+ return {
1168
+ ok: true,
1169
+ record: normalizeWhois(domain, tld, res.text, res.serverQueried, !!opts?.includeRaw)
1029
1170
  };
1030
1171
  }
1031
- const res = await followWhoisReferrals(whoisServer, domain, opts);
1172
+ const [first, ...rest] = chain.map((r) => normalizeWhois(domain, tld, r.text, r.serverQueried, !!opts?.includeRaw));
1032
1173
  return {
1033
1174
  ok: true,
1034
- record: normalizeWhois(domain, tld, res.text, res.serverQueried, !!opts?.includeRaw)
1175
+ record: rest.length ? mergeWhoisRecords(first, rest) : first
1035
1176
  };
1036
1177
  } catch (err) {
1037
1178
  return {
@@ -1056,4 +1197,4 @@ async function isRegistered(domain, opts) {
1056
1197
  }
1057
1198
 
1058
1199
  //#endregion
1059
- export { isAvailable, isRegistered, lookupDomain, toRegistrableDomain };
1200
+ export { getDomainParts, getDomainTld, isAvailable, isLikelyDomain, isRegistered, lookupDomain, toRegistrableDomain };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdapper",
3
- "version": "0.7.0",
3
+ "version": "0.9.1",
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": [
@@ -47,9 +49,9 @@
47
49
  "tldts": "7.0.17"
48
50
  },
49
51
  "devDependencies": {
50
- "@biomejs/biome": "2.2.5",
51
- "@types/node": "24.7.2",
52
- "tsdown": "0.15.6",
52
+ "@biomejs/biome": "2.2.6",
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
  },