chore: replace Biome with oxlint and oxfmt

This commit is contained in:
2026-09-18 14:21:15 -04:00
parent 5ff837b2bb
commit 87e20f4a47
28 changed files with 1154 additions and 629 deletions
+3 -2
View File
@@ -12,11 +12,12 @@ jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
package-manager-cache: false
- run: npm install -g npm@latest
- run: npm ci
- run: npm publish
+5 -5
View File
@@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22, 24]
node-version: [18, 20, 22, 24, 26]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run test:run
- run: npm run fmt:check
- run: npm run test
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["**/dist"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"options": {
"typeAware": true,
"typeCheck": true
},
"categories": {
"correctness": "error"
},
"ignorePatterns": ["dist"]
}
+96 -90
View File
@@ -37,8 +37,8 @@ Normalize arbitrary input (domain or URL) to its registrable domain (eTLD+1):
import { toRegistrableDomain } from "rdapper";
toRegistrableDomain("https://sub.example.co.uk/page"); // => "example.co.uk"
toRegistrableDomain("spark-public.s3.amazonaws.com"); // => "amazonaws.com" (ICANN-only default)
toRegistrableDomain("192.168.0.1"); // => null
toRegistrableDomain("spark-public.s3.amazonaws.com"); // => "amazonaws.com" (ICANN-only default)
toRegistrableDomain("192.168.0.1"); // => null
```
Convenience helpers to quickly check availability:
@@ -102,7 +102,7 @@ For production applications that perform many domain lookups, you can take contr
#### Example: In-memory caching with TTL
```ts
import { lookup, type BootstrapData } from 'rdapper';
import { lookup, type BootstrapData } from "rdapper";
// Simple in-memory cache with TTL
let cachedBootstrap: BootstrapData | null = null;
@@ -111,43 +111,43 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
async function getBootstrapData(): Promise<BootstrapData> {
const now = Date.now();
// Return cached data if still valid
if (cachedBootstrap && now < cacheExpiry) {
return cachedBootstrap;
}
// Fetch fresh data
const response = await fetch('https://data.iana.org/rdap/dns.json');
const response = await fetch("https://data.iana.org/rdap/dns.json");
if (!response.ok) {
throw new Error(`Failed to load bootstrap data: ${response.status} ${response.statusText}`);
}
const data: BootstrapData = await response.json();
// Update cache
cachedBootstrap = data;
cacheExpiry = now + CACHE_TTL_MS;
return data;
}
// Use the cached bootstrap data in lookups
const bootstrapData = await getBootstrapData();
const result = await lookup('example.com', {
customBootstrapData: bootstrapData
const result = await lookup("example.com", {
customBootstrapData: bootstrapData,
});
```
#### Example: Redis caching
```ts
import { lookup, type BootstrapData } from 'rdapper';
import { createClient } from 'redis';
import { lookup, type BootstrapData } from "rdapper";
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
const CACHE_KEY = 'rdap:bootstrap:dns';
const CACHE_KEY = "rdap:bootstrap:dns";
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours
async function getBootstrapData(): Promise<BootstrapData> {
@@ -156,34 +156,34 @@ async function getBootstrapData(): Promise<BootstrapData> {
if (cached) {
return JSON.parse(cached);
}
// Fetch fresh data
const response = await fetch('https://data.iana.org/rdap/dns.json');
const response = await fetch("https://data.iana.org/rdap/dns.json");
if (!response.ok) {
throw new Error(`Failed to load bootstrap data: ${response.status} ${response.statusText}`);
}
const data: BootstrapData = await response.json();
// Store in Redis with TTL
await redis.setEx(CACHE_KEY, CACHE_TTL_SECONDS, JSON.stringify(data));
return data;
}
// Use the cached bootstrap data in lookups
const bootstrapData = await getBootstrapData();
const result = await lookup('example.com', {
customBootstrapData: bootstrapData
const result = await lookup("example.com", {
customBootstrapData: bootstrapData,
});
```
#### Example: Filesystem caching
```ts
import { lookup, type BootstrapData } from 'rdapper';
import { readFile, writeFile, stat } from 'node:fs/promises';
import { lookup, type BootstrapData } from "rdapper";
import { readFile, writeFile, stat } from "node:fs/promises";
const CACHE_FILE = './cache/rdap-bootstrap.json';
const CACHE_FILE = "./cache/rdap-bootstrap.json";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
async function getBootstrapData(): Promise<BootstrapData> {
@@ -191,32 +191,32 @@ async function getBootstrapData(): Promise<BootstrapData> {
// Check if cache file exists and is fresh
const stats = await stat(CACHE_FILE);
const age = Date.now() - stats.mtimeMs;
if (age < CACHE_TTL_MS) {
const cached = await readFile(CACHE_FILE, 'utf-8');
const cached = await readFile(CACHE_FILE, "utf-8");
return JSON.parse(cached);
}
} catch {
// Cache file doesn't exist or is unreadable, will fetch fresh
}
// Fetch fresh data
const response = await fetch('https://data.iana.org/rdap/dns.json');
const response = await fetch("https://data.iana.org/rdap/dns.json");
if (!response.ok) {
throw new Error(`Failed to load bootstrap data: ${response.status} ${response.statusText}`);
}
const data: BootstrapData = await response.json();
// Write to cache file
await writeFile(CACHE_FILE, JSON.stringify(data, null, 2), 'utf-8');
await writeFile(CACHE_FILE, JSON.stringify(data, null, 2), "utf-8");
return data;
}
// Use the cached bootstrap data in lookups
const bootstrapData = await getBootstrapData();
const result = await lookup('example.com', {
customBootstrapData: bootstrapData
const result = await lookup("example.com", {
customBootstrapData: bootstrapData,
});
```
@@ -226,10 +226,10 @@ The `BootstrapData` type matches IANA's published format:
```ts
interface BootstrapData {
version: string; // e.g., "1.0"
publication: string; // ISO 8601 timestamp
version: string; // e.g., "1.0"
publication: string; // ISO 8601 timestamp
description?: string;
services: string[][][]; // Array of [TLDs, base URLs] tuples
services: string[][][]; // Array of [TLDs, base URLs] tuples
}
```
@@ -244,6 +244,7 @@ For advanced use cases, rdapper allows you to provide a custom `fetch` implement
#### What requests are affected?
Your custom fetch will be used for:
- **RDAP bootstrap registry requests** (fetching `dns.json` from IANA, unless `customBootstrapData` is provided)
- **RDAP domain lookups** (querying RDAP servers for domain data)
- **RDAP related/entity link requests** (following links to registrar information)
@@ -260,40 +261,40 @@ Your custom fetch will be used for:
#### Example 1: Simple in-memory cache
```ts
import { lookup } from 'rdapper';
import { lookup } from "rdapper";
const cache = new Map<string, Response>();
const cachedFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString();
const url = typeof input === "string" ? input : input.toString();
// Check cache first
if (cache.has(url)) {
console.log('[Cache Hit]', url);
console.log("[Cache Hit]", url);
return cache.get(url)!.clone();
}
// Fetch and cache
console.log('[Cache Miss]', url);
console.log("[Cache Miss]", url);
const response = await fetch(input, init);
cache.set(url, response.clone());
return response;
};
const result = await lookup('example.com', { customFetch: cachedFetch });
const result = await lookup("example.com", { customFetch: cachedFetch });
```
#### Example 2: Request logging and monitoring
```ts
import { lookup } from 'rdapper';
import { lookup } from "rdapper";
const loggingFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString();
const url = typeof input === "string" ? input : input.toString();
const start = Date.now();
console.log(`[→] ${init?.method || 'GET'} ${url}`);
console.log(`[→] ${init?.method || "GET"} ${url}`);
try {
const response = await fetch(input, init);
const duration = Date.now() - start;
@@ -306,54 +307,54 @@ const loggingFetch: typeof fetch = async (input, init) => {
}
};
const result = await lookup('example.com', { customFetch: loggingFetch });
const result = await lookup("example.com", { customFetch: loggingFetch });
```
#### Example 3: Retry logic with exponential backoff
```ts
import { lookup } from 'rdapper';
import { lookup } from "rdapper";
async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
maxRetries = 3
maxRetries = 3,
): Promise<Response> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(input, init);
// Retry on 5xx errors
if (response.status >= 500 && attempt < maxRetries) {
const delay = Math.min(1000 * 2 ** attempt, 10000);
console.log(`Retrying after ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries) {
const delay = Math.min(1000 * 2 ** attempt, 10000);
await new Promise(resolve => setTimeout(resolve, delay));
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
}
}
throw lastError || new Error('Max retries exceeded');
throw lastError || new Error("Max retries exceeded");
}
const result = await lookup('example.com', { customFetch: fetchWithRetry });
const result = await lookup("example.com", { customFetch: fetchWithRetry });
```
#### Example 4: HTTP caching with cache-control headers
```ts
import { lookup } from 'rdapper';
import { lookup } from "rdapper";
interface CachedResponse {
response: Response;
@@ -363,20 +364,20 @@ interface CachedResponse {
const httpCache = new Map<string, CachedResponse>();
const httpCachingFetch: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString();
const url = typeof input === "string" ? input : input.toString();
const now = Date.now();
// Check if we have a valid cached response
const cached = httpCache.get(url);
if (cached && cached.expiresAt > now) {
return cached.response.clone();
}
// Fetch fresh response
const response = await fetch(input, init);
// Parse Cache-Control header
const cacheControl = response.headers.get('cache-control');
const cacheControl = response.headers.get("cache-control");
if (cacheControl) {
const maxAgeMatch = cacheControl.match(/max-age=(\d+)/);
if (maxAgeMatch) {
@@ -387,11 +388,11 @@ const httpCachingFetch: typeof fetch = async (input, init) => {
});
}
}
return response;
};
const result = await lookup('example.com', { customFetch: httpCachingFetch });
const result = await lookup("example.com", { customFetch: httpCachingFetch });
```
#### Example 5: Combining with customBootstrapData
@@ -399,10 +400,10 @@ const result = await lookup('example.com', { customFetch: httpCachingFetch });
You can use both `customFetch` and `customBootstrapData` together for maximum control:
```ts
import { lookup, type BootstrapData } from 'rdapper';
import { lookup, type BootstrapData } from "rdapper";
// Pre-load bootstrap data (no fetch needed for this)
const bootstrapData: BootstrapData = await getFromCache('bootstrap');
const bootstrapData: BootstrapData = await getFromCache("bootstrap");
// Use custom fetch for all other RDAP requests
const cachedFetch: typeof fetch = async (input, init) => {
@@ -410,7 +411,7 @@ const cachedFetch: typeof fetch = async (input, init) => {
return fetch(input, init);
};
const result = await lookup('example.com', {
const result = await lookup("example.com", {
customBootstrapData: bootstrapData,
customFetch: cachedFetch,
});
@@ -441,13 +442,13 @@ The exact presence of fields depends on registry/registrar data and whether RDAP
```ts
interface DomainRecord {
domain: string; // normalized name (unicode when available)
tld: string; // public suffix (can be multi-label, e.g., "com", "co.uk")
isRegistered: boolean; // availability heuristic (WHOIS) or true (RDAP)
isIDN?: boolean; // uses punycode labels (xn--)
unicodeName?: string; // RDAP unicodeName when provided
punycodeName?: string; // RDAP ldhName when provided
registry?: string; // registry operator (rarely available)
domain: string; // normalized name (unicode when available)
tld: string; // public suffix (can be multi-label, e.g., "com", "co.uk")
isRegistered: boolean; // availability heuristic (WHOIS) or true (RDAP)
isIDN?: boolean; // uses punycode labels (xn--)
unicodeName?: string; // RDAP unicodeName when provided
punycodeName?: string; // RDAP ldhName when provided
registry?: string; // registry operator (rarely available)
registrar?: {
name?: string;
ianaId?: string;
@@ -461,11 +462,11 @@ interface DomainRecord {
description?: string;
raw?: string;
}>;
creationDate?: string; // ISO 8601 (UTC)
updatedDate?: string; // ISO 8601 (UTC)
expirationDate?: string; // ISO 8601 (UTC)
deletionDate?: string; // ISO 8601 (UTC)
transferLock?: boolean; // derived from EPP statuses
creationDate?: string; // ISO 8601 (UTC)
updatedDate?: string; // ISO 8601 (UTC)
expirationDate?: string; // ISO 8601 (UTC)
deletionDate?: string; // ISO 8601 (UTC)
transferLock?: boolean; // derived from EPP statuses
dnssec?: {
enabled: boolean;
dsRecords?: Array<{
@@ -481,7 +482,8 @@ interface DomainRecord {
ipv6?: string[];
}>;
contacts?: Array<{
type: "registrant" | "admin" | "tech" | "billing" | "abuse" | "registrar" | "reseller" | "unknown";
type:
"registrant" | "admin" | "tech" | "billing" | "abuse" | "registrar" | "reseller" | "unknown";
name?: string;
organization?: string;
email?: string | string[];
@@ -494,12 +496,12 @@ interface DomainRecord {
country?: string;
countryCode?: string;
}>;
privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics
whoisServer?: string; // authoritative WHOIS queried (if any)
rdapServers?: string[]; // RDAP URLs tried (bootstrap bases and related/entity links)
rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
rawWhois?: string; // raw WHOIS text (only when options.includeRaw)
source: "rdap" | "whois"; // which path produced data
privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics
whoisServer?: string; // authoritative WHOIS queried (if any)
rdapServers?: string[]; // RDAP URLs tried (bootstrap bases and related/entity links)
rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
rawWhois?: string; // raw WHOIS text (only when options.includeRaw)
source: "rdap" | "whois"; // which path produced data
warnings?: string[];
}
```
@@ -511,7 +513,10 @@ interface DomainRecord {
"domain": "example.com",
"tld": "com",
"isRegistered": true,
"registrar": { "name": "Internet Assigned Numbers Authority", "ianaId": "376" },
"registrar": {
"name": "Internet Assigned Numbers Authority",
"ianaId": "376"
},
"statuses": [{ "status": "clientTransferProhibited" }],
"nameservers": [{ "host": "a.iana-servers.net" }, { "host": "b.iana-servers.net" }],
"dnssec": { "enabled": true },
@@ -539,9 +544,10 @@ Timeouts are enforced per request using a simple race against `timeoutMs` (defau
- Test: `npm test` ([Vitest](https://vitest.dev/))
- By default, tests are offline/deterministic.
- Watch mode: `npm run dev`
- Coverage: `npm run test:run -- --coverage`
- Coverage: `npm run test -- --coverage`
- Smoke tests that hit the network are gated by `SMOKE=1`, e.g. `SMOKE=1 npm test`.
- Lint/format: `npm run lint` ([Biome](https://biomejs.dev/))
- Lint: `npm run lint` ([Oxlint](https://oxc.rs/docs/guide/usage/linter))
- Format: `npm run fmt` ([Oxfmt](https://oxc.rs/docs/guide/usage/formatter))
Project layout:
+2 -8
View File
@@ -11,13 +11,7 @@ import { lookup } from "../dist/index.mjs";
async function main() {
if (process.argv.length > 2) {
// URL(s) specified in the command arguments
console.log(
JSON.stringify(
await lookup(process.argv[process.argv.length - 1]),
null,
2,
),
);
console.log(JSON.stringify(await lookup(process.argv[process.argv.length - 1]), null, 2));
} else {
// No domain passed as argument, read from each line of stdin
const rlInterface = createInterface({
@@ -29,4 +23,4 @@ async function main() {
}
}
main();
void main();
-34
View File
@@ -1,34 +0,0 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git"
},
"files": {
"includes": ["**", "!**/dist"],
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+910 -164
View File
File diff suppressed because it is too large Load Diff
+31 -28
View File
@@ -1,30 +1,39 @@
{
"name": "rdapper",
"version": "0.13.0",
"license": "MIT",
"description": "🎩 RDAP/WHOIS fetcher, parser, and normalizer for Node",
"repository": {
"type": "git",
"url": "git+https://github.com/jakejarvis/rdapper.git"
},
"keywords": [
"domain",
"lookup",
"normalizer",
"parser",
"rdap",
"registration",
"whois"
],
"bugs": {
"url": "https://github.com/jakejarvis/rdapper/issues"
},
"license": "MIT",
"author": {
"name": "Jake Jarvis",
"email": "jake@jarv.is",
"url": "https://jarv.is"
},
"publishConfig": {
"access": "public"
"repository": {
"type": "git",
"url": "git+https://github.com/jakejarvis/rdapper.git"
},
"bin": {
"rdapper": "bin/cli.mjs"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"bin": {
"rdapper": "bin/cli.mjs"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
@@ -32,25 +41,28 @@
"default": "./dist/index.mjs"
}
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"cli": "node bin/cli.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"lint": "biome check",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"dependencies": {
"tldts": "~7.4.13"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@types/node": "^26.6.1",
"oxfmt": "^0.68.0",
"oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2002",
"tsdown": "^0.23.0",
"typescript": "^6.0.3",
"vite": "^8.3.0",
@@ -58,14 +70,5 @@
},
"engines": {
"node": ">=18.17"
},
"keywords": [
"rdap",
"whois",
"domain",
"registration",
"lookup",
"parser",
"normalizer"
]
}
}
+3 -9
View File
@@ -15,9 +15,7 @@ maybeTest("lookup smoke test (example.com)", async () => {
expect(res.ok, res.error).toBe(true);
expect(Boolean(res.record?.domain)).toBe(true);
expect(Boolean(res.record?.tld)).toBe(true);
expect(res.record?.source === "rdap" || res.record?.source === "whois").toBe(
true,
);
expect(res.record?.source === "rdap" || res.record?.source === "whois").toBe(true);
});
// RDAP-only smoke for reserved example domains (.com/.net/.org)
@@ -42,9 +40,7 @@ for (const c of rdapCases) {
if (c.tld !== "org") {
// .com/.net often include the IANA reserved name explicitly
expect(
(rec.registrar?.name || "")
.toLowerCase()
.includes("internet assigned numbers authority"),
(rec.registrar?.name || "").toLowerCase().includes("internet assigned numbers authority"),
).toBe(true);
}
// IANA nameservers
@@ -112,9 +108,7 @@ maybeTest("WHOIS-only lookup for example.io", async () => {
});
maybeTest("isRegistered true for example.com", async () => {
await expect(isRegistered("example.com", { timeoutMs: 15000 })).resolves.toBe(
true,
);
await expect(isRegistered("example.com", { timeoutMs: 15000 })).resolves.toBe(true);
});
maybeTest("isAvailable true for an unlikely .com", async () => {
+9 -21
View File
@@ -30,18 +30,13 @@ vi.mock("./whois/referral.js", async () => {
const client = await import("./whois/client.js");
return {
followWhoisReferrals: vi.fn(
async (
server: string,
domain: string,
opts?: import("./types").LookupOptions,
) => client.whoisQuery(server, domain, opts),
async (server: string, domain: string, opts?: import("./types").LookupOptions) =>
client.whoisQuery(server, domain, opts),
),
collectWhoisReferralChain: vi.fn(
async (
server: string,
domain: string,
opts?: import("./types").LookupOptions,
) => [await client.whoisQuery(server, domain, opts)],
async (server: string, domain: string, opts?: import("./types").LookupOptions) => [
await client.whoisQuery(server, domain, opts),
],
),
};
});
@@ -55,8 +50,7 @@ vi.mock("./whois/discovery.js", async () => {
});
vi.mock("./lib/domain.js", async () => {
const actual =
await vi.importActual<typeof import("./lib/domain.js")>("./lib/domain.js");
const actual = await vi.importActual<typeof import("./lib/domain.js")>("./lib/domain.js");
return {
...actual,
// Default to actual behavior; specific tests can override
@@ -75,9 +69,7 @@ import * as whoisReferral from "./whois/referral";
describe("lookup orchestration", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue(
"whois.verisign-grs.com",
);
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue("whois.verisign-grs.com");
});
it("uses RDAP when available and does not call WHOIS", async () => {
@@ -89,9 +81,7 @@ describe("lookup orchestration", () => {
});
it("falls back to WHOIS when RDAP fails", async () => {
vi.mocked(rdapClient.fetchRdapDomain).mockRejectedValueOnce(
new Error("rdap down"),
);
vi.mocked(rdapClient.fetchRdapDomain).mockRejectedValueOnce(new Error("rdap down"));
const res = await lookup("example.com", { timeoutMs: 200 });
expect(res.ok, res.error).toBe(true);
expect(res.record?.source).toBe("whois");
@@ -155,9 +145,7 @@ describe("RDAP 404 handling", () => {
describe("WHOIS referral & includeRaw", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue(
"whois.verisign-grs.com",
);
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue("whois.verisign-grs.com");
});
it("does not follow referral when followWhoisReferral is false", async () => {
+7 -30
View File
@@ -11,19 +11,13 @@ import {
} from "./whois/discovery";
import { mergeWhoisRecords } from "./whois/merge";
import { normalizeWhois } from "./whois/normalize";
import {
collectWhoisReferralChain,
followWhoisReferrals,
} from "./whois/referral";
import { collectWhoisReferralChain, followWhoisReferrals } from "./whois/referral";
/**
* High-level lookup that prefers RDAP and falls back to WHOIS.
* Ensures a standardized DomainRecord, independent of the source.
*/
export async function lookup(
domain: string,
opts?: LookupOptions,
): Promise<LookupResult> {
export async function lookup(domain: string, opts?: LookupOptions): Promise<LookupResult> {
try {
if (!isLikelyDomain(domain)) {
return { ok: false, error: "Input does not look like a domain" };
@@ -61,11 +55,7 @@ export async function lookup(
return { ok: true, record };
}
const rdapEnriched = await fetchAndMergeRdapRelated(
domain,
json,
opts,
);
const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts);
const record: DomainRecord = normalizeRdap(
domain,
tld,
@@ -92,9 +82,7 @@ export async function lookup(
if (!whoisServer) {
// Provide a clearer, actionable message
const ianaText = await getIanaWhoisTextForTld(tld, opts);
const regUrl = ianaText
? parseIanaRegistrationInfoUrl(ianaText)
: undefined;
const regUrl = ianaText ? parseIanaRegistrationInfoUrl(ianaText) : undefined;
const hint = regUrl ? ` See registration info at ${regUrl}.` : "";
return {
ok: false,
@@ -138,10 +126,7 @@ export async function lookup(
* Determine if a domain appears available (not registered).
* Performs a lookup and resolves to a boolean. Rejects on lookup error.
*/
export async function isAvailable(
domain: string,
opts?: LookupOptions,
): Promise<boolean> {
export async function isAvailable(domain: string, opts?: LookupOptions): Promise<boolean> {
const res = await lookup(domain, opts);
if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
return res.record.isRegistered === false;
@@ -151,10 +136,7 @@ export async function isAvailable(
* Determine if a domain appears registered.
* Performs a lookup and resolves to a boolean. Rejects on lookup error.
*/
export async function isRegistered(
domain: string,
opts?: LookupOptions,
): Promise<boolean> {
export async function isRegistered(domain: string, opts?: LookupOptions): Promise<boolean> {
const res = await lookup(domain, opts);
if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
return res.record.isRegistered === true;
@@ -165,10 +147,5 @@ export async function isRegistered(
*/
export const lookupDomain = lookup;
export {
getDomainParts,
getDomainTld,
isLikelyDomain,
toRegistrableDomain,
} from "./lib/domain";
export { getDomainParts, getDomainTld, isLikelyDomain, toRegistrableDomain } from "./lib/domain";
export type * from "./types";
+3 -15
View File
@@ -1,8 +1,6 @@
// Lightweight date parsing helpers to avoid external dependencies.
// We aim to parse common RDAP and WHOIS date representations and return a UTC ISO string.
export function toISO(
dateLike: string | number | Date | undefined | null,
): string | undefined {
export function toISO(dateLike: string | number | Date | undefined | null): string | undefined {
if (dateLike == null) return undefined;
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
@@ -53,10 +51,7 @@ function toIsoFromDate(d: Date): string | undefined {
}
}
function parseDateWithRegex(
m: RegExpMatchArray,
_re: RegExp,
): Date | undefined {
function parseDateWithRegex(m: RegExpMatchArray, _re: RegExp): Date | undefined {
const monthMap: Record<string, number> = {
jan: 0,
feb: 1,
@@ -77,14 +72,7 @@ function parseDateWithRegex(
const [_, y, mo, d, hh, mm, ss, offH, offM] = m;
if (!y || !mo || !d || !hh || !mm || !ss) return undefined;
// Base time as UTC
let dt = Date.UTC(
Number(y),
Number(mo) - 1,
Number(d),
Number(hh),
Number(mm),
Number(ss),
);
let dt = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss));
// Apply timezone offset if present (e.g., +0000, -0500, +05:30)
if (offH) {
const sign = offH.startsWith("-") ? -1 : 1;
+2 -6
View File
@@ -14,15 +14,11 @@ test("isLikelyDomain", () => {
test("toRegistrableDomain normalizes eTLD+1 and rejects non-ICANN", () => {
// Basic domains
expect(toRegistrableDomain("example.com")).toBe("example.com");
expect(toRegistrableDomain("http://www.writethedocs.org/conf")).toBe(
"writethedocs.org",
);
expect(toRegistrableDomain("http://www.writethedocs.org/conf")).toBe("writethedocs.org");
// Private/public SLDs should collapse to ICANN TLD + SLD by default
// (ICANN-only behavior; private suffixes ignored)
expect(toRegistrableDomain("spark-public.s3.amazonaws.com")).toBe(
"amazonaws.com",
);
expect(toRegistrableDomain("spark-public.s3.amazonaws.com")).toBe("amazonaws.com");
// Reject IPs and invalid inputs
expect(toRegistrableDomain("192.168.0.1")).toBeNull();
+3 -12
View File
@@ -6,10 +6,7 @@ type ParseOptions = Parameters<typeof parse>[1];
* Parse a domain into its parts. Passes options to `tldts.parse()`.
* @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
*/
export function getDomainParts(
domain: string,
opts?: ParseOptions,
): ReturnType<typeof parse> {
export function getDomainParts(domain: string, opts?: ParseOptions): ReturnType<typeof parse> {
return parse(domain, { ...opts });
}
@@ -17,10 +14,7 @@ export function getDomainParts(
* Get the TLD (ICANN-only public suffix) of a domain. Passes options to `tldts.parse()`.
* @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
*/
export function getDomainTld(
domain: string,
opts?: ParseOptions,
): string | null {
export function getDomainTld(domain: string, opts?: ParseOptions): string | null {
const result = getDomainParts(domain, {
allowPrivateDomains: false,
...opts,
@@ -54,10 +48,7 @@ export function punyToUnicode(domain: string): string {
* Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs)
* @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
*/
export function toRegistrableDomain(
input: string,
opts?: ParseOptions,
): string | null {
export function toRegistrableDomain(input: string, opts?: ParseOptions): string | null {
const raw = (input ?? "").trim();
if (raw === "") return null;
+1 -6
View File
@@ -69,11 +69,6 @@ export function asStringArray(value: unknown): string[] | undefined {
}
export function asDateLike(value: unknown): string | number | Date | undefined {
if (
typeof value === "string" ||
typeof value === "number" ||
value instanceof Date
)
return value;
if (typeof value === "string" || typeof value === "number" || value instanceof Date) return value;
return undefined;
}
+2 -12
View File
@@ -1,12 +1,4 @@
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 { getRdapBaseUrlsForTld } from "./bootstrap";
@@ -110,9 +102,7 @@ describe("getRdapBaseUrlsForTld with customBootstrapData", () => {
const dataWithDuplicates: BootstrapData = {
version: "1.0",
publication: "2025-01-15T12:00:00Z",
services: [
[["test"], ["https://rdap.example.com/", "https://rdap.example.com"]],
],
services: [[["test"], ["https://rdap.example.com/", "https://rdap.example.com"]]],
};
const urls = await getRdapBaseUrlsForTld("test", {
+1 -4
View File
@@ -24,10 +24,7 @@ export async function fetchRdapDomain(
baseUrl: string,
options?: LookupOptions,
): 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 res = await withTimeout(
fetchFn(url, {
+1 -3
View File
@@ -13,9 +13,7 @@ export function extractRdapRelatedLinks(
opts?: Pick<LookupOptions, "rdapLinkRels">,
): string[] {
const rels = (
opts?.rdapLinkRels?.length
? opts.rdapLinkRels
: ["related", "entity", "registrar", "alternate"]
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[]) : [];
+15 -14
View File
@@ -6,38 +6,40 @@ import { extractRdapRelatedLinks } from "./links";
type Json = Record<string, unknown>;
/** Coerce a loosely-typed JSON field to a string ("" for null/objects/etc.). */
function str(val: unknown): string {
return typeof val === "string" || typeof val === "number" || typeof val === "boolean"
? String(val)
: "";
}
/** Merge RDAP documents with a conservative, additive strategy. */
export function mergeRdapDocs(baseDoc: unknown, others: unknown[]): unknown {
const merged: Json = { ...(baseDoc as Json) };
for (const doc of others) {
const cur = (doc ?? {}) as Json;
// status: array of strings
merged.status = uniqStrings([
...toStringArray(merged.status),
...toStringArray(cur.status),
]);
merged.status = uniqStrings([...toStringArray(merged.status), ...toStringArray(cur.status)]);
// events: array of objects; dedupe by eventAction + eventDate
merged.events = uniqBy(
[...toArray<Json>(merged.events), ...toArray<Json>(cur.events)],
(e) =>
`${String(e?.eventAction ?? "").toLowerCase()}|${String(e?.eventDate ?? "")}`,
(e) => `${str(e?.eventAction).toLowerCase()}|${str(e?.eventDate)}`,
);
// nameservers: array of objects; dedupe by ldhName/unicodeName
merged.nameservers = uniqBy(
[...toArray<Json>(merged.nameservers), ...toArray<Json>(cur.nameservers)],
(n) => `${String(n?.ldhName ?? n?.unicodeName ?? "").toLowerCase()}`,
(n) => `${str(n?.ldhName ?? n?.unicodeName).toLowerCase()}`,
);
// entities: array; dedupe by handle if present, else by roles+vcard hash
merged.entities = uniqBy(
[...toArray<Json>(merged.entities), ...toArray<Json>(cur.entities)],
(e) =>
`${String(e?.handle ?? "").toLowerCase()}|${String(
`${str(e?.handle).toLowerCase()}|${String(
JSON.stringify(e?.roles || []),
).toLowerCase()}|${String(JSON.stringify(e?.vcardArray || [])).toLowerCase()}`,
);
// secureDNS: prefer existing; fill if missing
if (merged.secureDNS == null && cur.secureDNS != null)
merged.secureDNS = cur.secureDNS;
if (merged.secureDNS == null && cur.secureDNS != null) merged.secureDNS = cur.secureDNS;
// port43 (authoritative WHOIS): prefer existing; fill if missing
if (merged.port43 == null && cur.port43 != null) merged.port43 = cur.port43;
// remarks: concat simple strings if present
@@ -59,8 +61,7 @@ export async function fetchAndMergeRdapRelated(
opts?: LookupOptions,
): Promise<{ merged: unknown; serversTried: string[] }> {
const tried: string[] = [];
if (opts?.rdapFollowLinks === false)
return { merged: baseDoc, serversTried: tried };
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 };
@@ -83,8 +84,8 @@ export async function fetchAndMergeRdapRelated(
tried.push(url);
// only accept docs that appear related to the same domain when possible
// if ldhName/unicodeName present, they should match the queried domain (case-insensitive)
const ldh = String((json as Json)?.ldhName ?? "").toLowerCase();
const uni = String((json as Json)?.unicodeName ?? "").toLowerCase();
const ldh = str((json as Json)?.ldhName).toLowerCase();
const uni = str((json as Json)?.unicodeName).toLowerCase();
if (ldh && !sameDomain(ldh, domain)) continue;
if (uni && !sameDomain(uni, domain)) continue;
fetchedDocs.push(json);
+3 -9
View File
@@ -47,9 +47,7 @@ test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () =
],
secureDNS: {
delegationSigned: true,
dsData: [
{ keyTag: 12345, algorithm: 13, digestType: 2, digest: "ABCDEF" },
],
dsData: [{ keyTag: 12345, algorithm: 13, digestType: 2, digest: "ABCDEF" }],
},
events: [
{ eventAction: "registration", eventDate: "2020-01-02T03:04:05Z" },
@@ -59,9 +57,7 @@ test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () =
status: ["clientTransferProhibited"],
port43: "whois.example-registrar.test",
};
const rec = normalizeRdap("example.com", "com", rdap, [
"https://rdap.example/",
]);
const rec = normalizeRdap("example.com", "com", rdap, ["https://rdap.example/"]);
expect(rec.domain).toBe("example.com");
expect(rec.tld).toBe("com");
expect(rec.registrar?.name).toBe("Registrar LLC");
@@ -95,9 +91,7 @@ test("normalizeRdap derives privacyEnabled from registrant keywords", () => {
},
],
};
const rec = normalizeRdap("example.com", "com", rdap, [
"https://rdap.example/",
]);
const rec = normalizeRdap("example.com", "com", rdap, ["https://rdap.example/"]);
expect(rec.privacyEnabled).toBe(true);
});
+15 -48
View File
@@ -1,12 +1,7 @@
import { toISO } from "../lib/dates";
import { isPrivacyName } from "../lib/privacy";
import { asDateLike, asString, asStringArray, uniq } from "../lib/text";
import type {
Contact,
DomainRecord,
Nameserver,
RegistrarInfo,
} from "../types";
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
type RdapDoc = Record<string, unknown>;
@@ -24,24 +19,17 @@ export function normalizeRdap(
const doc = (rdap ?? {}) as RdapDoc;
// Prefer ldhName (punycode) and unicodeName if provided
const ldhName: string | undefined =
asString(doc.ldhName) || asString(doc.handle);
const ldhName: string | undefined = asString(doc.ldhName) || asString(doc.handle);
const unicodeName: string | undefined = asString(doc.unicodeName);
// Registrar entity can be provided with role "registrar"
const registrar: RegistrarInfo | undefined = extractRegistrar(
doc.entities as unknown,
);
const registrar: RegistrarInfo | undefined = extractRegistrar(doc.entities as unknown);
// Nameservers: normalize host + IPs
const nameservers: Nameserver[] | undefined = Array.isArray(doc.nameservers)
? (doc.nameservers as RdapDoc[])
.map((ns) => {
const host = (
asString(ns.ldhName) ??
asString(ns.unicodeName) ??
""
).toLowerCase();
const host = (asString(ns.ldhName) ?? asString(ns.unicodeName) ?? "").toLowerCase();
const ip = ns.ipAddresses as RdapDoc | undefined;
const ipv4 = asStringArray(ip?.v4);
const ipv6 = asStringArray(ip?.v6);
@@ -54,17 +42,13 @@ export function normalizeRdap(
: undefined;
// Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse
const contacts: Contact[] | undefined = extractContacts(
doc.entities as unknown,
);
const contacts: Contact[] | undefined = extractContacts(doc.entities as unknown);
// Derive privacy flag from registrant name/org keywords
const registrant = contacts?.find((c) => c.type === "registrant");
const privacyEnabled = !!(
registrant &&
(
[registrant.name, registrant.organization].filter(Boolean) as string[]
).some(isPrivacyName)
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
);
// RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
@@ -99,21 +83,16 @@ export function normalizeRdap(
: [];
const byAction = (action: string) =>
events.find(
(e) =>
typeof e?.eventAction === "string" &&
e.eventAction.toLowerCase().includes(action),
(e) => typeof e?.eventAction === "string" && e.eventAction.toLowerCase().includes(action),
);
const creationDate = toISO(
asDateLike(byAction("registration")?.eventDate) ??
asDateLike(doc.registrationDate),
asDateLike(byAction("registration")?.eventDate) ?? asDateLike(doc.registrationDate),
);
const updatedDate = toISO(
asDateLike(byAction("last changed")?.eventDate) ??
asDateLike(doc.lastChangedDate),
asDateLike(byAction("last changed")?.eventDate) ?? asDateLike(doc.lastChangedDate),
);
const expirationDate = toISO(
asDateLike(byAction("expiration")?.eventDate) ??
asDateLike(doc.expirationDate),
asDateLike(byAction("expiration")?.eventDate) ?? asDateLike(doc.expirationDate),
);
const deletionDate = toISO(
asDateLike(byAction("deletion")?.eventDate) ?? asDateLike(doc.deletionDate),
@@ -164,9 +143,7 @@ function extractRegistrar(entities: unknown): RegistrarInfo | undefined {
if (!Array.isArray(entities)) return undefined;
for (const ent of entities) {
const roles: string[] = Array.isArray((ent as RdapDoc)?.roles)
? ((ent as RdapDoc).roles as unknown[]).filter(
(r): r is string => typeof r === "string",
)
? ((ent as RdapDoc).roles as unknown[]).filter((r): r is string => typeof r === "string")
: [];
if (!roles.some((r) => /registrar/i.test(r))) continue;
const v = parseVcard((ent as RdapDoc)?.vcardArray);
@@ -191,9 +168,7 @@ function extractContacts(entities: unknown): Contact[] | undefined {
const out: Contact[] = [];
for (const ent of entities) {
const roles: string[] = Array.isArray((ent as RdapDoc)?.roles)
? ((ent as RdapDoc).roles as unknown[]).filter(
(r): r is string => typeof r === "string",
)
? ((ent as RdapDoc).roles as unknown[]).filter((r): r is string => typeof r === "string")
: [];
const v = parseVcard((ent as RdapDoc)?.vcardArray);
const type = roles.find((r) =>
@@ -245,15 +220,9 @@ interface ParsedVCard {
// Parse a minimal subset of vCard 4.0 arrays as used in RDAP "vcardArray" fields
function parseVcard(vcardArray: unknown): ParsedVCard {
// vcardArray is typically ["vcard", [["version",{} ,"text","4.0"], ["fn",{} ,"text","Example"], ...]]
if (
!Array.isArray(vcardArray) ||
vcardArray[0] !== "vcard" ||
!Array.isArray(vcardArray[1])
)
if (!Array.isArray(vcardArray) || vcardArray[0] !== "vcard" || !Array.isArray(vcardArray[1]))
return {};
const entries = vcardArray[1] as Array<
[string, Record<string, unknown>, string, unknown]
>;
const entries = vcardArray[1] as Array<[string, Record<string, unknown>, string, unknown]>;
const out: ParsedVCard = {};
for (const e of entries) {
const key = e?.[0];
@@ -264,9 +233,7 @@ function parseVcard(vcardArray: unknown): ParsedVCard {
out.fn = asString(value);
break;
case "org":
out.org = Array.isArray(value)
? value.map((x) => String(x)).join(" ")
: asString(value);
out.org = Array.isArray(value) ? value.map((x) => String(x)).join(" ") : asString(value);
break;
case "email":
out.email = asString(value);
+1 -4
View File
@@ -372,7 +372,4 @@ export interface LookupResult {
* Used internally for dependency injection and testing. Matches the signature
* of the global `fetch` function available in Node.js 18+ and browsers.
*/
export type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<Response>;
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
+1 -7
View File
@@ -31,13 +31,7 @@ describe("WHOIS coalescing", () => {
const [first] = chain;
if (!first) throw new Error("Expected first record");
const base = normalizeWhois(
"gitpod.io",
"io",
first.text,
first.serverQueried,
false,
);
const base = normalizeWhois("gitpod.io", "io", first.text, first.serverQueried, false);
const merged = mergeWhoisRecords(base, []);
expect(merged.isRegistered).toBe(true);
expect(merged.creationDate).toBeDefined();
+4 -16
View File
@@ -1,10 +1,7 @@
import { uniq } from "../lib/text";
import type { Contact, DomainRecord, Nameserver } from "../types";
function dedupeStatuses(
a?: DomainRecord["statuses"],
b?: DomainRecord["statuses"],
) {
function dedupeStatuses(a?: DomainRecord["statuses"], b?: DomainRecord["statuses"]) {
const list = [...(a || []), ...(b || [])];
const seen = new Set<string>();
const out: NonNullable<DomainRecord["statuses"]> = [];
@@ -48,10 +45,7 @@ function dedupeContacts(a?: Contact[], b?: Contact[]) {
}
/** Conservative merge: start with base; fill missing scalars; union arrays; prefer more informative dates. */
export function mergeWhoisRecords(
base: DomainRecord,
others: DomainRecord[],
): DomainRecord {
export function mergeWhoisRecords(base: DomainRecord, others: DomainRecord[]): DomainRecord {
const merged: DomainRecord = { ...base };
for (const cur of others) {
merged.isRegistered = merged.isRegistered || cur.isRegistered;
@@ -60,15 +54,9 @@ export function mergeWhoisRecords(
merged.reseller = merged.reseller ?? cur.reseller;
merged.statuses = dedupeStatuses(merged.statuses, cur.statuses);
// Dates: prefer earliest creation, latest updated/expiration when available
merged.creationDate = preferEarliestIso(
merged.creationDate,
cur.creationDate,
);
merged.creationDate = preferEarliestIso(merged.creationDate, cur.creationDate);
merged.updatedDate = preferLatestIso(merged.updatedDate, cur.updatedDate);
merged.expirationDate = preferLatestIso(
merged.expirationDate,
cur.expirationDate,
);
merged.expirationDate = preferLatestIso(merged.expirationDate, cur.expirationDate);
merged.deletionDate = merged.deletionDate ?? cur.deletionDate;
merged.transferLock = Boolean(merged.transferLock || cur.transferLock);
merged.dnssec = merged.dnssec ?? cur.dnssec;
+2 -12
View File
@@ -159,12 +159,7 @@ Name Server: NS2.EXAMPLE.COM
DNSSEC: unsigned
Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
`;
const rec = normalizeWhois(
"example.com",
"com",
text,
"whois.verisign-grs.com",
);
const rec = normalizeWhois("example.com", "com", text, "whois.verisign-grs.com");
expect(Boolean(rec.creationDate)).toBe(true);
expect(Boolean(rec.expirationDate)).toBe(true);
expect(rec.source).toBe("whois");
@@ -178,12 +173,7 @@ Registrar URL: http://www.registrar.test
Registrant Name: REDACTED FOR PRIVACY
Registrant Organization: Example Org
`;
const rec = normalizeWhois(
"example.com",
"com",
text,
"whois.verisign-grs.com",
);
const rec = normalizeWhois("example.com", "com", text, "whois.verisign-grs.com");
expect(rec.privacyEnabled).toBe(true);
});
+15 -65
View File
@@ -1,12 +1,7 @@
import { toISO } from "../lib/dates";
import { isPrivacyName } from "../lib/privacy";
import { parseKeyValueLines, uniq } from "../lib/text";
import type {
Contact,
DomainRecord,
Nameserver,
RegistrarInfo,
} from "../types";
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
// Common WHOIS availability phrases seen across registries/registrars
const WHOIS_AVAILABLE_PATTERNS: RegExp[] = [
@@ -123,11 +118,7 @@ export function normalizeWhois(
"organisation",
"record maintained by",
]);
const ianaId = anyValue(map, [
"registrar iana id",
"sponsoring registrar iana id",
"iana id",
]);
const ianaId = anyValue(map, ["registrar iana id", "sponsoring registrar iana id", "iana id"]);
const url = anyValue(map, [
"registrar url",
"registrar website",
@@ -135,16 +126,9 @@ export function normalizeWhois(
"url of the registrar",
"referrer",
]);
const abuseEmail = anyValue(map, [
"registrar abuse contact email",
"abuse contact email",
]);
const abusePhone = anyValue(map, [
"registrar abuse contact phone",
"abuse contact phone",
]);
if (!name && !ianaId && !url && !abuseEmail && !abusePhone)
return undefined;
const abuseEmail = anyValue(map, ["registrar abuse contact email", "abuse contact email"]);
const abusePhone = anyValue(map, ["registrar abuse contact phone", "abuse contact phone"]);
if (!name && !ianaId && !url && !abuseEmail && !abusePhone) return undefined;
return {
name: name || undefined,
ianaId: ianaId || undefined,
@@ -222,15 +206,11 @@ export function normalizeWhois(
const registrant = contacts?.find((c) => c.type === "registrant");
const privacyEnabled = !!(
registrant &&
(
[registrant.name, registrant.organization].filter(Boolean) as string[]
).some(isPrivacyName)
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
);
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
const dnssec = dnssecRaw
? { enabled: /signed|yes|true/.test(dnssecRaw) }
: undefined;
const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : undefined;
// Simple lock derivation from statuses
const transferLock = !!statuses?.some((s) =>
@@ -268,10 +248,7 @@ export function normalizeWhois(
return record;
}
function anyValue(
map: Record<string, string[]>,
keys: string[],
): string | undefined {
function anyValue(map: Record<string, string[]>, keys: string[]): string | undefined {
for (const k of keys) {
const v = map[k];
if (v?.length) return v[0];
@@ -327,11 +304,7 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
nameKeys.push("owner name"); // .tm
}
orgKeys.push(
`${prefix} organization`,
`${prefix} organisation`,
`${prefix} org`,
);
orgKeys.push(`${prefix} organization`, `${prefix} organisation`, `${prefix} org`);
if (prefix === "registrant") {
orgKeys.push("trading as"); // .uk, .co.uk
orgKeys.push("org"); // .ru
@@ -340,42 +313,22 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
orgKeys.push("owner orgname"); // .tm
}
emailKeys.push(
`${prefix} email`,
`${prefix} contact email`,
`${prefix} e-mail`,
);
emailKeys.push(`${prefix} email`, `${prefix} contact email`, `${prefix} e-mail`);
phoneKeys.push(
`${prefix} phone`,
`${prefix} contact phone`,
`${prefix} telephone`,
);
phoneKeys.push(`${prefix} phone`, `${prefix} contact phone`, `${prefix} telephone`);
faxKeys.push(`${prefix} fax`, `${prefix} facsimile`);
streetKeys.push(
`${prefix} street`,
`${prefix} address`,
`${prefix}'s address`,
);
streetKeys.push(`${prefix} street`, `${prefix} address`, `${prefix}'s address`);
if (prefix === "owner") {
streetKeys.push("owner addr"); // .tm
}
cityKeys.push(`${prefix} city`);
stateKeys.push(
`${prefix} state`,
`${prefix} province`,
`${prefix} state/province`,
);
stateKeys.push(`${prefix} state`, `${prefix} province`, `${prefix} state/province`);
postalCodeKeys.push(
`${prefix} postal code`,
`${prefix} postcode`,
`${prefix} zip`,
);
postalCodeKeys.push(`${prefix} postal code`, `${prefix} postcode`, `${prefix} zip`);
countryKeys.push(`${prefix} country`);
}
@@ -410,10 +363,7 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
return contacts.length ? contacts : undefined;
}
function multi(
map: Record<string, string[]>,
keys: string[],
): string[] | undefined {
function multi(map: Record<string, string[]>, keys: string[]): string[] | undefined {
for (const k of keys) {
const v = map[k];
if (v?.length) return v;
+4 -5
View File
@@ -32,11 +32,10 @@ describe("WHOIS referral contradiction handling", () => {
});
it("collects chain and does not append contradictory registrar", async () => {
const chain = await collectWhoisReferralChain(
"whois.nic.io",
"raindrop.io",
{ followWhoisReferral: true, maxWhoisReferralHops: 2 },
);
const chain = await collectWhoisReferralChain("whois.nic.io", "raindrop.io", {
followWhoisReferral: true,
maxWhoisReferralHops: 2,
});
expect(Array.isArray(chain)).toBe(true);
// Mocked registrar is contradictory, so chain should contain only the TLD response
expect(chain.length).toBe(1);