From ba59746bc1527b33c5c175c16ee0c096ef0b067d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 11:38:40 +0000 Subject: [PATCH] domainfree: find the domains you can actually register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from the bash version in profullstack/scripts to this repo's conventions, and exposed to moshcode as a `domain` plugin alongside `blog`. Availability is read from RDAP, never inferred from DNS, because DNS cannot tell registration apart from configuration: - a parked domain resolves fine and is taken - a domain registered with no nameservers returns NXDOMAIN, exactly like a name nobody owns Over 8,513 generated candidates the DNS shortcut (`dig NAME | grep "ANSWER: 0"`) called 20 registered domains free and missed none that were genuinely free. oubliette.com is the one to remember: registered 1996, paid through 2034, three nameservers, no A record, so dig reports ANSWER: 0 and it reads as available. Fine as a cheap prefilter, wrong as a buy signal. An indeterminate response — 429, 5xx, timeout — is retried once and then reported as ERR:, never as available, and the exit status is 2. A name wrongly reported free is the only failure here that costs real time. Layout follows the repo: logic in src/domain-free.ts with an injectable fetcher, a thin bin/ entry guarded by isMain, args through the shared parseArgs, and vitest tests that touch no network. New plugin `domain` exposes /domain:free and /domain:lookup, the latter wrapping the existing domainjson so the plugin covers both directions — one verdict across thousands of names, or everything about one. One thing worth recording: the first version of these tests used real setTimeout delays, and the added wall-clock load made blog.test.ts's concurrent-createPost race fail — it passed alone and failed in the full suite. That test is timing-sensitive and this change happened to expose it. Rather than touch it, checkMany's retry pause is now injectable (retryDelayMs), the tests pass 0, and no test in this file uses a real timer. Full suite is green across four consecutive runs. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 29 +++- README.md | 43 ++++++ bin/domainfree.ts | 124 ++++++++++++++++ plugins/domain/.claude-plugin/plugin.json | 13 ++ plugins/domain/README.md | 36 +++++ plugins/domain/commands/free.md | 68 +++++++++ plugins/domain/commands/lookup.md | 51 +++++++ src/domain-free.ts | 165 ++++++++++++++++++++++ test/domain-free.test.ts | 164 +++++++++++++++++++++ 9 files changed, 691 insertions(+), 2 deletions(-) create mode 100755 bin/domainfree.ts create mode 100644 plugins/domain/.claude-plugin/plugin.json create mode 100644 plugins/domain/README.md create mode 100644 plugins/domain/commands/free.md create mode 100644 plugins/domain/commands/lookup.md create mode 100644 src/domain-free.ts create mode 100644 test/domain-free.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b98b9d3..b7d88c5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "cli-tools", - "description": "Profullstack's command-line tools as installable plugins — publish to the plain-HTML blog without getting a convention wrong.", + "description": "Profullstack's command-line tools as installable plugins \u2014 publish to the plain-HTML blog without getting a convention wrong.", "owner": { "name": "profullstack", "url": "https://profullstack.com" @@ -17,7 +17,32 @@ "url": "https://profullstack.com" }, "homepage": "https://github.com/profullstack/cli-tools#blog", - "keywords": ["blog", "rss", "feed", "publishing", "smolweb"] + "keywords": [ + "blog", + "rss", + "feed", + "publishing", + "smolweb" + ] + }, + { + "name": "domain", + "description": "Find domains you can actually register, and look one up in depth. Availability is read from the registry over RDAP, never guessed from DNS, so parked names and registrations with no nameservers are not mistaken for free.", + "source": "./plugins/domain", + "category": "productivity", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#domainfree", + "keywords": [ + "domain", + "rdap", + "dns", + "whois", + "availability", + "naming" + ] } ] } diff --git a/README.md b/README.md index 9b84b36..35a2c71 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,49 @@ The scanner itself lives in the threatcrush checkout, so this is a launcher. Point it elsewhere with `TCFEED_REPO`; every other `TCFEED_*` variable is read by the script it launches and works unchanged. +### `domainfree` + +Bulk domain availability, straight from the registry. Prints only the names you +can actually buy, one per line, so it pipes into anything. + +```sh +domainfree sorrycheck.com sinkstate.com +domainfree --file candidates.txt +generate-names | domainfree --jobs 24 +domainfree --all example.com # show TAKEN rows too +``` + +Availability is read from **RDAP, never inferred from DNS**, because DNS cannot +tell registration apart from configuration: + +- a parked domain resolves fine and is taken; +- a domain registered with no nameservers returns `NXDOMAIN` — identical to a + name nobody owns. + +Measured over 8,513 generated candidates, the DNS shortcut +(`dig NAME | grep "ANSWER: 0"`) reported 20 registered domains as free while +missing none that were genuinely free. `oubliette.com` is the instructive one: +registered in 1996, paid through 2034, three nameservers, no `A` record — so +`dig` says `ANSWER: 0` and it reads as available. Fine as a cheap prefilter, +useless as a buy signal. + +Lookups run through a fixed-size pool (16 by default; about 8,500 names in 45 +seconds). Anything indeterminate — a 429, a 5xx, a timeout — is retried once +and then reported as `ERR:`, never as available, and the exit status is +`2` so an unknown cannot be mistaken for a free name. + +| Flag | Effect | +| --- | --- | +| `-f, --file FILE` | read names from FILE, one per line (`-` for stdin) | +| `-j, --jobs N` | parallel lookups, default 16 | +| `-t, --timeout MS` | per-lookup timeout, default 20000 | +| `-a, --all` | print every name as `STATUS domain`, not just the free ones | +| `-q, --quiet` | suppress the summary, which is written to stderr | + +The summary goes to stderr and the names to stdout, so `domainfree -f in.txt | +wc -l` counts what you can buy. For a deep look at one name rather than a +verdict across thousands, use `domainjson`. + ### `domainjson` One JSON object on stdout: `{ name, rdap | moshpit, dns }`. diff --git a/bin/domainfree.ts b/bin/domainfree.ts new file mode 100755 index 0000000..8bd1c03 --- /dev/null +++ b/bin/domainfree.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * domainfree — bulk domain availability, straight from the registry. + * + * Prints only the names that can actually be registered, one per line, so the + * output pipes into anything. Companion to `domainjson`, which looks one name + * up in depth; this answers one question across thousands. + */ + +import { readFile } from 'node:fs/promises'; +import { UsageError, integer, parseArgs } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { + DEFAULT_JOBS, + DEFAULT_TIMEOUT_MS, + checkMany, + normalizeNames, + summarize, +} from '../src/domain-free.ts'; + +const USAGE = `Usage: + domainfree ... + domainfree --file candidates.txt + generate-names | domainfree --jobs 24 + +Availability is read from RDAP, never inferred from DNS: a parked domain +resolves but is taken, and a domain registered with no nameservers returns +NXDOMAIN exactly like a free one. + +Options: + -f, --file FILE read names from FILE, one per line ("-" for stdin) + -j, --jobs N parallel lookups (default: ${DEFAULT_JOBS}) + -t, --timeout MS per-lookup timeout (default: ${DEFAULT_TIMEOUT_MS}) + -a, --all print every name as "STATUS domain", not just the free ones + -q, --quiet suppress the summary, which goes to stderr + -h, --help show this help + +Only available names go to stdout, so \`domainfree -f in.txt | wc -l\` counts +what you can buy. Exit status is 2 when any lookup stayed indeterminate — an +unknown is never reported as available. +`; + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf8'); +} + +if (isMain(import.meta.url)) { + try { + const { flags, values, positional } = parseArgs(process.argv.slice(2), { + boolean: ['-a', '--all', '-q', '--quiet', '-h', '--help'], + string: ['-f', '--file', '-j', '--jobs', '-t', '--timeout'], + }); + + if (flags.has('-h') || flags.has('--help')) { + process.stdout.write(USAGE); + process.exit(0); + } + + const showAll = flags.has('-a') || flags.has('--all'); + const quiet = flags.has('-q') || flags.has('--quiet'); + const jobs = integer(values, values.has('-j') ? '-j' : '--jobs', DEFAULT_JOBS, { + min: 1, + max: 128, + }); + const timeout = integer( + values, + values.has('-t') ? '-t' : '--timeout', + DEFAULT_TIMEOUT_MS, + { min: 100, max: 120_000 }, + ); + + const file = values.get('-f') ?? values.get('--file'); + let raw: string; + if (file) { + raw = file === '-' ? await readStdin() : await readFile(file, 'utf8'); + } else if (positional.length > 0) { + raw = positional.join('\n'); + } else if (!process.stdin.isTTY) { + raw = await readStdin(); + } else { + process.stderr.write(USAGE); + process.exit(1); + } + + const names = normalizeNames(raw); + if (names.length === 0) { + process.stderr.write('domainfree: no valid domain names given\n'); + process.exit(1); + } + + const results = await checkMany(names, { jobs, timeout }); + + for (const result of results.slice().sort((a, b) => a.domain.localeCompare(b.domain))) { + if (showAll) { + const label = + result.status === 'available' + ? 'AVAILABLE' + : result.status === 'taken' + ? 'TAKEN' + : `ERR:${result.code ?? 'timeout'}`; + process.stdout.write(`${label} ${result.domain}\n`); + } else if (result.status === 'available') { + process.stdout.write(`${result.domain}\n`); + } + } + + const { available, taken, unknown } = summarize(results); + if (!quiet) { + const parts = [`${names.length} checked`, `${available} available`, `${taken} taken`]; + if (unknown > 0) parts.push(`${unknown} unknown`); + process.stderr.write(`${parts.join(' · ')}\n`); + } + + process.exit(unknown > 0 ? 2 : 0); + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`domainfree: ${error.message}\n`); + process.exit(1); + } + throw error; + } +} diff --git a/plugins/domain/.claude-plugin/plugin.json b/plugins/domain/.claude-plugin/plugin.json new file mode 100644 index 0000000..a725898 --- /dev/null +++ b/plugins/domain/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "domain", + "description": "Find domains you can actually register, and look one up in depth. Availability is read from the registry over RDAP, never guessed from DNS, so parked names and registrations with no nameservers are not mistaken for free.", + "version": "0.1.0", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#domainfree", + "license": "MIT", + "keywords": ["domain", "rdap", "dns", "whois", "availability", "naming"] +} diff --git a/plugins/domain/README.md b/plugins/domain/README.md new file mode 100644 index 0000000..40a29b4 --- /dev/null +++ b/plugins/domain/README.md @@ -0,0 +1,36 @@ +# domain + +Two commands for working with domain names, both reading the registry rather +than guessing from DNS. + +| Command | Does | +| --- | --- | +| `/domain:free` | Filter a list of names down to the ones you can actually register. | +| `/domain:lookup` | Everything about one name — RDAP record, dates, nameservers, DNS, reverse PTR — as JSON. | + +## Install + +```sh +moshcode plugin marketplace add profullstack/cli-tools +moshcode plugin install domain@cli-tools +``` + +Both commands shell out to tools from this repo, so they need it installed and +linked: + +```sh +pnpm install && pnpm link:bin +``` + +## Why RDAP and not dig + +DNS cannot distinguish registration from configuration. A parked domain +resolves and is taken; a domain registered with no nameservers returns +`NXDOMAIN`, exactly like a name nobody owns. + +Over 8,513 generated candidates, `dig NAME | grep "ANSWER: 0"` reported 20 +registered domains as free and missed none that were genuinely free. +`oubliette.com` is the clearest case — registered in 1996, paid through 2034, +three nameservers, no `A` record. + +Good enough as a cheap prefilter. Wrong as a buy signal. diff --git a/plugins/domain/commands/free.md b/plugins/domain/commands/free.md new file mode 100644 index 0000000..9a89b24 --- /dev/null +++ b/plugins/domain/commands/free.md @@ -0,0 +1,68 @@ +--- +description: Check which domains are actually registerable — registry truth, not a DNS guess. +allowed-tools: Bash(domainfree:*), Read, Write +--- + +## Task + +Find out which of a set of domain names can actually be registered. + +```bash +domainfree sorrycheck.com sinkstate.com +domainfree --file candidates.txt +domainfree --all example.com # show TAKEN rows too +``` + +Only available names go to stdout, one per line, so the output pipes straight +into anything: + +```bash +domainfree --file candidates.txt | head -20 +domainfree --file candidates.txt | wc -l # how many you could buy +``` + +## Naming a new project + +Generate candidates first, then filter. The generation is the creative part; +this command is only the filter, and it is fast enough to be used on thousands +of names at a time — roughly 8,500 in 45 seconds at the default concurrency. + +```bash +printf '%s\n' proofcheck.com sorrycheck.com qedcheck.com axiomcheck.com \ + | domainfree +``` + +## Why not just use dig + +Because DNS cannot tell registration apart from configuration, and will hand +you names you cannot buy: + +- A **parked** domain resolves fine and is taken. +- A domain registered with **no nameservers** returns `NXDOMAIN` — exactly what + a name nobody owns returns. + +Measured over 8,513 generated candidates, `dig NAME | grep "ANSWER: 0"` called +**20 registered domains free** while missing none that were genuinely free. +`oubliette.com` is the one to remember: registered in 1996, paid through 2034, +three nameservers, no `A` record — so `dig` reports `ANSWER: 0` and it reads as +available. + +So DNS is a fine cheap prefilter and a bad buy signal. `domainfree` reads RDAP, +which is the registry's own record. + +## Reading the result + +An answer is only ever `AVAILABLE`, `TAKEN`, or `ERR:`. A rate limit, a +5xx or a timeout is retried once and then reported as `ERR` — **never** as +available, because a name reported free that is not is the one failure that +wastes real time. Exit status is `2` if anything stayed indeterminate, so this +is usable as a gate. + +## Before buying + +Availability is a moment in time. Re-check immediately before registering, and +remember that if the project is already public under that name, the name is +worth securing sooner rather than later. + +For everything about one name — RDAP record, registration and expiry dates, +nameservers, DNS records, reverse PTR — use `/domain:lookup` instead. diff --git a/plugins/domain/commands/lookup.md b/plugins/domain/commands/lookup.md new file mode 100644 index 0000000..4ec6d95 --- /dev/null +++ b/plugins/domain/commands/lookup.md @@ -0,0 +1,51 @@ +--- +description: Everything about one name — RDAP record, dates, nameservers, DNS, reverse PTR — as JSON. +allowed-tools: Bash(domainjson:*), Read +--- + +## Task + +Look one name up in depth. Output is a single JSON object, so it pipes into +`jq` without reshaping. + +```bash +domainjson example.com +domainjson --timeout 8000 test.hacker +domainjson -s https://rdap.nic.cz -t domain example.cz +``` + +```json +{ "name": "...", "rdap": { ... }, "dns": { "records": {}, "hosts": [], "reverse": [], "axfr": [] } } +``` + +## What you get + +- **`rdap`** — the registry's own record: status flags, registration, expiry + and last-changed dates, nameservers. This is where "is it actually + registered?" is answered, and it is the reason a name with no DNS is still + clearly taken. +- **`dns`** — `A`, `AAAA`, `CNAME`, `MX`, `TXT` and `NS` queried one type at a + time (never `ANY`), plus reverse PTR for every resolved address and an AXFR + attempt against each nameserver. A refused transfer is reported, never fatal. + +Names ending in a [Moshpit](https://pit.moshcode.sh) TLD skip RDAP and are +served from the registry API under a `moshpit` key instead. + +## Useful reads + +```bash +# When does it expire, and who runs its DNS? +domainjson example.com | jq '.rdap.events, .rdap.nameservers' + +# Registered, but is anything actually served? +domainjson example.com | jq '{status: .rdap.status, hosts: .dns.hosts}' +``` + +## Notes + +Errors are JSON too — a tool whose output gets parsed should not change shape +on failure. If every data source fails, the exit status is non-zero and the +object carries an `error` key. + +To check many names for availability rather than inspect one, use +`/domain:free`. diff --git a/src/domain-free.ts b/src/domain-free.ts new file mode 100644 index 0000000..4652e54 --- /dev/null +++ b/src/domain-free.ts @@ -0,0 +1,165 @@ +/** + * Bulk domain availability, read from the registry. + * + * Availability comes from RDAP and never from DNS, because DNS cannot tell + * registration apart from configuration: + * + * - a parked domain resolves fine and is taken; + * - a domain registered with no nameservers returns NXDOMAIN, which is exactly + * what an unregistered name returns. + * + * Measured over 8,513 generated candidates, the DNS shortcut + * (`dig NAME | grep "ANSWER: 0"`) reported 20 registered domains as free and + * missed none that were genuinely free. `oubliette.com` is the instructive + * case: registered in 1996, paid through 2034, three nameservers, no A record. + * Good enough as a cheap prefilter, wrong as a buy signal. + */ + +export const DEFAULT_JOBS = 16; +export const DEFAULT_TIMEOUT_MS = 20_000; +export const DEFAULT_RETRY_DELAY_MS = 300; + +/** Registries that answer RDAP directly. Everything else goes via rdap.org. */ +const ENDPOINTS: Record string> = { + com: (d, t) => `https://rdap.verisign.com/${t}/v1/domain/${d}`, + net: (d, t) => `https://rdap.verisign.com/${t}/v1/domain/${d}`, + org: (d) => `https://rdap.publicinterestregistry.org/rdap/domain/${d}`, +}; + +export type Availability = 'available' | 'taken' | 'unknown'; + +export interface Result { + domain: string; + status: Availability; + /** HTTP status behind the verdict, or null when the request never completed. */ + code: number | null; +} + +export function rdapEndpoint(domain: string): string { + const tld = domain.slice(domain.lastIndexOf('.') + 1); + const build = ENDPOINTS[tld]; + return build ? build(domain, tld) : `https://rdap.org/domain/${domain}`; +} + +/** + * 404 means the registry holds no record for the name, which is the only + * evidence of availability there is. Anything else that is not a clean 200 is + * reported as unknown rather than guessed at — reading a rate limit as + * "available" is how you try to buy a name someone already owns. + */ +export function classify(code: number | null): Availability { + if (code === 404) return 'available'; + if (code === 200) return 'taken'; + return 'unknown'; +} + +const DOMAIN_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/; + +/** Lowercase, strip whitespace, drop anything that is not a bare domain, dedupe. */ +export function normalizeNames(input: string): string[] { + const seen = new Set(); + for (const raw of input.split(/\r?\n/)) { + const name = raw.trim().toLowerCase().replace(/\s+/g, ''); + if (!name || name.startsWith('#')) continue; + if (!DOMAIN_RE.test(name)) continue; + seen.add(name); + } + return [...seen].sort(); +} + +export type Fetcher = (url: string, timeoutMs: number) => Promise; + +/** Default fetcher: HEAD-like GET, following redirects, body discarded. */ +export const httpFetcher: Fetcher = async (url, timeoutMs) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + redirect: 'follow', + signal: controller.signal, + headers: { accept: 'application/rdap+json, application/json' }, + }); + // The body is irrelevant; only the status carries the verdict. Cancel it so + // the socket is released instead of being held until GC. + await response.body?.cancel(); + return response.status; + } catch { + return null; + } finally { + clearTimeout(timer); + } +}; + +export async function checkOne( + domain: string, + { timeout = DEFAULT_TIMEOUT_MS, fetcher = httpFetcher }: CheckOptions = {}, +): Promise { + const code = await fetcher(rdapEndpoint(domain), timeout); + return { domain, status: classify(code), code }; +} + +export interface CheckOptions { + jobs?: number; + timeout?: number; + fetcher?: Fetcher; + /** Pause before the serial retry pass. Zero in tests; 300ms in practice, to + * let a rate limit clear. */ + retryDelayMs?: number; + /** Called after each lookup settles, for progress reporting. */ + onResult?: (result: Result) => void; +} + +/** + * Run lookups through a fixed-size pool, then retry anything indeterminate once + * serially — rate limiting is the usual cause and it clears at low concurrency. + */ +export async function checkMany( + domains: readonly string[], + options: CheckOptions = {}, +): Promise { + const { jobs = DEFAULT_JOBS, retryDelayMs = DEFAULT_RETRY_DELAY_MS, onResult } = options; + const results: Result[] = new Array(domains.length); + let next = 0; + + const worker = async (): Promise => { + for (;;) { + const index = next; + next += 1; + if (index >= domains.length) return; + const result = await checkOne(domains[index]!, options); + results[index] = result; + onResult?.(result); + } + }; + + await Promise.all( + Array.from({ length: Math.max(1, Math.min(jobs, domains.length)) }, worker), + ); + + for (let index = 0; index < results.length; index += 1) { + const result = results[index]!; + if (result.status !== 'unknown') continue; + if (retryDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + const retried = await checkOne(result.domain, options); + results[index] = retried; + onResult?.(retried); + } + + return results; +} + +export function summarize(results: readonly Result[]): { + available: number; + taken: number; + unknown: number; +} { + let available = 0; + let taken = 0; + let unknown = 0; + for (const { status } of results) { + if (status === 'available') available += 1; + else if (status === 'taken') taken += 1; + else unknown += 1; + } + return { available, taken, unknown }; +} diff --git a/test/domain-free.test.ts b/test/domain-free.test.ts new file mode 100644 index 0000000..445376c --- /dev/null +++ b/test/domain-free.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { + type Fetcher, + checkMany, + checkOne, + classify, + normalizeNames, + rdapEndpoint, + summarize, +} from '../src/domain-free.ts'; + +describe('rdapEndpoint', () => { + it('sends .com and .net straight to Verisign', () => { + expect(rdapEndpoint('sorrycheck.com')).toBe( + 'https://rdap.verisign.com/com/v1/domain/sorrycheck.com', + ); + expect(rdapEndpoint('example.net')).toBe( + 'https://rdap.verisign.com/net/v1/domain/example.net', + ); + }); + + it('sends .org to PIR', () => { + expect(rdapEndpoint('example.org')).toBe( + 'https://rdap.publicinterestregistry.org/rdap/domain/example.org', + ); + }); + + it('falls back to rdap.org for everything else', () => { + expect(rdapEndpoint('nosorry.dev')).toBe('https://rdap.org/domain/nosorry.dev'); + expect(rdapEndpoint('example.co.uk')).toBe('https://rdap.org/domain/example.co.uk'); + }); +}); + +describe('classify', () => { + it('treats 404 as available and 200 as taken', () => { + expect(classify(404)).toBe('available'); + expect(classify(200)).toBe('taken'); + }); + + it('never reports an indeterminate response as available', () => { + // The whole point: a rate limit read as "available" sends you to buy a + // name someone already owns. + for (const code of [429, 500, 502, 503, 0, null]) { + expect(classify(code as number | null)).toBe('unknown'); + } + }); +}); + +describe('normalizeNames', () => { + it('lowercases, trims and dedupes', () => { + expect(normalizeNames(' GOOGLE.com \nexample.com\ngoogle.com\n')).toEqual([ + 'example.com', + 'google.com', + ]); + }); + + it('drops blanks, comments and anything that is not a bare domain', () => { + const input = [ + '', + '# a comment', + 'not a domain', + 'http://example.com', + 'example.com/path', + 'localhost', + 'good.com', + ].join('\n'); + expect(normalizeNames(input)).toEqual(['good.com']); + }); + + it('keeps multi-label names', () => { + expect(normalizeNames('a.b.example.co.uk')).toEqual(['a.b.example.co.uk']); + }); +}); + +const fakeFetcher = + (byUrl: Record): Fetcher => + async (url) => + url in byUrl ? byUrl[url]! : 404; + +describe('checkOne', () => { + it('reports the registry verdict and the code behind it', async () => { + const fetcher = fakeFetcher({ + 'https://rdap.verisign.com/com/v1/domain/taken.com': 200, + }); + expect(await checkOne('taken.com', { fetcher })).toEqual({ + domain: 'taken.com', + status: 'taken', + code: 200, + }); + expect(await checkOne('free.com', { fetcher })).toEqual({ + domain: 'free.com', + status: 'available', + code: 404, + }); + }); +}); + +describe('checkMany', () => { + it('preserves input order regardless of completion order', async () => { + const fetcher: Fetcher = async (url) => { + // Force the first name to settle last, so completion order differs from + // input order, without spending wall-clock time on a timer. + if (url.endsWith('a.com')) { + for (let i = 0; i < 50; i += 1) await Promise.resolve(); + return 404; + } + return 200; + }; + const results = await checkMany(['a.com', 'b.com', 'c.com'], { + fetcher, + jobs: 3, + retryDelayMs: 0, + }); + expect(results.map((r) => r.domain)).toEqual(['a.com', 'b.com', 'c.com']); + expect(results.map((r) => r.status)).toEqual(['available', 'taken', 'taken']); + }); + + it('retries an indeterminate result once, and keeps the better answer', async () => { + let calls = 0; + const fetcher: Fetcher = async () => { + calls += 1; + return calls === 1 ? 429 : 404; // rate limited, then fine + }; + const results = await checkMany(['flaky.com'], { fetcher, jobs: 1, retryDelayMs: 0 }); + expect(calls).toBe(2); + expect(results[0]!.status).toBe('available'); + }); + + it('leaves a result unknown when the retry also fails', async () => { + const fetcher: Fetcher = async () => null; + const results = await checkMany(['down.com'], { fetcher, jobs: 1, retryDelayMs: 0 }); + expect(results[0]!.status).toBe('unknown'); + }); + + it('honours the job cap', async () => { + let live = 0; + let peak = 0; + const fetcher: Fetcher = async () => { + live += 1; + peak = Math.max(peak, live); + for (let i = 0; i < 10; i += 1) await Promise.resolve(); + live -= 1; + return 404; + }; + await checkMany( + Array.from({ length: 20 }, (_, i) => `n${i}.com`), + { fetcher, jobs: 4, retryDelayMs: 0 }, + ); + expect(peak).toBeLessThanOrEqual(4); + }); +}); + +describe('summarize', () => { + it('counts each verdict', () => { + expect( + summarize([ + { domain: 'a.com', status: 'available', code: 404 }, + { domain: 'b.com', status: 'taken', code: 200 }, + { domain: 'c.com', status: 'taken', code: 200 }, + { domain: 'd.com', status: 'unknown', code: 429 }, + ]), + ).toEqual({ available: 1, taken: 2, unknown: 1 }); + }); +});