Add support for custom fetch function and/or IANA bootstrap data (#21)

This commit is contained in:
2025-10-30 19:07:12 -04:00
committed by GitHub
parent acae007a4a
commit 619185b5d6
22 changed files with 1346 additions and 163 deletions
+76 -98
View File
@@ -1,27 +1,24 @@
/** biome-ignore-all lint/style/noNonNullAssertion: this is fine for tests */
import { expect, test } from "vitest";
import { isAvailable, isRegistered, lookupDomain } from ".";
import { isAvailable, isRegistered, lookup } from ".";
// Run only when SMOKE=1 to avoid flakiness and network in CI by default
const shouldRun = process.env.SMOKE === "1";
const maybeTest = process.env.SMOKE === "1" ? test : test.skip;
// Basic sanity: either RDAP or WHOIS should succeed for example.com
(shouldRun ? test : test.skip)(
"lookupDomain smoke test (example.com)",
async () => {
const res = await lookupDomain("example.com", {
timeoutMs: 12000,
followWhoisReferral: true,
});
expect(res.ok, res.error).toBe(true);
expect(Boolean(res.record?.domain)).toBe(true);
expect(Boolean(res.record?.tld)).toBe(true);
expect(
res.record?.source === "rdap" || res.record?.source === "whois",
).toBe(true);
},
);
maybeTest("lookup smoke test (example.com)", async () => {
const res = await lookup("example.com", {
timeoutMs: 12000,
followWhoisReferral: true,
});
expect(res.ok, res.error).toBe(true);
expect(Boolean(res.record?.domain)).toBe(true);
expect(Boolean(res.record?.tld)).toBe(true);
expect(res.record?.source === "rdap" || res.record?.source === "whois").toBe(
true,
);
});
// RDAP-only smoke for reserved example domains (.com/.net/.org)
const rdapCases: Array<{ domain: string; tld: string; expectDs?: boolean }> = [
@@ -31,78 +28,67 @@ const rdapCases: Array<{ domain: string; tld: string; expectDs?: boolean }> = [
];
for (const c of rdapCases) {
(shouldRun ? test : test.skip)(
`RDAP-only lookup for ${c.domain}`,
async () => {
const res = await lookupDomain(c.domain, {
timeoutMs: 15000,
rdapOnly: true,
});
expect(res.ok, res.error).toBe(true);
const rec = res.record!;
expect(rec.tld).toBe(c.tld);
expect(rec.source).toBe("rdap");
// Registrar ID is IANA (376) for example domains
expect(rec.registrar?.ianaId).toBe("376");
if (c.tld !== "org") {
// .com/.net often include the IANA reserved name explicitly
expect(
(rec.registrar?.name || "")
.toLowerCase()
.includes("internet assigned numbers authority"),
).toBe(true);
}
// IANA nameservers
const ns = (rec.nameservers || []).map((n) => n.host.toLowerCase());
expect(ns.includes("a.iana-servers.net")).toBe(true);
expect(ns.includes("b.iana-servers.net")).toBe(true);
if (c.expectDs) {
// DS records typically present for .com/.net
expect(rec.dnssec?.enabled).toBe(true);
expect((rec.dnssec?.dsRecords || []).length > 0).toBe(true);
}
},
);
}
// RDAP-only negative: .io lacks RDAP; expect failure
(shouldRun ? test : test.skip)(
"RDAP-only lookup for example.io fails",
async () => {
const res = await lookupDomain("example.io", {
maybeTest(`RDAP-only lookup for ${c.domain}`, async () => {
const res = await lookup(c.domain, {
timeoutMs: 15000,
rdapOnly: true,
});
expect(res.ok).toBe(false);
},
);
// WHOIS-only smoke for example.com
(shouldRun ? test : test.skip)(
"WHOIS-only lookup for example.com",
async () => {
const res = await lookupDomain("example.com", {
timeoutMs: 15000,
whoisOnly: true,
followWhoisReferral: true,
});
expect(res.ok, res.error).toBe(true);
expect(res.record?.tld).toBe("com");
expect(res.record?.source).toBe("whois");
// Invariants for example.com
expect(res.record?.whoisServer?.toLowerCase()).toBe(
"whois.verisign-grs.com",
);
expect(res.record?.registrar?.ianaId).toBe("376");
const ns = (res.record?.nameservers || []).map((n) => n.host.toLowerCase());
const rec = res.record!;
expect(rec.tld).toBe(c.tld);
expect(rec.source).toBe("rdap");
// Registrar ID is IANA (376) for example domains
expect(rec.registrar?.ianaId).toBe("376");
if (c.tld !== "org") {
// .com/.net often include the IANA reserved name explicitly
expect(
(rec.registrar?.name || "")
.toLowerCase()
.includes("internet assigned numbers authority"),
).toBe(true);
}
// IANA nameservers
const ns = (rec.nameservers || []).map((n) => n.host.toLowerCase());
expect(ns.includes("a.iana-servers.net")).toBe(true);
expect(ns.includes("b.iana-servers.net")).toBe(true);
},
);
if (c.expectDs) {
// DS records typically present for .com/.net
expect(rec.dnssec?.enabled).toBe(true);
expect((rec.dnssec?.dsRecords || []).length > 0).toBe(true);
}
});
}
// RDAP-only negative: .io lacks RDAP; expect failure
maybeTest("RDAP-only lookup for example.io fails", async () => {
const res = await lookup("example.io", {
timeoutMs: 15000,
rdapOnly: true,
});
expect(res.ok).toBe(false);
});
// WHOIS-only smoke for example.com
maybeTest("WHOIS-only lookup for example.com", async () => {
const res = await lookup("example.com", {
timeoutMs: 15000,
whoisOnly: true,
followWhoisReferral: true,
});
expect(res.ok, res.error).toBe(true);
expect(res.record?.tld).toBe("com");
expect(res.record?.source).toBe("whois");
// Invariants for example.com
expect(res.record?.whoisServer?.toLowerCase()).toBe("whois.verisign-grs.com");
expect(res.record?.registrar?.ianaId).toBe("376");
const ns = (res.record?.nameservers || []).map((n) => n.host.toLowerCase());
expect(ns.includes("a.iana-servers.net")).toBe(true);
expect(ns.includes("b.iana-servers.net")).toBe(true);
});
// WHOIS-only smoke for example.io (RDAP-incompatible TLD)
(shouldRun ? test : test.skip)("WHOIS-only lookup for example.io", async () => {
const res = await lookupDomain("example.io", {
maybeTest("WHOIS-only lookup for example.io", async () => {
const res = await lookup("example.io", {
timeoutMs: 15000,
whoisOnly: true,
followWhoisReferral: true,
@@ -125,21 +111,13 @@ for (const c of rdapCases) {
expect(ns.includes("ns3.digitalocean.com")).toBe(true);
});
(shouldRun ? test : test.skip)(
"isRegistered true for example.com",
async () => {
await expect(
isRegistered("example.com", { timeoutMs: 15000 }),
).resolves.toBe(true);
},
);
maybeTest("isRegistered true for example.com", async () => {
await expect(isRegistered("example.com", { timeoutMs: 15000 })).resolves.toBe(
true,
);
});
(shouldRun ? test : test.skip)(
"isAvailable true for an unlikely .com",
async () => {
const unlikely = `nonexistent-${Date.now()}-smoke-example.com`;
await expect(isAvailable(unlikely, { timeoutMs: 15000 })).resolves.toBe(
true,
);
},
);
maybeTest("isAvailable true for an unlikely .com", async () => {
const unlikely = `nonexistent-${Date.now()}-smoke-example.com`;
await expect(isAvailable(unlikely, { timeoutMs: 15000 })).resolves.toBe(true);
});
+7 -7
View File
@@ -64,7 +64,7 @@ vi.mock("./lib/domain.js", async () => {
};
});
import { lookupDomain } from ".";
import { lookup } from ".";
import * as rdapClient from "./rdap/client";
import type { WhoisQueryResult } from "./whois/client";
import * as whoisClient from "./whois/client";
@@ -72,7 +72,7 @@ import * as discovery from "./whois/discovery";
import * as whoisReferral from "./whois/referral";
// 1) Orchestration tests (RDAP path, fallback, whoisOnly)
describe("lookupDomain orchestration", () => {
describe("lookup orchestration", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue(
@@ -81,7 +81,7 @@ describe("lookupDomain orchestration", () => {
});
it("uses RDAP when available and does not call WHOIS", async () => {
const res = await lookupDomain("example.com", { timeoutMs: 200 });
const res = await lookup("example.com", { timeoutMs: 200 });
expect(res.ok, res.error).toBe(true);
expect(res.record?.source).toBe("rdap");
expect(vi.mocked(rdapClient.fetchRdapDomain)).toHaveBeenCalledOnce();
@@ -92,14 +92,14 @@ describe("lookupDomain orchestration", () => {
vi.mocked(rdapClient.fetchRdapDomain).mockRejectedValueOnce(
new Error("rdap down"),
);
const res = await lookupDomain("example.com", { timeoutMs: 200 });
const res = await lookup("example.com", { timeoutMs: 200 });
expect(res.ok, res.error).toBe(true);
expect(res.record?.source).toBe("whois");
expect(vi.mocked(whoisClient.whoisQuery)).toHaveBeenCalledOnce();
});
it("respects whoisOnly to skip RDAP entirely", async () => {
const res = await lookupDomain("example.com", {
const res = await lookup("example.com", {
timeoutMs: 200,
whoisOnly: true,
});
@@ -124,7 +124,7 @@ describe("WHOIS referral & includeRaw", () => {
const original = vi.mocked(whoisReferral.collectWhoisReferralChain);
original.mockClear();
const res = await lookupDomain("example.com", {
const res = await lookup("example.com", {
timeoutMs: 200,
whoisOnly: true,
followWhoisReferral: false,
@@ -141,7 +141,7 @@ describe("WHOIS referral & includeRaw", () => {
}),
);
const res = await lookupDomain("example.com", {
const res = await lookup("example.com", {
timeoutMs: 200,
whoisOnly: true,
followWhoisReferral: true,
+3
View File
@@ -110,6 +110,9 @@ export async function lookup(
normalizeWhois(domain, tld, r.text, r.serverQueried, !!opts?.includeRaw),
);
const [first, ...rest] = normalizedRecords;
if (!first) {
return { ok: false, error: "No WHOIS data retrieved" };
}
const mergedRecord = rest.length ? mergeWhoisRecords(first, rest) : first;
return { ok: true, record: mergedRecord };
} catch (err: unknown) {
+11 -1
View File
@@ -1 +1,11 @@
export const DEFAULT_TIMEOUT_MS = 15000;
/**
* The timeout for HTTP requests in milliseconds. Defaults to 10 seconds.
*/
export const DEFAULT_TIMEOUT_MS = 10_000 as const;
/**
* The default URL for the IANA RDAP bootstrap file.
*
* @see {@link https://data.iana.org/rdap/dns.json IANA RDAP Bootstrap File (dns.json)}
*/
export const DEFAULT_BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json";
+3
View File
@@ -75,6 +75,7 @@ function parseDateWithRegex(
// 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, offH, offM] = m;
if (!y || !mo || !d || !hh || !mm || !ss) return undefined;
// Base time as UTC
let dt = Date.UTC(
Number(y),
@@ -98,6 +99,7 @@ function parseDateWithRegex(
// If the matched string contains hyphens, check if numeric (DD-MM-YYYY) or alpha (DD-MMM-YYYY)
if (m[0].includes("-")) {
const [_, dd, monStr, yyyy] = m;
if (!monStr || !dd || !yyyy) return undefined;
// Check if month component is numeric (DD-MM-YYYY) or alphabetic (DD-MMM-YYYY)
if (/^\d+$/.test(monStr)) {
// DD-MM-YYYY format (e.g., 21-07-2026)
@@ -109,6 +111,7 @@ function parseDateWithRegex(
}
// Otherwise treat as MMM DD YYYY
const [_, monStr, dd, yyyy] = m;
if (!monStr || !dd || !yyyy) return undefined;
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
} catch {
+84
View File
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FetchLike, LookupOptions } from "../types";
import { resolveFetch } from "./fetch";
describe("resolveFetch", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return custom fetch when provided in options", () => {
const customFetch: FetchLike = vi.fn();
const options: LookupOptions = { customFetch };
const result = resolveFetch(options);
expect(result).toBe(customFetch);
});
it("should return global fetch when customFetch is not provided", () => {
const options: LookupOptions = {};
const result = resolveFetch(options);
expect(result).toBe(fetch);
});
it("should return global fetch when options is undefined", () => {
const result = resolveFetch(undefined);
expect(result).toBe(fetch);
});
it("should return global fetch when options is an empty object", () => {
const result = resolveFetch({});
expect(result).toBe(fetch);
});
it("should preserve custom fetch function signature", () => {
const customFetch: FetchLike = async (_input, _init) => {
return new Response("test", { status: 200 });
};
const options: LookupOptions = { customFetch };
const result = resolveFetch(options);
expect(typeof result).toBe("function");
expect(result).toBe(customFetch);
});
it("should work with type-compatible fetch implementations", async () => {
let called = false;
const customFetch: FetchLike = async (_input, _init) => {
called = true;
return new Response(JSON.stringify({ test: "data" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const options: LookupOptions = { customFetch };
const fetchFn = resolveFetch(options);
const response = await fetchFn("https://example.com", { method: "GET" });
const data = await response.json();
expect(called).toBe(true);
expect(data).toEqual({ test: "data" });
expect(response.status).toBe(200);
});
it("should handle async custom fetch correctly", async () => {
const customFetch: FetchLike = async (_input, _init) => {
await new Promise((resolve) => setTimeout(resolve, 10));
return new Response("delayed", { status: 200 });
};
const options: LookupOptions = { customFetch };
const fetchFn = resolveFetch(options);
const response = await fetchFn("https://example.com");
expect(response.status).toBe(200);
expect(await response.text()).toBe("delayed");
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { FetchLike } from "../types";
/**
* Resolve the fetch implementation to use for HTTP requests.
*
* Returns the custom fetch from options if provided, otherwise falls back
* to the global fetch function. This centralized helper ensures consistent
* fetch resolution across all RDAP HTTP operations.
*
* Used internally by:
* - Bootstrap registry fetching (`src/rdap/bootstrap.ts`)
* - RDAP domain lookups (`src/rdap/client.ts`)
* - RDAP related/entity link requests (`src/rdap/merge.ts`)
*
* @param options - Any object that may contain a custom fetch implementation
* @returns The fetch function to use for HTTP requests
*
* @example
* ```ts
* import { resolveFetch } from './lib/fetch';
*
* const fetchFn = resolveFetch(options);
* const response = await fetchFn('https://example.com/api', { method: 'GET' });
* ```
*/
export function resolveFetch(options?: { customFetch?: FetchLike }): FetchLike {
return options?.customFetch ?? fetch;
}
+1 -1
View File
@@ -12,7 +12,7 @@ export function parseKeyValueLines(text: string): Record<string, string[]> {
if (!line.trim()) continue;
// Bracketed form: [Key] value (common in .jp and some ccTLDs)
const bracket = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
if (bracket) {
if (bracket?.[1] !== undefined && bracket?.[2] !== undefined) {
const key = bracket[1].trim().toLowerCase();
const value = bracket[2].trim();
const list = map.get(key) ?? [];
+453
View File
@@ -0,0 +1,453 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import type { BootstrapData } from "../types";
import { getRdapBaseUrlsForTld } from "./bootstrap";
// Mock the global fetch function
beforeAll(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterAll(() => {
vi.unstubAllGlobals();
});
describe("getRdapBaseUrlsForTld with customBootstrapData", () => {
const validBootstrapData: BootstrapData = {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
description: "Test RDAP Bootstrap",
services: [
[["com", "net"], ["https://rdap.verisign.com/com/v1/"]],
[["org"], ["https://rdap.publicinterestregistry.org/"]],
[["io"], ["https://rdap.nic.io/"]],
],
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("valid customBootstrapData", () => {
it("should use customBootstrapData when provided", async () => {
const urls = await getRdapBaseUrlsForTld("com", {
customBootstrapData: validBootstrapData,
});
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(fetch).not.toHaveBeenCalled(); // No fetch when data is provided
});
it("should return multiple base URLs for TLD with multiple servers", async () => {
const dataWithMultiple: BootstrapData = {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
services: [
[
["test"],
[
"https://rdap1.example.com/",
"https://rdap2.example.com/",
"https://rdap3.example.com",
],
],
],
};
const urls = await getRdapBaseUrlsForTld("test", {
customBootstrapData: dataWithMultiple,
});
expect(urls).toEqual([
"https://rdap1.example.com/",
"https://rdap2.example.com/",
"https://rdap3.example.com/",
]);
expect(fetch).not.toHaveBeenCalled();
});
it("should return empty array when TLD not found in customBootstrapData", async () => {
const urls = await getRdapBaseUrlsForTld("notfound", {
customBootstrapData: validBootstrapData,
});
expect(urls).toEqual([]);
expect(fetch).not.toHaveBeenCalled();
});
it("should handle TLDs case-insensitively", async () => {
const urls = await getRdapBaseUrlsForTld("COM", {
customBootstrapData: validBootstrapData,
});
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(fetch).not.toHaveBeenCalled();
});
it("should normalize URLs without trailing slash", async () => {
const dataWithoutSlash: BootstrapData = {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
services: [[["test"], ["https://rdap.example.com"]]],
};
const urls = await getRdapBaseUrlsForTld("test", {
customBootstrapData: dataWithoutSlash,
});
expect(urls).toEqual(["https://rdap.example.com/"]);
expect(fetch).not.toHaveBeenCalled();
});
it("should deduplicate duplicate URLs", async () => {
const dataWithDuplicates: BootstrapData = {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
services: [
[["test"], ["https://rdap.example.com/", "https://rdap.example.com"]],
],
};
const urls = await getRdapBaseUrlsForTld("test", {
customBootstrapData: dataWithDuplicates,
});
expect(urls).toEqual(["https://rdap.example.com/"]);
expect(fetch).not.toHaveBeenCalled();
});
it("should handle multi-label TLDs (e.g., co.uk)", async () => {
const dataWithMultiLabel: BootstrapData = {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
services: [[["co.uk", "org.uk"], ["https://rdap.nominet.uk/"]]],
};
const urls = await getRdapBaseUrlsForTld("co.uk", {
customBootstrapData: dataWithMultiLabel,
});
expect(urls).toEqual(["https://rdap.nominet.uk/"]);
expect(fetch).not.toHaveBeenCalled();
});
});
describe("priority order: customBootstrapData over customBootstrapUrl", () => {
it("should use customBootstrapData and ignore customBootstrapUrl", async () => {
const urls = await getRdapBaseUrlsForTld("com", {
customBootstrapData: validBootstrapData,
customBootstrapUrl: "https://should-not-fetch.example.com/dns.json",
});
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(fetch).not.toHaveBeenCalled();
});
it("should use customBootstrapData and ignore default IANA URL", async () => {
const urls = await getRdapBaseUrlsForTld("com", {
customBootstrapData: validBootstrapData,
});
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(fetch).not.toHaveBeenCalled();
});
});
describe("invalid customBootstrapData validation", () => {
it("should throw when customBootstrapData is null", async () => {
await expect(
getRdapBaseUrlsForTld("com", {
customBootstrapData: null as unknown as BootstrapData,
}),
).rejects.toThrow(
"Invalid customBootstrapData: expected an object. See BootstrapData type for required structure.",
);
expect(fetch).not.toHaveBeenCalled();
});
it("should throw when customBootstrapData is undefined", async () => {
await expect(
getRdapBaseUrlsForTld("com", {
customBootstrapData: undefined as unknown as BootstrapData,
}),
).rejects.toThrow(
"Invalid customBootstrapData: expected an object. See BootstrapData type for required structure.",
);
expect(fetch).not.toHaveBeenCalled();
});
it("should throw when customBootstrapData is a string", async () => {
await expect(
getRdapBaseUrlsForTld("com", {
customBootstrapData: "invalid" as unknown as BootstrapData,
}),
).rejects.toThrow(
"Invalid customBootstrapData: expected an object. See BootstrapData type for required structure.",
);
expect(fetch).not.toHaveBeenCalled();
});
it("should throw when customBootstrapData is a number", async () => {
await expect(
getRdapBaseUrlsForTld("com", {
customBootstrapData: 123 as unknown as BootstrapData,
}),
).rejects.toThrow(
"Invalid customBootstrapData: expected an object. See BootstrapData type for required structure.",
);
expect(fetch).not.toHaveBeenCalled();
});
it("should throw when customBootstrapData is an array", async () => {
await expect(
getRdapBaseUrlsForTld("com", {
customBootstrapData: [] as unknown as BootstrapData,
}),
).rejects.toThrow(
'Invalid customBootstrapData: missing or invalid "services" array. See BootstrapData type for required structure.',
);
expect(fetch).not.toHaveBeenCalled();
});
it("should throw when customBootstrapData is missing services property", async () => {
await expect(
getRdapBaseUrlsForTld("com", {
customBootstrapData: {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
} as unknown as BootstrapData,
}),
).rejects.toThrow(
'Invalid customBootstrapData: missing or invalid "services" array. See BootstrapData type for required structure.',
);
expect(fetch).not.toHaveBeenCalled();
});
it("should throw when services is not an array", async () => {
await expect(
getRdapBaseUrlsForTld("com", {
customBootstrapData: {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
services: "not-an-array",
} as unknown as BootstrapData,
}),
).rejects.toThrow(
'Invalid customBootstrapData: missing or invalid "services" array. See BootstrapData type for required structure.',
);
expect(fetch).not.toHaveBeenCalled();
});
it("should throw when services is null", async () => {
await expect(
getRdapBaseUrlsForTld("com", {
customBootstrapData: {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
services: null,
} as unknown as BootstrapData,
}),
).rejects.toThrow(
'Invalid customBootstrapData: missing or invalid "services" array. See BootstrapData type for required structure.',
);
expect(fetch).not.toHaveBeenCalled();
});
});
describe("fallback to fetch when customBootstrapData is not provided", () => {
beforeEach(() => {
// Mock successful fetch response
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => validBootstrapData,
} as Response);
});
it("should fetch from default IANA URL when no custom options", async () => {
const urls = await getRdapBaseUrlsForTld("com");
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(fetch).toHaveBeenCalledWith(
"https://data.iana.org/rdap/dns.json",
expect.objectContaining({
method: "GET",
headers: { accept: "application/json" },
}),
);
});
it("should fetch from customBootstrapUrl when provided", async () => {
const customUrl = "https://custom.example.com/bootstrap.json";
const urls = await getRdapBaseUrlsForTld("com", {
customBootstrapUrl: customUrl,
});
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(fetch).toHaveBeenCalledWith(
customUrl,
expect.objectContaining({
method: "GET",
headers: { accept: "application/json" },
}),
);
});
it("should return empty array when fetch fails", async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 404,
} as Response);
const urls = await getRdapBaseUrlsForTld("com");
expect(urls).toEqual([]);
expect(fetch).toHaveBeenCalled();
});
it("should respect signal for cancellation", async () => {
const controller = new AbortController();
const signal = controller.signal;
await getRdapBaseUrlsForTld("com", { signal });
expect(fetch).toHaveBeenCalledWith(
"https://data.iana.org/rdap/dns.json",
expect.objectContaining({
signal,
}),
);
});
});
describe("custom fetch functionality", () => {
beforeEach(() => {
// Reset to default fetch mock behavior
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => validBootstrapData,
} as Response);
});
it("should use customFetch when provided", async () => {
const customFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => validBootstrapData,
} as Response);
const urls = await getRdapBaseUrlsForTld("com", { customFetch });
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(customFetch).toHaveBeenCalledWith(
"https://data.iana.org/rdap/dns.json",
expect.objectContaining({
method: "GET",
headers: { accept: "application/json" },
}),
);
expect(fetch).not.toHaveBeenCalled(); // global fetch should not be called
});
it("should pass custom fetch with customBootstrapUrl", async () => {
const customFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => validBootstrapData,
} as Response);
const customUrl = "https://custom.example.com/bootstrap.json";
const urls = await getRdapBaseUrlsForTld("com", {
customFetch,
customBootstrapUrl: customUrl,
});
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(customFetch).toHaveBeenCalledWith(
customUrl,
expect.objectContaining({
method: "GET",
headers: { accept: "application/json" },
}),
);
expect(fetch).not.toHaveBeenCalled();
});
it("should use customFetch for caching scenario", async () => {
let callCount = 0;
const customFetch = vi.fn(async (_input, _init) => {
callCount++;
if (callCount === 1) {
// First call - return fresh data
return {
ok: true,
json: async () => validBootstrapData,
} as Response;
}
// Second call - simulate cache hit (don't call global fetch)
return {
ok: true,
json: async () => validBootstrapData,
} as Response;
});
// First call
const urls1 = await getRdapBaseUrlsForTld("com", { customFetch });
expect(urls1).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(customFetch).toHaveBeenCalledTimes(1);
// Second call with same custom fetch
const urls2 = await getRdapBaseUrlsForTld("com", { customFetch });
expect(urls2).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(customFetch).toHaveBeenCalledTimes(2);
});
it("should not use custom fetch when customBootstrapData is provided", async () => {
const customFetch = vi.fn();
const urls = await getRdapBaseUrlsForTld("com", {
customBootstrapData: validBootstrapData,
customFetch,
});
expect(urls).toEqual(["https://rdap.verisign.com/com/v1/"]);
expect(customFetch).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("should handle custom fetch errors", async () => {
const customFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
} as Response);
const urls = await getRdapBaseUrlsForTld("com", { customFetch });
expect(urls).toEqual([]);
expect(customFetch).toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("should respect signal with custom fetch", async () => {
const controller = new AbortController();
const signal = controller.signal;
const customFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => validBootstrapData,
} as Response);
await getRdapBaseUrlsForTld("com", { customFetch, signal });
expect(customFetch).toHaveBeenCalledWith(
"https://data.iana.org/rdap/dns.json",
expect.objectContaining({
signal,
}),
);
});
});
});
+70 -26
View File
@@ -1,42 +1,86 @@
import { withTimeout } from "../lib/async";
import { DEFAULT_TIMEOUT_MS } from "../lib/constants";
import type { LookupOptions } from "../types";
// 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[][][];
}
import { DEFAULT_BOOTSTRAP_URL, DEFAULT_TIMEOUT_MS } from "../lib/constants";
import { resolveFetch } from "../lib/fetch";
import type { BootstrapData, LookupOptions } from "../types";
/**
* 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).
*
* Bootstrap data is resolved in the following priority order:
* 1. `options.customBootstrapData` - pre-loaded bootstrap data (no fetch)
* 2. `options.customBootstrapUrl` - custom URL to fetch bootstrap data from
* 3. Default IANA URL - https://data.iana.org/rdap/dns.json
*
* @param tld - The top-level domain to look up (e.g., "com", "co.uk")
* @param options - Optional lookup options including custom bootstrap data/URL
* @returns Array of RDAP base URLs for the TLD, or empty array if none found
*/
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;
let data: BootstrapData;
// Priority 1: Use pre-loaded bootstrap data if provided (no fetch)
if (options && "customBootstrapData" in options) {
const provided = options.customBootstrapData;
// Validate the structure to provide helpful error messages
if (!provided || typeof provided !== "object") {
throw new Error(
"Invalid customBootstrapData: expected an object. See BootstrapData type for required structure.",
);
}
if (!Array.isArray(provided.services)) {
throw new Error(
'Invalid customBootstrapData: missing or invalid "services" array. See BootstrapData type for required structure.',
);
}
provided.services.forEach((svc, idx) => {
if (
!Array.isArray(svc) ||
svc.length < 2 ||
!Array.isArray(svc[0]) ||
!Array.isArray(svc[1])
) {
throw new Error(
`Invalid customBootstrapData: services[${idx}] must be a tuple of [string[], string[]].`,
);
}
});
data = provided;
} else {
// Priority 2 & 3: Fetch from custom URL or default IANA URL
// Use custom fetch implementation if provided for caching/logging/monitoring
const fetchFn = resolveFetch(options);
const bootstrapUrl = options?.customBootstrapUrl ?? DEFAULT_BOOTSTRAP_URL;
try {
const res = await withTimeout(
fetchFn(bootstrapUrl, {
method: "GET",
headers: { accept: "application/json" },
signal: options?.signal,
}),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
"RDAP bootstrap timeout",
);
if (!res.ok) return [];
data = (await res.json()) as BootstrapData;
} catch (err: unknown) {
// Preserve caller cancellation behavior - rethrow if explicitly aborted
if (err instanceof Error && err.name === "AbortError") {
throw err;
}
// Network, timeout, or JSON parse errors - return empty array to fall back to WHOIS
return [];
}
}
// Parse the bootstrap data to find matching base URLs for the TLD
const target = tld.toLowerCase();
const bases: string[] = [];
for (const svc of data.services) {
if (!svc[0] || !svc[1]) continue;
const tlds = svc[0].map((x) => x.toLowerCase());
const urls = svc[1];
// Match exact TLD, and also support multi-label public suffixes present in IANA (rare)
+3 -3
View File
@@ -1,9 +1,8 @@
import { withTimeout } from "../lib/async";
import { DEFAULT_TIMEOUT_MS } from "../lib/constants";
import { resolveFetch } from "../lib/fetch";
import type { LookupOptions } from "../types";
// Use global fetch (Node 18+). For large JSON we keep it simple.
/**
* Fetch RDAP JSON for a domain from a specific RDAP base URL.
* Throws on HTTP >= 400 (includes RDAP error JSON payloads).
@@ -17,8 +16,9 @@ export async function fetchRdapDomain(
`domain/${encodeURIComponent(domain)}`,
baseUrl,
).toString();
const fetchFn = resolveFetch(options);
const res = await withTimeout(
fetch(url, {
fetchFn(url, {
method: "GET",
headers: { accept: "application/rdap+json, application/json" },
signal: options?.signal,
+3 -1
View File
@@ -1,5 +1,6 @@
import { withTimeout } from "../lib/async";
import { DEFAULT_TIMEOUT_MS } from "../lib/constants";
import { resolveFetch } from "../lib/fetch";
import type { LookupOptions } from "../types";
import { extractRdapRelatedLinks } from "./links";
@@ -102,8 +103,9 @@ async function fetchRdapUrl(
url: string,
options?: LookupOptions,
): Promise<{ url: string; json: unknown }> {
const fetchFn = resolveFetch(options);
const res = await withTimeout(
fetch(url, {
fetchFn(url, {
method: "GET",
headers: { accept: "application/rdap+json, application/json" },
signal: options?.signal,
+2 -1
View File
@@ -68,7 +68,8 @@ test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () =
expect(rec.registrar?.ianaId).toBe("9999");
expect(rec.contacts && rec.contacts.length >= 3).toBe(true);
expect(rec.nameservers && rec.nameservers.length === 2).toBe(true);
expect(rec.nameservers?.[0].host).toBe("ns1.example.com");
expect(rec.nameservers).toBeDefined();
expect(rec.nameservers?.[0]?.host).toBe("ns1.example.com");
expect(rec.dnssec?.enabled).toBeTruthy();
expect(rec.creationDate).toBe("2020-01-02T03:04:05Z");
expect(rec.expirationDate).toBe("2030-01-02T03:04:05Z");
+237 -2
View File
@@ -1,13 +1,37 @@
/**
* The data source used to retrieve domain information.
*
* - `rdap`: Data was retrieved via RDAP (Registration Data Access Protocol)
* - `whois`: Data was retrieved via WHOIS (port 43)
*/
export type LookupSource = "rdap" | "whois";
/**
* Domain registrar information.
*
* Contains identifying details about the registrar responsible for the domain registration.
* Fields may be incomplete depending on the data source and registry policies.
*/
export interface RegistrarInfo {
/** Registrar name (e.g., "GoDaddy.com, LLC") */
name?: string;
/** IANA-assigned registrar ID */
ianaId?: string;
/** Registrar website URL */
url?: string;
/** Registrar contact email address */
email?: string;
/** Registrar contact phone number */
phone?: string;
}
/**
* Contact information for various roles associated with a domain.
*
* Contacts may represent individuals or organizations responsible for different
* aspects of domain management. Availability and completeness of contact data
* varies by TLD, registrar, and privacy policies (GDPR, WHOIS privacy services).
*/
export interface Contact {
type:
| "registrant"
@@ -31,18 +55,66 @@ export interface Contact {
countryCode?: string;
}
/**
* DNS nameserver information.
*
* Represents a nameserver authoritative for the domain, including its hostname
* and optional glue records (IP addresses).
*/
export interface Nameserver {
/** Nameserver hostname (e.g., "ns1.example.com") */
host: string;
/** IPv4 glue records, if provided */
ipv4?: string[];
/** IPv6 glue records, if provided */
ipv6?: string[];
}
/**
* Domain status information.
*
* Represents EPP status codes and registry-specific statuses that indicate
* the operational state and restrictions on a domain.
*
* Common EPP statuses include: clientTransferProhibited, serverHold,
* serverDeleteProhibited, etc.
*
* @see {@link https://www.icann.org/resources/pages/epp-status-codes-2014-06-16-en ICANN EPP Status Codes}
*/
export interface StatusEvent {
/** Normalized status code (e.g., "clientTransferProhibited") */
status: string;
/** Human-readable description of the status, if available */
description?: string;
/** Original raw status string from the source */
raw?: string;
}
/**
* Normalized domain registration record.
*
* This is the primary data structure returned by domain lookups. It provides a unified
* view of domain registration data regardless of whether the information was obtained
* via RDAP or WHOIS.
*
* Field availability varies by:
* - TLD and registry policies
* - Data source (RDAP typically more structured than WHOIS)
* - Privacy protections (GDPR, WHOIS privacy services)
* - Registrar practices
*
* @example
* ```ts
* import { lookup } from 'rdapper';
*
* const { ok, record } = await lookup('example.com');
* if (ok && record) {
* console.log(record.registrar?.name); // "Example Registrar, Inc."
* console.log(record.isRegistered); // true
* console.log(record.source); // "rdap"
* }
* ```
*/
export interface DomainRecord {
/** Normalized domain name */
domain: string;
@@ -104,6 +176,66 @@ export interface DomainRecord {
warnings?: string[];
}
/**
* RDAP bootstrap JSON format as published by IANA at https://data.iana.org/rdap/dns.json
*
* This interface describes the structure of the RDAP bootstrap registry, which maps
* top-level domains to their authoritative RDAP servers.
*
* @example
* ```json
* {
* "version": "1.0",
* "publication": "2025-01-15T12:00:00Z",
* "description": "RDAP Bootstrap file for DNS top-level domains",
* "services": [
* [["com", "net"], ["https://rdap.verisign.com/com/v1/"]],
* [["org"], ["https://rdap.publicinterestregistry.org/"]]
* ]
* }
* ```
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc7484 RFC 7484 - Finding the Authoritative RDAP Service}
*/
export interface BootstrapData {
/** Bootstrap file format version */
version: string;
/** ISO 8601 timestamp of when this bootstrap data was published */
publication: string;
/** Optional human-readable description of the bootstrap file */
description?: string;
/**
* Service mappings array. Each entry is a tuple of [TLDs, base URLs]:
* - First element: array of TLD strings (e.g., ["com", "net"])
* - Second element: array of RDAP base URL strings (e.g., ["https://rdap.verisign.com/com/v1/"])
*/
services: string[][][];
}
/**
* Configuration options for domain lookups.
*
* Controls the lookup behavior, including which protocols to use (RDAP/WHOIS),
* timeout settings, referral following, and caching options.
*
* @example
* ```ts
* import { lookup } from 'rdapper';
*
* // RDAP-only lookup for edge runtime compatibility
* const result = await lookup('example.com', {
* rdapOnly: true,
* timeoutMs: 10000
* });
*
* // Cached bootstrap data for high-volume scenarios
* const cachedBootstrap = await getFromCache();
* const result = await lookup('example.com', {
* customBootstrapData: cachedBootstrap,
* includeRaw: true
* });
* ```
*/
export interface LookupOptions {
/** Total timeout budget */
timeoutMs?: number;
@@ -121,8 +253,83 @@ export interface LookupOptions {
maxRdapLinkHops?: number;
/** RDAP link rels to consider (default ["related","entity","registrar","alternate"]) */
rdapLinkRels?: string[];
/** Override IANA bootstrap */
/**
* Pre-loaded RDAP bootstrap data to use instead of fetching from IANA.
*
* Pass your own cached version of https://data.iana.org/rdap/dns.json to control
* caching behavior and avoid redundant network requests. This is useful when you want
* to cache the bootstrap data in Redis, memory, filesystem, or any other caching layer.
*
* If provided, this takes precedence over `customBootstrapUrl` and the default IANA URL.
*
* @example
* ```ts
* import { lookup, type BootstrapData } from 'rdapper';
*
* // Fetch and cache the bootstrap data yourself
* const bootstrapData: BootstrapData = await fetchFromCache()
* ?? await fetchAndCache('https://data.iana.org/rdap/dns.json');
*
* // Pass the cached data to rdapper
* const result = await lookup('example.com', {
* customBootstrapData: bootstrapData
* });
* ```
*
* @see {@link BootstrapData} for the expected data structure
*/
customBootstrapData?: BootstrapData;
/** Override IANA bootstrap URL (ignored if customBootstrapData is provided) */
customBootstrapUrl?: string;
/**
* Custom fetch implementation to use for all HTTP requests.
*
* Provides complete control over how HTTP requests are made, enabling advanced use cases:
* - **Caching**: Cache bootstrap data, RDAP responses, and related link responses
* - **Logging**: Log all outgoing requests and responses for monitoring
* - **Retry Logic**: Implement custom retry strategies with exponential backoff
* - **Rate Limiting**: Control request frequency to respect API limits
* - **Proxies/Auth**: Route requests through proxies or add authentication headers
* - **Testing**: Inject mock responses for testing without network calls
*
* The custom fetch will be used for:
* - RDAP bootstrap registry requests (unless `customBootstrapData` is provided)
* - RDAP domain lookup requests
* - RDAP related/entity link requests
*
* If not provided, the global `fetch` function is used (Node.js 18+ or browser).
*
* @example
* ```ts
* import { lookup } from 'rdapper';
*
* // Example 1: Simple in-memory cache
* const cache = new Map<string, Response>();
* const cachedFetch: typeof fetch = async (input, init) => {
* const key = typeof input === 'string' ? input : input.toString();
* if (cache.has(key)) return cache.get(key)!.clone();
* const response = await fetch(input, init);
* cache.set(key, response.clone());
* return response;
* };
*
* await lookup('example.com', { customFetch: cachedFetch });
*
* // Example 2: Request logging
* const loggingFetch: typeof fetch = async (input, init) => {
* const url = typeof input === 'string' ? input : input.toString();
* console.log('[Fetch]', url);
* const response = await fetch(input, init);
* console.log('[Response]', response.status, url);
* return response;
* };
*
* await lookup('example.com', { customFetch: loggingFetch });
* ```
*
* @see {@link FetchLike} for the expected function signature
*/
customFetch?: FetchLike;
/** Override/add authoritative WHOIS per TLD */
whoisHints?: Record<string, string>;
/** Include rawRdap/rawWhois in results (default false) */
@@ -131,13 +338,41 @@ export interface LookupOptions {
signal?: AbortSignal;
}
/**
* Result of a domain lookup operation.
*
* Provides a structured response indicating success or failure, with either
* a normalized domain record or an error message.
*
* @example
* ```ts
* import { lookup } from 'rdapper';
*
* const result = await lookup('example.com');
* if (result.ok) {
* console.log('Domain:', result.record.domain);
* console.log('Registered:', result.record.isRegistered);
* } else {
* console.error('Lookup failed:', result.error);
* }
* ```
*/
export interface LookupResult {
/** Whether the lookup completed successfully */
ok: boolean;
/** The normalized domain record, present when ok is true */
record?: DomainRecord;
/** Error message describing why the lookup failed, present when ok is false */
error?: string;
}
/**
* Fetch-compatible function signature.
*
* Used internally for dependency injection and testing. Matches the signature
* of the global `fetch` function available in Node.js 18+ and browsers.
*/
export type FetchLike = (
input: RequestInfo | URL,
input: string | URL,
init?: RequestInit,
) => Promise<Response>;
+1
View File
@@ -30,6 +30,7 @@ describe("WHOIS coalescing", () => {
expect(chain.length).toBe(1);
const [first] = chain;
if (!first) throw new Error("Expected first record");
const base = normalizeWhois(
"gitpod.io",
"io",
+2 -1
View File
@@ -11,7 +11,8 @@ Changed: 2020-01-02
`;
const rec = normalizeWhois("example.de", "de", text, "whois.denic.de");
expect(rec.nameservers && rec.nameservers.length === 2).toBe(true);
expect(rec.nameservers?.[0].host).toBe("ns1.example.net");
expect(rec.nameservers).toBeDefined();
expect(rec.nameservers?.[0]?.host).toBe("ns1.example.net");
});
test("WHOIS .uk Nominet style", () => {
+8 -3
View File
@@ -155,7 +155,12 @@ export function normalizeWhois(
map.eppstatus || // .fr
[];
const statuses = statusLines.length
? statusLines.map((line) => ({ status: line.split(/\s+/)[0], raw: line }))
? statusLines
.map((line) => {
const status = line.split(/\s+/)[0];
return status ? { status, raw: line } : null;
})
.filter((s): s is { status: string; raw: string } => s !== null)
: undefined;
// Nameservers: also appear as "nserver" on some ccTLDs (.de, .ru) and as "name server"
@@ -219,8 +224,8 @@ export function normalizeWhois(
: undefined;
// Simple lock derivation from statuses
const transferLock = !!statuses?.some((s) =>
/transferprohibited/i.test(s.status),
const transferLock = !!statuses?.some(
(s) => s.status && /transferprohibited/i.test(s.status),
);
const record: DomainRecord = {
+1 -1
View File
@@ -40,6 +40,6 @@ describe("WHOIS referral contradiction handling", () => {
expect(Array.isArray(chain)).toBe(true);
// Mocked registrar is contradictory, so chain should contain only the TLD response
expect(chain.length).toBe(1);
expect(chain[0].serverQueried).toBe("whois.nic.io");
expect(chain[0]?.serverQueried).toBe("whois.nic.io");
});
});