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
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [18, 20, 22, 24, 26]
|
node-version: [20, 22, 24, 26]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v7
|
||||||
|
|||||||
@@ -421,7 +421,8 @@ const result = await lookup("example.com", {
|
|||||||
|
|
||||||
### Options
|
### 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.
|
- `rdapOnly?: boolean` – Only attempt RDAP; do not fall back to WHOIS.
|
||||||
- `whoisOnly?: boolean` – Skip RDAP and query WHOIS directly.
|
- `whoisOnly?: boolean` – Skip RDAP and query WHOIS directly.
|
||||||
- `followWhoisReferral?: boolean` – Follow registrar referral from the TLD WHOIS (default `true`).
|
- `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)).
|
- `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://`).
|
- `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`).
|
- `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
|
### `DomainRecord` schema
|
||||||
|
|
||||||
@@ -536,7 +580,7 @@ interface DomainRecord {
|
|||||||
- Queries the TLD WHOIS and follows registrar referrals recursively up to `maxWhoisReferralHops` (unless disabled).
|
- 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).
|
- 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
|
## Development
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -69,6 +69,6 @@
|
|||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"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 { expect, test } from "vitest";
|
||||||
import { isAvailable, isRegistered, lookup } from ".";
|
import { isAvailable, isRegistered, lookup } from ".";
|
||||||
|
|
||||||
@@ -43,10 +41,10 @@ for (const c of rdapCases) {
|
|||||||
(rec.registrar?.name || "").toLowerCase().includes("internet assigned numbers authority"),
|
(rec.registrar?.name || "").toLowerCase().includes("internet assigned numbers authority"),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
// IANA nameservers
|
// Nameservers
|
||||||
const ns = (rec.nameservers || []).map((n) => n.host.toLowerCase());
|
const ns = (rec.nameservers || []).map((n) => n.host.toLowerCase());
|
||||||
expect(ns.includes("a.iana-servers.net")).toBe(true);
|
// The example domains' nameserver operator changes over time; only require that some exist
|
||||||
expect(ns.includes("b.iana-servers.net")).toBe(true);
|
expect(ns.length).toBeGreaterThan(0);
|
||||||
if (c.expectDs) {
|
if (c.expectDs) {
|
||||||
// DS records typically present for .com/.net
|
// DS records typically present for .com/.net
|
||||||
expect(rec.dnssec?.enabled).toBe(true);
|
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?.whoisServer?.toLowerCase()).toBe("whois.verisign-grs.com");
|
||||||
expect(res.record?.registrar?.ianaId).toBe("376");
|
expect(res.record?.registrar?.ianaId).toBe("376");
|
||||||
const ns = (res.record?.nameservers || []).map((n) => n.host.toLowerCase());
|
const ns = (res.record?.nameservers || []).map((n) => n.host.toLowerCase());
|
||||||
expect(ns.includes("a.iana-servers.net")).toBe(true);
|
expect(ns.length).toBeGreaterThan(0);
|
||||||
expect(ns.includes("b.iana-servers.net")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// WHOIS-only smoke for example.io (RDAP-incompatible TLD)
|
// 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.
|
// Shared, safe default mocks. Individual describes override implementations as needed.
|
||||||
vi.mock("./rdap/bootstrap.js", () => ({
|
vi.mock("./rdap/bootstrap.js", () => ({
|
||||||
getRdapBaseUrlsForTld: vi.fn(async () => ["https://rdap.example/"]),
|
getRdapBaseUrlsForPublicSuffix: vi.fn(async () => ["https://rdap.example/"]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./rdap/client.js", () => ({
|
vi.mock("./rdap/client.js", () => ({
|
||||||
@@ -45,7 +45,7 @@ vi.mock("./whois/discovery.js", async () => {
|
|||||||
const actual = await vi.importActual("./whois/discovery.js");
|
const actual = await vi.importActual("./whois/discovery.js");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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", () => {
|
describe("lookup orchestration", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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 () => {
|
it("uses RDAP when available and does not call WHOIS", async () => {
|
||||||
@@ -145,7 +147,9 @@ describe("RDAP 404 handling", () => {
|
|||||||
describe("WHOIS referral & includeRaw", () => {
|
describe("WHOIS referral & includeRaw", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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 () => {
|
it("does not follow referral when followWhoisReferral is false", async () => {
|
||||||
|
|||||||
+117
-41
@@ -1,47 +1,108 @@
|
|||||||
|
import { linkSignals, throwIfAborted } from "./lib/async";
|
||||||
import { getDomainParts, isLikelyDomain } from "./lib/domain";
|
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 { fetchRdapDomain } from "./rdap/client";
|
||||||
import { fetchAndMergeRdapRelated } from "./rdap/merge";
|
import { fetchAndMergeRdapRelated } from "./rdap/merge";
|
||||||
import { normalizeRdap } from "./rdap/normalize";
|
import { normalizeRdap } from "./rdap/normalize";
|
||||||
import type { DomainRecord, LookupOptions, LookupResult } from "./types";
|
import type {
|
||||||
import {
|
DomainRecord,
|
||||||
getIanaWhoisTextForTld,
|
LookupAttempt,
|
||||||
ianaWhoisServerForTld,
|
LookupErrorCode,
|
||||||
parseIanaRegistrationInfoUrl,
|
LookupOptions,
|
||||||
} from "./whois/discovery";
|
LookupResult,
|
||||||
|
} from "./types";
|
||||||
|
import { discoverWhoisServer, parseIanaRegistrationInfoUrl } from "./whois/discovery";
|
||||||
import { mergeWhoisRecords } from "./whois/merge";
|
import { mergeWhoisRecords } from "./whois/merge";
|
||||||
import { normalizeWhois } from "./whois/normalize";
|
import { normalizeWhois } from "./whois/normalize";
|
||||||
import { collectWhoisReferralChain, followWhoisReferrals } from "./whois/referral";
|
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.
|
* High-level lookup that prefers RDAP and falls back to WHOIS.
|
||||||
* Ensures a standardized DomainRecord, independent of the source.
|
* 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> {
|
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 {
|
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)) {
|
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);
|
const { publicSuffix: tld } = getDomainParts(domain);
|
||||||
if (!tld) {
|
if (!tld) {
|
||||||
return { ok: false, error: "Invalid TLD" };
|
return failure(ctx, "invalid_tld", "Invalid TLD");
|
||||||
}
|
}
|
||||||
|
|
||||||
// If WHOIS-only, skip RDAP path
|
// If WHOIS-only, skip RDAP path
|
||||||
if (!opts?.whoisOnly) {
|
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
|
||||||
// Some ccTLD registries publish RDAP only at the registry TLD (e.g., br),
|
// suffix can be multi-label (e.g., com.br); this falls back to the last label.
|
||||||
// while the public suffix can be multi-label (e.g., com.br). Fallback to last label.
|
const bases = await getRdapBaseUrlsForPublicSuffix(tld, opts, ctx);
|
||||||
if (bases.length === 0 && tld.includes(".")) {
|
|
||||||
const registryTld = tld.split(".").pop() ?? tld;
|
|
||||||
bases = await getRdapBaseUrlsForTld(registryTld, opts);
|
|
||||||
}
|
|
||||||
const tried: string[] = [];
|
const tried: string[] = [];
|
||||||
for (const base of bases) {
|
for (const base of bases) {
|
||||||
|
throwIfAborted(opts?.signal);
|
||||||
tried.push(base);
|
tried.push(base);
|
||||||
try {
|
try {
|
||||||
const { json, notFound } = await fetchRdapDomain(domain, base, opts);
|
const { json, notFound } = await fetchRdapDomain(domain, base, opts, ctx);
|
||||||
|
|
||||||
// HTTP 404 = domain not registered
|
// HTTP 404 = domain not registered
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
@@ -52,10 +113,10 @@ export async function lookup(domain: string, opts?: LookupOptions): Promise<Look
|
|||||||
rdapServers: tried,
|
rdapServers: tried,
|
||||||
source: "rdap",
|
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(
|
const record: DomainRecord = normalizeRdap(
|
||||||
domain,
|
domain,
|
||||||
tld,
|
tld,
|
||||||
@@ -63,39 +124,58 @@ export async function lookup(domain: string, opts?: LookupOptions): Promise<Look
|
|||||||
[...tried, ...rdapEnriched.serversTried],
|
[...tried, ...rdapEnriched.serversTried],
|
||||||
!!opts?.includeRaw,
|
!!opts?.includeRaw,
|
||||||
);
|
);
|
||||||
return { ok: true, record };
|
return { ok: true, record, attempts: ctx.attempts };
|
||||||
} catch {
|
} 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
|
// Some TLDs are not in bootstrap yet; continue to WHOIS fallback unless rdapOnly
|
||||||
if (opts?.rdapOnly) {
|
if (opts?.rdapOnly) {
|
||||||
return {
|
const last = lastFailedAttempt(ctx, ["rdap", "rdap_bootstrap"]);
|
||||||
ok: false,
|
const detail = last
|
||||||
error: `RDAP not available or failed for TLD '${tld}'. Many TLDs do not publish RDAP; try WHOIS fallback (omit rdapOnly).`,
|
? ` (${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
|
// WHOIS fallback path
|
||||||
const whoisServer = await ianaWhoisServerForTld(tld, opts);
|
const discovery = await discoverWhoisServer(tld, opts, ctx);
|
||||||
|
const whoisServer = discovery.server;
|
||||||
if (!whoisServer) {
|
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
|
// Provide a clearer, actionable message
|
||||||
const ianaText = await getIanaWhoisTextForTld(tld, opts);
|
const regUrl = discovery.ianaText
|
||||||
const regUrl = ianaText ? parseIanaRegistrationInfoUrl(ianaText) : undefined;
|
? parseIanaRegistrationInfoUrl(discovery.ianaText)
|
||||||
|
: undefined;
|
||||||
const hint = regUrl ? ` See registration info at ${regUrl}.` : "";
|
const hint = regUrl ? ` See registration info at ${regUrl}.` : "";
|
||||||
return {
|
return failure(
|
||||||
ok: false,
|
ctx,
|
||||||
error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`,
|
"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)
|
// 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.
|
// 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) {
|
if (chain.length === 0) {
|
||||||
// Fallback to previous behavior as a safety net
|
// 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(
|
const record: DomainRecord = normalizeWhois(
|
||||||
domain,
|
domain,
|
||||||
tld,
|
tld,
|
||||||
@@ -103,7 +183,7 @@ export async function lookup(domain: string, opts?: LookupOptions): Promise<Look
|
|||||||
res.serverQueried,
|
res.serverQueried,
|
||||||
!!opts?.includeRaw,
|
!!opts?.includeRaw,
|
||||||
);
|
);
|
||||||
return { ok: true, record };
|
return { ok: true, record, attempts: ctx.attempts };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize all WHOIS texts in the chain and merge conservatively
|
// 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;
|
const [first, ...rest] = normalizedRecords;
|
||||||
if (!first) {
|
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;
|
const mergedRecord = rest.length ? mergeWhoisRecords(first, rest) : first;
|
||||||
return { ok: true, record: mergedRecord };
|
return { ok: true, record: mergedRecord, attempts: ctx.attempts };
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
return { ok: false, error: message };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -1,19 +1,85 @@
|
|||||||
export function withTimeout<T>(
|
import { DEFAULT_TIMEOUT_MS } from "./constants";
|
||||||
promise: Promise<T>,
|
import { abortError, RdapperError } from "./errors";
|
||||||
timeoutMs: number,
|
|
||||||
reason = "Timeout",
|
/**
|
||||||
|
* 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> {
|
): 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;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
if (timeoutMs !== undefined) {
|
||||||
timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
|
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([
|
try {
|
||||||
promise.finally(() => {
|
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);
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
}),
|
link.dispose();
|
||||||
timeout,
|
}
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
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 { expect, test } from "vitest";
|
||||||
import { toISO } from "./dates";
|
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 { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { BootstrapData } from "../types";
|
import type { BootstrapData } from "../types";
|
||||||
import { getRdapBaseUrlsForTld } from "./bootstrap";
|
import { getRdapBaseUrlsForPublicSuffix, getRdapBaseUrlsForTld } from "./bootstrap";
|
||||||
|
|
||||||
// Mock the global fetch function
|
// Mock the global fetch function
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -310,7 +310,7 @@ describe("getRdapBaseUrlsForTld with customBootstrapData", () => {
|
|||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"https://data.iana.org/rdap/dns.json",
|
"https://data.iana.org/rdap/dns.json",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
signal,
|
signal: expect.any(AbortSignal),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -435,9 +435,49 @@ describe("getRdapBaseUrlsForTld with customBootstrapData", () => {
|
|||||||
expect(customFetch).toHaveBeenCalledWith(
|
expect(customFetch).toHaveBeenCalledWith(
|
||||||
"https://data.iana.org/rdap/dns.json",
|
"https://data.iana.org/rdap/dns.json",
|
||||||
expect.objectContaining({
|
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
@@ -1,25 +1,23 @@
|
|||||||
import { withTimeout } from "../lib/async";
|
import { resolveTimeoutMs, throwIfAborted, withTimeout } from "../lib/async";
|
||||||
import { DEFAULT_BOOTSTRAP_URL, DEFAULT_TIMEOUT_MS } from "../lib/constants";
|
import { DEFAULT_BOOTSTRAP_URL } from "../lib/constants";
|
||||||
|
import { RdapperError } from "../lib/errors";
|
||||||
import { resolveFetch } from "../lib/fetch";
|
import { resolveFetch } from "../lib/fetch";
|
||||||
|
import { type LookupContext, traced } from "../lib/trace";
|
||||||
import type { BootstrapData, LookupOptions } from "../types";
|
import type { BootstrapData, LookupOptions } from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve RDAP base URLs for a given TLD using IANA's bootstrap registry.
|
* Load RDAP bootstrap data, or `undefined` when it could not be fetched (the failure is
|
||||||
* Returns zero or more base URLs (always suffixed with a trailing slash).
|
* recorded in `ctx.attempts` and the caller falls back to WHOIS).
|
||||||
*
|
*
|
||||||
* Bootstrap data is resolved in the following priority order:
|
* Bootstrap data is resolved in the following priority order:
|
||||||
* 1. `options.customBootstrapData` - pre-loaded bootstrap data (no fetch)
|
* 1. `options.customBootstrapData` - pre-loaded bootstrap data (no fetch)
|
||||||
* 2. `options.customBootstrapUrl` - custom URL to fetch bootstrap data from
|
* 2. `options.customBootstrapUrl` - custom URL to fetch bootstrap data from
|
||||||
* 3. Default IANA URL - https://data.iana.org/rdap/dns.json
|
* 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(
|
async function loadBootstrapData(
|
||||||
tld: string,
|
|
||||||
options?: LookupOptions,
|
options?: LookupOptions,
|
||||||
): Promise<string[]> {
|
ctx?: LookupContext,
|
||||||
|
): Promise<BootstrapData | undefined> {
|
||||||
let data: BootstrapData;
|
let data: BootstrapData;
|
||||||
|
|
||||||
// Priority 1: Use pre-loaded bootstrap data if provided (no fetch)
|
// Priority 1: Use pre-loaded bootstrap data if provided (no fetch)
|
||||||
@@ -55,28 +53,42 @@ export async function getRdapBaseUrlsForTld(
|
|||||||
const fetchFn = resolveFetch(options);
|
const fetchFn = resolveFetch(options);
|
||||||
const bootstrapUrl = options?.customBootstrapUrl ?? DEFAULT_BOOTSTRAP_URL;
|
const bootstrapUrl = options?.customBootstrapUrl ?? DEFAULT_BOOTSTRAP_URL;
|
||||||
try {
|
try {
|
||||||
const res = await withTimeout(
|
data = await traced(ctx, { phase: "rdap_bootstrap", server: bootstrapUrl }, () =>
|
||||||
fetchFn(bootstrapUrl, {
|
withTimeout(
|
||||||
|
resolveTimeoutMs(options),
|
||||||
|
"RDAP bootstrap timeout",
|
||||||
|
options?.signal,
|
||||||
|
async (signal) => {
|
||||||
|
const res = await fetchFn(bootstrapUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { accept: "application/json" },
|
headers: { accept: "application/json" },
|
||||||
signal: options?.signal,
|
signal,
|
||||||
}),
|
});
|
||||||
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
if (!res.ok) {
|
||||||
"RDAP bootstrap timeout",
|
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) {
|
} catch (err: unknown) {
|
||||||
// Preserve caller cancellation behavior - rethrow if explicitly aborted
|
// Preserve caller cancellation behavior - rethrow if explicitly aborted (or deadline hit)
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (options?.signal?.aborted) throwIfAborted(options.signal);
|
||||||
throw err;
|
if (err instanceof Error && err.name === "AbortError") throw err;
|
||||||
}
|
|
||||||
// Network, timeout, or JSON parse errors - return empty array to fall back to WHOIS
|
// 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 target = tld.toLowerCase();
|
||||||
const bases: string[] = [];
|
const bases: string[] = [];
|
||||||
for (const svc of data.services) {
|
for (const svc of data.services) {
|
||||||
@@ -93,3 +105,41 @@ export async function getRdapBaseUrlsForTld(
|
|||||||
}
|
}
|
||||||
return Array.from(new Set(bases));
|
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
@@ -1,6 +1,7 @@
|
|||||||
import { withTimeout } from "../lib/async";
|
import { resolveTimeoutMs, withTimeout } from "../lib/async";
|
||||||
import { DEFAULT_TIMEOUT_MS } from "../lib/constants";
|
import { RdapperError } from "../lib/errors";
|
||||||
import { resolveFetch } from "../lib/fetch";
|
import { resolveFetch } from "../lib/fetch";
|
||||||
|
import { type LookupContext, traced } from "../lib/trace";
|
||||||
import type { LookupOptions } from "../types";
|
import type { LookupOptions } from "../types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,27 +24,33 @@ export async function fetchRdapDomain(
|
|||||||
domain: string,
|
domain: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
options?: LookupOptions,
|
options?: LookupOptions,
|
||||||
|
ctx?: LookupContext,
|
||||||
): Promise<RdapFetchResult> {
|
): Promise<RdapFetchResult> {
|
||||||
const url = new URL(`domain/${encodeURIComponent(domain)}`, baseUrl).toString();
|
const url = new URL(`domain/${encodeURIComponent(domain)}`, baseUrl).toString();
|
||||||
const fetchFn = resolveFetch(options);
|
const fetchFn = resolveFetch(options);
|
||||||
const res = await withTimeout(
|
return traced(ctx, { phase: "rdap", server: baseUrl }, () =>
|
||||||
fetchFn(url, {
|
withTimeout(
|
||||||
|
resolveTimeoutMs(options),
|
||||||
|
"RDAP lookup timeout",
|
||||||
|
options?.signal,
|
||||||
|
async (signal) => {
|
||||||
|
const res = await fetchFn(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { accept: "application/rdap+json, application/json" },
|
headers: { accept: "application/rdap+json, application/json" },
|
||||||
signal: options?.signal,
|
signal,
|
||||||
}),
|
});
|
||||||
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
||||||
"RDAP lookup timeout",
|
|
||||||
);
|
|
||||||
// HTTP 404 = domain not found (not registered)
|
// HTTP 404 = domain not found (not registered)
|
||||||
// Per RFC 9083, RDAP servers return 404 for objects that don't exist
|
// Per RFC 9083, RDAP servers return 404 for objects that don't exist
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
return { url, json: null, notFound: true };
|
return { url, json: null, notFound: true };
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const bodyText = await res.text();
|
const bodyText = await res.text().catch(() => "");
|
||||||
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
throw new RdapperError("http_error", `RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
||||||
}
|
}
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
return { url, json };
|
return { url, json };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-13
@@ -1,6 +1,7 @@
|
|||||||
import { withTimeout } from "../lib/async";
|
import { resolveTimeoutMs, throwIfAborted, withTimeout } from "../lib/async";
|
||||||
import { DEFAULT_TIMEOUT_MS } from "../lib/constants";
|
import { RdapperError } from "../lib/errors";
|
||||||
import { resolveFetch } from "../lib/fetch";
|
import { resolveFetch } from "../lib/fetch";
|
||||||
|
import { type LookupContext, traced } from "../lib/trace";
|
||||||
import type { LookupOptions } from "../types";
|
import type { LookupOptions } from "../types";
|
||||||
import { extractRdapRelatedLinks } from "./links";
|
import { extractRdapRelatedLinks } from "./links";
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export async function fetchAndMergeRdapRelated(
|
|||||||
domain: string,
|
domain: string,
|
||||||
baseDoc: unknown,
|
baseDoc: unknown,
|
||||||
opts?: LookupOptions,
|
opts?: LookupOptions,
|
||||||
|
ctx?: LookupContext,
|
||||||
): Promise<{ merged: unknown; serversTried: string[] }> {
|
): Promise<{ merged: unknown; serversTried: string[] }> {
|
||||||
const tried: string[] = [];
|
const tried: string[] = [];
|
||||||
if (opts?.rdapFollowLinks === false) return { merged: baseDoc, serversTried: tried };
|
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
|
// BFS: collect links from the latest merged doc only to keep it simple and bounded
|
||||||
while (hops < maxHops) {
|
while (hops < maxHops) {
|
||||||
|
throwIfAborted(opts?.signal);
|
||||||
const links = extractRdapRelatedLinks(current, {
|
const links = extractRdapRelatedLinks(current, {
|
||||||
rdapLinkRels: opts?.rdapLinkRels,
|
rdapLinkRels: opts?.rdapLinkRels,
|
||||||
});
|
});
|
||||||
@@ -78,9 +81,10 @@ export async function fetchAndMergeRdapRelated(
|
|||||||
if (nextBatch.length === 0) break;
|
if (nextBatch.length === 0) break;
|
||||||
const fetchedDocs: unknown[] = [];
|
const fetchedDocs: unknown[] = [];
|
||||||
for (const url of nextBatch) {
|
for (const url of nextBatch) {
|
||||||
|
throwIfAborted(opts?.signal);
|
||||||
visited.add(url);
|
visited.add(url);
|
||||||
try {
|
try {
|
||||||
const { json } = await fetchRdapUrl(url, opts);
|
const { json } = await fetchRdapUrl(url, opts, ctx);
|
||||||
tried.push(url);
|
tried.push(url);
|
||||||
// only accept docs that appear related to the same domain when possible
|
// only accept docs that appear related to the same domain when possible
|
||||||
// if ldhName/unicodeName present, they should match the queried domain (case-insensitive)
|
// 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;
|
if (uni && !sameDomain(uni, domain)) continue;
|
||||||
fetchedDocs.push(json);
|
fetchedDocs.push(json);
|
||||||
} catch {
|
} 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;
|
if (fetchedDocs.length === 0) break;
|
||||||
@@ -103,24 +108,30 @@ export async function fetchAndMergeRdapRelated(
|
|||||||
async function fetchRdapUrl(
|
async function fetchRdapUrl(
|
||||||
url: string,
|
url: string,
|
||||||
options?: LookupOptions,
|
options?: LookupOptions,
|
||||||
|
ctx?: LookupContext,
|
||||||
): Promise<{ url: string; json: unknown }> {
|
): Promise<{ url: string; json: unknown }> {
|
||||||
const fetchFn = resolveFetch(options);
|
const fetchFn = resolveFetch(options);
|
||||||
const res = await withTimeout(
|
return traced(ctx, { phase: "rdap_link", server: url }, () =>
|
||||||
fetchFn(url, {
|
withTimeout(
|
||||||
|
resolveTimeoutMs(options),
|
||||||
|
"RDAP link fetch timeout",
|
||||||
|
options?.signal,
|
||||||
|
async (signal) => {
|
||||||
|
const res = await fetchFn(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { accept: "application/rdap+json, application/json" },
|
headers: { accept: "application/rdap+json, application/json" },
|
||||||
signal: options?.signal,
|
signal,
|
||||||
}),
|
});
|
||||||
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
||||||
"RDAP link fetch timeout",
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const bodyText = await res.text();
|
const bodyText = await res.text().catch(() => "");
|
||||||
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
throw new RdapperError("http_error", `RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
||||||
}
|
}
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
// Optionally parse Link header for future iterations; the main loop inspects body.links
|
// Optionally parse Link header for future iterations; the main loop inspects body.links
|
||||||
return { url, json };
|
return { url, json };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toArray<T>(val: unknown): T[] {
|
function toArray<T>(val: unknown): T[] {
|
||||||
|
|||||||
+59
-3
@@ -237,8 +237,17 @@ export interface BootstrapData {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export interface LookupOptions {
|
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;
|
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 */
|
/** Don't fall back to WHOIS */
|
||||||
rdapOnly?: boolean;
|
rdapOnly?: boolean;
|
||||||
/** Don't attempt RDAP */
|
/** Don't attempt RDAP */
|
||||||
@@ -297,7 +306,7 @@ export interface LookupOptions {
|
|||||||
* - RDAP domain lookup requests
|
* - RDAP domain lookup requests
|
||||||
* - RDAP related/entity link 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
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
@@ -364,12 +373,59 @@ export interface LookupResult {
|
|||||||
record?: DomainRecord;
|
record?: DomainRecord;
|
||||||
/** Error message describing why the lookup failed, present when ok is false */
|
/** Error message describing why the lookup failed, present when ok is false */
|
||||||
error?: string;
|
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.
|
* Fetch-compatible function signature.
|
||||||
*
|
*
|
||||||
* Used internally for dependency injection and testing. Matches the 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>;
|
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 { resolveTimeoutMs, throwIfAborted } from "../lib/async";
|
||||||
import { DEFAULT_TIMEOUT_MS } from "../lib/constants";
|
import { abortError, RdapperError } from "../lib/errors";
|
||||||
import type { LookupOptions } from "../types";
|
import type { LookupOptions } from "../types";
|
||||||
|
|
||||||
export interface WhoisQueryResult {
|
export interface WhoisQueryResult {
|
||||||
serverQueried: string;
|
serverQueried: string;
|
||||||
text: 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,
|
query: string,
|
||||||
options?: LookupOptions,
|
options?: LookupOptions,
|
||||||
): Promise<WhoisQueryResult> {
|
): Promise<WhoisQueryResult> {
|
||||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
||||||
const port = 43;
|
const port = 43;
|
||||||
const host = server.replace(/^whois:\/\//i, "");
|
const host = server.replace(/^whois:\/\//i, "");
|
||||||
|
|
||||||
@@ -35,21 +36,19 @@ export async function whoisQuery(
|
|||||||
const transformer = WHOIS_QUERY_TRANSFORMERS[host];
|
const transformer = WHOIS_QUERY_TRANSFORMERS[host];
|
||||||
const transformedQuery = transformer ? transformer(query) : query;
|
const transformedQuery = transformer ? transformer(query) : query;
|
||||||
|
|
||||||
const text = await withTimeout(
|
const { text, partial } = await queryTcp(host, port, transformedQuery, options);
|
||||||
queryTcp(host, port, transformedQuery, options),
|
return { serverQueried: server, text, ...(partial ? { partial } : {}) };
|
||||||
timeoutMs,
|
|
||||||
"WHOIS timeout",
|
|
||||||
);
|
|
||||||
return { serverQueried: server, text };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Low-level WHOIS TCP client. Some registries require CRLF after the domain query.
|
// 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(
|
async function queryTcp(
|
||||||
host: string,
|
host: string,
|
||||||
port: number,
|
port: number,
|
||||||
query: string,
|
query: string,
|
||||||
options?: LookupOptions,
|
options?: LookupOptions,
|
||||||
): Promise<string> {
|
): Promise<{ text: string; partial?: boolean }> {
|
||||||
let net: typeof import("node:net") | null;
|
let net: typeof import("node:net") | null;
|
||||||
try {
|
try {
|
||||||
net = await import("node:net");
|
net = await import("node:net");
|
||||||
@@ -58,36 +57,84 @@ async function queryTcp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!net?.createConnection) {
|
if (!net?.createConnection) {
|
||||||
throw new Error(
|
throw new RdapperError(
|
||||||
|
"unsupported_runtime",
|
||||||
"WHOIS client is only available in Node.js runtimes; try setting `rdapOnly: true`.",
|
"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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = net.createConnection({ host, port });
|
const socket = createConnection({ host, port });
|
||||||
let data = "";
|
const chunks: Buffer[] = [];
|
||||||
|
let received = 0;
|
||||||
|
let connected = false;
|
||||||
let done = 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;
|
if (done) return;
|
||||||
done = true;
|
done = true;
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
|
settle();
|
||||||
};
|
};
|
||||||
socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1000, () => {
|
const onAbort = () => finish(() => reject(abortError(signal as AbortSignal)));
|
||||||
cleanup();
|
|
||||||
reject(new Error("WHOIS socket timeout"));
|
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) => {
|
socket.on("data", (chunk: Buffer | string) => {
|
||||||
cleanup();
|
const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
||||||
reject(err);
|
chunks.push(buf);
|
||||||
});
|
received += buf.length;
|
||||||
socket.on("data", (chunk) => {
|
|
||||||
data += chunk.toString("utf8");
|
|
||||||
});
|
});
|
||||||
socket.on("end", () => {
|
socket.on("end", () => {
|
||||||
cleanup();
|
finish(() => resolve({ text: text() }));
|
||||||
resolve(data);
|
});
|
||||||
|
// 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", () => {
|
socket.on("connect", () => {
|
||||||
|
connected = true;
|
||||||
socket.write(`${query}\r\n`);
|
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 { whoisQuery } from "./client";
|
||||||
import { WHOIS_TLD_EXCEPTIONS } from "./servers";
|
import { WHOIS_TLD_EXCEPTIONS } from "./servers";
|
||||||
|
|
||||||
@@ -43,14 +46,71 @@ export function parseIanaRegistrationInfoUrl(text: string): string | undefined {
|
|||||||
return 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). */
|
/** Fetch raw IANA WHOIS text for a TLD (best-effort). */
|
||||||
export async function getIanaWhoisTextForTld(
|
export async function getIanaWhoisTextForTld(
|
||||||
tld: string,
|
tld: string,
|
||||||
options?: LookupOptions,
|
options?: LookupOptions,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
const res = await whoisQuery("whois.iana.org", tld.toLowerCase(), options);
|
return await queryIana(tld, options);
|
||||||
return res.text;
|
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -62,27 +122,9 @@ export async function getIanaWhoisTextForTld(
|
|||||||
export async function ianaWhoisServerForTld(
|
export async function ianaWhoisServerForTld(
|
||||||
tld: string,
|
tld: string,
|
||||||
options?: LookupOptions,
|
options?: LookupOptions,
|
||||||
|
ctx?: LookupContext,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const key = tld.toLowerCase();
|
return (await discoverWhoisServer(tld, options, ctx)).server;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+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 { LookupOptions } from "../types";
|
||||||
import type { WhoisQueryResult } from "./client";
|
import type { WhoisQueryResult } from "./client";
|
||||||
import { whoisQuery } from "./client";
|
import { whoisQuery } from "./client";
|
||||||
@@ -12,23 +14,25 @@ export async function followWhoisReferrals(
|
|||||||
initialServer: string,
|
initialServer: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
opts?: LookupOptions,
|
opts?: LookupOptions,
|
||||||
|
ctx?: LookupContext,
|
||||||
): Promise<WhoisQueryResult> {
|
): Promise<WhoisQueryResult> {
|
||||||
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
||||||
// First query against the provided server
|
// 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;
|
if (opts?.followWhoisReferral === false || maxHops === 0) return current;
|
||||||
|
|
||||||
const visited = new Set<string>([normalize(current.serverQueried)]);
|
const visited = new Set<string>([normalize(current.serverQueried)]);
|
||||||
let hops = 0;
|
let hops = 0;
|
||||||
// Iterate while we see a new referral and are under hop limit
|
// Iterate while we see a new referral and are under hop limit
|
||||||
while (hops < maxHops) {
|
while (hops < maxHops) {
|
||||||
|
throwIfAborted(opts?.signal);
|
||||||
const next = extractWhoisReferral(current.text);
|
const next = extractWhoisReferral(current.text);
|
||||||
if (!next) break;
|
if (!next) break;
|
||||||
const normalized = normalize(next);
|
const normalized = normalize(next);
|
||||||
if (visited.has(normalized)) break; // cycle protection / same as current
|
if (visited.has(normalized)) break; // cycle protection / same as current
|
||||||
visited.add(normalized);
|
visited.add(normalized);
|
||||||
try {
|
try {
|
||||||
const res = await whoisQuery(next, domain, opts);
|
const res = await tracedWhoisQuery(next, domain, opts, ctx);
|
||||||
// Prefer authoritative TLD response when registrar contradicts availability
|
// Prefer authoritative TLD response when registrar contradicts availability
|
||||||
const registeredBefore = !isAvailableByWhois(current.text);
|
const registeredBefore = !isAvailableByWhois(current.text);
|
||||||
const registeredAfter = !isAvailableByWhois(res.text);
|
const registeredAfter = !isAvailableByWhois(res.text);
|
||||||
@@ -38,6 +42,7 @@ export async function followWhoisReferrals(
|
|||||||
}
|
}
|
||||||
current = res; // adopt registrar when it does not downgrade registration
|
current = res; // adopt registrar when it does not downgrade registration
|
||||||
} catch {
|
} catch {
|
||||||
|
throwIfAborted(opts?.signal);
|
||||||
// If referral server fails, stop following and keep the last good response
|
// If referral server fails, stop following and keep the last good response
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -55,10 +60,11 @@ export async function collectWhoisReferralChain(
|
|||||||
initialServer: string,
|
initialServer: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
opts?: LookupOptions,
|
opts?: LookupOptions,
|
||||||
|
ctx?: LookupContext,
|
||||||
): Promise<WhoisQueryResult[]> {
|
): Promise<WhoisQueryResult[]> {
|
||||||
const results: WhoisQueryResult[] = [];
|
const results: WhoisQueryResult[] = [];
|
||||||
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
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);
|
results.push(first);
|
||||||
if (opts?.followWhoisReferral === false || maxHops === 0) return results;
|
if (opts?.followWhoisReferral === false || maxHops === 0) return results;
|
||||||
|
|
||||||
@@ -66,13 +72,14 @@ export async function collectWhoisReferralChain(
|
|||||||
let current = first;
|
let current = first;
|
||||||
let hops = 0;
|
let hops = 0;
|
||||||
while (hops < maxHops) {
|
while (hops < maxHops) {
|
||||||
|
throwIfAborted(opts?.signal);
|
||||||
const next = extractWhoisReferral(current.text);
|
const next = extractWhoisReferral(current.text);
|
||||||
if (!next) break;
|
if (!next) break;
|
||||||
const normalized = normalize(next);
|
const normalized = normalize(next);
|
||||||
if (visited.has(normalized)) break;
|
if (visited.has(normalized)) break;
|
||||||
visited.add(normalized);
|
visited.add(normalized);
|
||||||
try {
|
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.
|
// If registrar claims availability while TLD indicated registered, stop.
|
||||||
const registeredBefore = !isAvailableByWhois(current.text);
|
const registeredBefore = !isAvailableByWhois(current.text);
|
||||||
const registeredAfter = !isAvailableByWhois(res.text);
|
const registeredAfter = !isAvailableByWhois(res.text);
|
||||||
@@ -83,6 +90,7 @@ export async function collectWhoisReferralChain(
|
|||||||
results.push(res);
|
results.push(res);
|
||||||
current = res;
|
current = res;
|
||||||
} catch {
|
} catch {
|
||||||
|
throwIfAborted(opts?.signal);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
hops += 1;
|
hops += 1;
|
||||||
@@ -93,3 +101,21 @@ export async function collectWhoisReferralChain(
|
|||||||
function normalize(server: string): string {
|
function normalize(server: string): string {
|
||||||
return server.replace(/^whois:\/\//i, "").toLowerCase();
|
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",
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2023",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig } from "tsdown";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
platform: "node",
|
platform: "node",
|
||||||
|
target: "node20",
|
||||||
entry: ["src/index.ts"],
|
entry: ["src/index.ts"],
|
||||||
dts: true,
|
dts: true,
|
||||||
nodeProtocol: "strip",
|
nodeProtocol: "strip",
|
||||||
|
|||||||
Reference in New Issue
Block a user