mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 01:25:31 -04:00
Add support for custom fetch function and/or IANA bootstrap data (#21)
This commit is contained in:
+76
-98
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) ?? [];
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user