fix: follow RDAP links breadth-first within maxRdapLinkHops fetches

Fetched documents' own links were never followed (merging dropped them), so
maxRdapLinkHops above 1 did nothing, while every link of one document was
fetched regardless of the limit, contradicting its documented meaning
("maximum related link fetches") and the README's timeout arithmetic.

Follow links breadth-first, including those in fetched documents, up to
maxRdapLinkHops fetches in all, "related" links first. Skip a document's self
link, and don't fall back to a link's "value", which is the context (the
document itself), not the target. A null entry in "links" no longer throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-03 15:33:23 -04:00
1 parent b723c39f23
commit 4ffb59370c
5 files changed
+105 -39

No files matched your search

+1 -1
View File
@@ -435,7 +435,7 @@ const result = await lookup("example.com", {
- `followWhoisReferral?: boolean` – Follow registrar referral from the TLD WHOIS (default `true`).
- `maxWhoisReferralHops?: number` – Maximum registrar WHOIS referral hops to follow (default `2`).
- `rdapFollowLinks?: boolean` – Follow related/entity RDAP links to enrich data (default `true`).
- `maxRdapLinkHops?: number` – Maximum RDAP related link hops to follow (default `2`).
- `maxRdapLinkHops?: number` – Maximum RDAP related link fetches, following links found in fetched documents too (default `2`).
- `rdapLinkRels?: string[]` – RDAP link rel values to consider (default `["related","entity","registrar","alternate"]`).
- `customBootstrapData?: BootstrapData` – Pre-loaded RDAP bootstrap data for caching control (see [Bootstrap Data Caching](#bootstrap-data-caching)).
- `customBootstrapUrl?: string` – Override RDAP bootstrap URL (ignored if `customBootstrapData` is provided).
+27 -8
View File
@@ -7,7 +7,25 @@ type RdapLink = {
type?: string;
};
/** Extract candidate RDAP link URLs from an RDAP document. */
function linksOf(doc: unknown): RdapLink[] {
const links = (doc as { links?: unknown } | null)?.links;
return Array.isArray(links)
? links.filter((l): l is RdapLink => !!l && typeof l === "object")
: [];
}
/** The document's own URLs, from its "self" links. */
export function selfLinks(doc: unknown): string[] {
return linksOf(doc)
.filter((l) => String(l.rel).toLowerCase() === "self" && typeof l.href === "string")
.map((l) => l.href as string);
}
/**
* Extract candidate RDAP link URLs from an RDAP document, "related" links (to the registrar's
* copy of the domain) first. A link's target is its href; its value is the context, the
* document itself.
*/
export function extractRdapRelatedLinks(
doc: unknown,
opts?: Pick<LookupOptions, "rdapLinkRels">,
@@ -15,16 +33,17 @@ export function extractRdapRelatedLinks(
const rels = (
opts?.rdapLinkRels?.length ? opts.rdapLinkRels : ["related", "entity", "registrar", "alternate"]
).map((r) => r.toLowerCase());
const d = (doc ?? {}) as Record<string, unknown> & { links?: RdapLink[] };
const arr = Array.isArray(d?.links) ? (d.links as RdapLink[]) : [];
const out: string[] = [];
for (const link of arr) {
const self = new Set(selfLinks(doc));
const out: Array<{ url: string; related: boolean }> = [];
for (const link of linksOf(doc)) {
const rel = String(link.rel || "").toLowerCase();
const type = String(link.type || "").toLowerCase();
if (!rels.includes(rel)) continue;
if (type && !/application\/rdap\+json/i.test(type)) continue;
const url = link.href || link.value;
if (url && /^https?:\/\//i.test(url)) out.push(url);
const url = link.href;
if (typeof url !== "string" || !/^https?:\/\//i.test(url) || self.has(url)) continue;
out.push({ url, related: rel === "related" });
}
return Array.from(new Set(out));
out.sort((a, b) => Number(b.related) - Number(a.related));
return Array.from(new Set(out.map((l) => l.url)));
}
+52
View File
@@ -68,4 +68,56 @@ describe("fetchAndMergeRdapRelated", () => {
const { merged } = await fetchAndMergeRdapRelated("example.com", base, { customFetch });
expect((merged as { status?: unknown }).status).toBeUndefined();
});
it("follows links found in fetched documents, up to maxRdapLinkHops fetches in all", async () => {
const domainDoc = (status: string, links: unknown[] = []) => ({
objectClassName: "domain",
ldhName: "example.com",
status: [status],
links,
});
const base = domainDoc("server hold", [
related("https://a.test/d"),
related("https://b.test/d"),
]);
const customFetch = serve({
"https://a.test/d": domainDoc("client hold", [related("https://c.test/d")]),
"https://b.test/d": domainDoc("inactive"),
"https://c.test/d": domainDoc("pending delete"),
});
const three = await fetchAndMergeRdapRelated("example.com", base, {
customFetch,
maxRdapLinkHops: 3,
});
expect(three.serversTried).toEqual([
"https://a.test/d",
"https://b.test/d",
"https://c.test/d",
]);
expect((three.merged as { status: string[] }).status).toContain("pending delete");
customFetch.mockClear();
const two = await fetchAndMergeRdapRelated("example.com", base, { customFetch });
expect(customFetch).toHaveBeenCalledTimes(2);
expect(two.serversTried).toEqual(["https://a.test/d", "https://b.test/d"]);
});
it("skips malformed links, self links, and links without an href", async () => {
const base = {
ldhName: "example.com",
links: [
null,
{ rel: "self", href: "https://rdap.test/domain/example.com" },
related("https://rdap.test/domain/example.com"),
{
rel: "related",
type: "application/rdap+json",
value: "https://rdap.test/domain/example.com",
},
],
};
const customFetch = serve({});
const { serversTried } = await fetchAndMergeRdapRelated("example.com", base, { customFetch });
expect(customFetch).not.toHaveBeenCalled();
expect(serversTried).toEqual([]);
});
});
+24 -29
View File
@@ -5,7 +5,7 @@ import { resolveFetch } from "../lib/fetch";
import { uniqBy } from "../lib/text";
import { type LookupContext, traced } from "../lib/trace";
import type { LookupOptions } from "../types";
import { extractRdapRelatedLinks } from "./links";
import { extractRdapRelatedLinks, selfLinks } from "./links";
type Json = Record<string, unknown>;
@@ -64,7 +64,10 @@ export function mergeRdapDocs(baseDoc: unknown, others: unknown[]): unknown {
return merged;
}
/** Fetch and merge RDAP related documents up to a hop limit. */
/**
* Fetch and merge related RDAP documents, breadth-first: links in fetched documents (a
* registrar's own related link) are followed too, up to `maxRdapLinkHops` fetches in all.
*/
export async function fetchAndMergeRdapRelated(
domain: string,
baseDoc: unknown,
@@ -73,37 +76,29 @@ export async function fetchAndMergeRdapRelated(
): Promise<{ merged: unknown; serversTried: string[] }> {
const tried: string[] = [];
if (opts?.rdapFollowLinks === false) return { merged: baseDoc, serversTried: tried };
const maxHops = Math.max(0, opts?.maxRdapLinkHops ?? 2);
if (maxHops === 0) return { merged: baseDoc, serversTried: tried };
const visited = new Set<string>();
const maxFetches = Math.max(0, opts?.maxRdapLinkHops ?? 2);
const rels = { rdapLinkRels: opts?.rdapLinkRels };
const visited = new Set<string>(selfLinks(baseDoc));
const queue = extractRdapRelatedLinks(baseDoc, rels);
let current = baseDoc;
let hops = 0;
// BFS: collect links from the latest merged doc only to keep it simple and bounded
while (hops < maxHops) {
let fetches = 0;
while (queue.length && fetches < maxFetches) {
throwIfAborted(opts?.signal);
const links = extractRdapRelatedLinks(current, {
rdapLinkRels: opts?.rdapLinkRels,
});
const nextBatch = links.filter((u) => !visited.has(u));
if (nextBatch.length === 0) break;
const fetchedDocs: unknown[] = [];
for (const url of nextBatch) {
const url = queue.shift() as string;
if (visited.has(url)) continue;
visited.add(url);
fetches += 1;
try {
const { json } = await fetchRdapUrl(url, opts, ctx);
tried.push(url);
if (!describesDomain(json, domain)) continue;
current = mergeRdapDocs(current, [json]);
for (const self of selfLinks(json)) visited.add(self);
queue.push(...extractRdapRelatedLinks(json, rels));
} catch {
// caller abort / deadline stops the lookup; other failures are recorded in attempts
throwIfAborted(opts?.signal);
visited.add(url);
try {
const { json } = await fetchRdapUrl(url, opts, ctx);
tried.push(url);
if (describesDomain(json, domain)) fetchedDocs.push(json);
} catch {
// caller abort / deadline stops the lookup; other failures are recorded in attempts
throwIfAborted(opts?.signal);
}
}
if (fetchedDocs.length === 0) break;
current = mergeRdapDocs(current, fetchedDocs);
hops += 1;
}
return { merged: current, serversTried: tried };
}
+1 -1
View File
@@ -349,7 +349,7 @@ export interface LookupOptions {
maxWhoisReferralHops?: number;
/** Follow RDAP related/entity links (default true) */
rdapFollowLinks?: boolean;
/** Maximum RDAP related link fetches (default 2) */
/** Maximum RDAP related link fetches, counting links found in fetched documents (default 2) */
maxRdapLinkHops?: number;
/** RDAP link rels to consider (default ["related","entity","registrar","alternate"]) */
rdapLinkRels?: string[];