From a787576392c57872f403c4cf699695fbdcfa245c Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 30 Oct 2025 18:16:06 -0400 Subject: [PATCH 1/6] Add support for custom pre-loaded IANA bootstrap data --- README.md | 145 ++++++++++++++++- src/rdap/bootstrap.test.ts | 315 +++++++++++++++++++++++++++++++++++++ src/rdap/bootstrap.ts | 69 +++++--- src/types.ts | 188 +++++++++++++++++++++- 4 files changed, 691 insertions(+), 26 deletions(-) create mode 100644 src/rdap/bootstrap.test.ts diff --git a/README.md b/README.md index a8bb48f..ea1e2a4 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,148 @@ 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 { + 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'); + 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 { + // 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'); + 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 { + 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'); + 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. + ### Options - `timeoutMs?: number` – Total timeout budget per network operation (default `15000`). @@ -96,7 +238,8 @@ 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). - `whoisHints?: Record` – 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. diff --git a/src/rdap/bootstrap.test.ts b/src/rdap/bootstrap.test.ts new file mode 100644 index 0000000..b7659dc --- /dev/null +++ b/src/rdap/bootstrap.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { BootstrapData } from "../types"; +import { getRdapBaseUrlsForTld } from "./bootstrap"; + +// Mock the fetch function +global.fetch = vi.fn(); + +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, + }), + ); + }); + }); +}); + diff --git a/src/rdap/bootstrap.ts b/src/rdap/bootstrap.ts index e66184c..07881ae 100644 --- a/src/rdap/bootstrap.ts +++ b/src/rdap/bootstrap.ts @@ -1,39 +1,60 @@ import { withTimeout } from "../lib/async"; import { DEFAULT_TIMEOUT_MS } from "../lib/constants"; -import type { LookupOptions } from "../types"; +import type { BootstrapData, 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[][][]; -} +const DEFAULT_BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json" as const; /** * 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 { - 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) { + data = options.customBootstrapData as BootstrapData; + // Validate the structure to provide helpful error messages + if (!data || typeof data !== "object") { + throw new Error( + "Invalid customBootstrapData: expected an object. See BootstrapData type for required structure.", + ); + } + if (!Array.isArray(data.services)) { + throw new Error( + 'Invalid customBootstrapData: missing or invalid "services" array. See BootstrapData type for required structure.', + ); + } + } else { + // Priority 2 & 3: Fetch from custom URL or default IANA URL + const bootstrapUrl = + options?.customBootstrapUrl ?? DEFAULT_BOOTSTRAP_URL; + 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 []; + data = (await res.json()) as BootstrapData; + } + + // 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) { diff --git a/src/types.ts b/src/types.ts index 0bf3c18..83188a2 100644 --- a/src/types.ts +++ b/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,7 +253,33 @@ 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; /** Override/add authoritative WHOIS per TLD */ whoisHints?: Record; @@ -131,12 +289,40 @@ 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, init?: RequestInit, From d38681f5fbd151e04e3d70dedb21f0bdb21aa303 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 30 Oct 2025 18:36:25 -0400 Subject: [PATCH 2/6] Add custom fetch implementation for advanced HTTP request handling in RDAP client --- README.md | 191 +++++++++++++++++++++++++++++++++++++ src/lib/fetch.test.ts | 84 ++++++++++++++++ src/lib/fetch.ts | 28 ++++++ src/rdap/bootstrap.test.ts | 146 +++++++++++++++++++++++++++- src/rdap/bootstrap.ts | 20 +++- src/rdap/client.ts | 6 +- src/rdap/merge.ts | 4 +- src/types.ts | 49 ++++++++++ 8 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 src/lib/fetch.test.ts create mode 100644 src/lib/fetch.ts diff --git a/README.md b/README.md index ea1e2a4..3c13e13 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,9 @@ async function getBootstrapData(): Promise { // 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 @@ -156,6 +159,9 @@ async function getBootstrapData(): Promise { // 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 @@ -196,6 +202,9 @@ async function getBootstrapData(): Promise { // 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 @@ -228,6 +237,187 @@ See the full documentation at [RFC 7484 - Finding the Authoritative RDAP Service **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(); + +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 { + 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(); + +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`). @@ -240,6 +430,7 @@ See the full documentation at [RFC 7484 - Finding the Authoritative RDAP Service - `rdapLinkRels?: string[]` – RDAP link rel values to consider (default `["related","entity","registrar","alternate"]`). - `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` – 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. diff --git a/src/lib/fetch.test.ts b/src/lib/fetch.test.ts new file mode 100644 index 0000000..4fc9feb --- /dev/null +++ b/src/lib/fetch.test.ts @@ -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"); + }); +}); diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts new file mode 100644 index 0000000..4a6063a --- /dev/null +++ b/src/lib/fetch.ts @@ -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; +} diff --git a/src/rdap/bootstrap.test.ts b/src/rdap/bootstrap.test.ts index b7659dc..7b1accf 100644 --- a/src/rdap/bootstrap.test.ts +++ b/src/rdap/bootstrap.test.ts @@ -1,9 +1,23 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import type { BootstrapData } from "../types"; import { getRdapBaseUrlsForTld } from "./bootstrap"; -// Mock the fetch function -global.fetch = vi.fn(); +// Mock the global fetch function +beforeAll(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); describe("getRdapBaseUrlsForTld with customBootstrapData", () => { const validBootstrapData: BootstrapData = { @@ -311,5 +325,129 @@ describe("getRdapBaseUrlsForTld with customBootstrapData", () => { ); }); }); -}); + 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, + }), + ); + }); + }); +}); diff --git a/src/rdap/bootstrap.ts b/src/rdap/bootstrap.ts index 07881ae..e78d851 100644 --- a/src/rdap/bootstrap.ts +++ b/src/rdap/bootstrap.ts @@ -1,5 +1,6 @@ import { withTimeout } from "../lib/async"; import { DEFAULT_TIMEOUT_MS } from "../lib/constants"; +import { resolveFetch } from "../lib/fetch"; import type { BootstrapData, LookupOptions } from "../types"; const DEFAULT_BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json" as const; @@ -37,12 +38,25 @@ export async function getRdapBaseUrlsForTld( 'Invalid customBootstrapData: missing or invalid "services" array. See BootstrapData type for required structure.', ); } + data.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[]].`, + ); + } + }); } else { // Priority 2 & 3: Fetch from custom URL or default IANA URL - const bootstrapUrl = - options?.customBootstrapUrl ?? DEFAULT_BOOTSTRAP_URL; + // Use custom fetch implementation if provided for caching/logging/monitoring + const fetchFn = resolveFetch(options); + const bootstrapUrl = options?.customBootstrapUrl ?? DEFAULT_BOOTSTRAP_URL; const res = await withTimeout( - fetch(bootstrapUrl, { + fetchFn(bootstrapUrl, { method: "GET", headers: { accept: "application/json" }, signal: options?.signal, diff --git a/src/rdap/client.ts b/src/rdap/client.ts index f322d21..268f1cd 100644 --- a/src/rdap/client.ts +++ b/src/rdap/client.ts @@ -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, diff --git a/src/rdap/merge.ts b/src/rdap/merge.ts index 9e572d5..affcad8 100644 --- a/src/rdap/merge.ts +++ b/src/rdap/merge.ts @@ -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, diff --git a/src/types.ts b/src/types.ts index 83188a2..17413b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -281,6 +281,55 @@ export interface LookupOptions { 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(); + * 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; /** Include rawRdap/rawWhois in results (default false) */ From 9496f4bcc792726ec22458277f6f3a5a1e92079e Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 30 Oct 2025 18:56:38 -0400 Subject: [PATCH 3/6] Refactor TypeScript configuration and update smoke tests to use new lookup function; enhance error handling in lookup and normalize functions --- package-lock.json | 13 +-- package.json | 2 +- src/index.smoke.test.ts | 174 ++++++++++++++++-------------------- src/index.test.ts | 14 +-- src/index.ts | 3 + src/lib/dates.ts | 2 + src/lib/text.ts | 2 +- src/rdap/bootstrap.ts | 10 ++- src/rdap/normalize.test.ts | 3 +- src/types.ts | 2 +- src/whois/merge.test.ts | 1 + src/whois/normalize.test.ts | 3 +- src/whois/normalize.ts | 11 ++- src/whois/referral.test.ts | 2 +- tsconfig.json | 20 +++-- 15 files changed, 128 insertions(+), 134 deletions(-) diff --git a/package-lock.json b/package-lock.json index f83f42c..4b35876 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index eec9a02..9c3568a 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/index.smoke.test.ts b/src/index.smoke.test.ts index c18c01c..92ad46b 100644 --- a/src/index.smoke.test.ts +++ b/src/index.smoke.test.ts @@ -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); +}); diff --git a/src/index.test.ts b/src/index.test.ts index 19ca276..471a130 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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, diff --git a/src/index.ts b/src/index.ts index 48f0468..398745d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) { diff --git a/src/lib/dates.ts b/src/lib/dates.ts index 25ee566..4d07ab0 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -98,6 +98,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 +110,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 { diff --git a/src/lib/text.ts b/src/lib/text.ts index bb8319e..9dafc18 100644 --- a/src/lib/text.ts +++ b/src/lib/text.ts @@ -12,7 +12,7 @@ export function parseKeyValueLines(text: string): Record { 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) ?? []; diff --git a/src/rdap/bootstrap.ts b/src/rdap/bootstrap.ts index e78d851..9ea07f9 100644 --- a/src/rdap/bootstrap.ts +++ b/src/rdap/bootstrap.ts @@ -26,19 +26,19 @@ export async function getRdapBaseUrlsForTld( // Priority 1: Use pre-loaded bootstrap data if provided (no fetch) if (options && "customBootstrapData" in options) { - data = options.customBootstrapData as BootstrapData; + const provided = options.customBootstrapData; // Validate the structure to provide helpful error messages - if (!data || typeof data !== "object") { + if (!provided || typeof provided !== "object") { throw new Error( "Invalid customBootstrapData: expected an object. See BootstrapData type for required structure.", ); } - if (!Array.isArray(data.services)) { + if (!Array.isArray(provided.services)) { throw new Error( 'Invalid customBootstrapData: missing or invalid "services" array. See BootstrapData type for required structure.', ); } - data.services.forEach((svc, idx) => { + provided.services.forEach((svc, idx) => { if ( !Array.isArray(svc) || svc.length < 2 || @@ -50,6 +50,7 @@ export async function getRdapBaseUrlsForTld( ); } }); + data = provided; } else { // Priority 2 & 3: Fetch from custom URL or default IANA URL // Use custom fetch implementation if provided for caching/logging/monitoring @@ -72,6 +73,7 @@ export async function getRdapBaseUrlsForTld( 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) diff --git a/src/rdap/normalize.test.ts b/src/rdap/normalize.test.ts index 28b6322..ef590fb 100644 --- a/src/rdap/normalize.test.ts +++ b/src/rdap/normalize.test.ts @@ -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"); diff --git a/src/types.ts b/src/types.ts index 17413b6..e4b0d67 100644 --- a/src/types.ts +++ b/src/types.ts @@ -373,6 +373,6 @@ export interface LookupResult { * of the global `fetch` function available in Node.js 18+ and browsers. */ export type FetchLike = ( - input: RequestInfo | URL, + input: string | URL, init?: RequestInit, ) => Promise; diff --git a/src/whois/merge.test.ts b/src/whois/merge.test.ts index 23800b9..9eb6fe9 100644 --- a/src/whois/merge.test.ts +++ b/src/whois/merge.test.ts @@ -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", diff --git a/src/whois/normalize.test.ts b/src/whois/normalize.test.ts index 843898f..294fa1f 100644 --- a/src/whois/normalize.test.ts +++ b/src/whois/normalize.test.ts @@ -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", () => { diff --git a/src/whois/normalize.ts b/src/whois/normalize.ts index 3c433fa..4385995 100644 --- a/src/whois/normalize.ts +++ b/src/whois/normalize.ts @@ -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 = { diff --git a/src/whois/referral.test.ts b/src/whois/referral.test.ts index 29f12d6..ff5ef6b 100644 --- a/src/whois/referral.test.ts +++ b/src/whois/referral.test.ts @@ -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"); }); }); diff --git a/tsconfig.json b/tsconfig.json index 5e1110b..55cd104 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] From 330716d311621a5460111c1045ac87c084c8afb8 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 30 Oct 2025 19:01:30 -0400 Subject: [PATCH 4/6] Reduce default fetch timeout from 15 seconds to 10 seconds --- src/lib/constants.ts | 12 +++++++++++- src/rdap/bootstrap.ts | 4 +--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 37cb528..ae36ca6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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"; diff --git a/src/rdap/bootstrap.ts b/src/rdap/bootstrap.ts index 9ea07f9..5ee8cf3 100644 --- a/src/rdap/bootstrap.ts +++ b/src/rdap/bootstrap.ts @@ -1,10 +1,8 @@ import { withTimeout } from "../lib/async"; -import { DEFAULT_TIMEOUT_MS } from "../lib/constants"; +import { DEFAULT_BOOTSTRAP_URL, DEFAULT_TIMEOUT_MS } from "../lib/constants"; import { resolveFetch } from "../lib/fetch"; import type { BootstrapData, LookupOptions } from "../types"; -const DEFAULT_BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json" as const; - /** * 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). From f516ea5b4babffdb7ed53ad50fc7f0c435797b6d Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 30 Oct 2025 19:04:08 -0400 Subject: [PATCH 5/6] Enhance date parsing by adding validation for year, month, day, hour, minute, and second components in parseDateWithRegex function --- src/lib/dates.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/dates.ts b/src/lib/dates.ts index 4d07ab0..000678d 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -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), From 643d06beb4a2d57917483d214d3f0b2aeca9a5bb Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 30 Oct 2025 19:05:16 -0400 Subject: [PATCH 6/6] Improve error handling in RDAP bootstrap URL fetching by adding a try-catch block to manage network, timeout, and JSON parse errors, while preserving cancellation behavior. --- src/rdap/bootstrap.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/rdap/bootstrap.ts b/src/rdap/bootstrap.ts index 5ee8cf3..1e232ba 100644 --- a/src/rdap/bootstrap.ts +++ b/src/rdap/bootstrap.ts @@ -54,17 +54,26 @@ export async function getRdapBaseUrlsForTld( // Use custom fetch implementation if provided for caching/logging/monitoring const fetchFn = resolveFetch(options); const bootstrapUrl = options?.customBootstrapUrl ?? DEFAULT_BOOTSTRAP_URL; - 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; + 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