From 69f85ee925da4f1c62a9db4a8f4bf71d689a70e7 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Fri, 18 Sep 2026 14:41:16 -0400 Subject: [PATCH] feat: add attempt tracking, deadlineMs, and structured error codes - Resolve `LookupResult` with an `attempts` array describing every network operation (phase, server, duration, error) so failures recovered by fallback remain visible - Add `deadlineMs` option for a hard cap on total lookup time, distinct from per-operation `timeoutMs` (lowered default from 15 s to 10 s) - Add `errorCode` (machine-readable `LookupErrorCode`), `errorPhase`, and `errorServer` to `LookupResult`; `timeout` covers both per-op and deadline timeouts, `aborted` means the caller's signal fired - WHOIS timeouts now distinguish `connect` vs `read` stage and resolve with partial text (marked `partial: true`) when data arrived before the socket stalled - Drop Node 18 support; minimum engine is now 20 --- .github/workflows/test.yml | 2 +- README.md | 50 +++++- package.json | 2 +- src/index.attempts.test.ts | 266 ++++++++++++++++++++++++++++++ src/index.smoke.test.ts | 11 +- src/index.test.ts | 12 +- src/index.ts | 268 ++++++++++++++++++++----------- src/lib/async.test.ts | 84 ++++++++++ src/lib/async.ts | 92 +++++++++-- src/lib/dates.test.ts | 2 - src/lib/errors.test.ts | 55 +++++++ src/lib/errors.ts | 94 +++++++++++ src/lib/trace.ts | 52 ++++++ src/rdap/bootstrap.test.ts | 46 +++++- src/rdap/bootstrap.ts | 104 ++++++++---- src/rdap/client.ts | 49 +++--- src/rdap/merge.ts | 49 +++--- src/types.ts | 62 ++++++- src/whois/client.timeout.test.ts | 124 ++++++++++++++ src/whois/client.ts | 97 ++++++++--- src/whois/discovery.ts | 88 +++++++--- src/whois/referral.ts | 34 +++- tsconfig.json | 4 +- tsdown.config.ts | 1 + 24 files changed, 1394 insertions(+), 254 deletions(-) create mode 100644 src/index.attempts.test.ts create mode 100644 src/lib/async.test.ts create mode 100644 src/lib/errors.test.ts create mode 100644 src/lib/errors.ts create mode 100644 src/lib/trace.ts create mode 100644 src/whois/client.timeout.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72556c6..07fcb3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18, 20, 22, 24, 26] + node-version: [20, 22, 24, 26] steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 diff --git a/README.md b/README.md index 8994a69..c7bef8b 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,8 @@ const result = await lookup("example.com", { ### Options -- `timeoutMs?: number` – Total timeout budget per network operation (default `15000`). +- `timeoutMs?: number` – Timeout for each individual network operation (default `10000`). A lookup performs several operations in sequence, so see [Timeouts and diagnostics](#timeouts-and-diagnostics) for the worst case. A value that is not a finite number > 0 disables the timeout. +- `deadlineMs?: number` – Overall deadline for the whole lookup (default: none). When it elapses, in-flight requests and WHOIS sockets are cancelled and the result has `errorCode: "timeout"`. - `rdapOnly?: boolean` – Only attempt RDAP; do not fall back to WHOIS. - `whoisOnly?: boolean` – Skip RDAP and query WHOIS directly. - `followWhoisReferral?: boolean` – Follow registrar referral from the TLD WHOIS (default `true`). @@ -434,7 +435,50 @@ const result = await lookup("example.com", { - `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. +- `signal?: AbortSignal` – Optional cancellation signal. Honored by RDAP requests _and_ WHOIS sockets; an abort stops the lookup rather than falling through to the next phase. + +### Timeouts and diagnostics + +`lookup()` never throws for lookup failures; it resolves to a `LookupResult`: + +```ts +interface LookupResult { + ok: boolean; + record?: DomainRecord; + error?: string; // human-readable + errorCode?: LookupErrorCode; // machine-readable, present when ok is false + errorPhase?: "rdap_bootstrap" | "rdap" | "rdap_link" | "iana" | "whois"; + errorServer?: string; // RDAP URL or WHOIS host involved in the failure + attempts: LookupAttempt[]; // every network operation, in order (always present) +} +``` + +`errorCode` is one of `invalid_input`, `invalid_tld`, `timeout`, `aborted`, `connect_failed`, `http_error`, `rdap_unavailable`, `no_server`, `no_data`, `unsupported_runtime`, or `unknown`. Prefer it over matching `error` text. `timeout` covers every timeout, including `deadlineMs`; `aborted` means your own `signal` fired. + +Each entry in `attempts` describes one operation, successful or not, so a failure that was recovered from (say, an RDAP server that was down before WHOIS answered) is still visible: + +```json +{ + "phase": "whois", + "server": "whois.example", + "ok": false, + "durationMs": 1503, + "errorCode": "timeout", + "error": "WHOIS connect timeout (whois.example)", + "stage": "connect" +} +``` + +`stage` (`"connect"` or `"read"`) is set on WHOIS timeouts. If a WHOIS server sends some data but never closes the connection, the timeout **resolves with the partial text** instead of failing, and the attempt is marked `partial: true`. + +`timeoutMs` applies to each network operation (including reading the response body), not to the lookup as a whole. Without `deadlineMs`, the worst case is roughly `timeoutMs × (1 bootstrap + N RDAP servers + up to 2 RDAP links + 1 IANA + 1 + maxWhoisReferralHops WHOIS queries)`. Set `deadlineMs` to put a hard cap on the total, e.g. for serverless functions with an execution limit: + +```ts +const result = await lookup("example.sh", { timeoutMs: 4000, deadlineMs: 9000 }); +if (!result.ok && result.errorCode === "timeout") { + console.warn(result.errorPhase, result.attempts); +} +``` ### `DomainRecord` schema @@ -536,7 +580,7 @@ interface DomainRecord { - Queries the TLD WHOIS and follows registrar referrals recursively up to `maxWhoisReferralHops` (unless disabled). - Normalizes common key/value variants across gTLD/ccTLD formats (dates, statuses, nameservers, contacts). Availability is inferred from common phrases (best‑effort heuristic). -Timeouts are enforced per request using a simple race against `timeoutMs` (default 15s). All network I/O is performed with global `fetch` (RDAP) and a raw TCP socket (WHOIS). +Each network operation is bounded by `timeoutMs` (default 10s) and cancelled when it elapses (`fetch` is aborted through its `signal`; WHOIS sockets are destroyed); an optional `deadlineMs` bounds the whole lookup. All network I/O is performed with global `fetch` (RDAP) and a raw TCP socket (WHOIS). ## Development diff --git a/package.json b/package.json index a3702dc..3e0dd3f 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,6 @@ "vitest": "^5.0.1" }, "engines": { - "node": ">=18.17" + "node": ">=20" } } diff --git a/src/index.attempts.test.ts b/src/index.attempts.test.ts new file mode 100644 index 0000000..eb861dc --- /dev/null +++ b/src/index.attempts.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RdapperError } from "./lib/errors"; +import type { BootstrapData, FetchLike } from "./types"; + +vi.mock("./whois/client.js", () => ({ whoisQuery: vi.fn() })); + +import { lookup } from "."; +import { whoisQuery } from "./whois/client"; + +const bootstrap: BootstrapData = { + version: "1.0", + publication: "2025-01-01T00:00:00Z", + services: [[["com"], ["https://rdap-a.example/", "https://rdap-b.example/"]]], +}; + +const whoisText = + "Domain Name: EXAMPLE.COM\nRegistrar: Test Registrar\nCreation Date: 2001-01-01T00:00:00Z\n"; + +const never = () => new Promise(() => {}); +const rdapOk = (): Response => + new Response(JSON.stringify({ ldhName: "example.com", links: [] }), { status: 200 }); + +/** WHOIS mock that never answers but honours abort like the real socket client. */ +function hangingWhois(_server: string, _q: string, opts?: { signal?: AbortSignal }) { + return new Promise((_, reject) => { + opts?.signal?.addEventListener("abort", () => + reject( + opts.signal?.reason instanceof RdapperError + ? opts.signal.reason + : new RdapperError("aborted", "Lookup aborted"), + ), + ); + }); +} + +beforeEach(() => { + vi.mocked(whoisQuery).mockReset(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("attempts trace", () => { + it("records a failed RDAP base followed by a WHOIS success, in order", async () => { + const customFetch: FetchLike = vi.fn(async () => new Response("nope", { status: 503 })); + vi.mocked(whoisQuery).mockImplementation(async (server) => ({ + serverQueried: server, + text: server === "whois.iana.org" ? "whois: whois.verisign-grs.com\n" : whoisText, + })); + + const res = await lookup("example.com", { customBootstrapData: bootstrap, customFetch }); + + expect(res.ok, res.error).toBe(true); + expect(res.record?.source).toBe("whois"); + expect(res.attempts.map((a) => [a.phase, a.server, a.ok])).toEqual([ + ["rdap", "https://rdap-a.example/", false], + ["rdap", "https://rdap-b.example/", false], + ["iana", "whois.iana.org", true], + ["whois", "whois.verisign-grs.com", true], + ]); + expect(res.attempts[0]).toMatchObject({ errorCode: "http_error", error: "RDAP 503: nope" }); + for (const a of res.attempts) expect(a.durationMs).toBeGreaterThanOrEqual(0); + }); + + it("includes attempts on a successful RDAP lookup", async () => { + const customFetch: FetchLike = vi.fn(async () => rdapOk()); + const res = await lookup("example.com", { customBootstrapData: bootstrap, customFetch }); + expect(res.ok, res.error).toBe(true); + expect(res.attempts).toMatchObject([ + { phase: "rdap", server: "https://rdap-a.example/", ok: true }, + ]); + }); + + it("fetches the bootstrap once for a multi-label public suffix (co.uk)", async () => { + const customFetch: FetchLike = vi.fn(async (url) => + String(url).endsWith("dns.json") + ? new Response( + JSON.stringify({ ...bootstrap, services: [[["uk"], ["https://rdap-uk.example/"]]] }), + ) + : rdapOk(), + ); + const res = await lookup("example.co.uk", { customFetch }); + expect(res.ok, res.error).toBe(true); + expect(res.attempts.map((a) => [a.phase, a.server])).toEqual([ + ["rdap_bootstrap", "https://data.iana.org/rdap/dns.json"], + ["rdap", "https://rdap-uk.example/"], + ]); + }); + + it("includes attempts on validation failures", async () => { + expect(await lookup("not a domain")).toMatchObject({ + ok: false, + errorCode: "invalid_input", + attempts: [], + }); + }); + + it("marks partial WHOIS reads", async () => { + vi.mocked(whoisQuery).mockImplementation(async (server) => ({ + serverQueried: server, + text: whoisText, + partial: true, + })); + const res = await lookup("example.com", { + whoisOnly: true, + whoisHints: { com: "whois.example" }, + followWhoisReferral: false, + }); + expect(res.ok, res.error).toBe(true); + expect(res.attempts).toMatchObject([ + { phase: "whois", server: "whois.example", partial: true }, + ]); + }); +}); + +describe("error reporting", () => { + it("reports a hung IANA as a timeout, with exactly one IANA query", async () => { + vi.mocked(whoisQuery).mockRejectedValue( + new RdapperError("timeout", "WHOIS connect timeout (whois.iana.org)", { stage: "connect" }), + ); + const res = await lookup("example.sh", { whoisOnly: true }); + + expect(res).toMatchObject({ + ok: false, + errorCode: "timeout", + errorPhase: "iana", + errorServer: "whois.iana.org", + }); + expect(res.error).toMatch(/discovery via IANA failed for '\.sh'/); + expect(res.error).not.toMatch(/may not publish/); + expect(vi.mocked(whoisQuery)).toHaveBeenCalledTimes(1); + expect(res.attempts).toMatchObject([{ phase: "iana", ok: false, stage: "connect" }]); + }); + + it("reports no_server when IANA answers without a server", async () => { + vi.mocked(whoisQuery).mockResolvedValue({ + serverQueried: "whois.iana.org", + text: "remarks: Registration information: https://nic.example\n", + }); + const res = await lookup("example.zz", { whoisOnly: true }); + expect(res).toMatchObject({ ok: false, errorCode: "no_server" }); + expect(res.error).toContain("https://nic.example"); + expect(vi.mocked(whoisQuery)).toHaveBeenCalledTimes(1); + }); + + it("reports rdap_unavailable with the last RDAP failure", async () => { + const customFetch: FetchLike = vi.fn(async () => new Response("down", { status: 500 })); + const res = await lookup("example.com", { + rdapOnly: true, + customBootstrapData: bootstrap, + customFetch, + }); + expect(res).toMatchObject({ + ok: false, + errorCode: "rdap_unavailable", + errorPhase: "rdap", + errorServer: "https://rdap-b.example/", + }); + expect(res.error).toContain("RDAP 500: down"); + expect(vi.mocked(whoisQuery)).not.toHaveBeenCalled(); + }); + + it("surfaces an unsupported runtime from IANA discovery", async () => { + vi.mocked(whoisQuery).mockRejectedValue(new RdapperError("unsupported_runtime", "no net")); + const res = await lookup("example.sh", { whoisOnly: true }); + expect(res).toMatchObject({ ok: false, errorCode: "unsupported_runtime" }); + }); +}); + +describe("timeouts, deadline and abort", () => { + it("aborts a stalled RDAP body read at timeoutMs", async () => { + vi.useFakeTimers(); + const customFetch: FetchLike = async () => + ({ + ok: true, + status: 200, + json: never, + text: never, + }) as unknown as Response; + const p = lookup("example.com", { + rdapOnly: true, + timeoutMs: 1000, + customBootstrapData: { ...bootstrap, services: [[["com"], ["https://rdap-a.example/"]]] }, + customFetch, + }); + await vi.advanceTimersByTimeAsync(1000); + const res = await p; + expect(res.ok).toBe(false); + expect(res.attempts[0]).toMatchObject({ phase: "rdap", ok: false, errorCode: "timeout" }); + }); + + it("deadlineMs bounds the whole lookup instead of N x timeoutMs", async () => { + vi.useFakeTimers(); + const customFetch: FetchLike = vi.fn(never); // ignores its signal on purpose + vi.mocked(whoisQuery).mockImplementation(hangingWhois); + const start = Date.now(); + const p = lookup("example.com", { + customBootstrapData: bootstrap, + customFetch, + timeoutMs: 10_000, + deadlineMs: 2500, + }); + await vi.advanceTimersByTimeAsync(2500); + const res = await p; + expect(Date.now() - start).toBe(2500); + expect(res).toMatchObject({ ok: false, errorCode: "timeout" }); + expect(res.error).toBe("Lookup deadline exceeded (2500ms)"); + // The deadline stops the lookup: no second RDAP base, no WHOIS fallback + expect(customFetch).toHaveBeenCalledTimes(1); + expect(vi.mocked(whoisQuery)).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("deadlineMs cancels a hung WHOIS phase", async () => { + vi.useFakeTimers(); + vi.mocked(whoisQuery).mockImplementation(hangingWhois); + const p = lookup("example.com", { whoisOnly: true, timeoutMs: 10_000, deadlineMs: 1500 }); + await vi.advanceTimersByTimeAsync(1500); + expect(await p).toMatchObject({ + ok: false, + errorCode: "timeout", + errorPhase: "iana", + }); + }); + + it("a caller abort during RDAP does not fall through to WHOIS", async () => { + const ctrl = new AbortController(); + const customFetch: FetchLike = (_url, init) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("boom"))); + queueMicrotask(() => ctrl.abort()); + }); + const res = await lookup("example.com", { + customBootstrapData: bootstrap, + customFetch, + signal: ctrl.signal, + }); + expect(res).toMatchObject({ ok: false, errorCode: "aborted" }); + expect(vi.mocked(whoisQuery)).not.toHaveBeenCalled(); + }); + + it("an already-aborted signal fails fast", async () => { + const ctrl = new AbortController(); + ctrl.abort(); + const customFetch: FetchLike = vi.fn(async () => rdapOk()); + const res = await lookup("example.com", { + customBootstrapData: bootstrap, + customFetch, + signal: ctrl.signal, + }); + expect(res).toMatchObject({ ok: false, errorCode: "aborted" }); + expect(customFetch).not.toHaveBeenCalled(); + }); + + it("aborting during bootstrap fetch is not swallowed", async () => { + const ctrl = new AbortController(); + const customFetch: FetchLike = (_url, init) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("boom"))); + queueMicrotask(() => ctrl.abort()); + }); + const res = await lookup("example.com", { customFetch, signal: ctrl.signal }); + expect(res).toMatchObject({ ok: false, errorCode: "aborted" }); + expect(vi.mocked(whoisQuery)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/index.smoke.test.ts b/src/index.smoke.test.ts index d93dc39..0f80534 100644 --- a/src/index.smoke.test.ts +++ b/src/index.smoke.test.ts @@ -1,5 +1,3 @@ -/** biome-ignore-all lint/style/noNonNullAssertion: this is fine for tests */ - import { expect, test } from "vitest"; import { isAvailable, isRegistered, lookup } from "."; @@ -43,10 +41,10 @@ for (const c of rdapCases) { (rec.registrar?.name || "").toLowerCase().includes("internet assigned numbers authority"), ).toBe(true); } - // IANA nameservers + // 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); + // The example domains' nameserver operator changes over time; only require that some exist + expect(ns.length).toBeGreaterThan(0); if (c.expectDs) { // DS records typically present for .com/.net expect(rec.dnssec?.enabled).toBe(true); @@ -78,8 +76,7 @@ maybeTest("WHOIS-only lookup for example.com", async () => { 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); + expect(ns.length).toBeGreaterThan(0); }); // WHOIS-only smoke for example.io (RDAP-incompatible TLD) diff --git a/src/index.test.ts b/src/index.test.ts index b6e5a94..54a1b7c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // Shared, safe default mocks. Individual describes override implementations as needed. vi.mock("./rdap/bootstrap.js", () => ({ - getRdapBaseUrlsForTld: vi.fn(async () => ["https://rdap.example/"]), + getRdapBaseUrlsForPublicSuffix: vi.fn(async () => ["https://rdap.example/"]), })); vi.mock("./rdap/client.js", () => ({ @@ -45,7 +45,7 @@ vi.mock("./whois/discovery.js", async () => { const actual = await vi.importActual("./whois/discovery.js"); return { ...actual, - ianaWhoisServerForTld: vi.fn(async () => "whois.verisign-grs.com"), + discoverWhoisServer: vi.fn(async () => ({ server: "whois.verisign-grs.com" })), }; }); @@ -69,7 +69,9 @@ import * as whoisReferral from "./whois/referral"; describe("lookup orchestration", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue("whois.verisign-grs.com"); + vi.mocked(discovery.discoverWhoisServer).mockResolvedValue({ + server: "whois.verisign-grs.com", + }); }); it("uses RDAP when available and does not call WHOIS", async () => { @@ -145,7 +147,9 @@ describe("RDAP 404 handling", () => { describe("WHOIS referral & includeRaw", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue("whois.verisign-grs.com"); + vi.mocked(discovery.discoverWhoisServer).mockResolvedValue({ + server: "whois.verisign-grs.com", + }); }); it("does not follow referral when followWhoisReferral is false", async () => { diff --git a/src/index.ts b/src/index.ts index c53da8c..9fe4a8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,125 +1,201 @@ +import { linkSignals, throwIfAborted } from "./lib/async"; import { getDomainParts, isLikelyDomain } from "./lib/domain"; -import { getRdapBaseUrlsForTld } from "./rdap/bootstrap"; +import { classifyError, RdapperError } from "./lib/errors"; +import { attemptForError, type LookupContext } from "./lib/trace"; +import { getRdapBaseUrlsForPublicSuffix } from "./rdap/bootstrap"; import { fetchRdapDomain } from "./rdap/client"; import { fetchAndMergeRdapRelated } from "./rdap/merge"; import { normalizeRdap } from "./rdap/normalize"; -import type { DomainRecord, LookupOptions, LookupResult } from "./types"; -import { - getIanaWhoisTextForTld, - ianaWhoisServerForTld, - parseIanaRegistrationInfoUrl, -} from "./whois/discovery"; +import type { + DomainRecord, + LookupAttempt, + LookupErrorCode, + LookupOptions, + LookupResult, +} from "./types"; +import { discoverWhoisServer, parseIanaRegistrationInfoUrl } from "./whois/discovery"; import { mergeWhoisRecords } from "./whois/merge"; import { normalizeWhois } from "./whois/normalize"; import { collectWhoisReferralChain, followWhoisReferrals } from "./whois/referral"; +function failure( + ctx: LookupContext, + errorCode: LookupErrorCode, + error: string, + where?: { phase?: LookupAttempt["phase"]; server?: string }, +): LookupResult { + return { + ok: false, + error, + errorCode, + ...(where?.phase ? { errorPhase: where.phase } : {}), + ...(where?.server ? { errorServer: where.server } : {}), + attempts: ctx.attempts, + }; +} + +function lastFailedAttempt( + ctx: LookupContext, + phases?: LookupAttempt["phase"][], +): LookupAttempt | undefined { + return ctx.attempts.findLast((a) => !a.ok && (!phases || phases.includes(a.phase))); +} + /** * High-level lookup that prefers RDAP and falls back to WHOIS. * Ensures a standardized DomainRecord, independent of the source. + * + * Every result carries `attempts`, a per-operation trace; failures carry an `errorCode`. */ export async function lookup(domain: string, opts?: LookupOptions): Promise { + const ctx: LookupContext = { attempts: [] }; + + // Optional overall deadline: one signal (linked to the caller's) that every phase observes. + let signalOpts = opts; + let deadlineTimer: ReturnType | undefined; + let link: ReturnType | undefined; + const deadlineMs = opts?.deadlineMs; + if (deadlineMs !== undefined && Number.isFinite(deadlineMs) && deadlineMs > 0) { + const deadline = new AbortController(); + link = linkSignals(opts?.signal, deadline.signal); + deadlineTimer = setTimeout( + () => + deadline.abort(new RdapperError("timeout", `Lookup deadline exceeded (${deadlineMs}ms)`)), + deadlineMs, + ); + signalOpts = { ...opts, signal: link.signal }; + } + try { - if (!isLikelyDomain(domain)) { - return { ok: false, error: "Input does not look like a domain" }; - } + return await runLookup(domain, signalOpts, ctx); + } catch (err: unknown) { + const { code, error } = classifyError(err); + const source = attemptForError(err); + return failure(ctx, code, error, { phase: source?.phase, server: source?.server }); + } finally { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + link?.dispose(); + } +} - const { publicSuffix: tld } = getDomainParts(domain); - if (!tld) { - return { ok: false, error: "Invalid TLD" }; - } +async function runLookup( + domain: string, + opts: LookupOptions | undefined, + ctx: LookupContext, +): Promise { + if (!isLikelyDomain(domain)) { + return failure(ctx, "invalid_input", "Input does not look like a domain"); + } - // If WHOIS-only, skip RDAP path - if (!opts?.whoisOnly) { - let bases = await getRdapBaseUrlsForTld(tld, opts); - // Some ccTLD registries publish RDAP only at the registry TLD (e.g., br), - // while the public suffix can be multi-label (e.g., com.br). Fallback to last label. - if (bases.length === 0 && tld.includes(".")) { - const registryTld = tld.split(".").pop() ?? tld; - bases = await getRdapBaseUrlsForTld(registryTld, opts); - } - const tried: string[] = []; - for (const base of bases) { - tried.push(base); - try { - const { json, notFound } = await fetchRdapDomain(domain, base, opts); + const { publicSuffix: tld } = getDomainParts(domain); + if (!tld) { + return failure(ctx, "invalid_tld", "Invalid TLD"); + } - // HTTP 404 = domain not registered - if (notFound) { - const record: DomainRecord = { - domain, - tld, - isRegistered: false, - rdapServers: tried, - source: "rdap", - }; - return { ok: true, record }; - } + // If WHOIS-only, skip RDAP path + if (!opts?.whoisOnly) { + // Some ccTLD registries publish RDAP only at the registry TLD (e.g., br) while the public + // suffix can be multi-label (e.g., com.br); this falls back to the last label. + const bases = await getRdapBaseUrlsForPublicSuffix(tld, opts, ctx); + const tried: string[] = []; + for (const base of bases) { + throwIfAborted(opts?.signal); + tried.push(base); + try { + const { json, notFound } = await fetchRdapDomain(domain, base, opts, ctx); - const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts); - const record: DomainRecord = normalizeRdap( + // HTTP 404 = domain not registered + if (notFound) { + const record: DomainRecord = { domain, tld, - rdapEnriched.merged, - [...tried, ...rdapEnriched.serversTried], - !!opts?.includeRaw, - ); - return { ok: true, record }; - } catch { - // try next base + isRegistered: false, + rdapServers: tried, + source: "rdap", + }; + return { ok: true, record, attempts: ctx.attempts }; } - } - // Some TLDs are not in bootstrap yet; continue to WHOIS fallback unless rdapOnly - if (opts?.rdapOnly) { - return { - ok: false, - error: `RDAP not available or failed for TLD '${tld}'. Many TLDs do not publish RDAP; try WHOIS fallback (omit rdapOnly).`, - }; + + const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts, ctx); + const record: DomainRecord = normalizeRdap( + domain, + tld, + rdapEnriched.merged, + [...tried, ...rdapEnriched.serversTried], + !!opts?.includeRaw, + ); + return { ok: true, record, attempts: ctx.attempts }; + } catch { + // Caller abort / deadline must stop the lookup, not fall through to the next phase + throwIfAborted(opts?.signal); + // otherwise try next base (the failure is recorded in ctx.attempts) } } - - // WHOIS fallback path - const whoisServer = await ianaWhoisServerForTld(tld, opts); - if (!whoisServer) { - // Provide a clearer, actionable message - const ianaText = await getIanaWhoisTextForTld(tld, opts); - const regUrl = ianaText ? parseIanaRegistrationInfoUrl(ianaText) : undefined; - const hint = regUrl ? ` See registration info at ${regUrl}.` : ""; - return { - ok: false, - error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`, - }; - } - - // Query the TLD server first; optionally follow registrar referrals (multi-hop) - // Collect the chain and coalesce so we don't lose details when a registrar returns minimal/empty data. - const chain = await collectWhoisReferralChain(whoisServer, domain, opts); - if (chain.length === 0) { - // Fallback to previous behavior as a safety net - const res = await followWhoisReferrals(whoisServer, domain, opts); - const record: DomainRecord = normalizeWhois( - domain, - tld, - res.text, - res.serverQueried, - !!opts?.includeRaw, + // Some TLDs are not in bootstrap yet; continue to WHOIS fallback unless rdapOnly + if (opts?.rdapOnly) { + const last = lastFailedAttempt(ctx, ["rdap", "rdap_bootstrap"]); + const detail = last + ? ` (${last.phase} ${last.server}: ${last.error})` + : " (no RDAP server listed in the IANA bootstrap)"; + return failure( + ctx, + "rdap_unavailable", + `RDAP not available or failed for TLD '${tld}'${detail}. Many TLDs do not publish RDAP; try WHOIS fallback (omit rdapOnly).`, + { phase: last?.phase, server: last?.server }, ); - return { ok: true, record }; } - - // Normalize all WHOIS texts in the chain and merge conservatively - const normalizedRecords = chain.map((r) => - 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) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: message }; } + + // WHOIS fallback path + const discovery = await discoverWhoisServer(tld, opts, ctx); + const whoisServer = discovery.server; + if (!whoisServer) { + if (discovery.ianaFailure) { + // IANA never answered (timeout, connection error): don't claim the registry has no WHOIS + const { code, error } = discovery.ianaFailure; + return failure(ctx, code, `WHOIS server discovery via IANA failed for '.${tld}' (${error})`, { + phase: "iana", + server: "whois.iana.org", + }); + } + // Provide a clearer, actionable message + const regUrl = discovery.ianaText + ? parseIanaRegistrationInfoUrl(discovery.ianaText) + : undefined; + const hint = regUrl ? ` See registration info at ${regUrl}.` : ""; + return failure( + ctx, + "no_server", + `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`, + ); + } + + // Query the TLD server first; optionally follow registrar referrals (multi-hop) + // Collect the chain and coalesce so we don't lose details when a registrar returns minimal/empty data. + const chain = await collectWhoisReferralChain(whoisServer, domain, opts, ctx); + if (chain.length === 0) { + // Fallback to previous behavior as a safety net + const res = await followWhoisReferrals(whoisServer, domain, opts, ctx); + const record: DomainRecord = normalizeWhois( + domain, + tld, + res.text, + res.serverQueried, + !!opts?.includeRaw, + ); + return { ok: true, record, attempts: ctx.attempts }; + } + + // Normalize all WHOIS texts in the chain and merge conservatively + const normalizedRecords = chain.map((r) => + normalizeWhois(domain, tld, r.text, r.serverQueried, !!opts?.includeRaw), + ); + const [first, ...rest] = normalizedRecords; + if (!first) { + return failure(ctx, "no_data", "No WHOIS data retrieved"); + } + const mergedRecord = rest.length ? mergeWhoisRecords(first, rest) : first; + return { ok: true, record: mergedRecord, attempts: ctx.attempts }; } /** diff --git a/src/lib/async.test.ts b/src/lib/async.test.ts new file mode 100644 index 0000000..f7c0379 --- /dev/null +++ b/src/lib/async.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { linkSignals, resolveTimeoutMs, withTimeout } from "./async"; +import { RdapperError } from "./errors"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("resolveTimeoutMs", () => { + it("defaults, passes valid values through, and disables the rest", () => { + expect(resolveTimeoutMs()).toBe(10_000); + expect(resolveTimeoutMs({ timeoutMs: 500 })).toBe(500); + for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(resolveTimeoutMs({ timeoutMs: bad })).toBeUndefined(); + } + }); +}); + +describe("linkSignals", () => { + it("aborts with the source's reason and detaches on dispose", () => { + const a = new AbortController(); + const b = new AbortController(); + const link = linkSignals(a.signal, b.signal); + const reason = new Error("why"); + b.abort(reason); + expect(link.signal.aborted).toBe(true); + expect(link.signal.reason).toBe(reason); + }); + + it("is aborted immediately when an input already is", () => { + const a = new AbortController(); + a.abort(); + expect(linkSignals(a.signal).signal.aborted).toBe(true); + }); +}); + +describe("withTimeout", () => { + it("times out with a timeout error, aborting the request even if it ignores the signal", async () => { + vi.useFakeTimers(); + let seen: AbortSignal | undefined; + const p = withTimeout(100, "RDAP lookup timeout", undefined, (signal) => { + seen = signal; + return new Promise(() => {}); + }); + const assertion = expect(p).rejects.toMatchObject({ + code: "timeout", + message: "RDAP lookup timeout", + }); + await vi.advanceTimersByTimeAsync(100); + await assertion; + expect(seen?.aborted).toBe(true); + }); + + it("covers the whole of fn (e.g. a stalled body), not just the first await", async () => { + vi.useFakeTimers(); + const p = withTimeout(100, "slow body", undefined, async () => { + await Promise.resolve(); // "headers" arrive + await new Promise(() => {}); // body never does + }); + const assertion = expect(p).rejects.toMatchObject({ code: "timeout" }); + await vi.advanceTimersByTimeAsync(100); + await assertion; + }); + + it("propagates a caller abort as aborted", async () => { + const ctrl = new AbortController(); + const p = withTimeout(undefined, "x", ctrl.signal, () => new Promise(() => {})); + ctrl.abort(); + await expect(p).rejects.toMatchObject({ code: "aborted", name: "AbortError" }); + }); + + it("propagates an internal RdapperError reason (deadline) unchanged", async () => { + const ctrl = new AbortController(); + const p = withTimeout(undefined, "x", ctrl.signal, () => new Promise(() => {})); + ctrl.abort(new RdapperError("timeout", "Lookup deadline exceeded (5ms)")); + await expect(p).rejects.toMatchObject({ code: "timeout", message: /deadline/ }); + }); + + it("resolves normally and clears its timer", async () => { + vi.useFakeTimers(); + await expect(withTimeout(100, "x", undefined, async () => 42)).resolves.toBe(42); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/lib/async.ts b/src/lib/async.ts index 265b49f..50ac3f7 100644 --- a/src/lib/async.ts +++ b/src/lib/async.ts @@ -1,19 +1,85 @@ -export function withTimeout( - promise: Promise, - timeoutMs: number, - reason = "Timeout", +import { DEFAULT_TIMEOUT_MS } from "./constants"; +import { abortError, RdapperError } from "./errors"; + +/** + * Resolve the per-operation timeout. A finite value > 0 is used as-is; + * anything else (0, negative, NaN, Infinity) means "no timeout". + */ +export function resolveTimeoutMs(opts?: { timeoutMs?: number }): number | undefined { + const ms = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return Number.isFinite(ms) && ms > 0 ? ms : undefined; +} + +/** Throw the appropriate abort/deadline error if `signal` has fired. */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw abortError(signal); +} + +/** + * Manual equivalent of `AbortSignal.any`: aborts (with the same reason) when any input aborts. + * Hand-rolled so it works under fake timers and on runtimes lacking the static helpers. + * Call `dispose` when finished to detach listeners from long-lived signals. + */ +export function linkSignals(...signals: (AbortSignal | undefined)[]): { + signal: AbortSignal; + dispose: () => void; +} { + const ctrl = new AbortController(); + const cleanups: (() => void)[] = []; + const dispose = () => { + for (const c of cleanups) c(); + cleanups.length = 0; + }; + for (const s of signals) { + if (!s) continue; + if (s.aborted) { + ctrl.abort(s.reason); + dispose(); + break; + } + const onAbort = () => { + ctrl.abort(s.reason); + dispose(); + }; + s.addEventListener("abort", onAbort, { once: true }); + cleanups.push(() => s.removeEventListener("abort", onAbort)); + } + return { signal: ctrl.signal, dispose }; +} + +/** + * Run `fn` with a signal that aborts on timeout or when `parentSignal` aborts. + * The whole of `fn` (including reading a response body) is covered by the timer, and the + * underlying request is cancelled on timeout. `Promise.race` remains as a backstop for + * custom fetch implementations that ignore `signal`. + */ +export async function withTimeout( + timeoutMs: number | undefined, + reason: string, + parentSignal: AbortSignal | undefined, + fn: (signal: AbortSignal) => Promise, ): Promise { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise; + throwIfAborted(parentSignal); + const timeoutCtrl = new AbortController(); + const link = linkSignals(parentSignal, timeoutCtrl.signal); + const signal = link.signal; let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(reason)), timeoutMs); + if (timeoutMs !== undefined) { + timer = setTimeout(() => timeoutCtrl.abort(new RdapperError("timeout", reason)), timeoutMs); + } + const aborted = new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(abortError(signal)), { once: true }); }); - return Promise.race([ - promise.finally(() => { - if (timer !== undefined) clearTimeout(timer); - }), - timeout, - ]); + try { + return await Promise.race([fn(signal), aborted]); + } catch (err) { + // Whatever the fetch rejected with, an abort/timeout we triggered is the real cause + if (signal.aborted) throw abortError(signal); + throw err; + } finally { + if (timer !== undefined) clearTimeout(timer); + link.dispose(); + } } export function sleep(ms: number): Promise { diff --git a/src/lib/dates.test.ts b/src/lib/dates.test.ts index 2b56b04..c1cad44 100644 --- a/src/lib/dates.test.ts +++ b/src/lib/dates.test.ts @@ -1,5 +1,3 @@ -/** biome-ignore-all lint/style/noNonNullAssertion: this is fine for tests */ - import { expect, test } from "vitest"; import { toISO } from "./dates"; diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts new file mode 100644 index 0000000..cc13124 --- /dev/null +++ b/src/lib/errors.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { classifyError, RdapperError } from "./errors"; + +function errno(code: string, message = code): Error { + return Object.assign(new Error(message), { code }); +} + +describe("classifyError", () => { + it.each(["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EHOSTUNREACH"])( + "maps %s to connect_failed", + (code) => { + expect(classifyError(errno(code)).code).toBe("connect_failed"); + }, + ); + + it("maps ETIMEDOUT to timeout", () => { + expect(classifyError(errno("ETIMEDOUT")).code).toBe("timeout"); + }); + + it("maps AbortError to aborted", () => { + const err = new DOMException("This operation was aborted", "AbortError"); + expect(classifyError(err).code).toBe("aborted"); + }); + + it("uses the code carried by an RdapperError", () => { + const err = new RdapperError("http_error", "RDAP 500: boom"); + expect(classifyError(err)).toEqual({ code: "http_error", error: "RDAP 500: boom" }); + }); + + it("reads errno codes from an undici-style cause and surfaces them", () => { + const err = new TypeError("fetch failed", { cause: errno("ECONNRESET") }); + expect(classifyError(err)).toEqual({ + code: "connect_failed", + error: "fetch failed (ECONNRESET)", + }); + }); + + it("describes an empty-message AggregateError from its per-address errors", () => { + const err = new AggregateError([errno("ECONNREFUSED", "connect ECONNREFUSED 1.2.3.4:43")], ""); + expect(classifyError(err)).toEqual({ + code: "connect_failed", + error: "connect ECONNREFUSED 1.2.3.4:43", + }); + expect(classifyError(new AggregateError([], "")).error).toBe("AggregateError"); + }); + + it("falls back to unknown for anything else", () => { + expect(classifyError(new Error("weird"))).toEqual({ code: "unknown", error: "weird" }); + expect(classifyError("just a string")).toEqual({ code: "unknown", error: "just a string" }); + }); + + it("names aborted RdapperErrors AbortError for backwards compatibility", () => { + expect(new RdapperError("aborted", "x").name).toBe("AbortError"); + }); +}); diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..070dc91 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,94 @@ +import type { LookupAttempt, LookupErrorCode } from "../types"; + +/** + * Error thrown by rdapper internals with a machine-readable code. + * Aborts are named "AbortError" so callers that sniff `err.name` keep working. + */ +export class RdapperError extends Error { + readonly code: LookupErrorCode; + readonly phase?: LookupAttempt["phase"]; + readonly server?: string; + readonly stage?: "connect" | "read"; + + constructor( + code: LookupErrorCode, + message: string, + extra?: { + phase?: LookupAttempt["phase"]; + server?: string; + stage?: "connect" | "read"; + cause?: unknown; + }, + ) { + super(message, extra?.cause !== undefined ? { cause: extra.cause } : undefined); + this.name = code === "aborted" ? "AbortError" : "RdapperError"; + this.code = code; + this.phase = extra?.phase; + this.server = extra?.server; + this.stage = extra?.stage; + } +} + +/** + * The error to surface for an aborted signal. A deadline (or any other internal abort) + * carries its own RdapperError as the reason; anything else is a caller abort. + */ +export function abortError(signal: AbortSignal): RdapperError { + const reason: unknown = signal.reason; + if (reason instanceof RdapperError) return reason; + return new RdapperError("aborted", "Lookup aborted", { cause: reason }); +} + +const CONNECT_ERRNOS = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ENOTFOUND", + "EHOSTUNREACH", + "ENETUNREACH", + "ENETDOWN", + "EAI_AGAIN", + "EPIPE", + "EPERM", + "EACCES", +]); + +function errnoOf(err: unknown): string | undefined { + if (typeof err !== "object" || err === null) return undefined; + const code = (err as { code?: unknown }).code; + if (typeof code === "string") return code; + // Dual-stack connects fail with an AggregateError whose per-address errors carry the code + const inner = (err as { errors?: unknown }).errors; + return Array.isArray(inner) ? inner.map(errnoOf).find(Boolean) : undefined; +} + +/** A non-empty description of `err`; Node's AggregateErrors have an empty `message`. */ +function describeError(err: unknown, errno: string | undefined): string { + const message = err instanceof Error ? err.message : String(err); + if (message) return message; + const inner = (err as { errors?: unknown } | null)?.errors; + if (Array.isArray(inner)) { + const parts = inner.map((e) => (e instanceof Error ? e.message : String(e))).filter(Boolean); + if (parts.length) return parts.join("; "); + } + return errno ?? (err instanceof Error ? err.name : "Unknown error"); +} + +/** Map any thrown value to a stable error code plus a human-readable message. */ +export function classifyError(err: unknown): { code: LookupErrorCode; error: string } { + if (err instanceof RdapperError) return { code: err.code, error: err.message }; + + const name = err instanceof Error ? err.name : ""; + const cause = err instanceof Error ? (err as { cause?: unknown }).cause : undefined; + const errno = errnoOf(err) ?? errnoOf(cause); + const message = describeError(err, errno); + if (name === "AbortError") return { code: "aborted", error: message }; + if (name === "TimeoutError") return { code: "timeout", error: message }; + + // undici reports network failures as an opaque "fetch failed" with the details in `cause` + const error = message === "fetch failed" && errno ? `${message} (${errno})` : message; + if (errno === "ETIMEDOUT" || errno === "UND_ERR_CONNECT_TIMEOUT") { + return { code: "timeout", error }; + } + if (errno && CONNECT_ERRNOS.has(errno)) return { code: "connect_failed", error }; + return { code: "unknown", error }; +} diff --git a/src/lib/trace.ts b/src/lib/trace.ts new file mode 100644 index 0000000..0761c7a --- /dev/null +++ b/src/lib/trace.ts @@ -0,0 +1,52 @@ +import type { LookupAttempt } from "../types"; +import { classifyError, RdapperError } from "./errors"; + +const attemptByError = new WeakMap(); + +/** The failed attempt (if any) that produced this thrown error. */ +export function attemptForError(err: unknown): LookupAttempt | undefined { + return typeof err === "object" && err !== null ? attemptByError.get(err) : undefined; +} + +/** Mutable per-lookup state shared by every phase. */ +export interface LookupContext { + attempts: LookupAttempt[]; +} + +/** Extra fields a traced operation may attach to its attempt record. */ +export type AttemptNotes = Partial>; + +/** + * Time `fn` and record the outcome in `ctx.attempts`. Rethrows on failure. + * A no-op when `ctx` is undefined so internals can still be called directly. + */ +export async function traced( + ctx: LookupContext | undefined, + meta: { phase: LookupAttempt["phase"]; server: string }, + fn: (notes: AttemptNotes) => Promise, +): Promise { + if (!ctx) return fn({}); + const notes: AttemptNotes = {}; + const start = Date.now(); + try { + const result = await fn(notes); + ctx.attempts.push({ ...meta, ok: true, durationMs: Date.now() - start, ...notes }); + return result; + } catch (err) { + const { code, error } = classifyError(err); + const attempt: LookupAttempt = { + ...meta, + ok: false, + durationMs: Date.now() - start, + errorCode: code, + error, + ...(err instanceof RdapperError && err.stage ? { stage: err.stage } : {}), + ...notes, + }; + ctx.attempts.push(attempt); + if (typeof err === "object" && err !== null && !attemptByError.has(err)) { + attemptByError.set(err, attempt); + } + throw err; + } +} diff --git a/src/rdap/bootstrap.test.ts b/src/rdap/bootstrap.test.ts index 4c76512..2a96395 100644 --- a/src/rdap/bootstrap.test.ts +++ b/src/rdap/bootstrap.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { BootstrapData } from "../types"; -import { getRdapBaseUrlsForTld } from "./bootstrap"; +import { getRdapBaseUrlsForPublicSuffix, getRdapBaseUrlsForTld } from "./bootstrap"; // Mock the global fetch function beforeAll(() => { @@ -310,7 +310,7 @@ describe("getRdapBaseUrlsForTld with customBootstrapData", () => { expect(fetch).toHaveBeenCalledWith( "https://data.iana.org/rdap/dns.json", expect.objectContaining({ - signal, + signal: expect.any(AbortSignal), }), ); }); @@ -435,9 +435,49 @@ describe("getRdapBaseUrlsForTld with customBootstrapData", () => { expect(customFetch).toHaveBeenCalledWith( "https://data.iana.org/rdap/dns.json", expect.objectContaining({ - signal, + signal: expect.any(AbortSignal), }), ); }); }); }); + +describe("getRdapBaseUrlsForPublicSuffix", () => { + const data: BootstrapData = { + version: "1.0", + publication: "2025-01-15T12:00:00Z", + services: [ + [["uk"], ["https://rdap.nominet.uk/uk/"]], + [["com"], ["https://rdap.verisign.com/com/v1/"]], + [["org.uk"], ["https://rdap.example-org-uk/"]], + ], + }; + const okFetch = () => vi.fn().mockResolvedValue({ ok: true, json: async () => data } as Response); + + it("falls back to the registry TLD with a single bootstrap fetch", async () => { + const customFetch = okFetch(); + const urls = await getRdapBaseUrlsForPublicSuffix("co.uk", { customFetch }); + expect(urls).toEqual(["https://rdap.nominet.uk/uk/"]); + expect(customFetch).toHaveBeenCalledTimes(1); + }); + + it("prefers an exact multi-label match over the registry TLD", async () => { + const urls = await getRdapBaseUrlsForPublicSuffix("org.uk", { customBootstrapData: data }); + expect(urls).toEqual(["https://rdap.example-org-uk/"]); + }); + + it("returns single-label results unchanged and empty when nothing matches", async () => { + expect(await getRdapBaseUrlsForPublicSuffix("com", { customBootstrapData: data })).toEqual([ + "https://rdap.verisign.com/com/v1/", + ]); + expect(await getRdapBaseUrlsForPublicSuffix("co.zz", { customBootstrapData: data })).toEqual( + [], + ); + }); + + it("returns [] (no retry) when the bootstrap fetch fails", async () => { + const customFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 } as Response); + expect(await getRdapBaseUrlsForPublicSuffix("co.uk", { customFetch })).toEqual([]); + expect(customFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/rdap/bootstrap.ts b/src/rdap/bootstrap.ts index 1e232ba..e68a0ca 100644 --- a/src/rdap/bootstrap.ts +++ b/src/rdap/bootstrap.ts @@ -1,25 +1,23 @@ -import { withTimeout } from "../lib/async"; -import { DEFAULT_BOOTSTRAP_URL, DEFAULT_TIMEOUT_MS } from "../lib/constants"; +import { resolveTimeoutMs, throwIfAborted, withTimeout } from "../lib/async"; +import { DEFAULT_BOOTSTRAP_URL } from "../lib/constants"; +import { RdapperError } from "../lib/errors"; import { resolveFetch } from "../lib/fetch"; +import { type LookupContext, traced } from "../lib/trace"; 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). + * Load RDAP bootstrap data, or `undefined` when it could not be fetched (the failure is + * recorded in `ctx.attempts` and the caller falls back to WHOIS). * * 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, +async function loadBootstrapData( options?: LookupOptions, -): Promise { + ctx?: LookupContext, +): Promise { let data: BootstrapData; // Priority 1: Use pre-loaded bootstrap data if provided (no fetch) @@ -55,28 +53,42 @@ export async function getRdapBaseUrlsForTld( 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", + data = await traced(ctx, { phase: "rdap_bootstrap", server: bootstrapUrl }, () => + withTimeout( + resolveTimeoutMs(options), + "RDAP bootstrap timeout", + options?.signal, + async (signal) => { + const res = await fetchFn(bootstrapUrl, { + method: "GET", + headers: { accept: "application/json" }, + signal, + }); + if (!res.ok) { + throw new RdapperError("http_error", `RDAP bootstrap ${res.status}`); + } + const json = (await res.json()) as BootstrapData; + if (!json || !Array.isArray(json.services)) { + throw new RdapperError("no_data", "RDAP bootstrap has no services array"); + } + return json; + }, + ), ); - 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; - } + // Preserve caller cancellation behavior - rethrow if explicitly aborted (or deadline hit) + if (options?.signal?.aborted) throwIfAborted(options.signal); + if (err instanceof Error && err.name === "AbortError") throw err; // Network, timeout, or JSON parse errors - return empty array to fall back to WHOIS - return []; + // (the failure is recorded in ctx.attempts) + return undefined; } } + return data; +} - // Parse the bootstrap data to find matching base URLs for the TLD +/** Find the RDAP base URLs listed for `tld` (always suffixed with a trailing slash). */ +function matchBases(data: BootstrapData, tld: string): string[] { const target = tld.toLowerCase(); const bases: string[] = []; for (const svc of data.services) { @@ -93,3 +105,41 @@ export async function getRdapBaseUrlsForTld( } return Array.from(new Set(bases)); } + +/** + * 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). + * See {@link loadBootstrapData} for how the bootstrap data is sourced. + * + * @param tld - The top-level domain to look up (e.g., "com", "co.uk") + * @param options - Optional lookup options including custom bootstrap data/URL + * @param ctx - Optional context that records the bootstrap fetch as an attempt + * @returns Array of RDAP base URLs for the TLD, or empty array if none found + */ +export async function getRdapBaseUrlsForTld( + tld: string, + options?: LookupOptions, + ctx?: LookupContext, +): Promise { + const data = await loadBootstrapData(options, ctx); + return data ? matchBases(data, tld) : []; +} + +/** + * Like {@link getRdapBaseUrlsForTld}, for a public suffix that may be multi-label. + * + * IANA lists registry TLDs (`uk`, `br`), not public suffixes (`co.uk`, `com.br`), so the + * suffix usually misses and the last label is tried next. The bootstrap data is loaded + * once and reused for both lookups. + */ +export async function getRdapBaseUrlsForPublicSuffix( + publicSuffix: string, + options?: LookupOptions, + ctx?: LookupContext, +): Promise { + const data = await loadBootstrapData(options, ctx); + if (!data) return []; + const bases = matchBases(data, publicSuffix); + if (bases.length > 0 || !publicSuffix.includes(".")) return bases; + return matchBases(data, publicSuffix.split(".").pop() ?? publicSuffix); +} diff --git a/src/rdap/client.ts b/src/rdap/client.ts index 50062bf..408d5ee 100644 --- a/src/rdap/client.ts +++ b/src/rdap/client.ts @@ -1,6 +1,7 @@ -import { withTimeout } from "../lib/async"; -import { DEFAULT_TIMEOUT_MS } from "../lib/constants"; +import { resolveTimeoutMs, withTimeout } from "../lib/async"; +import { RdapperError } from "../lib/errors"; import { resolveFetch } from "../lib/fetch"; +import { type LookupContext, traced } from "../lib/trace"; import type { LookupOptions } from "../types"; /** @@ -23,27 +24,33 @@ export async function fetchRdapDomain( domain: string, baseUrl: string, options?: LookupOptions, + ctx?: LookupContext, ): Promise { const url = new URL(`domain/${encodeURIComponent(domain)}`, baseUrl).toString(); const fetchFn = resolveFetch(options); - const res = await withTimeout( - fetchFn(url, { - method: "GET", - headers: { accept: "application/rdap+json, application/json" }, - signal: options?.signal, - }), - options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, - "RDAP lookup timeout", + return traced(ctx, { phase: "rdap", server: baseUrl }, () => + withTimeout( + resolveTimeoutMs(options), + "RDAP lookup timeout", + options?.signal, + async (signal) => { + const res = await fetchFn(url, { + method: "GET", + headers: { accept: "application/rdap+json, application/json" }, + signal, + }); + // HTTP 404 = domain not found (not registered) + // Per RFC 9083, RDAP servers return 404 for objects that don't exist + if (res.status === 404) { + return { url, json: null, notFound: true }; + } + if (!res.ok) { + const bodyText = await res.text().catch(() => ""); + throw new RdapperError("http_error", `RDAP ${res.status}: ${bodyText.slice(0, 500)}`); + } + const json = await res.json(); + return { url, json }; + }, + ), ); - // HTTP 404 = domain not found (not registered) - // Per RFC 9083, RDAP servers return 404 for objects that don't exist - if (res.status === 404) { - return { url, json: null, notFound: true }; - } - if (!res.ok) { - const bodyText = await res.text(); - throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`); - } - const json = await res.json(); - return { url, json }; } diff --git a/src/rdap/merge.ts b/src/rdap/merge.ts index 99f2b91..5942bda 100644 --- a/src/rdap/merge.ts +++ b/src/rdap/merge.ts @@ -1,6 +1,7 @@ -import { withTimeout } from "../lib/async"; -import { DEFAULT_TIMEOUT_MS } from "../lib/constants"; +import { resolveTimeoutMs, throwIfAborted, withTimeout } from "../lib/async"; +import { RdapperError } from "../lib/errors"; import { resolveFetch } from "../lib/fetch"; +import { type LookupContext, traced } from "../lib/trace"; import type { LookupOptions } from "../types"; import { extractRdapRelatedLinks } from "./links"; @@ -59,6 +60,7 @@ export async function fetchAndMergeRdapRelated( domain: string, baseDoc: unknown, opts?: LookupOptions, + ctx?: LookupContext, ): Promise<{ merged: unknown; serversTried: string[] }> { const tried: string[] = []; if (opts?.rdapFollowLinks === false) return { merged: baseDoc, serversTried: tried }; @@ -71,6 +73,7 @@ export async function fetchAndMergeRdapRelated( // BFS: collect links from the latest merged doc only to keep it simple and bounded while (hops < maxHops) { + throwIfAborted(opts?.signal); const links = extractRdapRelatedLinks(current, { rdapLinkRels: opts?.rdapLinkRels, }); @@ -78,9 +81,10 @@ export async function fetchAndMergeRdapRelated( if (nextBatch.length === 0) break; const fetchedDocs: unknown[] = []; for (const url of nextBatch) { + throwIfAborted(opts?.signal); visited.add(url); try { - const { json } = await fetchRdapUrl(url, opts); + const { json } = await fetchRdapUrl(url, opts, ctx); tried.push(url); // only accept docs that appear related to the same domain when possible // if ldhName/unicodeName present, they should match the queried domain (case-insensitive) @@ -90,7 +94,8 @@ export async function fetchAndMergeRdapRelated( if (uni && !sameDomain(uni, domain)) continue; fetchedDocs.push(json); } catch { - // ignore failures and continue + // caller abort / deadline stops the lookup; other failures are recorded in attempts + throwIfAborted(opts?.signal); } } if (fetchedDocs.length === 0) break; @@ -103,24 +108,30 @@ export async function fetchAndMergeRdapRelated( async function fetchRdapUrl( url: string, options?: LookupOptions, + ctx?: LookupContext, ): Promise<{ url: string; json: unknown }> { const fetchFn = resolveFetch(options); - const res = await withTimeout( - fetchFn(url, { - method: "GET", - headers: { accept: "application/rdap+json, application/json" }, - signal: options?.signal, - }), - options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, - "RDAP link fetch timeout", + return traced(ctx, { phase: "rdap_link", server: url }, () => + withTimeout( + resolveTimeoutMs(options), + "RDAP link fetch timeout", + options?.signal, + async (signal) => { + const res = await fetchFn(url, { + method: "GET", + headers: { accept: "application/rdap+json, application/json" }, + signal, + }); + if (!res.ok) { + const bodyText = await res.text().catch(() => ""); + throw new RdapperError("http_error", `RDAP ${res.status}: ${bodyText.slice(0, 500)}`); + } + const json = await res.json(); + // Optionally parse Link header for future iterations; the main loop inspects body.links + return { url, json }; + }, + ), ); - if (!res.ok) { - const bodyText = await res.text(); - throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`); - } - const json = await res.json(); - // Optionally parse Link header for future iterations; the main loop inspects body.links - return { url, json }; } function toArray(val: unknown): T[] { diff --git a/src/types.ts b/src/types.ts index 18e87b3..c14e8a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -237,8 +237,17 @@ export interface BootstrapData { * ``` */ export interface LookupOptions { - /** Total timeout budget */ + /** + * Timeout for each individual network operation (default 10000). A lookup makes several + * sequential operations, so its worst-case duration is a multiple of this; use `deadlineMs` + * to bound the whole lookup. A value that is not a finite number > 0 disables the timeout. + */ timeoutMs?: number; + /** + * Overall deadline for the entire lookup in milliseconds (default: none). When it elapses, + * in-flight requests and WHOIS sockets are cancelled and the result has `errorCode: "timeout"`. + */ + deadlineMs?: number; /** Don't fall back to WHOIS */ rdapOnly?: boolean; /** Don't attempt RDAP */ @@ -297,7 +306,7 @@ export interface LookupOptions { * - RDAP domain lookup requests * - RDAP related/entity link requests * - * If not provided, the global `fetch` function is used (Node.js 18+ or browser). + * If not provided, the global `fetch` function is used (Node.js 20+ or browser). * * @example * ```ts @@ -364,12 +373,59 @@ export interface LookupResult { record?: DomainRecord; /** Error message describing why the lookup failed, present when ok is false */ error?: string; + /** Machine-readable failure reason, present when ok is false */ + errorCode?: LookupErrorCode; + /** Phase in which the failure occurred, when known */ + errorPhase?: LookupAttempt["phase"]; + /** Server (RDAP URL or WHOIS host) involved in the failure, when known */ + errorServer?: string; + /** Every network attempt made during the lookup, in order (successes and failures) */ + attempts: LookupAttempt[]; +} + +/** + * Machine-readable reason a lookup (or one attempt within it) failed. + * + * - `timeout`: any timeout, including the overall `deadlineMs` + * - `aborted`: the caller's `AbortSignal` fired + * - `connect_failed`: network-level failure (ECONNREFUSED, ECONNRESET, ENOTFOUND, ...) + * - `http_error`: RDAP responded with a non-2xx status other than 404 + * - `rdap_unavailable`: `rdapOnly` was set and no RDAP server worked + * - `no_server`: IANA answered but no WHOIS server exists for the TLD + * - `unsupported_runtime`: WHOIS needs `node:net`, which this runtime lacks + */ +export type LookupErrorCode = + | "invalid_input" + | "invalid_tld" + | "timeout" + | "aborted" + | "connect_failed" + | "http_error" + | "rdap_unavailable" + | "no_server" + | "no_data" + | "unsupported_runtime" + | "unknown"; + +/** One network operation performed during a lookup. */ +export interface LookupAttempt { + phase: "rdap_bootstrap" | "rdap" | "rdap_link" | "iana" | "whois"; + /** RDAP base/link URL or WHOIS host */ + server: string; + ok: boolean; + durationMs: number; + errorCode?: LookupErrorCode; + error?: string; + /** WHOIS timeouts only: whether the socket never connected or connected but sent nothing */ + stage?: "connect" | "read"; + /** WHOIS only: the read timed out after some data arrived, so the text is partial */ + partial?: boolean; } /** * 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. + * of the global `fetch` function available in Node.js 20+ and browsers. */ export type FetchLike = (input: string | URL, init?: RequestInit) => Promise; diff --git a/src/whois/client.timeout.test.ts b/src/whois/client.timeout.test.ts new file mode 100644 index 0000000..76cc174 --- /dev/null +++ b/src/whois/client.timeout.test.ts @@ -0,0 +1,124 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +class FakeSocket extends EventEmitter { + written: string[] = []; + destroyed = false; + write(data: string) { + this.written.push(data); + } + destroy() { + this.destroyed = true; + } +} + +let socket: FakeSocket; + +vi.mock("node:net", () => ({ + createConnection: () => socket, +})); + +import { whoisQuery } from "./client"; + +beforeEach(() => { + vi.useFakeTimers(); + socket = new FakeSocket(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("whoisQuery timeouts", () => { + it("rejects with a connect-stage timeout when the socket never connects", async () => { + const p = whoisQuery("whois.example", "example.test", { timeoutMs: 1500 }); + const assertion = expect(p).rejects.toMatchObject({ + code: "timeout", + stage: "connect", + message: "WHOIS connect timeout (whois.example)", + }); + await vi.advanceTimersByTimeAsync(1500); + await assertion; + expect(socket.destroyed).toBe(true); + }); + + it("rejects with a read-stage timeout when connected but silent", async () => { + const p = whoisQuery("whois.example", "example.test", { timeoutMs: 1500 }); + const assertion = expect(p).rejects.toMatchObject({ code: "timeout", stage: "read" }); + await vi.advanceTimersByTimeAsync(0); + socket.emit("connect"); + expect(socket.written).toEqual(["example.test\r\n"]); + await vi.advanceTimersByTimeAsync(1500); + await assertion; + }); + + it("resolves with the partial text when a read times out after some data", async () => { + const p = whoisQuery("whois.example", "example.test", { timeoutMs: 1500 }); + await vi.advanceTimersByTimeAsync(0); + socket.emit("connect"); + socket.emit("data", Buffer.from("Domain Name: EXAMPLE.TEST\n")); + await vi.advanceTimersByTimeAsync(1500); + await expect(p).resolves.toEqual({ + serverQueried: "whois.example", + text: "Domain Name: EXAMPLE.TEST\n", + partial: true, + }); + expect(socket.destroyed).toBe(true); + }); + + it("does not corrupt multibyte characters split across chunks", async () => { + const p = whoisQuery("whois.example", "example.test"); + await vi.advanceTimersByTimeAsync(0); + socket.emit("connect"); + const bytes = Buffer.from("Registrant: Zoë"); + socket.emit("data", bytes.subarray(0, bytes.length - 1)); + socket.emit("data", bytes.subarray(bytes.length - 1)); + socket.emit("end"); + await expect(p).resolves.toMatchObject({ text: "Registrant: Zoë" }); + }); + + it.each([500, 1000])("honours a %ims timeout exactly (no -1000ms adjustment)", async (ms) => { + const p = whoisQuery("whois.example", "example.test", { timeoutMs: ms }); + const assertion = expect(p).rejects.toMatchObject({ stage: "connect" }); + await vi.advanceTimersByTimeAsync(ms - 1); + expect(socket.destroyed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await assertion; + }); + + it("does not throw for timeoutMs 0; the timeout is simply disabled", async () => { + const p = whoisQuery("whois.example", "example.test", { timeoutMs: 0 }); + await vi.advanceTimersByTimeAsync(60_000); + expect(socket.destroyed).toBe(false); + socket.emit("connect"); + socket.emit("data", Buffer.from("ok")); + socket.emit("end"); + await expect(p).resolves.toMatchObject({ text: "ok" }); + }); + + it("destroys the socket and rejects as aborted on signal abort", async () => { + const ctrl = new AbortController(); + const p = whoisQuery("whois.example", "example.test", { signal: ctrl.signal }); + await vi.advanceTimersByTimeAsync(0); + ctrl.abort(); + await expect(p).rejects.toMatchObject({ code: "aborted" }); + expect(socket.destroyed).toBe(true); + }); + + it("rejects immediately when the signal is already aborted", async () => { + const ctrl = new AbortController(); + ctrl.abort(); + await expect( + whoisQuery("whois.example", "example.test", { signal: ctrl.signal }), + ).rejects.toMatchObject({ code: "aborted" }); + }); + + it("keeps the data when a server resets the connection after replying", async () => { + const p = whoisQuery("whois.example", "example.test"); + await vi.advanceTimersByTimeAsync(0); + socket.emit("connect"); + socket.emit("data", Buffer.from("answer")); + socket.emit("error", Object.assign(new Error("reset"), { code: "ECONNRESET" })); + await expect(p).resolves.toMatchObject({ text: "answer", partial: true }); + }); +}); diff --git a/src/whois/client.ts b/src/whois/client.ts index e8d3826..23e8cf7 100644 --- a/src/whois/client.ts +++ b/src/whois/client.ts @@ -1,10 +1,12 @@ -import { withTimeout } from "../lib/async"; -import { DEFAULT_TIMEOUT_MS } from "../lib/constants"; +import { resolveTimeoutMs, throwIfAborted } from "../lib/async"; +import { abortError, RdapperError } from "../lib/errors"; import type { LookupOptions } from "../types"; export interface WhoisQueryResult { serverQueried: string; text: string; + /** True when the read timed out after some data had arrived, so `text` may be truncated */ + partial?: boolean; } /** @@ -27,7 +29,6 @@ export async function whoisQuery( query: string, options?: LookupOptions, ): Promise { - const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; const port = 43; const host = server.replace(/^whois:\/\//i, ""); @@ -35,21 +36,19 @@ export async function whoisQuery( const transformer = WHOIS_QUERY_TRANSFORMERS[host]; const transformedQuery = transformer ? transformer(query) : query; - const text = await withTimeout( - queryTcp(host, port, transformedQuery, options), - timeoutMs, - "WHOIS timeout", - ); - return { serverQueried: server, text }; + const { text, partial } = await queryTcp(host, port, transformedQuery, options); + return { serverQueried: server, text, ...(partial ? { partial } : {}) }; } // Low-level WHOIS TCP client. Some registries require CRLF after the domain query. +// The socket code owns the timeout so it can tell a connect timeout from a read timeout, +// and can hand back whatever text arrived before a read timeout. async function queryTcp( host: string, port: number, query: string, options?: LookupOptions, -): Promise { +): Promise<{ text: string; partial?: boolean }> { let net: typeof import("node:net") | null; try { net = await import("node:net"); @@ -58,36 +57,84 @@ async function queryTcp( } if (!net?.createConnection) { - throw new Error( + throw new RdapperError( + "unsupported_runtime", "WHOIS client is only available in Node.js runtimes; try setting `rdapOnly: true`.", ); } + const signal = options?.signal; + throwIfAborted(signal); + const timeoutMs = resolveTimeoutMs(options); + const createConnection = net.createConnection; + return new Promise((resolve, reject) => { - const socket = net.createConnection({ host, port }); - let data = ""; + const socket = createConnection({ host, port }); + const chunks: Buffer[] = []; + let received = 0; + let connected = false; let done = false; - const cleanup = () => { + let timer: ReturnType | undefined; + + const text = () => Buffer.concat(chunks).toString("utf8"); + const finish = (settle: () => void) => { if (done) return; done = true; + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); socket.destroy(); + settle(); }; - socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1000, () => { - cleanup(); - reject(new Error("WHOIS socket timeout")); + const onAbort = () => finish(() => reject(abortError(signal as AbortSignal))); + + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + if (!connected) { + finish(() => + reject( + new RdapperError("timeout", `WHOIS connect timeout (${host})`, { + stage: "connect", + }), + ), + ); + } else if (received > 0) { + finish(() => resolve({ text: text(), partial: true })); + } else { + finish(() => + reject(new RdapperError("timeout", `WHOIS read timeout (${host})`, { stage: "read" })), + ); + } + }, timeoutMs); + } + signal?.addEventListener("abort", onAbort, { once: true }); + + socket.on("error", (err: NodeJS.ErrnoException) => { + // Servers that reset the connection after replying still gave us an answer + if (err.code === "ECONNRESET" && received > 0) { + finish(() => resolve({ text: text(), partial: true })); + } else { + finish(() => reject(err)); + } }); - socket.on("error", (err) => { - cleanup(); - reject(err); - }); - socket.on("data", (chunk) => { - data += chunk.toString("utf8"); + socket.on("data", (chunk: Buffer | string) => { + const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + chunks.push(buf); + received += buf.length; }); socket.on("end", () => { - cleanup(); - resolve(data); + finish(() => resolve({ text: text() })); + }); + // A close without a preceding end/error (half-open teardown) would otherwise wait out the timer + socket.on("close", () => { + if (connected) finish(() => resolve({ text: text() })); + else { + finish(() => + reject(new RdapperError("connect_failed", `WHOIS connection closed (${host})`)), + ); + } }); socket.on("connect", () => { + connected = true; socket.write(`${query}\r\n`); }); }); diff --git a/src/whois/discovery.ts b/src/whois/discovery.ts index d0e7f6f..bf15141 100644 --- a/src/whois/discovery.ts +++ b/src/whois/discovery.ts @@ -1,4 +1,7 @@ -import type { LookupOptions } from "../types"; +import { throwIfAborted } from "../lib/async"; +import { classifyError } from "../lib/errors"; +import { type LookupContext, traced } from "../lib/trace"; +import type { LookupErrorCode, LookupOptions } from "../types"; import { whoisQuery } from "./client"; import { WHOIS_TLD_EXCEPTIONS } from "./servers"; @@ -43,14 +46,71 @@ export function parseIanaRegistrationInfoUrl(text: string): string | undefined { return undefined; } +const IANA_WHOIS_HOST = "whois.iana.org"; + +/** Result of {@link discoverWhoisServer}. */ +export interface WhoisDiscovery { + /** Authoritative WHOIS server for the TLD, if one was found */ + server?: string; + /** Raw IANA response, when IANA was queried successfully */ + ianaText?: string; + /** Why the IANA query failed, when it did (timeouts and connection errors are otherwise silent) */ + ianaFailure?: { code: LookupErrorCode; error: string }; +} + +/** Query IANA's WHOIS for a TLD, recording the attempt. Throws on failure. */ +async function queryIana( + tld: string, + options?: LookupOptions, + ctx?: LookupContext, +): Promise { + const res = await traced(ctx, { phase: "iana", server: IANA_WHOIS_HOST }, () => + whoisQuery(IANA_WHOIS_HOST, tld.toLowerCase(), options), + ); + return res.text; +} + +/** + * Discover the authoritative WHOIS server for a TLD in a single IANA round trip, keeping the + * IANA text (for registration-info hints) and any failure (so callers can report it accurately). + * Caller aborts and deadlines are rethrown rather than swallowed. + */ +export async function discoverWhoisServer( + tld: string, + options?: LookupOptions, + ctx?: LookupContext, +): Promise { + const key = tld.toLowerCase(); + // 1) Explicit hint override + const hint = options?.whoisHints?.[key]; + if (hint) return { server: normalizeServer(hint) }; + + // 2) IANA WHOIS authoritative discovery over TCP 43 + const out: WhoisDiscovery = {}; + try { + const text = await queryIana(key, options, ctx); + out.ianaText = text; + const server = parseIanaWhoisServer(text); + if (server) return { ...out, server: normalizeServer(server) }; + } catch (err) { + throwIfAborted(options?.signal); + out.ianaFailure = classifyError(err); + } + + // 3) Curated exceptions + const exception = WHOIS_TLD_EXCEPTIONS[key]; + if (exception) return { ...out, server: normalizeServer(exception) }; + + return out; +} + /** Fetch raw IANA WHOIS text for a TLD (best-effort). */ export async function getIanaWhoisTextForTld( tld: string, options?: LookupOptions, ): Promise { try { - const res = await whoisQuery("whois.iana.org", tld.toLowerCase(), options); - return res.text; + return await queryIana(tld, options); } catch { return undefined; } @@ -62,27 +122,9 @@ export async function getIanaWhoisTextForTld( export async function ianaWhoisServerForTld( tld: string, options?: LookupOptions, + ctx?: LookupContext, ): Promise { - const key = tld.toLowerCase(); - // 1) Explicit hint override - const hint = options?.whoisHints?.[key]; - if (hint) return normalizeServer(hint); - - // 2) IANA WHOIS authoritative discovery over TCP 43 - try { - const res = await whoisQuery("whois.iana.org", key, options); - const txt = res.text; - const server = parseIanaWhoisServer(txt); - if (server) return normalizeServer(server); - } catch { - // fallthrough to exceptions/guess - } - - // 3) Curated exceptions - const exception = WHOIS_TLD_EXCEPTIONS[key]; - if (exception) return normalizeServer(exception); - - return undefined; + return (await discoverWhoisServer(tld, options, ctx)).server; } /** diff --git a/src/whois/referral.ts b/src/whois/referral.ts index a8c736d..3e09191 100644 --- a/src/whois/referral.ts +++ b/src/whois/referral.ts @@ -1,3 +1,5 @@ +import { throwIfAborted } from "../lib/async"; +import { type LookupContext, traced } from "../lib/trace"; import type { LookupOptions } from "../types"; import type { WhoisQueryResult } from "./client"; import { whoisQuery } from "./client"; @@ -12,23 +14,25 @@ export async function followWhoisReferrals( initialServer: string, domain: string, opts?: LookupOptions, + ctx?: LookupContext, ): Promise { const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2); // First query against the provided server - let current = await whoisQuery(initialServer, domain, opts); + let current = await tracedWhoisQuery(initialServer, domain, opts, ctx); if (opts?.followWhoisReferral === false || maxHops === 0) return current; const visited = new Set([normalize(current.serverQueried)]); let hops = 0; // Iterate while we see a new referral and are under hop limit while (hops < maxHops) { + throwIfAborted(opts?.signal); const next = extractWhoisReferral(current.text); if (!next) break; const normalized = normalize(next); if (visited.has(normalized)) break; // cycle protection / same as current visited.add(normalized); try { - const res = await whoisQuery(next, domain, opts); + const res = await tracedWhoisQuery(next, domain, opts, ctx); // Prefer authoritative TLD response when registrar contradicts availability const registeredBefore = !isAvailableByWhois(current.text); const registeredAfter = !isAvailableByWhois(res.text); @@ -38,6 +42,7 @@ export async function followWhoisReferrals( } current = res; // adopt registrar when it does not downgrade registration } catch { + throwIfAborted(opts?.signal); // If referral server fails, stop following and keep the last good response break; } @@ -55,10 +60,11 @@ export async function collectWhoisReferralChain( initialServer: string, domain: string, opts?: LookupOptions, + ctx?: LookupContext, ): Promise { const results: WhoisQueryResult[] = []; const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2); - const first = await whoisQuery(initialServer, domain, opts); + const first = await tracedWhoisQuery(initialServer, domain, opts, ctx); results.push(first); if (opts?.followWhoisReferral === false || maxHops === 0) return results; @@ -66,13 +72,14 @@ export async function collectWhoisReferralChain( let current = first; let hops = 0; while (hops < maxHops) { + throwIfAborted(opts?.signal); const next = extractWhoisReferral(current.text); if (!next) break; const normalized = normalize(next); if (visited.has(normalized)) break; visited.add(normalized); try { - const res = await whoisQuery(next, domain, opts); + const res = await tracedWhoisQuery(next, domain, opts, ctx); // If registrar claims availability while TLD indicated registered, stop. const registeredBefore = !isAvailableByWhois(current.text); const registeredAfter = !isAvailableByWhois(res.text); @@ -83,6 +90,7 @@ export async function collectWhoisReferralChain( results.push(res); current = res; } catch { + throwIfAborted(opts?.signal); break; } hops += 1; @@ -93,3 +101,21 @@ export async function collectWhoisReferralChain( function normalize(server: string): string { return server.replace(/^whois:\/\//i, "").toLowerCase(); } + +/** whoisQuery wrapped so each query (TLD or registrar hop) is recorded as an attempt. */ +function tracedWhoisQuery( + server: string, + domain: string, + opts?: LookupOptions, + ctx?: LookupContext, +): Promise { + return traced( + ctx, + { phase: "whois", server: server.replace(/^whois:\/\//i, "") }, + async (notes) => { + const res = await whoisQuery(server, domain, opts); + if (res.partial) notes.partial = true; + return res; + }, + ); +} diff --git a/tsconfig.json b/tsconfig.json index 1e5623b..0a251ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], + "target": "ES2023", + "lib": ["ES2023"], "module": "ESNext", "moduleResolution": "bundler", "moduleDetection": "force", diff --git a/tsdown.config.ts b/tsdown.config.ts index 8d0de34..84bafb8 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ platform: "node", + target: "node20", entry: ["src/index.ts"], dts: true, nodeProtocol: "strip",