1
mirror of https://github.com/jakejarvis/rdapper.git synced 2025-10-18 20:14:27 -04:00

Core functionality for domain lookup using RDAP and WHOIS protocols, along with utility functions and tests.

This commit is contained in:
2025-09-24 14:28:09 -04:00
parent bd4846302d
commit 33a648460b
20 changed files with 1570 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

View File

@@ -1,2 +1,34 @@
# rdapper
🤵Domain RDAP/WHOIS fetched and parser for Node
rdapper
========
Fetch and parse domain registration data with RDAP-first and WHOIS fallback. Node 18+.
Install
-------
```bash
pnpm add rdapper
```
Usage
-----
```ts
import { lookupDomain } from "rdapper";
const { ok, record, error } = await lookupDomain("example.com", {
timeoutMs: 15000,
followWhoisReferral: true,
});
if (!ok) throw new Error(error);
console.log(record);
```
Notes
-----
- Uses IANA RDAP bootstrap and RDAP JSON when available; falls back to WHOIS.
- Standardized output regardless of source.
- No external HTTP client deps; relies on global fetch. WHOIS uses TCP 43.

34
biome.json Normal file
View File

@@ -0,0 +1,34 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "rdapper",
"version": "0.0.0-beta.1",
"license": "MIT",
"description": "Fetch and parse domain registration data via RDAP with WHOIS fallback.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc --build",
"test": "pnpm build && node --test dist/**/*.test.js"
},
"engines": {
"node": ">=18.17"
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@types/node": "^24.5.2",
"typescript": "^5.9.2"
},
"packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a"
}

130
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,130 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@biomejs/biome':
specifier: 2.2.4
version: 2.2.4
'@types/node':
specifier: ^24.5.2
version: 24.5.2
typescript:
specifier: ^5.9.2
version: 5.9.2
packages:
'@biomejs/biome@2.2.4':
resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.2.4':
resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.2.4':
resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.2.4':
resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@2.2.4':
resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@2.2.4':
resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@2.2.4':
resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@2.2.4':
resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.2.4':
resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@types/node@24.5.2':
resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==}
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.12.0:
resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==}
snapshots:
'@biomejs/biome@2.2.4':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.2.4
'@biomejs/cli-darwin-x64': 2.2.4
'@biomejs/cli-linux-arm64': 2.2.4
'@biomejs/cli-linux-arm64-musl': 2.2.4
'@biomejs/cli-linux-x64': 2.2.4
'@biomejs/cli-linux-x64-musl': 2.2.4
'@biomejs/cli-win32-arm64': 2.2.4
'@biomejs/cli-win32-x64': 2.2.4
'@biomejs/cli-darwin-arm64@2.2.4':
optional: true
'@biomejs/cli-darwin-x64@2.2.4':
optional: true
'@biomejs/cli-linux-arm64-musl@2.2.4':
optional: true
'@biomejs/cli-linux-arm64@2.2.4':
optional: true
'@biomejs/cli-linux-x64-musl@2.2.4':
optional: true
'@biomejs/cli-linux-x64@2.2.4':
optional: true
'@biomejs/cli-win32-arm64@2.2.4':
optional: true
'@biomejs/cli-win32-x64@2.2.4':
optional: true
'@types/node@24.5.2':
dependencies:
undici-types: 7.12.0
typescript@5.9.2: {}
undici-types@7.12.0: {}

1
src/config.ts Normal file
View File

@@ -0,0 +1 @@
export const DEFAULT_TIMEOUT_MS = 15000;

2
src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./lookup";
export * from "./types";

20
src/lookup.smoke.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { lookupDomain } from "./lookup.js";
// Run only when SMOKE=1 to avoid flakiness and network in CI by default
const shouldRun = process.env.SMOKE === "1";
(shouldRun ? test : test.skip)(
"lookupDomain smoke test (example.com)",
async () => {
const res = await lookupDomain("example.com", {
timeoutMs: 12000,
followWhoisReferral: true,
});
assert.equal(res.ok, true, res.error);
assert.ok(res.record?.domain);
assert.ok(res.record?.tld);
assert.ok(res.record?.source === "rdap" || res.record?.source === "whois");
},
);

83
src/lookup.ts Normal file
View File

@@ -0,0 +1,83 @@
import { normalizeRdap } from "./normalize-rdap.js";
import { normalizeWhois } from "./normalize-whois.js";
import { fetchRdapDomain, getRdapBaseUrlsForTld } from "./rdap.js";
import type { DomainRecord, LookupOptions, LookupResult } from "./types.js";
import { extractTld, isLikelyDomain, toISO } from "./utils.js";
import {
extractWhoisReferral,
ianaWhoisServerForTld,
whoisQuery,
} from "./whois.js";
/**
* High-level lookup that prefers RDAP and falls back to WHOIS.
* Ensures a standardized DomainRecord, independent of the source.
*/
export async function lookupDomain(
domain: string,
opts?: LookupOptions,
): Promise<LookupResult> {
try {
if (!isLikelyDomain(domain)) {
return { ok: false, error: "Input does not look like a domain" };
}
const tld = extractTld(domain);
const now = toISO(new Date())!;
// If WHOIS-only, skip RDAP path
if (!opts?.whoisOnly) {
const bases = await getRdapBaseUrlsForTld(tld, opts);
const tried: string[] = [];
for (const base of bases) {
tried.push(base);
try {
const { json } = await fetchRdapDomain(domain, base, opts);
const record: DomainRecord = normalizeRdap(
domain,
tld,
json,
tried,
now,
);
return { ok: true, record };
} catch {
// try next base
}
}
// Some TLDs are not in bootstrap yet; continue to WHOIS fallback unless rdapOnly
if (opts?.rdapOnly) {
return {
ok: false,
error: "RDAP not available or failed for this TLD",
};
}
}
// WHOIS fallback path
const whoisServer = await ianaWhoisServerForTld(tld, opts);
if (!whoisServer) {
return { ok: false, error: "No WHOIS server discovered for TLD" };
}
let res = await whoisQuery(whoisServer, domain, opts);
if (opts?.followWhoisReferral !== false) {
const referral = extractWhoisReferral(res.text);
if (referral && referral.toLowerCase() !== whoisServer.toLowerCase()) {
try {
res = await whoisQuery(referral, domain, opts);
} catch {
// keep original
}
}
}
const record: DomainRecord = normalizeWhois(
domain,
tld,
res.text,
res.serverQueried,
now,
);
return { ok: true, record };
} catch (err: any) {
return { ok: false, error: String(err?.message || err) };
}
}

View File

@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeRdap } from "./normalize-rdap.js";
test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () => {
const rdap = {
ldhName: "example.com",
unicodeName: "example.com",
entities: [
{
roles: ["registrar"],
vcardArray: [
"vcard",
[
["fn", {}, "text", "Registrar LLC"],
["email", {}, "text", "support@registrar.test"],
["tel", {}, "text", "+1.5555555555"],
["url", {}, "text", "https://registrar.example"],
],
],
publicIds: [{ type: "IANA Registrar ID", identifier: "9999" }],
},
{
roles: ["registrant"],
vcardArray: [
"vcard",
[
["fn", {}, "text", "Alice Registrant"],
["email", {}, "text", "alice@example.com"],
],
],
},
{
roles: ["administrative"],
vcardArray: ["vcard", [["fn", {}, "text", "Bob Admin"]]],
},
{
roles: ["technical"],
vcardArray: ["vcard", [["fn", {}, "text", "Carol Tech"]]],
},
],
nameservers: [
{
ldhName: "NS1.EXAMPLE.COM",
ipAddresses: { v4: ["192.0.2.1"], v6: ["2001:db8::1"] },
},
{ unicodeName: "ns2.example.com" },
],
secureDNS: {
delegationSigned: true,
dsData: [
{ keyTag: 12345, algorithm: 13, digestType: 2, digest: "ABCDEF" },
],
},
events: [
{ eventAction: "registration", eventDate: "2020-01-02T03:04:05Z" },
{ eventAction: "last changed", eventDate: "2021-01-02T03:04:05Z" },
{ eventAction: "expiration", eventDate: "2030-01-02T03:04:05Z" },
],
status: ["clientTransferProhibited"],
port43: "whois.example-registrar.test",
};
const rec = normalizeRdap(
"example.com",
"com",
rdap,
["https://rdap.example/"],
"2025-01-01T00:00:00Z",
);
assert.equal(rec.domain, "example.com");
assert.equal(rec.tld, "com");
assert.equal(rec.registrar?.name, "Registrar LLC");
assert.equal(rec.registrar?.ianaId, "9999");
assert.ok(rec.contacts && rec.contacts.length >= 3);
assert.ok(rec.nameservers && rec.nameservers.length === 2);
assert.equal(rec.nameservers?.[0].host, "ns1.example.com");
assert.ok(rec.dnssec?.enabled);
assert.equal(rec.creationDate, "2020-01-02T03:04:05Z");
assert.equal(rec.expirationDate, "2030-01-02T03:04:05Z");
assert.equal(rec.transferLock, true);
assert.equal(rec.whoisServer, "whois.example-registrar.test");
assert.equal(rec.source, "rdap");
});

240
src/normalize-rdap.ts Normal file
View File

@@ -0,0 +1,240 @@
import type {
Contact,
DomainRecord,
Nameserver,
RegistrarInfo,
} from "./types.js";
import { toISO, uniq } from "./utils.js";
/**
* Convert RDAP JSON into our normalized DomainRecord.
* This function is defensive: RDAP servers vary in completeness and field naming.
*/
export function normalizeRdap(
inputDomain: string,
tld: string,
rdap: any,
rdapServersTried: string[],
fetchedAtISO: string,
): DomainRecord {
// Safe helpers for optional fields
const get = (obj: any, path: string[]): any =>
path.reduce((o, k) => (o && k in o ? o[k] : undefined), obj);
// Prefer ldhName (punycode) and unicodeName if provided
const ldhName: string | undefined =
rdap?.ldhName || rdap?.handle || undefined;
const unicodeName: string | undefined = rdap?.unicodeName || undefined;
// Registrar entity can be provided with role "registrar"
const registrar: RegistrarInfo | undefined = extractRegistrar(rdap?.entities);
// Nameservers: normalize host + IPs
const nameservers: Nameserver[] | undefined = Array.isArray(rdap?.nameservers)
? rdap.nameservers
.map((ns: any) => ({
host: ns?.ldhName || ns?.unicodeName || "",
ipv4: get(ns, ["ipAddresses", "v4"]) || undefined,
ipv6: get(ns, ["ipAddresses", "v6"]) || undefined,
}))
.filter((n: Nameserver) => !!n.host)
: undefined;
// Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse
const contacts: Contact[] | undefined = extractContacts(rdap?.entities);
// RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
const statuses = Array.isArray(rdap?.status)
? rdap.status.map((s: string) => ({ status: s, raw: s }))
: undefined;
// Secure DNS info
const secureDNS = rdap?.secureDNS;
const dnssec = secureDNS
? {
enabled: !!secureDNS.delegationSigned,
dsRecords: Array.isArray(secureDNS.dsData)
? secureDNS.dsData.map((d: any) => ({
keyTag: d.keyTag,
algorithm: d.algorithm,
digestType: d.digestType,
digest: d.digest,
}))
: undefined,
}
: undefined;
// RDAP "events" contain timestamps for registration, last changed, expiration, deletion, etc.
const events: any[] = Array.isArray(rdap?.events) ? rdap.events : [];
const byAction = (action: string) =>
events.find(
(e) =>
typeof e?.eventAction === "string" &&
e.eventAction.toLowerCase().includes(action),
);
const creationDate = toISO(
byAction("registration")?.eventDate || rdap?.registrationDate,
);
const updatedDate = toISO(
byAction("last changed")?.eventDate || rdap?.lastChangedDate,
);
const expirationDate = toISO(
byAction("expiration")?.eventDate || rdap?.expirationDate,
);
const deletionDate = toISO(
byAction("deletion")?.eventDate || rdap?.deletionDate,
);
// Derive a simple transfer lock flag from statuses
const transferLock = !!statuses?.some((s: { status: string }) =>
/transferprohibited/i.test(s.status),
);
// The RDAP document may include "port43" pointer to authoritative WHOIS
const whoisServer: string | undefined =
typeof rdap?.port43 === "string" ? rdap.port43 : undefined;
const record: DomainRecord = {
domain: unicodeName || ldhName || inputDomain,
tld,
isIDN: /(^|\.)xn--/i.test(ldhName || inputDomain),
unicodeName: unicodeName || undefined,
punycodeName: ldhName || undefined,
registry: undefined, // RDAP rarely includes a clean registry operator name
registrar: registrar,
reseller: undefined,
statuses: statuses,
creationDate,
updatedDate,
expirationDate,
deletionDate,
transferLock,
dnssec,
nameservers: nameservers
? uniq(nameservers.map((n) => ({ ...n, host: n.host.toLowerCase() })))
: undefined,
contacts,
whoisServer,
rdapServers: rdapServersTried,
rawRdap: rdap,
rawWhois: undefined,
source: "rdap",
fetchedAt: fetchedAtISO,
warnings: undefined,
};
return record;
}
function extractRegistrar(
entities: any[] | undefined,
): RegistrarInfo | undefined {
if (!Array.isArray(entities)) return undefined;
for (const ent of entities) {
const roles: string[] = Array.isArray(ent?.roles) ? ent.roles : [];
if (!roles.some((r) => /registrar/i.test(r))) continue;
const v = parseVcard(ent?.vcardArray);
const ianaId = Array.isArray(ent?.publicIds)
? ent.publicIds.find((id: any) => /iana\s*registrar\s*id/i.test(id?.type))
?.identifier
: undefined;
return {
name: v.fn || v.org || ent?.handle || undefined,
ianaId: ianaId,
url: v.url,
email: v.email,
phone: v.tel,
};
}
return undefined;
}
function extractContacts(entities: any[] | undefined): Contact[] | undefined {
if (!Array.isArray(entities)) return undefined;
const out: Contact[] = [];
for (const ent of entities) {
const roles: string[] = Array.isArray(ent?.roles) ? ent.roles : [];
const v = parseVcard(ent?.vcardArray);
const type = roles.find((r) =>
/registrant|administrative|technical|billing|abuse|reseller/i.test(r),
);
if (!type) continue;
const map: Record<string, Contact["type"]> = {
registrant: "registrant",
administrative: "admin",
technical: "tech",
billing: "billing",
abuse: "abuse",
reseller: "reseller",
} as const;
const roleKey = (
type.toLowerCase() in map ? (map as any)[type.toLowerCase()] : "unknown"
) as Contact["type"];
out.push({
type: roleKey,
name: v.fn,
organization: v.org,
email: v.email,
phone: v.tel,
fax: v.fax,
street: v.street,
city: v.locality,
state: v.region,
postalCode: v.postcode,
country: v.country,
countryCode: v.countryCode,
});
}
return out.length ? out : undefined;
}
// Parse a minimal subset of vCard 4.0 arrays as used in RDAP "vcardArray" fields
function parseVcard(vcardArray: any): Record<string, any> {
// vcardArray is typically ["vcard", [["version",{},"text","4.0"], ["fn",{},"text","Example"], ...]]
if (
!Array.isArray(vcardArray) ||
vcardArray[0] !== "vcard" ||
!Array.isArray(vcardArray[1])
)
return {};
const entries: any[] = vcardArray[1];
const out: Record<string, any> = {};
for (const e of entries) {
const key = e?.[0];
const _valueType = e?.[2];
const value = e?.[3];
if (!key) continue;
switch (String(key).toLowerCase()) {
case "fn":
out.fn = value;
break;
case "org":
out.org = Array.isArray(value) ? value.join(" ") : value;
break;
case "email":
out.email = value;
break;
case "tel":
out.tel = value;
break;
case "url":
out.url = value;
break;
case "adr": {
// adr value is [postOfficeBox, extendedAddress, street, locality, region, postalCode, country]
if (Array.isArray(value)) {
out.street = value[2]
? String(value[2]).split(/\\n|,\s*/)
: undefined;
out.locality = value[3];
out.region = value[4];
out.postcode = value[5];
out.country = value[6];
}
break;
}
}
}
// Best effort country code from country name (often omitted). Leaving undefined unless explicitly provided.
return out;
}

141
src/normalize-whois.test.ts Normal file
View File

@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeWhois } from "./normalize-whois.js";
function _runCase(label: string, tld: string, text: string) {
const rec = normalizeWhois(
`example.${tld}`,
tld,
text,
"whois.test",
"2025-01-01T00:00:00Z",
);
assert.equal(rec.tld, tld, label);
assert.equal(rec.source, "whois");
}
test("WHOIS .de (DENIC-like) nserver lines", () => {
const text = `
Domain: example.de
Nserver: ns1.example.net 192.0.2.1 2001:db8::1
Nserver: ns2.example.net
Status: connect
Changed: 2020-01-02
`;
const rec = normalizeWhois(
"example.de",
"de",
text,
"whois.denic.de",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.nameservers && rec.nameservers.length === 2);
assert.equal(rec.nameservers?.[0].host, "ns1.example.net");
});
test("WHOIS .uk Nominet style", () => {
const text = `
Domain name:
example.uk
Data validation:
Nominet was able to match the registrant's name and address against a 3rd party data source on 01-Jan-2020
Registrar:
Registrar Ltd [Tag = REGTAG]
URL: https://registrar.example
Registered on: 01-Jan-2020
Expiry date: 01-Jan-2030
Last updated: 01-Jan-2021
Name servers:
ns1.example.net 192.0.2.1
ns2.example.net
`;
const rec = normalizeWhois(
"example.uk",
"uk",
text,
"whois.nic.uk",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.nameservers && rec.nameservers.length === 2);
assert.ok(rec.creationDate);
assert.ok(rec.expirationDate);
});
test("WHOIS .jp JPRS style privacy redacted", () => {
const text = `
[Domain Name] EXAMPLE.JP
[Registrant] (Not Disclosed)
[Name Server] ns1.example.jp
[Name Server] ns2.example.jp
[Created on] 2020/01/02
[Expires on] 2030/01/02
[Status] Active
`;
const rec = normalizeWhois(
"example.jp",
"jp",
text,
"whois.jprs.jp",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.creationDate);
assert.ok(rec.expirationDate);
assert.ok(rec.statuses);
});
test("WHOIS .io NIC.IO style", () => {
const text = `
Domain Name: EXAMPLE.IO
Registry Domain ID: D000000000000-IONIC
Registrar WHOIS Server: whois.registrar.test
Registrar URL: http://www.registrar.test
Updated Date: 2021-01-02T03:04:05Z
Creation Date: 2020-01-02T03:04:05Z
Registry Expiry Date: 2030-01-02T03:04:05Z
Registrar: Registrar LLC
Name Server: NS1.EXAMPLE.IO
Name Server: NS2.EXAMPLE.IO
DNSSEC: unsigned
`;
const rec = normalizeWhois(
"example.io",
"io",
text,
"whois.nic.io",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.creationDate);
assert.ok(rec.expirationDate);
assert.ok(rec.nameservers && rec.nameservers.length === 2);
});
test("Privacy redacted WHOIS normalizes without contacts", () => {
const text = `
Domain Name: EXAMPLE.COM
Registry Domain ID: 0000000000_DOMAIN_COM-VRSN
Registrar WHOIS Server: whois.registrar.test
Registrar URL: http://www.registrar.test
Updated Date: 2021-01-02T03:04:05Z
Creation Date: 2020-01-02T03:04:05Z
Registry Expiry Date: 2030-01-02T03:04:05Z
Registrar: Registrar LLC
Registrant Organization: Privacy Protect, LLC
Registrant State/Province: CA
Registrant Country: US
Registrant Email: Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name.
Name Server: NS1.EXAMPLE.COM
Name Server: NS2.EXAMPLE.COM
DNSSEC: unsigned
Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
`;
const rec = normalizeWhois(
"example.com",
"com",
text,
"whois.verisign-grs.com",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.creationDate);
assert.ok(rec.expirationDate);
assert.equal(rec.source, "whois");
});

239
src/normalize-whois.ts Normal file
View File

@@ -0,0 +1,239 @@
import type {
Contact,
DomainRecord,
Nameserver,
RegistrarInfo,
} from "./types.js";
import { parseKeyValueLines, toISO, uniq } from "./utils.js";
/**
* Convert raw WHOIS text into our normalized DomainRecord.
* Heuristics cover many gTLD and ccTLD formats; exact fields vary per registry.
*/
export function normalizeWhois(
domain: string,
tld: string,
whoisText: string,
whoisServer: string | undefined,
fetchedAtISO: string,
): DomainRecord {
const map = parseKeyValueLines(whoisText);
// Date extraction across common synonyms
const creationDate = anyValue(map, [
"creation date",
"created on",
"registered on",
"domain registration date",
"domain create date",
"created",
"registered",
]);
const updatedDate = anyValue(map, [
"updated date",
"last updated",
"last modified",
"modified",
]);
const expirationDate = anyValue(map, [
"registry expiry date",
"expiry date",
"expiration date",
"paid-till",
"expires on",
"renewal date",
]);
// Registrar info (thin registries like .com/.net require referral follow for full data)
const registrar: RegistrarInfo | undefined = (() => {
const name = anyValue(map, [
"registrar",
"sponsoring registrar",
"registrar name",
]);
const ianaId = anyValue(map, ["registrar iana id", "iana id"]);
const url = anyValue(map, [
"registrar url",
"url of the registrar",
"referrer",
]);
const abuseEmail = anyValue(map, [
"registrar abuse contact email",
"abuse contact email",
]);
const abusePhone = anyValue(map, [
"registrar abuse contact phone",
"abuse contact phone",
]);
if (!name && !ianaId && !url && !abuseEmail && !abusePhone)
return undefined;
return {
name: name || undefined,
ianaId: ianaId || undefined,
url: url || undefined,
email: abuseEmail || undefined,
phone: abusePhone || undefined,
};
})();
// Statuses: multiple entries are expected; keep raw
const statusLines = map["domain status"] || map.status || [];
const statuses = statusLines.length
? statusLines.map((line) => ({ status: line.split(/\s+/)[0], raw: line }))
: undefined;
// Nameservers: also appear as "nserver" on some ccTLDs (.de, .ru) and as "name server"
const nsLines: string[] = [
...(map["name server"] || []),
...(map.nameserver || []),
...(map["name servers"] || []),
...(map.nserver || []),
];
const nameservers: Nameserver[] | undefined = nsLines.length
? (uniq(
nsLines
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
// Common formats: "ns1.example.com" or "ns1.example.com 192.0.2.1" or "ns1.example.com 2001:db8::1"
const parts = line.split(/\s+/);
const host = parts.shift()?.toLowerCase() || "";
const ipv4: string[] = [];
const ipv6: string[] = [];
for (const p of parts) {
if (/^\d+\.\d+\.\d+\.\d+$/.test(p)) ipv4.push(p);
else if (/^[0-9a-f:]+$/i.test(p)) ipv6.push(p);
}
if (!host) return undefined;
const ns: Nameserver = { host };
if (ipv4.length) ns.ipv4 = ipv4;
if (ipv6.length) ns.ipv6 = ipv6;
return ns;
})
.filter((x): x is Nameserver => !!x),
) as Nameserver[])
: undefined;
// Contacts: best-effort parse common keys
const contacts = collectContacts(map);
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
const dnssec = dnssecRaw
? { enabled: /signed|yes|true/.test(dnssecRaw) }
: undefined;
// Simple lock derivation from statuses
const transferLock = !!statuses?.some((s) =>
/transferprohibited/i.test(s.status),
);
const record: DomainRecord = {
domain,
tld,
isIDN: /(^|\.)xn--/i.test(domain),
unicodeName: undefined,
punycodeName: undefined,
registry: undefined,
registrar,
reseller: anyValue(map, ["reseller"]) || undefined,
statuses,
creationDate: toISO(creationDate || undefined),
updatedDate: toISO(updatedDate || undefined),
expirationDate: toISO(expirationDate || undefined),
deletionDate: undefined,
transferLock,
dnssec,
nameservers,
contacts,
whoisServer,
rdapServers: undefined,
rawRdap: undefined,
rawWhois: whoisText,
source: "whois",
fetchedAt: fetchedAtISO,
warnings: undefined,
};
return record;
}
function anyValue(
map: Record<string, string[]>,
keys: string[],
): string | undefined {
for (const k of keys) {
const v = map[k];
if (v?.length) return v[0];
}
return undefined;
}
function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
const roles: Array<{ role: Contact["type"]; prefix: string }> = [
{ role: "registrant", prefix: "registrant" },
{ role: "admin", prefix: "admin" },
{ role: "tech", prefix: "tech" },
{ role: "billing", prefix: "billing" },
{ role: "abuse", prefix: "abuse" },
];
const contacts: Contact[] = [];
for (const r of roles) {
const name = anyValue(map, [
`${r.prefix} name`,
`${r.prefix} contact name`,
`${r.prefix}`,
]);
const org = anyValue(map, [`${r.prefix} organization`, `${r.prefix} org`]);
const email = anyValue(map, [
`${r.prefix} email`,
`${r.prefix} contact email`,
`${r.prefix} e-mail`,
]);
const phone = anyValue(map, [
`${r.prefix} phone`,
`${r.prefix} contact phone`,
`${r.prefix} telephone`,
]);
const fax = anyValue(map, [`${r.prefix} fax`, `${r.prefix} facsimile`]);
const street = multi(map, [`${r.prefix} street`, `${r.prefix} address`]);
const city = anyValue(map, [`${r.prefix} city`]);
const state = anyValue(map, [
`${r.prefix} state`,
`${r.prefix} province`,
`${r.prefix} state/province`,
]);
const postalCode = anyValue(map, [
`${r.prefix} postal code`,
`${r.prefix} postcode`,
`${r.prefix} zip`,
]);
const country = anyValue(map, [`${r.prefix} country`]);
if (name || org || email || phone || street?.length) {
contacts.push({
type: r.role,
name: name || undefined,
organization: org || undefined,
email: email || undefined,
phone: phone || undefined,
fax: fax || undefined,
street: street,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
});
}
}
return contacts.length ? contacts : undefined;
}
function multi(
map: Record<string, string[]>,
keys: string[],
): string[] | undefined {
for (const k of keys) {
const v = map[k];
if (v?.length) return v;
}
return undefined;
}

80
src/rdap.ts Normal file
View File

@@ -0,0 +1,80 @@
import { DEFAULT_TIMEOUT_MS } from "./config.js";
import type { LookupOptions } from "./types.js";
import { withTimeout } from "./utils.js";
// Use global fetch (Node 18+). For large JSON we keep it simple.
// RDAP bootstrap JSON format as published by IANA
interface BootstrapData {
version: string;
publication: string;
description?: string;
// Each service entry is [[tld1, tld2, ...], [baseUrl1, baseUrl2, ...]]
services: string[][][];
}
/**
* Resolve RDAP base URLs for a given TLD using IANA's bootstrap registry.
* Returns zero or more base URLs (always suffixed with a trailing slash).
*/
export async function getRdapBaseUrlsForTld(
tld: string,
options?: LookupOptions,
): Promise<string[]> {
const bootstrapUrl =
options?.customBootstrapUrl ?? "https://data.iana.org/rdap/dns.json";
const res = await withTimeout(
fetch(bootstrapUrl, {
method: "GET",
headers: { accept: "application/json" },
signal: options?.signal,
}),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
"RDAP bootstrap timeout",
);
if (!res.ok) return [];
const data = (await res.json()) as BootstrapData;
const target = tld.toLowerCase();
const bases: string[] = [];
for (const svc of data.services) {
const tlds = svc[0];
const urls = svc[1];
if (tlds.map((x) => x.toLowerCase()).includes(target)) {
for (const u of urls) {
const base = u.endsWith("/") ? u : `${u}/`;
bases.push(base);
}
}
}
return Array.from(new Set(bases));
}
/**
* Fetch RDAP JSON for a domain from a specific RDAP base URL.
* Throws on HTTP >= 400 (includes RDAP error JSON payloads).
*/
export async function fetchRdapDomain(
domain: string,
baseUrl: string,
options?: LookupOptions,
): Promise<{ url: string; json: any }> {
const url = new URL(
`domain/${encodeURIComponent(domain)}`,
baseUrl,
).toString();
const res = await withTimeout(
fetch(url, {
method: "GET",
headers: { accept: "application/rdap+json, application/json" },
signal: options?.signal,
}),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
"RDAP lookup timeout",
);
if (!res.ok) {
const bodyText = await res.text();
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
}
const json = await res.json();
return { url, json };
}

99
src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,99 @@
export type LookupSource = "rdap" | "whois";
export interface RegistrarInfo {
name?: string;
ianaId?: string;
url?: string;
email?: string;
phone?: string;
}
export interface Contact {
type:
| "registrant"
| "admin"
| "tech"
| "billing"
| "abuse"
| "registrar"
| "reseller"
| "unknown";
name?: string;
organization?: string;
email?: string | string[];
phone?: string | string[];
fax?: string | string[];
street?: string[];
city?: string;
state?: string;
postalCode?: string;
country?: string;
countryCode?: string;
}
export interface Nameserver {
host: string;
ipv4?: string[];
ipv6?: string[];
}
export interface StatusEvent {
status: string;
description?: string;
raw?: string;
}
export interface DomainRecord {
domain: string;
tld: string;
isIDN?: boolean;
unicodeName?: string;
punycodeName?: string;
registry?: string; // Registry operator if available
registrar?: RegistrarInfo;
reseller?: string;
statuses?: StatusEvent[]; // EPP statuses
creationDate?: string; // ISO 8601
updatedDate?: string; // ISO 8601
expirationDate?: string; // ISO 8601
deletionDate?: string; // ISO 8601
transferLock?: boolean;
dnssec?: {
enabled: boolean;
dsRecords?: Array<{
keyTag?: number;
algorithm?: number;
digestType?: number;
digest?: string;
}>;
};
nameservers?: Nameserver[];
contacts?: Contact[];
whoisServer?: string; // authoritative WHOIS queried (if any)
rdapServers?: string[]; // RDAP base URLs tried
rawRdap?: any; // raw RDAP JSON
rawWhois?: string; // raw WHOIS text (last authoritative)
source: LookupSource; // which source produced data
fetchedAt: string; // ISO 8601
warnings?: string[];
}
export interface LookupOptions {
timeoutMs?: number; // total timeout budget
rdapOnly?: boolean; // don't fall back to WHOIS
whoisOnly?: boolean; // don't attempt RDAP
followWhoisReferral?: boolean; // follow referral server (default true)
customBootstrapUrl?: string; // override IANA bootstrap
signal?: AbortSignal;
}
export interface LookupResult {
ok: boolean;
record?: DomainRecord;
error?: string;
}
export type FetchLike = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;

30
src/utils.test.ts Normal file
View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractTld, isLikelyDomain, toISO } from "./utils.js";
test("toISO parses ISO and common whois formats", () => {
const iso = toISO("2023-01-02T03:04:05Z");
assert.equal(iso, "2023-01-02T03:04:05Z");
const noZ = toISO("2023-01-02 03:04:05");
assert.match(noZ!, /^2023-01-02T03:04:05Z$/);
const slash = toISO("2023/01/02 03:04:05");
assert.match(slash!, /^2023-01-02T03:04:05Z$/);
const dmy = toISO("02-Jan-2023");
assert.equal(dmy, "2023-01-02T00:00:00Z");
const mdy = toISO("Jan 02 2023");
assert.equal(mdy, "2023-01-02T00:00:00Z");
});
test("extractTld basic", () => {
assert.equal(extractTld("example.com"), "com");
assert.equal(extractTld("sub.example.co.uk"), "uk");
});
test("isLikelyDomain", () => {
assert.equal(isLikelyDomain("example.com"), true);
assert.equal(isLikelyDomain("not a domain"), false);
});

193
src/utils.ts Normal file
View File

@@ -0,0 +1,193 @@
// Lightweight date parsing helpers to avoid external dependencies.
// We aim to parse common RDAP and WHOIS date representations and return a UTC ISO string.
export function toISO(
dateLike: string | number | Date | undefined | null,
): string | undefined {
if (dateLike == null) return undefined;
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
const raw = String(dateLike).trim();
if (!raw) return undefined;
// Try several structured formats seen in WHOIS outputs (treat as UTC when no TZ provided)
const tryFormats = [
// 2023-01-02 03:04:05Z or without Z
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z)?$/,
// 2023/01/02 03:04:05
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/,
// 02-Jan-2023
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
// Jan 02 2023
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/,
];
for (const re of tryFormats) {
const m = raw.match(re);
if (!m) continue;
const d = parseWithRegex(m, re);
if (d) return toIsoFromDate(d);
}
// Fallback to native Date parsing (handles ISO and RFC2822 with TZ)
const native = new Date(raw);
if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
return undefined;
}
function toIsoFromDate(d: Date): string | undefined {
try {
return new Date(
Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds(),
0,
),
)
.toISOString()
.replace(/\.\d{3}Z$/, "Z");
} catch {
return undefined;
}
}
function parseWithRegex(m: RegExpMatchArray, _re: RegExp): Date | undefined {
const monthMap: Record<string, number> = {
jan: 0,
feb: 1,
mar: 2,
apr: 3,
may: 4,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
oct: 9,
nov: 10,
dec: 11,
};
try {
// If the matched string contains time components, parse as Y-M-D H:M:S
if (m[0].includes(":")) {
const [_, y, mo, d, hh, mm, ss] = m;
return new Date(
Date.UTC(
Number(y),
Number(mo) - 1,
Number(d),
Number(hh),
Number(mm),
Number(ss),
),
);
}
// If the matched string contains hyphens, treat as DD-MMM-YYYY
if (m[0].includes("-")) {
const [_, dd, monStr, yyyy] = m;
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
}
// Otherwise treat as MMM DD YYYY
const [_, monStr, dd, yyyy] = m;
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
} catch {
return undefined;
}
return undefined;
}
export function uniq<T>(arr: T[] | undefined | null): T[] | undefined {
if (!arr) return undefined;
return Array.from(new Set(arr));
}
export function parseKeyValueLines(text: string): Record<string, string[]> {
const map = new Map<string, string[]>();
const lines = text.split(/\r?\n/);
let lastKey: string | undefined;
for (const rawLine of lines) {
const line = rawLine.replace(/\s+$/, "");
if (!line.trim()) continue;
// Bracketed form: [Key] value (common in .jp and some ccTLDs)
const bracket = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
if (bracket) {
const key = bracket[1].trim().toLowerCase();
const value = bracket[2].trim();
const list = map.get(key) ?? [];
if (value) list.push(value);
map.set(key, list);
lastKey = key;
continue;
}
// Colon form: Key: value
const idx = line.indexOf(":");
if (idx !== -1) {
const key = line.slice(0, idx).trim().toLowerCase();
const value = line.slice(idx + 1).trim();
if (!key) {
lastKey = undefined;
continue;
}
const list = map.get(key) ?? [];
if (value) list.push(value);
map.set(key, list);
lastKey = key;
continue;
}
// Continuation line: starts with indentation after a key appeared
if (lastKey && /^\s+/.test(line)) {
const value = line.trim();
if (value) {
const list = map.get(lastKey) ?? [];
list.push(value);
map.set(lastKey, list);
}
}
// Otherwise ignore non key-value lines
}
return Object.fromEntries(map);
}
export function parseCsv(value: string | undefined): string[] | undefined {
if (!value) return undefined;
return value
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
}
export function punyToUnicode(domain: string): string {
try {
return domain.normalize("NFC");
} catch {
return domain;
}
}
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
reason = "Timeout",
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
let timer: any;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
});
return Promise.race([promise.finally(() => clearTimeout(timer)), timeout]);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function extractTld(domain: string): string {
const lower = domain.trim().toLowerCase();
const parts = lower.split(".").filter(Boolean);
return parts[parts.length - 1] ?? lower;
}
export function isLikelyDomain(input: string): boolean {
return /^[a-z0-9.-]+$/i.test(input) && input.includes(".");
}

108
src/whois.ts Normal file
View File

@@ -0,0 +1,108 @@
import { createConnection } from "node:net";
import { DEFAULT_TIMEOUT_MS } from "./config.js";
import type { LookupOptions } from "./types.js";
import { withTimeout } from "./utils.js";
export interface WhoisQueryResult {
serverQueried: string;
text: string;
}
/**
* Perform a WHOIS query against an RFC 3912 server over TCP 43.
* Returns the raw text and the server used.
*/
export async function whoisQuery(
server: string,
query: string,
options?: LookupOptions,
): Promise<WhoisQueryResult> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const port = 43;
const host = server.replace(/^whois:\/\//i, "");
const text = await withTimeout(
queryTcp(host, port, query, options),
timeoutMs,
"WHOIS timeout",
);
return { serverQueried: server, text };
}
// Low-level WHOIS TCP client. Some registries require CRLF after the domain query.
function queryTcp(
host: string,
port: number,
query: string,
options?: LookupOptions,
): Promise<string> {
return new Promise((resolve, reject) => {
const socket = createConnection({ host, port });
let data = "";
let done = false;
const cleanup = () => {
if (done) return;
done = true;
socket.destroy();
};
socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1000, () => {
cleanup();
reject(new Error("WHOIS socket timeout"));
});
socket.on("error", (err) => {
cleanup();
reject(err);
});
socket.on("data", (chunk) => {
data += chunk.toString("utf8");
});
socket.on("end", () => {
cleanup();
resolve(data);
});
socket.on("connect", () => {
socket.write(`${query}\r\n`);
});
});
}
/**
* Best-effort discovery of the authoritative WHOIS server for a TLD via IANA root DB.
*/
export async function ianaWhoisServerForTld(
tld: string,
options?: LookupOptions,
): Promise<string | undefined> {
const url = `https://www.iana.org/domains/root/db/${encodeURIComponent(tld)}.html`;
try {
const res = await withTimeout(
fetch(url, { method: "GET" }),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
);
if (!res.ok) return undefined;
const html = await res.text();
const m =
html.match(/Whois Server:\s*<a[^>]*>([^<]+)<\/a>/i) ||
html.match(/Whois Server:\s*([^<\n]+)/i);
const server = m?.[1]?.trim();
if (!server) return undefined;
return server.replace(/^https?:\/\//i, "").replace(/\/$/, "");
} catch {
return undefined;
}
}
/**
* Extract registrar referral WHOIS server from a WHOIS response, if present.
*/
export function extractWhoisReferral(text: string): string | undefined {
const patterns = [
/^Registrar WHOIS Server:\s*(.+)$/im,
/^Whois Server:\s*(.+)$/im,
/^ReferralServer:\s*whois:\/\/(.+)$/im,
];
for (const re of patterns) {
const m = text.match(re);
if (m?.[1]) return m[1].trim();
}
return undefined;
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ES2022",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

1
tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/config.ts","./src/index.ts","./src/lookup.smoke.test.ts","./src/lookup.ts","./src/normalize-rdap.test.ts","./src/normalize-rdap.ts","./src/normalize-whois.test.ts","./src/normalize-whois.ts","./src/rdap.ts","./src/types.d.ts","./src/utils.test.ts","./src/utils.ts","./src/whois.ts"],"version":"5.9.2"}