mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-10-26 08:26:22 -04:00
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:
5 files changed
+105
-39
No files matched your search
@@ -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
@@ -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)));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user