mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-22 23:05:31 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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 (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
|
||||
|
||||
|
||||
+1
-1
@@ -69,6 +69,6 @@
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
+172
-96
@@ -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<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 {
|
||||
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<LookupResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+79
-13
@@ -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(() => {
|
||||
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<void> {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/** biome-ignore-all lint/style/noNonNullAssertion: this is fine for tests */
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
import { toISO } from "./dates";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+77
-27
@@ -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, {
|
||||
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<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);
|
||||
}
|
||||
|
||||
+28
-21
@@ -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, {
|
||||
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 };
|
||||
}
|
||||
|
||||
+30
-19
@@ -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<T>(val: unknown): T[] {
|
||||
|
||||
+59
-3
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
entry: ["src/index.ts"],
|
||||
dts: true,
|
||||
nodeProtocol: "strip",
|
||||
|
||||
Reference in New Issue
Block a user