rdapper 0.2.0 → 0.3.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 +6 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +189 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,10 @@ await isAvailable("likely-unregistered-thing-320485230458.com"); // => false
|
|
|
49
49
|
- `rdapOnly?: boolean` – Only attempt RDAP; do not fall back to WHOIS.
|
|
50
50
|
- `whoisOnly?: boolean` – Skip RDAP and query WHOIS directly.
|
|
51
51
|
- `followWhoisReferral?: boolean` – Follow registrar referral from the TLD WHOIS (default `true`).
|
|
52
|
+
- `maxWhoisReferralHops?: number` – Maximum registrar WHOIS referral hops to follow (default `2`).
|
|
53
|
+
- `rdapFollowLinks?: boolean` – Follow related/entity RDAP links to enrich data (default `true`).
|
|
54
|
+
- `maxRdapLinkHops?: number` – Maximum RDAP related link hops to follow (default `2`).
|
|
55
|
+
- `rdapLinkRels?: string[]` – RDAP link rel values to consider (default `["related","entity","registrar","alternate"]`).
|
|
52
56
|
- `customBootstrapUrl?: string` – Override RDAP bootstrap URL.
|
|
53
57
|
- `whoisHints?: Record<string, string>` – Override/add authoritative WHOIS per TLD (keys are lowercase TLDs, values may include or omit `whois://`).
|
|
54
58
|
- `includeRaw?: boolean` – Include `rawRdap`/`rawWhois` in the returned record (default `false`).
|
|
@@ -144,10 +148,11 @@ interface DomainRecord {
|
|
|
144
148
|
- RDAP
|
|
145
149
|
- Discovers base URLs for the TLD via IANA’s RDAP bootstrap JSON.
|
|
146
150
|
- Tries each base until one responds successfully; parses standard RDAP domain JSON.
|
|
151
|
+
- Optionally follows related/entity links to registrar RDAP resources and merges results (bounded by hop limits).
|
|
147
152
|
- Normalizes registrar (from `entities`), contacts (vCard), nameservers (`ipAddresses`), events (created/changed/expiration), statuses, and DNSSEC (`secureDNS`).
|
|
148
153
|
- WHOIS
|
|
149
154
|
- Discovers the authoritative TLD WHOIS via `whois.iana.org` (TCP 43), with curated exceptions for tricky zones and public SLDs.
|
|
150
|
-
- Queries the TLD WHOIS
|
|
155
|
+
- Queries the TLD WHOIS and follows registrar referrals recursively up to `maxWhoisReferralHops` (unless disabled).
|
|
151
156
|
- Normalizes common key/value variants across gTLD/ccTLD formats (dates, statuses, nameservers, contacts). Availability is inferred from common phrases (best‑effort heuristic).
|
|
152
157
|
|
|
153
158
|
Timeouts are enforced per request using a simple race against `timeoutMs` (default 15s). All network I/O is performed with global `fetch` (RDAP) and a raw TCP socket (WHOIS).
|
package/dist/index.d.ts
CHANGED
|
@@ -71,6 +71,10 @@ interface LookupOptions {
|
|
|
71
71
|
rdapOnly?: boolean;
|
|
72
72
|
whoisOnly?: boolean;
|
|
73
73
|
followWhoisReferral?: boolean;
|
|
74
|
+
maxWhoisReferralHops?: number;
|
|
75
|
+
rdapFollowLinks?: boolean;
|
|
76
|
+
maxRdapLinkHops?: number;
|
|
77
|
+
rdapLinkRels?: string[];
|
|
74
78
|
customBootstrapUrl?: string;
|
|
75
79
|
whoisHints?: Record<string, string>;
|
|
76
80
|
includeRaw?: boolean;
|
package/dist/index.js
CHANGED
|
@@ -9,14 +9,14 @@ function toISO(dateLike) {
|
|
|
9
9
|
const raw = String(dateLike).trim();
|
|
10
10
|
if (!raw) return void 0;
|
|
11
11
|
for (const re of [
|
|
12
|
-
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z)?$/,
|
|
13
|
-
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})
|
|
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
14
|
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
|
|
15
15
|
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/
|
|
16
16
|
]) {
|
|
17
17
|
const m = raw.match(re);
|
|
18
18
|
if (!m) continue;
|
|
19
|
-
const d =
|
|
19
|
+
const d = parseDateWithRegex(m, re);
|
|
20
20
|
if (d) return toIsoFromDate(d);
|
|
21
21
|
}
|
|
22
22
|
const native = new Date(raw);
|
|
@@ -29,7 +29,7 @@ function toIsoFromDate(d) {
|
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
function
|
|
32
|
+
function parseDateWithRegex(m, _re) {
|
|
33
33
|
const monthMap = {
|
|
34
34
|
jan: 0,
|
|
35
35
|
feb: 1,
|
|
@@ -46,8 +46,16 @@ function parseWithRegex(m, _re) {
|
|
|
46
46
|
};
|
|
47
47
|
try {
|
|
48
48
|
if (m[0].includes(":")) {
|
|
49
|
-
const [_$1, y, mo, d, hh, mm, ss] = m;
|
|
50
|
-
|
|
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);
|
|
51
59
|
}
|
|
52
60
|
if (m[0].includes("-")) {
|
|
53
61
|
const [_$1, dd$1, monStr$1, yyyy$1] = m;
|
|
@@ -167,6 +175,133 @@ async function fetchRdapDomain(domain, baseUrl, options) {
|
|
|
167
175
|
};
|
|
168
176
|
}
|
|
169
177
|
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/rdap/links.ts
|
|
180
|
+
/** Extract candidate RDAP link URLs from an RDAP document. */
|
|
181
|
+
function extractRdapRelatedLinks(doc, opts) {
|
|
182
|
+
const rels = (opts?.rdapLinkRels?.length ? opts.rdapLinkRels : [
|
|
183
|
+
"related",
|
|
184
|
+
"entity",
|
|
185
|
+
"registrar",
|
|
186
|
+
"alternate"
|
|
187
|
+
]).map((r) => r.toLowerCase());
|
|
188
|
+
const d = doc ?? {};
|
|
189
|
+
const arr = Array.isArray(d?.links) ? d.links : [];
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const link of arr) {
|
|
192
|
+
const rel = String(link.rel || "").toLowerCase();
|
|
193
|
+
const type = String(link.type || "").toLowerCase();
|
|
194
|
+
if (!rels.includes(rel)) continue;
|
|
195
|
+
if (type && !/application\/rdap\+json/i.test(type)) continue;
|
|
196
|
+
const url = link.href || link.value;
|
|
197
|
+
if (url && /^https?:\/\//i.test(url)) out.push(url);
|
|
198
|
+
}
|
|
199
|
+
return Array.from(new Set(out));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/rdap/merge.ts
|
|
204
|
+
/** Merge RDAP documents with a conservative, additive strategy. */
|
|
205
|
+
function mergeRdapDocs(baseDoc, others) {
|
|
206
|
+
const merged = { ...baseDoc };
|
|
207
|
+
for (const doc of others) {
|
|
208
|
+
const cur = doc ?? {};
|
|
209
|
+
merged.status = uniqStrings([...toStringArray(merged.status), ...toStringArray(cur.status)]);
|
|
210
|
+
merged.events = uniqBy([...toArray(merged.events), ...toArray(cur.events)], (e) => `${String(e?.eventAction ?? "").toLowerCase()}|${String(e?.eventDate ?? "")}`);
|
|
211
|
+
merged.nameservers = uniqBy([...toArray(merged.nameservers), ...toArray(cur.nameservers)], (n) => `${String(n?.ldhName ?? n?.unicodeName ?? "").toLowerCase()}`);
|
|
212
|
+
merged.entities = uniqBy([...toArray(merged.entities), ...toArray(cur.entities)], (e) => `${String(e?.handle ?? "").toLowerCase()}|${String(JSON.stringify(e?.roles || [])).toLowerCase()}|${String(JSON.stringify(e?.vcardArray || [])).toLowerCase()}`);
|
|
213
|
+
if (merged.secureDNS == null && cur.secureDNS != null) merged.secureDNS = cur.secureDNS;
|
|
214
|
+
if (merged.port43 == null && cur.port43 != null) merged.port43 = cur.port43;
|
|
215
|
+
const mergedRemarks = merged.remarks;
|
|
216
|
+
const curRemarks = cur.remarks;
|
|
217
|
+
if (Array.isArray(mergedRemarks) || Array.isArray(curRemarks)) {
|
|
218
|
+
const a = toArray(mergedRemarks);
|
|
219
|
+
const b = toArray(curRemarks);
|
|
220
|
+
merged.remarks = [...a, ...b];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return merged;
|
|
224
|
+
}
|
|
225
|
+
/** Fetch and merge RDAP related documents up to a hop limit. */
|
|
226
|
+
async function fetchAndMergeRdapRelated(domain, baseDoc, opts) {
|
|
227
|
+
const tried = [];
|
|
228
|
+
if (opts?.rdapFollowLinks === false) return {
|
|
229
|
+
merged: baseDoc,
|
|
230
|
+
serversTried: tried
|
|
231
|
+
};
|
|
232
|
+
const maxHops = Math.max(0, opts?.maxRdapLinkHops ?? 2);
|
|
233
|
+
if (maxHops === 0) return {
|
|
234
|
+
merged: baseDoc,
|
|
235
|
+
serversTried: tried
|
|
236
|
+
};
|
|
237
|
+
const visited = /* @__PURE__ */ new Set();
|
|
238
|
+
let current = baseDoc;
|
|
239
|
+
let hops = 0;
|
|
240
|
+
while (hops < maxHops) {
|
|
241
|
+
const nextBatch = extractRdapRelatedLinks(current, { rdapLinkRels: opts?.rdapLinkRels }).filter((u) => !visited.has(u));
|
|
242
|
+
if (nextBatch.length === 0) break;
|
|
243
|
+
const fetchedDocs = [];
|
|
244
|
+
for (const url of nextBatch) {
|
|
245
|
+
visited.add(url);
|
|
246
|
+
try {
|
|
247
|
+
const { json } = await fetchRdapUrl(url, opts);
|
|
248
|
+
tried.push(url);
|
|
249
|
+
const ldh = String(json?.ldhName ?? "").toLowerCase();
|
|
250
|
+
const uni = String(json?.unicodeName ?? "").toLowerCase();
|
|
251
|
+
if (ldh && !sameDomain(ldh, domain)) continue;
|
|
252
|
+
if (uni && !sameDomain(uni, domain)) continue;
|
|
253
|
+
fetchedDocs.push(json);
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
if (fetchedDocs.length === 0) break;
|
|
257
|
+
current = mergeRdapDocs(current, fetchedDocs);
|
|
258
|
+
hops += 1;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
merged: current,
|
|
262
|
+
serversTried: tried
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function fetchRdapUrl(url, options) {
|
|
266
|
+
const res = await withTimeout(fetch(url, {
|
|
267
|
+
method: "GET",
|
|
268
|
+
headers: { accept: "application/rdap+json, application/json" },
|
|
269
|
+
signal: options?.signal
|
|
270
|
+
}), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, "RDAP link fetch timeout");
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const bodyText = await res.text();
|
|
273
|
+
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
|
274
|
+
}
|
|
275
|
+
const json = await res.json();
|
|
276
|
+
return {
|
|
277
|
+
url,
|
|
278
|
+
json
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function toArray(val) {
|
|
282
|
+
return Array.isArray(val) ? val : [];
|
|
283
|
+
}
|
|
284
|
+
function toStringArray(val) {
|
|
285
|
+
return Array.isArray(val) ? val.map((v) => String(v)) : [];
|
|
286
|
+
}
|
|
287
|
+
function uniqStrings(arr) {
|
|
288
|
+
return Array.from(new Set(arr));
|
|
289
|
+
}
|
|
290
|
+
function uniqBy(arr, key) {
|
|
291
|
+
const seen = /* @__PURE__ */ new Set();
|
|
292
|
+
const out = [];
|
|
293
|
+
for (const item of arr) {
|
|
294
|
+
const k = key(item);
|
|
295
|
+
if (seen.has(k)) continue;
|
|
296
|
+
seen.add(k);
|
|
297
|
+
out.push(item);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
function sameDomain(a, b) {
|
|
302
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
303
|
+
}
|
|
304
|
+
|
|
170
305
|
//#endregion
|
|
171
306
|
//#region src/lib/text.ts
|
|
172
307
|
function uniq(arr) {
|
|
@@ -592,18 +727,26 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, inclu
|
|
|
592
727
|
"domain registration date",
|
|
593
728
|
"domain create date",
|
|
594
729
|
"created",
|
|
595
|
-
"registered"
|
|
730
|
+
"registered",
|
|
731
|
+
"domain record activated"
|
|
596
732
|
]);
|
|
597
733
|
const updatedDate = anyValue(map, [
|
|
598
734
|
"updated date",
|
|
599
735
|
"last updated",
|
|
600
736
|
"last modified",
|
|
601
|
-
"modified"
|
|
737
|
+
"modified",
|
|
738
|
+
"domain record last updated"
|
|
602
739
|
]);
|
|
603
740
|
const expirationDate = anyValue(map, [
|
|
604
741
|
"registry expiry date",
|
|
742
|
+
"registry expiration date",
|
|
605
743
|
"expiry date",
|
|
606
744
|
"expiration date",
|
|
745
|
+
"registrar registration expiration date",
|
|
746
|
+
"registrar registration expiry date",
|
|
747
|
+
"registrar expiration date",
|
|
748
|
+
"registrar expiry date",
|
|
749
|
+
"domain expires",
|
|
607
750
|
"paid-till",
|
|
608
751
|
"expires on",
|
|
609
752
|
"renewal date"
|
|
@@ -771,6 +914,37 @@ function multi(map, keys) {
|
|
|
771
914
|
}
|
|
772
915
|
}
|
|
773
916
|
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region src/whois/referral.ts
|
|
919
|
+
/**
|
|
920
|
+
* Follow registrar WHOIS referrals up to a configured hop limit.
|
|
921
|
+
* Returns the last successful WHOIS response (best-effort; keeps original on failures).
|
|
922
|
+
*/
|
|
923
|
+
async function followWhoisReferrals(initialServer, domain, opts) {
|
|
924
|
+
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
|
925
|
+
let current = await whoisQuery(initialServer, domain, opts);
|
|
926
|
+
if (opts?.followWhoisReferral === false || maxHops === 0) return current;
|
|
927
|
+
const visited = new Set([normalize(current.serverQueried)]);
|
|
928
|
+
let hops = 0;
|
|
929
|
+
while (hops < maxHops) {
|
|
930
|
+
const next = extractWhoisReferral(current.text);
|
|
931
|
+
if (!next) break;
|
|
932
|
+
const normalized = normalize(next);
|
|
933
|
+
if (visited.has(normalized)) break;
|
|
934
|
+
visited.add(normalized);
|
|
935
|
+
try {
|
|
936
|
+
current = await whoisQuery(next, domain, opts);
|
|
937
|
+
} catch {
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
940
|
+
hops += 1;
|
|
941
|
+
}
|
|
942
|
+
return current;
|
|
943
|
+
}
|
|
944
|
+
function normalize(server) {
|
|
945
|
+
return server.replace(/^whois:\/\//i, "").toLowerCase();
|
|
946
|
+
}
|
|
947
|
+
|
|
774
948
|
//#endregion
|
|
775
949
|
//#region src/index.ts
|
|
776
950
|
/**
|
|
@@ -792,9 +966,10 @@ async function lookupDomain(domain, opts) {
|
|
|
792
966
|
tried.push(base);
|
|
793
967
|
try {
|
|
794
968
|
const { json } = await fetchRdapDomain(domain, base, opts);
|
|
969
|
+
const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts);
|
|
795
970
|
return {
|
|
796
971
|
ok: true,
|
|
797
|
-
record: normalizeRdap(domain, tld,
|
|
972
|
+
record: normalizeRdap(domain, tld, rdapEnriched.merged, [...tried, ...rdapEnriched.serversTried], now, !!opts?.includeRaw)
|
|
798
973
|
};
|
|
799
974
|
} catch {}
|
|
800
975
|
}
|
|
@@ -813,13 +988,7 @@ async function lookupDomain(domain, opts) {
|
|
|
813
988
|
error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`
|
|
814
989
|
};
|
|
815
990
|
}
|
|
816
|
-
|
|
817
|
-
if (opts?.followWhoisReferral !== false) {
|
|
818
|
-
const referral = extractWhoisReferral(res.text);
|
|
819
|
-
if (referral && referral.toLowerCase() !== whoisServer.toLowerCase()) try {
|
|
820
|
-
res = await whoisQuery(referral, domain, opts);
|
|
821
|
-
} catch {}
|
|
822
|
-
}
|
|
991
|
+
const res = await followWhoisReferrals(whoisServer, domain, opts);
|
|
823
992
|
if (publicSuffix.includes(".") && /no match|not found/i.test(res.text) && opts?.followWhoisReferral !== false) {
|
|
824
993
|
const candidates = [];
|
|
825
994
|
const ps = publicSuffix.toLowerCase();
|
|
@@ -827,10 +996,10 @@ async function lookupDomain(domain, opts) {
|
|
|
827
996
|
if (exception) candidates.push(exception);
|
|
828
997
|
for (const server of candidates) try {
|
|
829
998
|
const alt = await whoisQuery(server, domain, opts);
|
|
830
|
-
if (alt.text && !/error/i.test(alt.text)) {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
999
|
+
if (alt.text && !/error/i.test(alt.text)) return {
|
|
1000
|
+
ok: true,
|
|
1001
|
+
record: normalizeWhois(domain, tld, alt.text, alt.serverQueried, now, !!opts?.includeRaw)
|
|
1002
|
+
};
|
|
834
1003
|
} catch {}
|
|
835
1004
|
}
|
|
836
1005
|
return {
|