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