rdapper 0.4.0 → 0.5.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
@@ -35,6 +35,16 @@ await isRegistered("example.com"); // => true
35
35
  await isAvailable("likely-unregistered-thing-320485230458.com"); // => false
36
36
  ```
37
37
 
38
+ Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1):
39
+
40
+ ```ts
41
+ import { toRegistrableDomain } from "rdapper";
42
+
43
+ toRegistrableDomain("https://sub.example.co.uk/page"); // => "example.co.uk"
44
+ toRegistrableDomain("spark-public.s3.amazonaws.com"); // => "amazonaws.com" (ICANN-only default)
45
+ toRegistrableDomain("192.168.0.1"); // => null
46
+ ```
47
+
38
48
  ## API
39
49
 
40
50
  - `lookupDomain(domain, options?) => Promise<LookupResult>`
@@ -123,7 +133,6 @@ interface DomainRecord {
123
133
  rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
124
134
  rawWhois?: string; // raw WHOIS text (only when options.includeRaw)
125
135
  source: "rdap" | "whois"; // which path produced data
126
- fetchedAt: string; // ISO 8601 timestamp
127
136
  warnings?: string[];
128
137
  }
129
138
  ```
@@ -139,8 +148,7 @@ interface DomainRecord {
139
148
  "statuses": [{ "status": "clientTransferProhibited" }],
140
149
  "nameservers": [{ "host": "a.iana-servers.net" }, { "host": "b.iana-servers.net" }],
141
150
  "dnssec": { "enabled": true },
142
- "source": "rdap",
143
- "fetchedAt": "2025-01-01T00:00:00Z"
151
+ "source": "rdap"
144
152
  }
145
153
  ```
146
154
 
@@ -183,6 +191,7 @@ Project layout:
183
191
  - Some TLDs provide no RDAP service; `rdapOnly: true` will fail for them.
184
192
  - Registries may throttle or block WHOIS; respect rate limits and usage policies.
185
193
  - Field presence depends on source and privacy policies (e.g., redaction/withholding).
194
+ - 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).
186
195
 
187
196
  ## License
188
197
 
package/dist/index.d.ts CHANGED
@@ -88,8 +88,6 @@ interface DomainRecord {
88
88
  rawWhois?: string;
89
89
  /** Which source produced data */
90
90
  source: LookupSource;
91
- /** ISO 8601 timestamp at time of lookup */
92
- fetchedAt: string;
93
91
  /** Warnings generated during lookup */
94
92
  warnings?: string[];
95
93
  }
@@ -126,6 +124,13 @@ interface LookupResult {
126
124
  }
127
125
  type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
128
126
  //#endregion
127
+ //#region src/lib/domain.d.ts
128
+ /**
129
+ * Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1).
130
+ * Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs).
131
+ */
132
+ declare function toRegistrableDomain(input: string): string | null;
133
+ //#endregion
129
134
  //#region src/index.d.ts
130
135
  /**
131
136
  * High-level lookup that prefers RDAP and falls back to WHOIS.
@@ -139,4 +144,4 @@ declare function isAvailable(domain: string, opts?: LookupOptions): Promise<bool
139
144
  * Performs a lookup and resolves to a boolean. Rejects on lookup error. */
140
145
  declare function isRegistered(domain: string, opts?: LookupOptions): Promise<boolean>;
141
146
  //#endregion
142
- export { Contact, DomainRecord, FetchLike, LookupOptions, LookupResult, LookupSource, Nameserver, RegistrarInfo, StatusEvent, isAvailable, isRegistered, lookupDomain };
147
+ export { Contact, DomainRecord, FetchLike, LookupOptions, LookupResult, LookupSource, Nameserver, RegistrarInfo, StatusEvent, isAvailable, isRegistered, lookupDomain, toRegistrableDomain };
package/dist/index.js CHANGED
@@ -1,94 +1,33 @@
1
- import psl from "psl";
1
+ import { parse } from "tldts";
2
2
  import { createConnection } from "node:net";
3
3
 
4
- //#region src/lib/dates.ts
5
- function toISO(dateLike) {
6
- if (dateLike == null) return void 0;
7
- if (dateLike instanceof Date) return toIsoFromDate(dateLike);
8
- if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
9
- const raw = String(dateLike).trim();
10
- if (!raw) return void 0;
11
- for (const re of [
12
- /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
13
- /^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
14
- /^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
15
- /^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/
16
- ]) {
17
- const m = raw.match(re);
18
- if (!m) continue;
19
- const d = parseDateWithRegex(m, re);
20
- if (d) return toIsoFromDate(d);
21
- }
22
- const native = new Date(raw);
23
- if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
24
- }
25
- function toIsoFromDate(d) {
26
- try {
27
- return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), 0)).toISOString().replace(/\.\d{3}Z$/, "Z");
28
- } catch {
29
- return;
30
- }
31
- }
32
- function parseDateWithRegex(m, _re) {
33
- const monthMap = {
34
- jan: 0,
35
- feb: 1,
36
- mar: 2,
37
- apr: 3,
38
- may: 4,
39
- jun: 5,
40
- jul: 6,
41
- aug: 7,
42
- sep: 8,
43
- oct: 9,
44
- nov: 10,
45
- dec: 11
46
- };
47
- try {
48
- if (m[0].includes(":")) {
49
- const [_$1, y, mo, d, hh, mm, ss, offH, offM] = m;
50
- let dt = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss));
51
- if (offH) {
52
- const sign = offH.startsWith("-") ? -1 : 1;
53
- const hours = Math.abs(Number(offH));
54
- const minutes = offM ? Number(offM) : 0;
55
- const offsetMs = sign * (hours * 60 + minutes) * 60 * 1e3;
56
- dt -= offsetMs;
57
- }
58
- return new Date(dt);
59
- }
60
- if (m[0].includes("-")) {
61
- const [_$1, dd$1, monStr$1, yyyy$1] = m;
62
- const mon$1 = monthMap[monStr$1.toLowerCase()];
63
- return new Date(Date.UTC(Number(yyyy$1), mon$1, Number(dd$1)));
64
- }
65
- const [_, monStr, dd, yyyy] = m;
66
- const mon = monthMap[monStr.toLowerCase()];
67
- return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
68
- } catch {}
69
- }
70
-
71
- //#endregion
72
4
  //#region src/lib/domain.ts
5
+ /**
6
+ * Parse a domain into its parts.
7
+ */
73
8
  function getDomainParts(domain) {
74
- const lower = domain.toLowerCase().trim();
75
- let publicSuffix;
76
- try {
77
- publicSuffix = (psl.parse?.(lower))?.tld;
78
- } catch {}
79
- if (!publicSuffix) {
80
- const parts = lower.split(".").filter(Boolean);
81
- publicSuffix = parts.length ? parts[parts.length - 1] : lower;
82
- }
83
- const labels = publicSuffix.split(".").filter(Boolean);
84
- const tld = labels.length ? labels[labels.length - 1] : publicSuffix;
85
- return {
86
- publicSuffix,
87
- tld
88
- };
9
+ return parse(domain);
10
+ }
11
+ /**
12
+ * Basic domain validity check (hostname-like), not performing DNS or RDAP.
13
+ */
14
+ function isLikelyDomain(value) {
15
+ const v = (value ?? "").trim();
16
+ return /^(?=.{1,253}$)(?:(?!-)[a-z0-9-]{1,63}(?<!-)\.)+(?!-)[a-z0-9-]{2,63}(?<!-)$/.test(v.toLowerCase());
89
17
  }
90
- function isLikelyDomain(input) {
91
- return /^[a-z0-9.-]+$/i.test(input) && input.includes(".");
18
+ /**
19
+ * Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1).
20
+ * Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs).
21
+ */
22
+ function toRegistrableDomain(input) {
23
+ const raw = (input ?? "").trim();
24
+ if (raw === "") return null;
25
+ const result = parse(raw);
26
+ if (result.isIp) return null;
27
+ if (!result.isIcann) return null;
28
+ const domain = result.domain ?? "";
29
+ if (domain === "") return null;
30
+ return domain.toLowerCase();
92
31
  }
93
32
  const WHOIS_AVAILABLE_PATTERNS = [
94
33
  /\bno match\b/i,
@@ -302,6 +241,74 @@ function sameDomain(a, b) {
302
241
  return a.toLowerCase() === b.toLowerCase();
303
242
  }
304
243
 
244
+ //#endregion
245
+ //#region src/lib/dates.ts
246
+ function toISO(dateLike) {
247
+ if (dateLike == null) return void 0;
248
+ if (dateLike instanceof Date) return toIsoFromDate(dateLike);
249
+ if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
250
+ const raw = String(dateLike).trim();
251
+ if (!raw) return void 0;
252
+ for (const re of [
253
+ /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
254
+ /^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
255
+ /^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
256
+ /^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/
257
+ ]) {
258
+ const m = raw.match(re);
259
+ if (!m) continue;
260
+ const d = parseDateWithRegex(m, re);
261
+ if (d) return toIsoFromDate(d);
262
+ }
263
+ const native = new Date(raw);
264
+ if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
265
+ }
266
+ function toIsoFromDate(d) {
267
+ try {
268
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), 0)).toISOString().replace(/\.\d{3}Z$/, "Z");
269
+ } catch {
270
+ return;
271
+ }
272
+ }
273
+ function parseDateWithRegex(m, _re) {
274
+ const monthMap = {
275
+ jan: 0,
276
+ feb: 1,
277
+ mar: 2,
278
+ apr: 3,
279
+ may: 4,
280
+ jun: 5,
281
+ jul: 6,
282
+ aug: 7,
283
+ sep: 8,
284
+ oct: 9,
285
+ nov: 10,
286
+ dec: 11
287
+ };
288
+ try {
289
+ if (m[0].includes(":")) {
290
+ const [_$1, y, mo, d, hh, mm, ss, offH, offM] = m;
291
+ let dt = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss));
292
+ if (offH) {
293
+ const sign = offH.startsWith("-") ? -1 : 1;
294
+ const hours = Math.abs(Number(offH));
295
+ const minutes = offM ? Number(offM) : 0;
296
+ const offsetMs = sign * (hours * 60 + minutes) * 60 * 1e3;
297
+ dt -= offsetMs;
298
+ }
299
+ return new Date(dt);
300
+ }
301
+ if (m[0].includes("-")) {
302
+ const [_$1, dd$1, monStr$1, yyyy$1] = m;
303
+ const mon$1 = monthMap[monStr$1.toLowerCase()];
304
+ return new Date(Date.UTC(Number(yyyy$1), mon$1, Number(dd$1)));
305
+ }
306
+ const [_, monStr, dd, yyyy] = m;
307
+ const mon = monthMap[monStr.toLowerCase()];
308
+ return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
309
+ } catch {}
310
+ }
311
+
305
312
  //#endregion
306
313
  //#region src/lib/privacy.ts
307
314
  const PRIVACY_NAME_KEYWORDS = [
@@ -382,7 +389,7 @@ function asDateLike(value) {
382
389
  * Convert RDAP JSON into our normalized DomainRecord.
383
390
  * This function is defensive: RDAP servers vary in completeness and field naming.
384
391
  */
385
- function normalizeRdap(inputDomain, tld, rdap, rdapServersTried, fetchedAtISO, includeRaw = false) {
392
+ function normalizeRdap(inputDomain, tld, rdap, rdapServersTried, includeRaw = false) {
386
393
  const doc = rdap ?? {};
387
394
  const ldhName = asString(doc.ldhName) || asString(doc.handle);
388
395
  const unicodeName = asString(doc.unicodeName);
@@ -450,7 +457,6 @@ function normalizeRdap(inputDomain, tld, rdap, rdapServersTried, fetchedAtISO, i
450
457
  rawRdap: includeRaw ? rdap : void 0,
451
458
  rawWhois: void 0,
452
459
  source: "rdap",
453
- fetchedAt: fetchedAtISO,
454
460
  warnings: void 0
455
461
  };
456
462
  }
@@ -737,7 +743,7 @@ function normalizeServer(server) {
737
743
  * Convert raw WHOIS text into our normalized DomainRecord.
738
744
  * Heuristics cover many gTLD and ccTLD formats; exact fields vary per registry.
739
745
  */
740
- function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, includeRaw = false) {
746
+ function normalizeWhois(domain, tld, whoisText, whoisServer, includeRaw = false) {
741
747
  const map = parseKeyValueLines(whoisText);
742
748
  const creationDate = anyValue(map, [
743
749
  "creation date",
@@ -848,7 +854,6 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, inclu
848
854
  rawRdap: void 0,
849
855
  rawWhois: includeRaw ? whoisText : void 0,
850
856
  source: "whois",
851
- fetchedAt: fetchedAtISO,
852
857
  warnings: void 0
853
858
  };
854
859
  }
@@ -979,8 +984,11 @@ async function lookupDomain(domain, opts) {
979
984
  ok: false,
980
985
  error: "Input does not look like a domain"
981
986
  };
982
- const { publicSuffix, tld } = getDomainParts(domain);
983
- const now = toISO(/* @__PURE__ */ new Date()) ?? (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
987
+ const { publicSuffix: tld } = getDomainParts(domain);
988
+ if (!tld) return {
989
+ ok: false,
990
+ error: "Invalid TLD"
991
+ };
984
992
  if (!opts?.whoisOnly) {
985
993
  const bases = await getRdapBaseUrlsForTld(tld, opts);
986
994
  const tried = [];
@@ -991,7 +999,7 @@ async function lookupDomain(domain, opts) {
991
999
  const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts);
992
1000
  return {
993
1001
  ok: true,
994
- record: normalizeRdap(domain, tld, rdapEnriched.merged, [...tried, ...rdapEnriched.serversTried], now, !!opts?.includeRaw)
1002
+ record: normalizeRdap(domain, tld, rdapEnriched.merged, [...tried, ...rdapEnriched.serversTried], !!opts?.includeRaw)
995
1003
  };
996
1004
  } catch {}
997
1005
  }
@@ -1011,22 +1019,9 @@ async function lookupDomain(domain, opts) {
1011
1019
  };
1012
1020
  }
1013
1021
  const res = await followWhoisReferrals(whoisServer, domain, opts);
1014
- if (publicSuffix.includes(".") && /no match|not found/i.test(res.text) && opts?.followWhoisReferral !== false) {
1015
- const candidates = [];
1016
- const ps = publicSuffix.toLowerCase();
1017
- const exception = WHOIS_TLD_EXCEPTIONS[ps];
1018
- if (exception) candidates.push(exception);
1019
- for (const server of candidates) try {
1020
- const alt = await whoisQuery(server, domain, opts);
1021
- if (alt.text && !/error/i.test(alt.text)) return {
1022
- ok: true,
1023
- record: normalizeWhois(domain, tld, alt.text, alt.serverQueried, now, !!opts?.includeRaw)
1024
- };
1025
- } catch {}
1026
- }
1027
1022
  return {
1028
1023
  ok: true,
1029
- record: normalizeWhois(domain, tld, res.text, res.serverQueried, now, !!opts?.includeRaw)
1024
+ record: normalizeWhois(domain, tld, res.text, res.serverQueried, !!opts?.includeRaw)
1030
1025
  };
1031
1026
  } catch (err) {
1032
1027
  return {
@@ -1051,4 +1046,4 @@ async function isRegistered(domain, opts) {
1051
1046
  }
1052
1047
 
1053
1048
  //#endregion
1054
- export { isAvailable, isRegistered, lookupDomain };
1049
+ export { isAvailable, isRegistered, lookupDomain, toRegistrableDomain };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdapper",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "description": "🎩 RDAP/WHOIS fetcher, parser, and normalizer for Node",
6
6
  "repository": {
@@ -39,14 +39,13 @@
39
39
  "prepublishOnly": "npm run build"
40
40
  },
41
41
  "dependencies": {
42
- "psl": "1.15.0"
42
+ "tldts": "7.0.17"
43
43
  },
44
44
  "devDependencies": {
45
- "@biomejs/biome": "2.2.4",
46
- "@types/node": "24.5.2",
47
- "@types/psl": "1.1.3",
48
- "tsdown": "0.15.5",
49
- "typescript": "5.9.2",
45
+ "@biomejs/biome": "2.2.5",
46
+ "@types/node": "24.7.1",
47
+ "tsdown": "0.15.6",
48
+ "typescript": "5.9.3",
50
49
  "vitest": "^3.2.4"
51
50
  },
52
51
  "engines": {