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
This commit is contained in:
2026-09-18 14:41:16 -04:00
parent 87e20f4a47
commit 69f85ee925
24 changed files with 1394 additions and 254 deletions
+1 -1
View File
@@ -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
+47 -3
View File
@@ -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<string, string>` Override/add authoritative WHOIS per TLD (keys are lowercase TLDs, values may include or omit `whois://`).
- `includeRaw?: boolean` Include `rawRdap`/`rawWhois` in the returned record (default `false`).
- `signal?: AbortSignal` Optional cancellation signal.
- `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 (besteffort 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
+1 -1
View File
@@ -69,6 +69,6 @@
"vitest": "^5.0.1"
},
"engines": {
"node": ">=18.17"
"node": ">=20"
}
}
+266
View File
@@ -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<never>(() => {});
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<never>((_, 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<Response>((_, 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<Response>((_, 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();
});
});
+4 -7
View File
@@ -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)
+8 -4
View File
@@ -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 () => {
+117 -41
View File
@@ -1,47 +1,108 @@
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<LookupResult> {
const ctx: LookupContext = { attempts: [] };
// Optional overall deadline: one signal (linked to the caller's) that every phase observes.
let signalOpts = opts;
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
let link: ReturnType<typeof linkSignals> | 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 {
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();
}
}
async function runLookup(
domain: string,
opts: LookupOptions | undefined,
ctx: LookupContext,
): Promise<LookupResult> {
if (!isLikelyDomain(domain)) {
return { ok: false, error: "Input does not look like a domain" };
return failure(ctx, "invalid_input", "Input does not look like a domain");
}
const { publicSuffix: tld } = getDomainParts(domain);
if (!tld) {
return { ok: false, error: "Invalid TLD" };
return failure(ctx, "invalid_tld", "Invalid TLD");
}
// 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);
}
// 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);
const { json, notFound } = await fetchRdapDomain(domain, base, opts, ctx);
// HTTP 404 = domain not registered
if (notFound) {
@@ -52,10 +113,10 @@ export async function lookup(domain: string, opts?: LookupOptions): Promise<Look
rdapServers: tried,
source: "rdap",
};
return { ok: true, record };
return { ok: true, record, attempts: ctx.attempts };
}
const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts);
const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts, ctx);
const record: DomainRecord = normalizeRdap(
domain,
tld,
@@ -63,39 +124,58 @@ export async function lookup(domain: string, opts?: LookupOptions): Promise<Look
[...tried, ...rdapEnriched.serversTried],
!!opts?.includeRaw,
);
return { ok: true, record };
return { ok: true, record, attempts: ctx.attempts };
} catch {
// try next base
// 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)
}
}
// 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 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 },
);
}
}
// WHOIS fallback path
const whoisServer = await ianaWhoisServerForTld(tld, opts);
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 ianaText = await getIanaWhoisTextForTld(tld, opts);
const regUrl = ianaText ? parseIanaRegistrationInfoUrl(ianaText) : undefined;
const regUrl = discovery.ianaText
? parseIanaRegistrationInfoUrl(discovery.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}`,
};
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);
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);
const res = await followWhoisReferrals(whoisServer, domain, opts, ctx);
const record: DomainRecord = normalizeWhois(
domain,
tld,
@@ -103,7 +183,7 @@ export async function lookup(domain: string, opts?: LookupOptions): Promise<Look
res.serverQueried,
!!opts?.includeRaw,
);
return { ok: true, record };
return { ok: true, record, attempts: ctx.attempts };
}
// Normalize all WHOIS texts in the chain and merge conservatively
@@ -112,14 +192,10 @@ export async function lookup(domain: string, opts?: LookupOptions): Promise<Look
);
const [first, ...rest] = normalizedRecords;
if (!first) {
return { ok: false, error: "No WHOIS data retrieved" };
return failure(ctx, "no_data", "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 };
}
return { ok: true, record: mergedRecord, attempts: ctx.attempts };
}
/**
+84
View File
@@ -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<never>(() => {});
});
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<never>(() => {}));
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<never>(() => {}));
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);
});
});
+78 -12
View File
@@ -1,19 +1,85 @@
export function withTimeout<T>(
promise: Promise<T>,
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<T>(
timeoutMs: number | undefined,
reason: string,
parentSignal: AbortSignal | undefined,
fn: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
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<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
if (timeoutMs !== undefined) {
timer = setTimeout(() => timeoutCtrl.abort(new RdapperError("timeout", reason)), timeoutMs);
}
const aborted = new Promise<never>((_, reject) => {
signal.addEventListener("abort", () => reject(abortError(signal)), { once: true });
});
return Promise.race([
promise.finally(() => {
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);
}),
timeout,
]);
link.dispose();
}
}
export function sleep(ms: number): Promise<void> {
-2
View File
@@ -1,5 +1,3 @@
/** biome-ignore-all lint/style/noNonNullAssertion: this is fine for tests */
import { expect, test } from "vitest";
import { toISO } from "./dates";
+55
View File
@@ -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");
});
});
+94
View File
@@ -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 };
}
+52
View File
@@ -0,0 +1,52 @@
import type { LookupAttempt } from "../types";
import { classifyError, RdapperError } from "./errors";
const attemptByError = new WeakMap<object, LookupAttempt>();
/** 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<Pick<LookupAttempt, "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<T>(
ctx: LookupContext | undefined,
meta: { phase: LookupAttempt["phase"]; server: string },
fn: (notes: AttemptNotes) => Promise<T>,
): Promise<T> {
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;
}
}
+43 -3
View File
@@ -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);
});
});
+75 -25
View File
@@ -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<string[]> {
ctx?: LookupContext,
): Promise<BootstrapData | undefined> {
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, {
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: options?.signal,
}),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
"RDAP bootstrap timeout",
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<string[]> {
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<string[]> {
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);
}
+18 -11
View File
@@ -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<RdapFetchResult> {
const url = new URL(`domain/${encodeURIComponent(domain)}`, baseUrl).toString();
const fetchFn = resolveFetch(options);
const res = await withTimeout(
fetchFn(url, {
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: options?.signal,
}),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
"RDAP lookup timeout",
);
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();
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
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 };
},
),
);
}
+24 -13
View File
@@ -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, {
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: options?.signal,
}),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
"RDAP link fetch timeout",
);
signal,
});
if (!res.ok) {
const bodyText = await res.text();
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
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 };
},
),
);
}
function toArray<T>(val: unknown): T[] {
+59 -3
View File
@@ -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<Response>;
+124
View File
@@ -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 });
});
});
+72 -25
View File
@@ -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<WhoisQueryResult> {
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<string> {
): 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<typeof setTimeout> | 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`);
});
});
+65 -23
View File
@@ -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<string> {
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<WhoisDiscovery> {
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<string | undefined> {
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<string | undefined> {
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;
}
/**
+30 -4
View File
@@ -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<WhoisQueryResult> {
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<string>([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<WhoisQueryResult[]> {
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<WhoisQueryResult> {
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;
},
);
}
+2 -2
View File
@@ -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",
+1
View File
@@ -2,6 +2,7 @@ import { defineConfig } from "tsdown";
export default defineConfig({
platform: "node",
target: "node20",
entry: ["src/index.ts"],
dts: true,
nodeProtocol: "strip",