mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-22 23:05:31 -04:00
chore: replace Biome with oxlint and oxfmt
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"ignorePatterns": ["**/dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"options": {
|
||||||
|
"typeAware": true,
|
||||||
|
"typeCheck": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist"]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -118,7 +118,7 @@ async function getBootstrapData(): Promise<BootstrapData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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}`);
|
||||||
}
|
}
|
||||||
@@ -133,21 +133,21 @@ async function getBootstrapData(): Promise<BootstrapData> {
|
|||||||
|
|
||||||
// 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> {
|
||||||
@@ -158,7 +158,7 @@ async function getBootstrapData(): Promise<BootstrapData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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}`);
|
||||||
}
|
}
|
||||||
@@ -172,18 +172,18 @@ async function getBootstrapData(): Promise<BootstrapData> {
|
|||||||
|
|
||||||
// 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> {
|
||||||
@@ -193,7 +193,7 @@ async function getBootstrapData(): Promise<BootstrapData> {
|
|||||||
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 {
|
||||||
@@ -201,22 +201,22 @@ async function getBootstrapData(): Promise<BootstrapData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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,39 +261,39 @@ 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);
|
||||||
@@ -306,18 +307,18 @@ 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;
|
||||||
|
|
||||||
@@ -329,7 +330,7 @@ async function fetchWithRetry(
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,22 +339,22 @@ async function fetchWithRetry(
|
|||||||
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,7 +364,7 @@ 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
|
||||||
@@ -376,7 +377,7 @@ const httpCachingFetch: typeof fetch = async (input, init) => {
|
|||||||
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) {
|
||||||
@@ -391,7 +392,7 @@ 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,
|
||||||
});
|
});
|
||||||
@@ -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[];
|
||||||
@@ -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
@@ -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
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+910
-164
File diff suppressed because it is too large
Load Diff
+31
-28
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>;
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user