1
mirror of https://github.com/jakejarvis/rdapper.git synced 2025-10-18 14:24:29 -04:00

Enhance project structure: add AGENTS.md for repository guidelines, update .gitignore to exclude TypeScript build info, and refine package.json scripts for improved build and publish processes.

This commit is contained in:
2025-09-24 18:24:47 -04:00
parent 56e9e23e4b
commit 36cf6d6b38
26 changed files with 566 additions and 442 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/
dist/
*.tsbuildinfo

37
AGENTS.md Normal file
View File

@@ -0,0 +1,37 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/api/`: Public lookup orchestration (`lookup.ts`).
- `src/rdap/`: RDAP bootstrap, client, and normalization (`bootstrap.ts`, `client.ts`, `normalize.ts`).
- `src/whois/`: WHOIS TCP client, discovery, referral, normalization, catalog.
- `src/lib/`: Shared utilities (`dates.ts`, `async.ts`, `domain.ts`, `text.ts`).
- `src/types.ts`: Public types. `src/index.ts` re-exports API and types.
- Tests: per-module `__tests__/` folders with `*.test.ts` (e.g., `src/lib/__tests__/dates.test.ts`).
- `dist/`: Build output (generated). Do not edit.
- `cli.mjs`: Local CLI for manual checks.
## Build, Test, and Development Commands
- `npm run build`: Clean and compile with `tsc -p tsconfig.build.json` (excludes tests); outputs to `dist/`.
- `npm test`: Compile tests, then run Nodes test runner on `dist/**/*.test.js`.
- `npm run lint`: Biome format+lint with autofix per `biome.json`.
- Example CLI: `npm run build && node cli.mjs example.com`.
## Coding Style & Naming Conventions
- TypeScript strict; ES2022 ESM (`tsconfig.json`).
- Biome-enforced: spaces indentation; double quotes; organized imports.
- Filenames: kebab-case for modules (e.g., `normalize-rdap.ts`).
- Identifiers: camelCase; avoid abbreviations; explicit return types for exported functions.
## Testing Guidelines
- Framework: Node `node:test`.
- Tests live under `src/**/__tests__` and are deterministic/offline by default.
- Smoke tests gated by `SMOKE=1` (e.g., `SMOKE=1 npm test`).
- Run all tests: `npm test`.
## Commit & Pull Request Guidelines
- Commits: imperative, concise summaries (e.g., “Refactor lookup: tighten error handling”).
- PRs: include what/why, linked issues, and test notes; ensure `npm run lint && npm test` pass.
## Release & Security Notes
- Publish only `dist/`; `prepublishOnly` runs the build. Tests are excluded via `tsconfig.build.json` and `files` in `package.json`.
- Node >= 18.17 with global `fetch`. WHOIS uses TCP 43; be mindful of registry rate limits.

View File

@@ -17,9 +17,11 @@
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"build": "npm run clean && tsc -p tsconfig.build.json",
"test": "tsc && node --test dist/**/*.test.js",
"lint": "biome check --write"
"lint": "biome check --write",
"prepublishOnly": "npm run build"
},
"dependencies": {
"psl": "1.15.0"
@@ -30,6 +32,7 @@
"@types/psl": "1.1.3",
"typescript": "5.9.2"
},
"engines": {
"node": ">=18.17"
}

View File

@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { lookupDomain } from "./lookup.js";
import { lookupDomain } from "../lookup.js";
// Run only when SMOKE=1 to avoid flakiness and network in CI by default
const shouldRun = process.env.SMOKE === "1";

View File

@@ -1,13 +1,16 @@
import { normalizeRdap } from "./normalize-rdap.js";
import { normalizeWhois } from "./normalize-whois.js";
import { fetchRdapDomain, getRdapBaseUrlsForTld } from "./rdap.js";
import type { DomainRecord, LookupOptions, LookupResult } from "./types.js";
import { getDomainParts, isLikelyDomain, toISO } from "./utils.js";
import { toISO } from "../lib/dates.js";
import { getDomainParts, isLikelyDomain } from "../lib/domain.js";
import { getRdapBaseUrlsForTld } from "../rdap/bootstrap.js";
import { fetchRdapDomain } from "../rdap/client.js";
import { normalizeRdap } from "../rdap/normalize.js";
import type { DomainRecord, LookupOptions, LookupResult } from "../types.js";
import { WHOIS_TLD_EXCEPTIONS } from "../whois/catalog.js";
import { whoisQuery } from "../whois/client.js";
import {
extractWhoisReferral,
ianaWhoisServerForTld,
whoisQuery,
} from "./whois.js";
} from "../whois/discovery.js";
import { normalizeWhois } from "../whois/normalize.js";
/**
* High-level lookup that prefers RDAP and falls back to WHOIS.
@@ -79,11 +82,11 @@ export async function lookupDomain(
/no match|not found/i.test(res.text) &&
opts?.followWhoisReferral !== false
) {
const candidates = [
`whois.nic.${publicSuffix.toLowerCase()}`,
// Widely used by many second-level public suffix registries
"whois.centralnic.com",
];
const candidates: string[] = [];
const ps = publicSuffix.toLowerCase();
// Prefer explicit exceptions when known
const exception = WHOIS_TLD_EXCEPTIONS[ps];
if (exception) candidates.push(exception);
for (const server of candidates) {
try {
const alt = await whoisQuery(server, domain, opts);

View File

@@ -1,2 +1,2 @@
export * from "./lookup.js";
// export * from "./types.js";
export * from "./api/lookup.js";
export type * from "./types.js";

View File

@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractTld, isLikelyDomain, toISO } from "./utils.js";
import { toISO } from "../dates.js";
test("toISO parses ISO and common whois formats", () => {
const iso = toISO("2023-01-02T03:04:05Z");
@@ -20,13 +20,3 @@ test("toISO parses ISO and common whois formats", () => {
const mdy = toISO("Jan 02 2023");
assert.equal(mdy, "2023-01-02T00:00:00Z");
});
test("extractTld basic", () => {
assert.equal(extractTld("example.com"), "com");
assert.equal(extractTld("sub.example.co.uk"), "uk");
});
test("isLikelyDomain", () => {
assert.equal(isLikelyDomain("example.com"), true);
assert.equal(isLikelyDomain("not a domain"), false);
});

View File

@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractTld, isLikelyDomain } from "../domain.js";
test("extractTld basic", () => {
assert.equal(extractTld("example.com"), "com");
assert.equal(extractTld("sub.example.co.uk"), "uk");
});
test("isLikelyDomain", () => {
assert.equal(isLikelyDomain("example.com"), true);
assert.equal(isLikelyDomain("not a domain"), false);
});

21
src/lib/async.ts Normal file
View File

@@ -0,0 +1,21 @@
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
reason = "Timeout",
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
});
return Promise.race([
promise.finally(() => {
if (timer !== undefined) clearTimeout(timer);
}),
timeout,
]);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

98
src/lib/dates.ts Normal file
View File

@@ -0,0 +1,98 @@
// 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 {
if (dateLike == null) return undefined;
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
const raw = String(dateLike).trim();
if (!raw) return undefined;
// Try several structured formats seen in WHOIS outputs (treat as UTC when no TZ provided)
const tryFormats = [
// 2023-01-02 03:04:05Z or without Z
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z)?$/,
// 2023/01/02 03:04:05
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/,
// 02-Jan-2023
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
// Jan 02 2023
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/,
];
for (const re of tryFormats) {
const m = raw.match(re);
if (!m) continue;
const d = parseWithRegex(m, re);
if (d) return toIsoFromDate(d);
}
// Fallback to native Date parsing (handles ISO and RFC2822 with TZ)
const native = new Date(raw);
if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
return undefined;
}
function toIsoFromDate(d: Date): string | undefined {
try {
return new Date(
Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds(),
0,
),
)
.toISOString()
.replace(/\.\d{3}Z$/, "Z");
} catch {
return undefined;
}
}
function parseWithRegex(m: RegExpMatchArray, _re: RegExp): Date | undefined {
const monthMap: Record<string, number> = {
jan: 0,
feb: 1,
mar: 2,
apr: 3,
may: 4,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
oct: 9,
nov: 10,
dec: 11,
};
try {
// If the matched string contains time components, parse as Y-M-D H:M:S
if (m[0].includes(":")) {
const [_, y, mo, d, hh, mm, ss] = m;
return new Date(
Date.UTC(
Number(y),
Number(mo) - 1,
Number(d),
Number(hh),
Number(mm),
Number(ss),
),
);
}
// If the matched string contains hyphens, treat as DD-MMM-YYYY
if (m[0].includes("-")) {
const [_, dd, monStr, yyyy] = m;
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
}
// Otherwise treat as MMM DD YYYY
const [_, monStr, dd, yyyy] = m;
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
} catch {
// fall through to undefined
}
return undefined;
}

50
src/lib/domain.ts Normal file
View File

@@ -0,0 +1,50 @@
import psl from "psl";
export function extractTld(domain: string): string {
const lower = domain.trim().toLowerCase();
try {
const parsed = psl.parse?.(lower) as { tld?: string };
const suffix = parsed?.tld;
if (suffix) {
const labels = String(suffix).split(".").filter(Boolean);
if (labels.length) return labels[labels.length - 1];
}
} catch {
// ignore and fall back
}
const parts = lower.split(".").filter(Boolean);
return parts[parts.length - 1] ?? lower;
}
export function getDomainParts(domain: string): {
publicSuffix: string;
tld: string;
} {
const lower = domain.toLowerCase().trim();
let publicSuffix: string | undefined;
try {
const parsed = psl.parse?.(lower) as { tld?: string };
publicSuffix = parsed?.tld;
} catch {
// ignore
}
if (!publicSuffix) {
const parts = lower.split(".").filter(Boolean);
publicSuffix = parts.length ? parts[parts.length - 1] : lower;
}
const labels = publicSuffix.split(".").filter(Boolean);
const tld = labels.length ? labels[labels.length - 1] : publicSuffix;
return { publicSuffix, tld };
}
export function isLikelyDomain(input: string): boolean {
return /^[a-z0-9.-]+$/i.test(input) && input.includes(".");
}
export function punyToUnicode(domain: string): string {
try {
return domain.normalize("NFC");
} catch {
return domain;
}
}

79
src/lib/text.ts Normal file
View File

@@ -0,0 +1,79 @@
export function uniq<T>(arr: T[] | undefined | null): T[] | undefined {
if (!arr) return undefined;
return Array.from(new Set(arr));
}
export function parseKeyValueLines(text: string): Record<string, string[]> {
const map = new Map<string, string[]>();
const lines = text.split(/\r?\n/);
let lastKey: string | undefined;
for (const rawLine of lines) {
const line = rawLine.replace(/\s+$/, "");
if (!line.trim()) continue;
// Bracketed form: [Key] value (common in .jp and some ccTLDs)
const bracket = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
if (bracket) {
const key = bracket[1].trim().toLowerCase();
const value = bracket[2].trim();
const list = map.get(key) ?? [];
if (value) list.push(value);
map.set(key, list);
lastKey = key;
continue;
}
// Colon form: Key: value
const idx = line.indexOf(":");
if (idx !== -1) {
const key = line.slice(0, idx).trim().toLowerCase();
const value = line.slice(idx + 1).trim();
if (!key) {
lastKey = undefined;
continue;
}
const list = map.get(key) ?? [];
if (value) list.push(value);
map.set(key, list);
lastKey = key;
continue;
}
// Continuation line: starts with indentation after a key appeared
if (lastKey && /^\s+/.test(line)) {
const value = line.trim();
if (value) {
const list = map.get(lastKey) ?? [];
list.push(value);
map.set(lastKey, list);
}
}
// Otherwise ignore non key-value lines
}
return Object.fromEntries(map);
}
export function parseCsv(value: string | undefined): string[] | undefined {
if (!value) return undefined;
return value
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
}
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function asStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? (value.filter((x) => typeof x === "string") as string[])
: undefined;
}
export function asDateLike(value: unknown): string | number | Date | undefined {
if (
typeof value === "string" ||
typeof value === "number" ||
value instanceof Date
)
return value;
return undefined;
}

View File

@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeRdap } from "./normalize-rdap.js";
import { normalizeRdap } from "../normalize.js";
test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () => {
const rdap = {

View File

@@ -1,6 +1,6 @@
import { DEFAULT_TIMEOUT_MS } from "./config.js";
import type { LookupOptions } from "./types.js";
import { withTimeout } from "./utils.js";
import { DEFAULT_TIMEOUT_MS } from "../config.js";
import { withTimeout } from "../lib/async.js";
import type { LookupOptions } from "../types.js";
// Use global fetch (Node 18+). For large JSON we keep it simple.
@@ -48,33 +48,3 @@ export async function getRdapBaseUrlsForTld(
}
return Array.from(new Set(bases));
}
/**
* Fetch RDAP JSON for a domain from a specific RDAP base URL.
* Throws on HTTP >= 400 (includes RDAP error JSON payloads).
*/
export async function fetchRdapDomain(
domain: string,
baseUrl: string,
options?: LookupOptions,
): Promise<{ url: string; json: unknown }> {
const url = new URL(
`domain/${encodeURIComponent(domain)}`,
baseUrl,
).toString();
const res = await withTimeout(
fetch(url, {
method: "GET",
headers: { accept: "application/rdap+json, application/json" },
signal: options?.signal,
}),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
"RDAP lookup timeout",
);
if (!res.ok) {
const bodyText = await res.text();
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
}
const json = await res.json();
return { url, json };
}

35
src/rdap/client.ts Normal file
View File

@@ -0,0 +1,35 @@
import { DEFAULT_TIMEOUT_MS } from "../config.js";
import { withTimeout } from "../lib/async.js";
import type { LookupOptions } from "../types.js";
// Use global fetch (Node 18+). For large JSON we keep it simple.
/**
* Fetch RDAP JSON for a domain from a specific RDAP base URL.
* Throws on HTTP >= 400 (includes RDAP error JSON payloads).
*/
export async function fetchRdapDomain(
domain: string,
baseUrl: string,
options?: LookupOptions,
): Promise<{ url: string; json: unknown }> {
const url = new URL(
`domain/${encodeURIComponent(domain)}`,
baseUrl,
).toString();
const res = await withTimeout(
fetch(url, {
method: "GET",
headers: { accept: "application/rdap+json, application/json" },
signal: options?.signal,
}),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
"RDAP lookup timeout",
);
if (!res.ok) {
const bodyText = await res.text();
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
}
const json = await res.json();
return { url, json };
}

View File

@@ -1,10 +1,11 @@
import { toISO } from "../lib/dates.js";
import { asDateLike, asString, asStringArray, uniq } from "../lib/text.js";
import type {
Contact,
DomainRecord,
Nameserver,
RegistrarInfo,
} from "./types.js";
import { asDateLike, asString, asStringArray, toISO, uniq } from "./utils.js";
} from "../types.js";
type RdapDoc = Record<string, unknown>;

View File

@@ -84,6 +84,9 @@ export interface LookupOptions {
whoisOnly?: boolean; // don't attempt RDAP
followWhoisReferral?: boolean; // follow referral server (default true)
customBootstrapUrl?: string; // override IANA bootstrap
// WHOIS discovery and query tuning
whoisHints?: Record<string, string>; // override/add authoritative WHOIS per TLD
maxWhoisHops?: number; // max referral hops to follow (default 2)
signal?: AbortSignal;
}

View File

@@ -1,248 +0,0 @@
import psl from "psl";
// 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 {
if (dateLike == null) return undefined;
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
const raw = String(dateLike).trim();
if (!raw) return undefined;
// Try several structured formats seen in WHOIS outputs (treat as UTC when no TZ provided)
const tryFormats = [
// 2023-01-02 03:04:05Z or without Z
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z)?$/,
// 2023/01/02 03:04:05
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/,
// 02-Jan-2023
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
// Jan 02 2023
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/,
];
for (const re of tryFormats) {
const m = raw.match(re);
if (!m) continue;
const d = parseWithRegex(m, re);
if (d) return toIsoFromDate(d);
}
// Fallback to native Date parsing (handles ISO and RFC2822 with TZ)
const native = new Date(raw);
if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
return undefined;
}
function toIsoFromDate(d: Date): string | undefined {
try {
return new Date(
Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds(),
0,
),
)
.toISOString()
.replace(/\.\d{3}Z$/, "Z");
} catch {
return undefined;
}
}
function parseWithRegex(m: RegExpMatchArray, _re: RegExp): Date | undefined {
const monthMap: Record<string, number> = {
jan: 0,
feb: 1,
mar: 2,
apr: 3,
may: 4,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
oct: 9,
nov: 10,
dec: 11,
};
try {
// If the matched string contains time components, parse as Y-M-D H:M:S
if (m[0].includes(":")) {
const [_, y, mo, d, hh, mm, ss] = m;
return new Date(
Date.UTC(
Number(y),
Number(mo) - 1,
Number(d),
Number(hh),
Number(mm),
Number(ss),
),
);
}
// If the matched string contains hyphens, treat as DD-MMM-YYYY
if (m[0].includes("-")) {
const [_, dd, monStr, yyyy] = m;
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
}
// Otherwise treat as MMM DD YYYY
const [_, monStr, dd, yyyy] = m;
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
} catch {
// fall through to undefined
}
return undefined;
}
export function uniq<T>(arr: T[] | undefined | null): T[] | undefined {
if (!arr) return undefined;
return Array.from(new Set(arr));
}
export function parseKeyValueLines(text: string): Record<string, string[]> {
const map = new Map<string, string[]>();
const lines = text.split(/\r?\n/);
let lastKey: string | undefined;
for (const rawLine of lines) {
const line = rawLine.replace(/\s+$/, "");
if (!line.trim()) continue;
// Bracketed form: [Key] value (common in .jp and some ccTLDs)
const bracket = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
if (bracket) {
const key = bracket[1].trim().toLowerCase();
const value = bracket[2].trim();
const list = map.get(key) ?? [];
if (value) list.push(value);
map.set(key, list);
lastKey = key;
continue;
}
// Colon form: Key: value
const idx = line.indexOf(":");
if (idx !== -1) {
const key = line.slice(0, idx).trim().toLowerCase();
const value = line.slice(idx + 1).trim();
if (!key) {
lastKey = undefined;
continue;
}
const list = map.get(key) ?? [];
if (value) list.push(value);
map.set(key, list);
lastKey = key;
continue;
}
// Continuation line: starts with indentation after a key appeared
if (lastKey && /^\s+/.test(line)) {
const value = line.trim();
if (value) {
const list = map.get(lastKey) ?? [];
list.push(value);
map.set(lastKey, list);
}
}
// Otherwise ignore non key-value lines
}
return Object.fromEntries(map);
}
export function parseCsv(value: string | undefined): string[] | undefined {
if (!value) return undefined;
return value
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
}
export function punyToUnicode(domain: string): string {
try {
return domain.normalize("NFC");
} catch {
return domain;
}
}
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
reason = "Timeout",
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
});
return Promise.race([
promise.finally(() => {
if (timer !== undefined) clearTimeout(timer);
}),
timeout,
]);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function extractTld(domain: string): string {
const lower = domain.trim().toLowerCase();
try {
const parsed = psl.parse?.(lower) as { tld?: string };
const suffix = parsed?.tld;
if (suffix) {
const labels = String(suffix).split(".").filter(Boolean);
if (labels.length) return labels[labels.length - 1];
}
} catch {
// ignore and fall back
}
const parts = lower.split(".").filter(Boolean);
return parts[parts.length - 1] ?? lower;
}
export function getDomainParts(domain: string): { publicSuffix: string; tld: string } {
const lower = domain.toLowerCase().trim();
let publicSuffix: string | undefined;
try {
const parsed = psl.parse?.(lower) as { tld?: string };
publicSuffix = parsed?.tld;
} catch {
// ignore
}
if (!publicSuffix) {
const parts = lower.split(".").filter(Boolean);
publicSuffix = parts.length ? parts[parts.length - 1] : lower;
}
const labels = publicSuffix.split(".").filter(Boolean);
const tld = labels.length ? labels[labels.length - 1] : publicSuffix;
return { publicSuffix, tld };
}
export function isLikelyDomain(input: string): boolean {
return /^[a-z0-9.-]+$/i.test(input) && input.includes(".");
}
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function asStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? (value.filter((x) => typeof x === "string") as string[])
: undefined;
}
export function asDateLike(value: unknown): string | number | Date | undefined {
if (
typeof value === "string" ||
typeof value === "number" ||
value instanceof Date
)
return value;
return undefined;
}

View File

@@ -1,114 +0,0 @@
import { createConnection } from "node:net";
import { DEFAULT_TIMEOUT_MS } from "./config.js";
import type { LookupOptions } from "./types.js";
import { withTimeout } from "./utils.js";
export interface WhoisQueryResult {
serverQueried: string;
text: string;
}
/**
* Perform a WHOIS query against an RFC 3912 server over TCP 43.
* Returns the raw text and the server used.
*/
export async function whoisQuery(
server: string,
query: string,
options?: LookupOptions,
): Promise<WhoisQueryResult> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const port = 43;
const host = server.replace(/^whois:\/\//i, "");
const text = await withTimeout(
queryTcp(host, port, query, options),
timeoutMs,
"WHOIS timeout",
);
return { serverQueried: server, text };
}
// Low-level WHOIS TCP client. Some registries require CRLF after the domain query.
function queryTcp(
host: string,
port: number,
query: string,
options?: LookupOptions,
): Promise<string> {
return new Promise((resolve, reject) => {
const socket = createConnection({ host, port });
let data = "";
let done = false;
const cleanup = () => {
if (done) return;
done = true;
socket.destroy();
};
socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1000, () => {
cleanup();
reject(new Error("WHOIS socket timeout"));
});
socket.on("error", (err) => {
cleanup();
reject(err);
});
socket.on("data", (chunk) => {
data += chunk.toString("utf8");
});
socket.on("end", () => {
cleanup();
resolve(data);
});
socket.on("connect", () => {
socket.write(`${query}\r\n`);
});
});
}
/**
* Best-effort discovery of the authoritative WHOIS server for a TLD via IANA root DB.
*/
export async function ianaWhoisServerForTld(
tld: string,
options?: LookupOptions,
): Promise<string | undefined> {
const EXCEPTIONS: Record<string, string> = {
com: "whois.verisign-grs.com",
net: "whois.verisign-grs.com",
org: "whois.pir.org",
};
const url = `https://www.iana.org/domains/root/db/${encodeURIComponent(tld)}.html`;
try {
const res = await withTimeout(
fetch(url, { method: "GET" }),
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
);
if (!res.ok) return undefined;
const html = await res.text();
const m =
html.match(/Whois Server:\s*<a[^>]*>([^<]+)<\/a>/i) ||
html.match(/Whois Server:\s*([^<\n]+)/i);
const server = m?.[1]?.trim();
if (!server)
return EXCEPTIONS[tld.toLowerCase()] ?? `whois.nic.${tld.toLowerCase()}`;
return server.replace(/^https?:\/\//i, "").replace(/\/$/, "");
} catch {
return EXCEPTIONS[tld.toLowerCase()] ?? `whois.nic.${tld.toLowerCase()}`;
}
}
/**
* Extract registrar referral WHOIS server from a WHOIS response, if present.
*/
export function extractWhoisReferral(text: string): string | undefined {
const patterns = [
/^Registrar WHOIS Server:\s*(.+)$/im,
/^Whois Server:\s*(.+)$/im,
/^ReferralServer:\s*whois:\/\/(.+)$/im,
];
for (const re of patterns) {
const m = text.match(re);
if (m?.[1]) return m[1].trim();
}
return undefined;
}

View File

@@ -1,18 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeWhois } from "./normalize-whois.js";
function _runCase(label: string, tld: string, text: string) {
const rec = normalizeWhois(
`example.${tld}`,
tld,
text,
"whois.test",
"2025-01-01T00:00:00Z",
);
assert.equal(rec.tld, tld, label);
assert.equal(rec.source, "whois");
}
import { normalizeWhois } from "../normalize.js";
test("WHOIS .de (DENIC-like) nserver lines", () => {
const text = `

60
src/whois/catalog.ts Normal file
View File

@@ -0,0 +1,60 @@
// Centralized WHOIS data catalog.
// - tldExceptions: curated non-standard authoritative WHOIS servers by TLD
// - centralnicZones: known CentralNic-operated second-level public suffix zones
export const WHOIS_CATALOG = {
tldExceptions: {
// gTLDs
com: "whois.verisign-grs.com",
net: "whois.verisign-grs.com",
org: "whois.pir.org",
info: "whois.afilias.net",
biz: "whois.nic.biz",
edu: "whois.educause.edu",
gov: "whois.dotgov.gov",
// ccTLDs and others
de: "whois.denic.de",
jp: "whois.jprs.jp",
fr: "whois.nic.fr",
it: "whois.nic.it",
pl: "whois.dns.pl",
nl: "whois.domain-registry.nl",
be: "whois.dns.be",
se: "whois.iis.se",
no: "whois.norid.no",
fi: "whois.fi",
cz: "whois.nic.cz",
es: "whois.nic.es",
br: "whois.registro.br",
ca: "whois.cira.ca",
dk: "whois.dk-hostmaster.dk",
hk: "whois.hkirc.hk",
sg: "whois.sgnic.sg",
in: "whois.registry.in",
nz: "whois.srs.net.nz",
ch: "whois.nic.ch",
li: "whois.nic.li",
io: "whois.nic.io",
ai: "whois.nic.ai",
ru: "whois.tcinet.ru",
su: "whois.tcinet.ru",
"xn--p1ai": "whois.tcinet.ru", // .рф
// CentralNic-operated second-level zones (treat as exceptions here for simplicity)
"uk.com": "whois.centralnic.com",
"uk.net": "whois.centralnic.com",
"gb.com": "whois.centralnic.com",
"gb.net": "whois.centralnic.com",
"eu.com": "whois.centralnic.com",
"us.com": "whois.centralnic.com",
"se.com": "whois.centralnic.com",
"de.com": "whois.centralnic.com",
"br.com": "whois.centralnic.com",
"ru.com": "whois.centralnic.com",
"cn.com": "whois.centralnic.com",
"sa.com": "whois.centralnic.com",
} as Record<string, string>,
} as const;
export const WHOIS_TLD_EXCEPTIONS = WHOIS_CATALOG.tldExceptions;

66
src/whois/client.ts Normal file
View File

@@ -0,0 +1,66 @@
import { createConnection } from "node:net";
import { DEFAULT_TIMEOUT_MS } from "../config.js";
import { withTimeout } from "../lib/async.js";
import type { LookupOptions } from "../types.js";
export interface WhoisQueryResult {
serverQueried: string;
text: string;
}
/**
* Perform a WHOIS query against an RFC 3912 server over TCP 43.
* Returns the raw text and the server used.
*/
export async function whoisQuery(
server: string,
query: string,
options?: LookupOptions,
): Promise<WhoisQueryResult> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const port = 43;
const host = server.replace(/^whois:\/\//i, "");
const text = await withTimeout(
queryTcp(host, port, query, options),
timeoutMs,
"WHOIS timeout",
);
return { serverQueried: server, text };
}
// Low-level WHOIS TCP client. Some registries require CRLF after the domain query.
function queryTcp(
host: string,
port: number,
query: string,
options?: LookupOptions,
): Promise<string> {
return new Promise((resolve, reject) => {
const socket = createConnection({ host, port });
let data = "";
let done = false;
const cleanup = () => {
if (done) return;
done = true;
socket.destroy();
};
socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1000, () => {
cleanup();
reject(new Error("WHOIS socket timeout"));
});
socket.on("error", (err) => {
cleanup();
reject(err);
});
socket.on("data", (chunk) => {
data += chunk.toString("utf8");
});
socket.on("end", () => {
cleanup();
resolve(data);
});
socket.on("connect", () => {
socket.write(`${query}\r\n`);
});
});
}

56
src/whois/discovery.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { LookupOptions } from "../types.js";
import { WHOIS_TLD_EXCEPTIONS } from "./catalog.js";
import { whoisQuery } from "./client.js";
/**
* Best-effort discovery of the authoritative WHOIS server for a TLD via IANA root DB.
*/
export async function ianaWhoisServerForTld(
tld: string,
options?: LookupOptions,
): Promise<string | undefined> {
const key = tld.toLowerCase();
// 1) Explicit hint override
const hint = options?.whoisHints?.[key];
if (hint) return normalizeServer(hint);
// 2) IANA WHOIS authoritative discovery over TCP 43
try {
const res = await whoisQuery("whois.iana.org", key, options);
const txt = res.text;
const m =
txt.match(/^whois:\s*(\S+)/im) ||
txt.match(/^refer:\s*(\S+)/im) ||
txt.match(/^whois server:\s*(\S+)/im);
const server = m?.[1];
if (server) return normalizeServer(server);
} catch {
// fallthrough to exceptions/guess
}
// 3) Curated exceptions
const exception = WHOIS_TLD_EXCEPTIONS[key];
if (exception) return normalizeServer(exception);
return undefined;
}
/**
* Extract registrar referral WHOIS server from a WHOIS response, if present.
*/
export function extractWhoisReferral(text: string): string | undefined {
const patterns = [
/^Registrar WHOIS Server:\s*(.+)$/im,
/^Whois Server:\s*(.+)$/im,
/^ReferralServer:\s*whois:\/\/(.+)$/im,
];
for (const re of patterns) {
const m = text.match(re);
if (m?.[1]) return m[1].trim();
}
return undefined;
}
function normalizeServer(server: string): string {
return server.replace(/^whois:\/\//i, "").replace(/\/$/, "");
}

View File

@@ -1,10 +1,11 @@
import { toISO } from "../lib/dates.js";
import { parseKeyValueLines, uniq } from "../lib/text.js";
import type {
Contact,
DomainRecord,
Nameserver,
RegistrarInfo,
} from "./types.js";
import { parseKeyValueLines, toISO, uniq } from "./utils.js";
} from "../types.js";
/**
* Convert raw WHOIS text into our normalized DomainRecord.

12
tsconfig.build.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"]
}

View File

@@ -1 +0,0 @@
{"root":["./src/config.ts","./src/index.ts","./src/lookup.smoke.test.ts","./src/lookup.ts","./src/normalize-rdap.test.ts","./src/normalize-rdap.ts","./src/normalize-whois.test.ts","./src/normalize-whois.ts","./src/rdap.ts","./src/types.d.ts","./src/utils.test.ts","./src/utils.ts","./src/whois.ts"],"version":"5.9.2"}