mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 00:15:31 -04:00
Implement domain lookup in index.ts: consolidate RDAP and WHOIS logic into a single function, add availability checks, and enhance type definitions. Remove deprecated lookup.ts and introduce smoke tests for comprehensive coverage of lookup scenarios.
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { lookupDomain } from "../lookup.js";
|
import { isAvailable, isRegistered, lookupDomain } from "../index.js";
|
||||||
|
|
||||||
// Run only when SMOKE=1 to avoid flakiness and network in CI by default
|
// Run only when SMOKE=1 to avoid flakiness and network in CI by default
|
||||||
const shouldRun = process.env.SMOKE === "1";
|
const shouldRun = process.env.SMOKE === "1";
|
||||||
@@ -124,3 +124,18 @@ for (const c of rdapCases) {
|
|||||||
assert.ok(ns.includes("ns2.digitalocean.com"));
|
assert.ok(ns.includes("ns2.digitalocean.com"));
|
||||||
assert.ok(ns.includes("ns3.digitalocean.com"));
|
assert.ok(ns.includes("ns3.digitalocean.com"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(shouldRun ? test : test.skip)(
|
||||||
|
"isRegistered true for example.com",
|
||||||
|
async () => {
|
||||||
|
assert.equal(await isRegistered("example.com", { timeoutMs: 15000 }), true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
(shouldRun ? test : test.skip)(
|
||||||
|
"isAvailable true for an unlikely .com",
|
||||||
|
async () => {
|
||||||
|
const unlikely = `nonexistent-${Date.now()}-smoke-example.com`;
|
||||||
|
assert.equal(await isAvailable(unlikely, { timeoutMs: 15000 }), true);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { toISO } from "../lib/dates.js";
|
|
||||||
import { getDomainParts, isLikelyDomain } from "../lib/domain.js";
|
|
||||||
import { getRdapBaseUrlsForTld } from "../rdap/bootstrap.js";
|
|
||||||
import { fetchRdapDomain } from "../rdap/client.js";
|
|
||||||
import { normalizeRdap } from "../rdap/normalize.js";
|
|
||||||
import type { DomainRecord, LookupOptions, LookupResult } from "../types.js";
|
|
||||||
import { WHOIS_TLD_EXCEPTIONS } from "../whois/catalog.js";
|
|
||||||
import { whoisQuery } from "../whois/client.js";
|
|
||||||
import {
|
|
||||||
extractWhoisReferral,
|
|
||||||
ianaWhoisServerForTld,
|
|
||||||
} from "../whois/discovery.js";
|
|
||||||
import { normalizeWhois } from "../whois/normalize.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 { publicSuffix, tld } = getDomainParts(domain);
|
|
||||||
// Avoid non-null assertion: fallback to a stable ISO string if parsing ever fails
|
|
||||||
const now =
|
|
||||||
toISO(new Date()) ?? new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
||||||
|
|
||||||
// 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" };
|
|
||||||
}
|
|
||||||
// Query the TLD server first; if it returns a referral, we follow it below.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If TLD registry returns no match and there was no referral, try multi-label public suffix candidates
|
|
||||||
if (
|
|
||||||
publicSuffix.includes(".") &&
|
|
||||||
/no match|not found/i.test(res.text) &&
|
|
||||||
opts?.followWhoisReferral !== false
|
|
||||||
) {
|
|
||||||
const candidates: string[] = [];
|
|
||||||
const ps = publicSuffix.toLowerCase();
|
|
||||||
// Prefer explicit exceptions when known
|
|
||||||
const exception = WHOIS_TLD_EXCEPTIONS[ps];
|
|
||||||
if (exception) candidates.push(exception);
|
|
||||||
for (const server of candidates) {
|
|
||||||
try {
|
|
||||||
const alt = await whoisQuery(server, domain, opts);
|
|
||||||
if (alt.text && !/error/i.test(alt.text)) {
|
|
||||||
res = alt;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// try next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const record: DomainRecord = normalizeWhois(
|
|
||||||
domain,
|
|
||||||
tld,
|
|
||||||
res.text,
|
|
||||||
res.serverQueried,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
return { ok: true, record };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
return { ok: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+137
-1
@@ -1,2 +1,138 @@
|
|||||||
export * from "./api/lookup.js";
|
import { toISO } from "./lib/dates.js";
|
||||||
|
import { getDomainParts, isLikelyDomain } from "./lib/domain.js";
|
||||||
|
import { getRdapBaseUrlsForTld } from "./rdap/bootstrap.js";
|
||||||
|
import { fetchRdapDomain } from "./rdap/client.js";
|
||||||
|
import { normalizeRdap } from "./rdap/normalize.js";
|
||||||
|
import type { DomainRecord, LookupOptions, LookupResult } from "./types.js";
|
||||||
|
import { WHOIS_TLD_EXCEPTIONS } from "./whois/catalog.js";
|
||||||
|
import { whoisQuery } from "./whois/client.js";
|
||||||
|
import {
|
||||||
|
extractWhoisReferral,
|
||||||
|
ianaWhoisServerForTld,
|
||||||
|
} from "./whois/discovery.js";
|
||||||
|
import { normalizeWhois } from "./whois/normalize.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 { publicSuffix, tld } = getDomainParts(domain);
|
||||||
|
// Avoid non-null assertion: fallback to a stable ISO string if parsing ever fails
|
||||||
|
const now =
|
||||||
|
toISO(new Date()) ?? new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
|
||||||
|
// 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" };
|
||||||
|
}
|
||||||
|
// Query the TLD server first; if it returns a referral, we follow it below.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If TLD registry returns no match and there was no referral, try multi-label public suffix candidates
|
||||||
|
if (
|
||||||
|
publicSuffix.includes(".") &&
|
||||||
|
/no match|not found/i.test(res.text) &&
|
||||||
|
opts?.followWhoisReferral !== false
|
||||||
|
) {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const ps = publicSuffix.toLowerCase();
|
||||||
|
// Prefer explicit exceptions when known
|
||||||
|
const exception = WHOIS_TLD_EXCEPTIONS[ps];
|
||||||
|
if (exception) candidates.push(exception);
|
||||||
|
for (const server of candidates) {
|
||||||
|
try {
|
||||||
|
const alt = await whoisQuery(server, domain, opts);
|
||||||
|
if (alt.text && !/error/i.test(alt.text)) {
|
||||||
|
res = alt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const record: DomainRecord = normalizeWhois(
|
||||||
|
domain,
|
||||||
|
tld,
|
||||||
|
res.text,
|
||||||
|
res.serverQueried,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return { ok: true, record };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine if a domain appears available (not registered).
|
||||||
|
* Performs a lookup and resolves to a boolean. Rejects on lookup error. */
|
||||||
|
export async function isAvailable(
|
||||||
|
domain: string,
|
||||||
|
opts?: LookupOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const res = await lookupDomain(domain, opts);
|
||||||
|
if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
|
||||||
|
return res.record.isRegistered === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine if a domain appears registered.
|
||||||
|
* Performs a lookup and resolves to a boolean. Rejects on lookup error. */
|
||||||
|
export async function isRegistered(
|
||||||
|
domain: string,
|
||||||
|
opts?: LookupOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const res = await lookupDomain(domain, opts);
|
||||||
|
if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
|
||||||
|
return res.record.isRegistered === true;
|
||||||
|
}
|
||||||
|
|
||||||
export type * from "./types.js";
|
export type * from "./types.js";
|
||||||
|
|||||||
@@ -48,3 +48,21 @@ export function punyToUnicode(domain: string): string {
|
|||||||
return domain;
|
return domain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Common WHOIS availability phrases seen across registries/registrars
|
||||||
|
const WHOIS_AVAILABLE_PATTERNS: RegExp[] = [
|
||||||
|
/\bno match\b/i,
|
||||||
|
/\bnot found\b/i,
|
||||||
|
/\bno entries found\b/i,
|
||||||
|
/\bno data found\b/i,
|
||||||
|
/\bavailable for registration\b/i,
|
||||||
|
/\bdomain\s+available\b/i,
|
||||||
|
/\bdomain status[:\s]+available\b/i,
|
||||||
|
/\bobject does not exist\b/i,
|
||||||
|
/\bthe queried object does not exist\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isWhoisAvailable(text: string | undefined): boolean {
|
||||||
|
if (!text) return false;
|
||||||
|
return WHOIS_AVAILABLE_PATTERNS.some((re) => re.test(text));
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function normalizeRdap(
|
|||||||
const record: DomainRecord = {
|
const record: DomainRecord = {
|
||||||
domain: unicodeName || ldhName || inputDomain,
|
domain: unicodeName || ldhName || inputDomain,
|
||||||
tld,
|
tld,
|
||||||
|
isRegistered: true,
|
||||||
isIDN: /(^|\.)xn--/i.test(ldhName || inputDomain),
|
isIDN: /(^|\.)xn--/i.test(ldhName || inputDomain),
|
||||||
unicodeName: unicodeName || undefined,
|
unicodeName: unicodeName || undefined,
|
||||||
punycodeName: ldhName || undefined,
|
punycodeName: ldhName || undefined,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface StatusEvent {
|
|||||||
export interface DomainRecord {
|
export interface DomainRecord {
|
||||||
domain: string;
|
domain: string;
|
||||||
tld: string;
|
tld: string;
|
||||||
|
isRegistered: boolean; // whether the domain appears registered
|
||||||
isIDN?: boolean;
|
isIDN?: boolean;
|
||||||
unicodeName?: string;
|
unicodeName?: string;
|
||||||
punycodeName?: string;
|
punycodeName?: string;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { toISO } from "../lib/dates.js";
|
import { toISO } from "../lib/dates.js";
|
||||||
|
import { isWhoisAvailable } from "../lib/domain.js";
|
||||||
import { parseKeyValueLines, uniq } from "../lib/text.js";
|
import { parseKeyValueLines, uniq } from "../lib/text.js";
|
||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
@@ -131,6 +132,7 @@ export function normalizeWhois(
|
|||||||
const record: DomainRecord = {
|
const record: DomainRecord = {
|
||||||
domain,
|
domain,
|
||||||
tld,
|
tld,
|
||||||
|
isRegistered: !isWhoisAvailable(whoisText),
|
||||||
isIDN: /(^|\.)xn--/i.test(domain),
|
isIDN: /(^|\.)xn--/i.test(domain),
|
||||||
unicodeName: undefined,
|
unicodeName: undefined,
|
||||||
punycodeName: undefined,
|
punycodeName: undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user