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 +41 -31
- package/bin/cli.js +3 -3
- package/dist/index.d.ts +22 -9
- package/dist/index.js +190 -44
- package/package.json +8 -6
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
|
|
6
|
-
- WHOIS TCP 43
|
|
7
|
-
-
|
|
8
|
-
-
|
|
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
|
-
|
|
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 {
|
|
26
|
+
import { lookup } from "rdapper";
|
|
22
27
|
|
|
23
|
-
const { ok, record, error } = await
|
|
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
|
-
- `
|
|
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.
|
|
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 {
|
|
82
|
+
import { lookup } from "rdapper";
|
|
73
83
|
|
|
74
|
-
const res = await
|
|
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
|
|
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; //
|
|
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
|
|
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
|
|
198
|
-
- Coverage: `npm run test:coverage`
|
|
199
|
-
- Smoke tests that hit the network are gated by `SMOKE=1`, e.g. `SMOKE=1 npm
|
|
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).
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
154
|
-
/**
|
|
155
|
-
*
|
|
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
|
-
/**
|
|
158
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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.
|
|
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: !
|
|
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 = !
|
|
982
|
-
const registeredAfter = !
|
|
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
|
|
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
|
-
|
|
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.${
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
1055
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
1062
|
-
*
|
|
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
|
|
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.
|
|
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":
|
|
25
|
+
"bin": {
|
|
26
|
+
"rdapper": "bin/cli.js"
|
|
27
|
+
},
|
|
26
28
|
"exports": {
|
|
27
29
|
".": {
|
|
28
|
-
"
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
29
31
|
"import": "./dist/index.js",
|
|
30
|
-
"
|
|
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.
|
|
52
|
-
"tsdown": "0.15.
|
|
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
|
},
|