1
mirror of https://github.com/jakejarvis/rdapper.git synced 2025-12-02 19:23:49 -05:00

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

336
README.md
View File

@@ -86,6 +86,338 @@ const res = await lookup("example.com", { rdapOnly: true });
- If `rdapOnly` is omitted and the code path reaches WHOIS on edge, rdapper throws a clear runtime error advising to run in Node or set `{ rdapOnly: true }`.
### Bootstrap Data Caching
By default, rdapper fetches IANA's RDAP bootstrap registry from [`https://data.iana.org/rdap/dns.json`](https://data.iana.org/rdap/dns.json) on every RDAP lookup to discover the authoritative RDAP servers for a given TLD. While this ensures you always have up-to-date server mappings, it also adds latency and a network dependency to each lookup.
For production applications that perform many domain lookups, you can take control of bootstrap data caching by fetching and caching the data yourself, then passing it to rdapper using the `customBootstrapData` option. This eliminates redundant network requests and gives you full control over cache invalidation.
#### Why cache bootstrap data?
- **Performance**: Eliminate an extra HTTP request per lookup (or per TLD if you're looking up many domains)
- **Reliability**: Reduce dependency on IANA's availability during lookups
- **Control**: Manage cache TTL and invalidation according to your needs (IANA updates this file infrequently)
- **Cost**: Reduce bandwidth and API calls in high-volume scenarios
#### Example: In-memory caching with TTL
```ts
import { lookup, type BootstrapData } from 'rdapper';
// Simple in-memory cache with TTL
let cachedBootstrap: BootstrapData | null = null;
let cacheExpiry = 0;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
async function getBootstrapData(): Promise<BootstrapData> {
const now = Date.now();
// Return cached data if still valid
if (cachedBootstrap && now < cacheExpiry) {
return cachedBootstrap;
}
// Fetch fresh data
const response = await fetch('https://data.iana.org/rdap/dns.json');
if (!response.ok) {
throw new Error(`Failed to load bootstrap data: ${response.status} ${response.statusText}`);
}
const data: BootstrapData = await response.json();
// Update cache
cachedBootstrap = data;
cacheExpiry = now + CACHE_TTL_MS;
return data;
}
// Use the cached bootstrap data in lookups
const bootstrapData = await getBootstrapData();
const result = await lookup('example.com', {
customBootstrapData: bootstrapData
});
```
#### Example: Redis caching
```ts
import { lookup, type BootstrapData } from 'rdapper';
import { createClient } from 'redis';
const redis = createClient();
await redis.connect();
const CACHE_KEY = 'rdap:bootstrap:dns';
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours
async function getBootstrapData(): Promise<BootstrapData> {
// Try to get from Redis first
const cached = await redis.get(CACHE_KEY);
if (cached) {
return JSON.parse(cached);
}
// Fetch fresh data
const response = await fetch('https://data.iana.org/rdap/dns.json');
if (!response.ok) {
throw new Error(`Failed to load bootstrap data: ${response.status} ${response.statusText}`);
}
const data: BootstrapData = await response.json();
// Store in Redis with TTL
await redis.setEx(CACHE_KEY, CACHE_TTL_SECONDS, JSON.stringify(data));
return data;
}
// Use the cached bootstrap data in lookups
const bootstrapData = await getBootstrapData();
const result = await lookup('example.com', {
customBootstrapData: bootstrapData
});
```
#### Example: Filesystem caching
```ts
import { lookup, type BootstrapData } from 'rdapper';
import { readFile, writeFile, stat } from 'node:fs/promises';
const CACHE_FILE = './cache/rdap-bootstrap.json';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
async function getBootstrapData(): Promise<BootstrapData> {
try {
// Check if cache file exists and is fresh
const stats = await stat(CACHE_FILE);
const age = Date.now() - stats.mtimeMs;
if (age < CACHE_TTL_MS) {
const cached = await readFile(CACHE_FILE, 'utf-8');
return JSON.parse(cached);
}
} catch {
// Cache file doesn't exist or is unreadable, will fetch fresh
}
// Fetch fresh data
const response = await fetch('https://data.iana.org/rdap/dns.json');
if (!response.ok) {
throw new Error(`Failed to load bootstrap data: ${response.status} ${response.statusText}`);
}
const data: BootstrapData = await response.json();
// Write to cache file
await writeFile(CACHE_FILE, JSON.stringify(data, null, 2), 'utf-8');
return data;
}
// Use the cached bootstrap data in lookups
const bootstrapData = await getBootstrapData();
const result = await lookup('example.com', {
customBootstrapData: bootstrapData
});
```
#### Bootstrap data structure
The `BootstrapData` type matches IANA's published format:
```ts
interface BootstrapData {
version: string; // e.g., "1.0"
publication: string; // ISO 8601 timestamp
description?: string;
services: string[][][]; // Array of [TLDs, base URLs] tuples
}
```
See the full documentation at [RFC 7484 - Finding the Authoritative RDAP Service](https://datatracker.ietf.org/doc/html/rfc7484).
**Note**: The bootstrap data structure is stable and rarely changes. IANA updates the _contents_ (server mappings) periodically as TLDs are added or servers change, but a 24-hour cache TTL is typically safe for most applications.
### Custom Fetch Implementation
For advanced use cases, rdapper allows you to provide a custom `fetch` implementation that will be used for **all HTTP requests** in the library. This enables powerful patterns for caching, logging, retry logic, and more.
#### What requests are affected?
Your custom fetch will be used for:
- **RDAP bootstrap registry requests** (fetching `dns.json` from IANA, unless `customBootstrapData` is provided)
- **RDAP domain lookups** (querying RDAP servers for domain data)
- **RDAP related/entity link requests** (following links to registrar information)
#### Why use custom fetch?
- **Caching**: Implement sophisticated caching strategies for all RDAP requests
- **Logging & Monitoring**: Track all outgoing requests and responses
- **Retry Logic**: Add exponential backoff for failed requests
- **Rate Limiting**: Control request frequency to respect API limits
- **Proxies & Authentication**: Route requests through proxies or add auth headers
- **Testing**: Inject mock responses without network calls
#### Example 1: Simple in-memory cache
```ts
import { lookup } from 'rdapper';
const cache = new Map<string, Response>();
const cachedFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString();
// Check cache first
if (cache.has(url)) {
console.log('[Cache Hit]', url);
return cache.get(url)!.clone();
}
// Fetch and cache
console.log('[Cache Miss]', url);
const response = await fetch(input, init);
cache.set(url, response.clone());
return response;
};
const result = await lookup('example.com', { customFetch: cachedFetch });
```
#### Example 2: Request logging and monitoring
```ts
import { lookup } from 'rdapper';
const loggingFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString();
const start = Date.now();
console.log(`[→] ${init?.method || 'GET'} ${url}`);
try {
const response = await fetch(input, init);
const duration = Date.now() - start;
console.log(`[←] ${response.status} ${url} (${duration}ms)`);
return response;
} catch (error) {
const duration = Date.now() - start;
console.error(`[✗] ${url} failed after ${duration}ms:`, error);
throw error;
}
};
const result = await lookup('example.com', { customFetch: loggingFetch });
```
#### Example 3: Retry logic with exponential backoff
```ts
import { lookup } from 'rdapper';
async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
maxRetries = 3
): Promise<Response> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(input, init);
// Retry on 5xx errors
if (response.status >= 500 && attempt < maxRetries) {
const delay = Math.min(1000 * 2 ** attempt, 10000);
console.log(`Retrying after ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries) {
const delay = Math.min(1000 * 2 ** attempt, 10000);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
}
}
throw lastError || new Error('Max retries exceeded');
}
const result = await lookup('example.com', { customFetch: fetchWithRetry });
```
#### Example 4: HTTP caching with cache-control headers
```ts
import { lookup } from 'rdapper';
interface CachedResponse {
response: Response;
expiresAt: number;
}
const httpCache = new Map<string, CachedResponse>();
const httpCachingFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString();
const now = Date.now();
// Check if we have a valid cached response
const cached = httpCache.get(url);
if (cached && cached.expiresAt > now) {
return cached.response.clone();
}
// Fetch fresh response
const response = await fetch(input, init);
// Parse Cache-Control header
const cacheControl = response.headers.get('cache-control');
if (cacheControl) {
const maxAgeMatch = cacheControl.match(/max-age=(\d+)/);
if (maxAgeMatch) {
const maxAge = parseInt(maxAgeMatch[1], 10);
httpCache.set(url, {
response: response.clone(),
expiresAt: now + maxAge * 1000,
});
}
}
return response;
};
const result = await lookup('example.com', { customFetch: httpCachingFetch });
```
#### Example 5: Combining with customBootstrapData
You can use both `customFetch` and `customBootstrapData` together for maximum control:
```ts
import { lookup, type BootstrapData } from 'rdapper';
// Pre-load bootstrap data (no fetch needed for this)
const bootstrapData: BootstrapData = await getFromCache('bootstrap');
// Use custom fetch for all other RDAP requests
const cachedFetch: typeof fetch = async (input, init) => {
// Your caching logic for RDAP domain and entity lookups
return fetch(input, init);
};
const result = await lookup('example.com', {
customBootstrapData: bootstrapData,
customFetch: cachedFetch,
});
```
**Note**: When `customBootstrapData` is provided, the bootstrap registry will not be fetched, so your custom fetch will only be used for RDAP domain and entity/related link requests.
### Options
- `timeoutMs?: number` Total timeout budget per network operation (default `15000`).
@@ -96,7 +428,9 @@ const res = await lookup("example.com", { rdapOnly: true });
- `rdapFollowLinks?: boolean` Follow related/entity RDAP links to enrich data (default `true`).
- `maxRdapLinkHops?: number` Maximum RDAP related link hops to follow (default `2`).
- `rdapLinkRels?: string[]` RDAP link rel values to consider (default `["related","entity","registrar","alternate"]`).
- `customBootstrapUrl?: string` Override RDAP bootstrap URL.
- `customBootstrapData?: BootstrapData` Pre-loaded RDAP bootstrap data for caching control (see [Bootstrap Data Caching](#bootstrap-data-caching)).
- `customBootstrapUrl?: string` Override RDAP bootstrap URL (ignored if `customBootstrapData` is provided).
- `customFetch?: FetchLike` Custom fetch implementation for all HTTP requests (see [Custom Fetch Implementation](#custom-fetch-implementation)).
- `whoisHints?: Record<string, string>` Override/add authoritative WHOIS per TLD (keys are lowercase TLDs, values may include or omit `whois://`).
- `includeRaw?: boolean` Include `rawRdap`/`rawWhois` in the returned record (default `false`).
- `signal?: AbortSignal` Optional cancellation signal.

13
package-lock.json generated
View File

@@ -16,7 +16,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.2",
"@types/node": "24.9.0",
"@types/node": "24.9.2",
"tsdown": "0.15.12",
"typescript": "5.9.3",
"vitest": "^4.0.0"
@@ -1402,12 +1402,11 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.0.tgz",
"integrity": "sha512-MKNwXh3seSK8WurXF7erHPJ2AONmMwkI7zAMrXZDPIru8jRqkk6rGDBVbw4mLwfqA+ZZliiDPg05JQ3uW66tKQ==",
"version": "24.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1863,7 +1862,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1947,7 +1945,6 @@
"integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.95.0",
"@rolldown/pluginutils": "1.0.0-beta.45"
@@ -2249,7 +2246,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2287,7 +2283,6 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View File

@@ -50,7 +50,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.2",
"@types/node": "24.9.0",
"@types/node": "24.9.2",
"tsdown": "0.15.12",
"typescript": "5.9.3",
"vitest": "^4.0.0"

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);
});

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,

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) {

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";

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
src/lib/fetch.test.ts Normal file
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
src/lib/fetch.ts Normal file
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;
}

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
src/rdap/bootstrap.test.ts Normal file
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,
}),
);
});
});
});

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)

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,

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,

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");

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>;

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",

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", () => {

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 = {

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");
});
});

View File

@@ -1,20 +1,26 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "esnext",
"moduleDetection": "force",
"module": "preserve",
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"moduleDetection": "force",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"emitDeclarationOnly": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["vitest/globals"]
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]