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:
336
README.md
336
README.md
@@ -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
13
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
84
src/lib/fetch.test.ts
Normal file
84
src/lib/fetch.test.ts
Normal 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
28
src/lib/fetch.ts
Normal 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;
|
||||
}
|
||||
@@ -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
453
src/rdap/bootstrap.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
239
src/types.ts
239
src/types.ts
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user