rdapper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +109 -0
- package/dist/lib/async.d.ts +2 -0
- package/dist/lib/async.js +18 -0
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +1 -0
- package/dist/lib/dates.d.ts +1 -0
- package/dist/lib/dates.js +84 -0
- package/dist/lib/domain.d.ts +8 -0
- package/dist/lib/domain.js +64 -0
- package/dist/lib/text.d.ts +6 -0
- package/dist/lib/text.js +77 -0
- package/dist/rdap/bootstrap.d.ts +6 -0
- package/dist/rdap/bootstrap.js +30 -0
- package/dist/rdap/client.d.ts +9 -0
- package/dist/rdap/client.js +21 -0
- package/dist/rdap/normalize.d.ts +6 -0
- package/dist/rdap/normalize.js +214 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.js +1 -0
- package/dist/whois/client.d.ts +10 -0
- package/dist/whois/client.js +46 -0
- package/dist/whois/discovery.d.ts +9 -0
- package/dist/whois/discovery.js +50 -0
- package/dist/whois/normalize.d.ts +6 -0
- package/dist/whois/normalize.js +214 -0
- package/dist/whois/servers.d.ts +1 -0
- package/dist/whois/servers.js +64 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jake Jarvis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# 🎩 rdapper
|
|
2
|
+
|
|
3
|
+
RDAP‑first domain registration lookups with WHOIS fallback. Produces a single, normalized record shape regardless of source.
|
|
4
|
+
|
|
5
|
+
- RDAP discovery via IANA bootstrap (`https://data.iana.org/rdap/dns.json`)
|
|
6
|
+
- WHOIS TCP 43 client with TLD discovery, registrar referral follow, and curated exceptions
|
|
7
|
+
- Normalized output: registrar, contacts, nameservers, statuses, dates, DNSSEC, source metadata
|
|
8
|
+
- TypeScript types included; ESM‑only; no external HTTP client (uses global `fetch`)
|
|
9
|
+
|
|
10
|
+
Requirements: Node >= 18.17 (global `fetch`). WHOIS uses TCP port 43.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install rdapper
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { lookupDomain } from "rdapper";
|
|
22
|
+
|
|
23
|
+
const { ok, record, error } = await lookupDomain("example.com");
|
|
24
|
+
|
|
25
|
+
if (!ok) throw new Error(error);
|
|
26
|
+
console.log(record); // normalized DomainRecord
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Also available:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { isRegistered, isAvailable } from "rdapper";
|
|
33
|
+
|
|
34
|
+
await isRegistered("example.com"); // => true
|
|
35
|
+
await isAvailable("likely-unregistered-thing-320485230458.com"); // => false
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
- `lookupDomain(domain, options?) => Promise<LookupResult>`
|
|
41
|
+
- Tries RDAP first if supported by the domain’s TLD; if unavailable or fails, falls back to WHOIS (unless toggled off).
|
|
42
|
+
- Result is `{ ok: boolean, record?: DomainRecord, error?: string }`.
|
|
43
|
+
- `isRegistered(domain, options?) => Promise<boolean>`
|
|
44
|
+
- `isAvailable(domain, options?) => Promise<boolean>`
|
|
45
|
+
|
|
46
|
+
### Options
|
|
47
|
+
|
|
48
|
+
- `timeoutMs?: number` – Total timeout budget per network operation (default `15000`).
|
|
49
|
+
- `rdapOnly?: boolean` – Only attempt RDAP; do not fall back to WHOIS.
|
|
50
|
+
- `whoisOnly?: boolean` – Skip RDAP and query WHOIS directly.
|
|
51
|
+
- `followWhoisReferral?: boolean` – Follow registrar referral from the TLD WHOIS (default `true`).
|
|
52
|
+
- `customBootstrapUrl?: string` – Override RDAP bootstrap URL.
|
|
53
|
+
- `whoisHints?: Record<string, string>` – Override/add authoritative WHOIS per TLD (keys are lowercase TLDs, values may include or omit `whois://`).
|
|
54
|
+
- `includeRaw?: boolean` – Include `rawRdap`/`rawWhois` in the returned record (default `false`).
|
|
55
|
+
- `signal?: AbortSignal` – Optional cancellation signal.
|
|
56
|
+
|
|
57
|
+
### `DomainRecord` schema
|
|
58
|
+
|
|
59
|
+
The exact presence of fields depends on registry/registrar data and whether RDAP or WHOIS was used.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
interface DomainRecord {
|
|
63
|
+
domain: string; // normalized name (unicode when available)
|
|
64
|
+
tld: string; // terminal TLD label (e.g., "com")
|
|
65
|
+
isRegistered: boolean; // availability heuristic (WHOIS) or true (RDAP)
|
|
66
|
+
isIDN?: boolean; // uses punycode labels (xn--)
|
|
67
|
+
unicodeName?: string; // RDAP unicodeName when provided
|
|
68
|
+
punycodeName?: string; // RDAP ldhName when provided
|
|
69
|
+
registry?: string; // registry operator (rarely available)
|
|
70
|
+
registrar?: {
|
|
71
|
+
name?: string;
|
|
72
|
+
ianaId?: string;
|
|
73
|
+
url?: string;
|
|
74
|
+
email?: string;
|
|
75
|
+
phone?: string;
|
|
76
|
+
};
|
|
77
|
+
reseller?: string;
|
|
78
|
+
statuses?: Array<{
|
|
79
|
+
status: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
raw?: string;
|
|
82
|
+
}>;
|
|
83
|
+
creationDate?: string; // ISO 8601 (UTC)
|
|
84
|
+
updatedDate?: string; // ISO 8601 (UTC)
|
|
85
|
+
expirationDate?: string; // ISO 8601 (UTC)
|
|
86
|
+
deletionDate?: string; // ISO 8601 (UTC)
|
|
87
|
+
transferLock?: boolean; // derived from EPP statuses
|
|
88
|
+
dnssec?: {
|
|
89
|
+
enabled: boolean;
|
|
90
|
+
dsRecords?: Array<{
|
|
91
|
+
keyTag?: number;
|
|
92
|
+
algorithm?: number;
|
|
93
|
+
digestType?: number;
|
|
94
|
+
digest?: string;
|
|
95
|
+
}>;
|
|
96
|
+
};
|
|
97
|
+
nameservers?: Array<{
|
|
98
|
+
host: string;
|
|
99
|
+
ipv4?: string[];
|
|
100
|
+
ipv6?: string[];
|
|
101
|
+
}>;
|
|
102
|
+
contacts?: Array<{
|
|
103
|
+
type: "registrant" | "admin" | "tech" | "billing" | "abuse" | "registrar" | "reseller" | "unknown";
|
|
104
|
+
name?: string;
|
|
105
|
+
organization?: string;
|
|
106
|
+
email?: string | string[];
|
|
107
|
+
phone?: string | string[];
|
|
108
|
+
fax?: string | string[];
|
|
109
|
+
street?: string[];
|
|
110
|
+
city?: string;
|
|
111
|
+
state?: string;
|
|
112
|
+
postalCode?: string;
|
|
113
|
+
country?: string;
|
|
114
|
+
countryCode?: string;
|
|
115
|
+
}>;
|
|
116
|
+
whoisServer?: string; // authoritative WHOIS queried (if any)
|
|
117
|
+
rdapServers?: string[]; // RDAP base URLs tried
|
|
118
|
+
rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
|
|
119
|
+
rawWhois?: string; // raw WHOIS text (only when options.includeRaw)
|
|
120
|
+
source: "rdap" | "whois"; // which path produced data
|
|
121
|
+
fetchedAt: string; // ISO 8601 timestamp
|
|
122
|
+
warnings?: string[];
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Example output
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"domain": "example.com",
|
|
131
|
+
"tld": "com",
|
|
132
|
+
"isRegistered": true,
|
|
133
|
+
"registrar": { "name": "Internet Assigned Numbers Authority", "ianaId": "376" },
|
|
134
|
+
"statuses": [{ "status": "clientTransferProhibited" }],
|
|
135
|
+
"nameservers": [{ "host": "a.iana-servers.net" }, { "host": "b.iana-servers.net" }],
|
|
136
|
+
"dnssec": { "enabled": true },
|
|
137
|
+
"source": "rdap",
|
|
138
|
+
"fetchedAt": "2025-01-01T00:00:00Z"
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## How it works
|
|
143
|
+
|
|
144
|
+
- RDAP
|
|
145
|
+
- Discovers base URLs for the TLD via IANA’s RDAP bootstrap JSON.
|
|
146
|
+
- Tries each base until one responds successfully; parses standard RDAP domain JSON.
|
|
147
|
+
- Normalizes registrar (from `entities`), contacts (vCard), nameservers (`ipAddresses`), events (created/changed/expiration), statuses, and DNSSEC (`secureDNS`).
|
|
148
|
+
- WHOIS
|
|
149
|
+
- Discovers the authoritative TLD WHOIS via `whois.iana.org` (TCP 43), with curated exceptions for tricky zones and public SLDs.
|
|
150
|
+
- Queries the TLD WHOIS; if a registrar referral is present and `followWhoisReferral !== false`, follows one hop to the registrar WHOIS.
|
|
151
|
+
- Normalizes common key/value variants across gTLD/ccTLD formats (dates, statuses, nameservers, contacts). Availability is inferred from common phrases (best‑effort heuristic).
|
|
152
|
+
|
|
153
|
+
Timeouts are enforced per request using a simple race against `timeoutMs` (default 15s). All network I/O is performed with global `fetch` (RDAP) and a raw TCP socket (WHOIS).
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
- Build: `npm run build`
|
|
158
|
+
- Test: `npm test`
|
|
159
|
+
- By default, tests are offline/deterministic.
|
|
160
|
+
- Smoke tests that hit the network are gated by `SMOKE=1`, e.g. `SMOKE=1 npm test`.
|
|
161
|
+
- Lint/format: `npm run lint` (Biome)
|
|
162
|
+
|
|
163
|
+
Project layout:
|
|
164
|
+
|
|
165
|
+
- `src/rdap/` – RDAP bootstrap, client, and normalization
|
|
166
|
+
- `src/whois/` – WHOIS TCP client, discovery/referral, normalization, exceptions
|
|
167
|
+
- `src/lib/` – utilities for dates, text parsing, domain processing, async
|
|
168
|
+
- `src/types.ts` – public types; `src/index.ts` re‑exports API and types
|
|
169
|
+
- `cli.mjs` – local CLI helper for quick testing
|
|
170
|
+
|
|
171
|
+
## Caveats
|
|
172
|
+
|
|
173
|
+
- WHOIS text formats vary significantly across registries/registrars; normalization is best‑effort.
|
|
174
|
+
- Availability detection relies on common WHOIS phrases and is not authoritative.
|
|
175
|
+
- Some TLDs provide no RDAP service; `rdapOnly: true` will fail for them.
|
|
176
|
+
- Registries may throttle or block WHOIS; respect rate limits and usage policies.
|
|
177
|
+
- Field presence depends on source and privacy policies (e.g., redaction/withholding).
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
[MIT](LICENSE)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LookupOptions, LookupResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* High-level lookup that prefers RDAP and falls back to WHOIS.
|
|
4
|
+
* Ensures a standardized DomainRecord, independent of the source.
|
|
5
|
+
*/
|
|
6
|
+
export declare function lookupDomain(domain: string, opts?: LookupOptions): Promise<LookupResult>;
|
|
7
|
+
/** Determine if a domain appears available (not registered).
|
|
8
|
+
* Performs a lookup and resolves to a boolean. Rejects on lookup error. */
|
|
9
|
+
export declare function isAvailable(domain: string, opts?: LookupOptions): Promise<boolean>;
|
|
10
|
+
/** Determine if a domain appears registered.
|
|
11
|
+
* Performs a lookup and resolves to a boolean. Rejects on lookup error. */
|
|
12
|
+
export declare function isRegistered(domain: string, opts?: LookupOptions): Promise<boolean>;
|
|
13
|
+
export type * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { toISO } from "./lib/dates.js";
|
|
2
|
+
import { getDomainParts, isLikelyDomain } from "./lib/domain.js";
|
|
3
|
+
import { getRdapBaseUrlsForTld } from "./rdap/bootstrap.js";
|
|
4
|
+
import { fetchRdapDomain } from "./rdap/client.js";
|
|
5
|
+
import { normalizeRdap } from "./rdap/normalize.js";
|
|
6
|
+
import { whoisQuery } from "./whois/client.js";
|
|
7
|
+
import { extractWhoisReferral, ianaWhoisServerForTld, } from "./whois/discovery.js";
|
|
8
|
+
import { normalizeWhois } from "./whois/normalize.js";
|
|
9
|
+
import { WHOIS_TLD_EXCEPTIONS } from "./whois/servers.js";
|
|
10
|
+
/**
|
|
11
|
+
* High-level lookup that prefers RDAP and falls back to WHOIS.
|
|
12
|
+
* Ensures a standardized DomainRecord, independent of the source.
|
|
13
|
+
*/
|
|
14
|
+
export async function lookupDomain(domain, opts) {
|
|
15
|
+
try {
|
|
16
|
+
if (!isLikelyDomain(domain)) {
|
|
17
|
+
return { ok: false, error: "Input does not look like a domain" };
|
|
18
|
+
}
|
|
19
|
+
const { publicSuffix, tld } = getDomainParts(domain);
|
|
20
|
+
// Avoid non-null assertion: fallback to a stable ISO string if parsing ever fails
|
|
21
|
+
const now = toISO(new Date()) ?? new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
22
|
+
// If WHOIS-only, skip RDAP path
|
|
23
|
+
if (!opts?.whoisOnly) {
|
|
24
|
+
const bases = await getRdapBaseUrlsForTld(tld, opts);
|
|
25
|
+
const tried = [];
|
|
26
|
+
for (const base of bases) {
|
|
27
|
+
tried.push(base);
|
|
28
|
+
try {
|
|
29
|
+
const { json } = await fetchRdapDomain(domain, base, opts);
|
|
30
|
+
const record = normalizeRdap(domain, tld, json, tried, now, !!opts?.includeRaw);
|
|
31
|
+
return { ok: true, record };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// try next base
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Some TLDs are not in bootstrap yet; continue to WHOIS fallback unless rdapOnly
|
|
38
|
+
if (opts?.rdapOnly) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
error: "RDAP not available or failed for this TLD",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// WHOIS fallback path
|
|
46
|
+
const whoisServer = await ianaWhoisServerForTld(tld, opts);
|
|
47
|
+
if (!whoisServer) {
|
|
48
|
+
return { ok: false, error: "No WHOIS server discovered for TLD" };
|
|
49
|
+
}
|
|
50
|
+
// Query the TLD server first; if it returns a referral, we follow it below.
|
|
51
|
+
let res = await whoisQuery(whoisServer, domain, opts);
|
|
52
|
+
if (opts?.followWhoisReferral !== false) {
|
|
53
|
+
const referral = extractWhoisReferral(res.text);
|
|
54
|
+
if (referral && referral.toLowerCase() !== whoisServer.toLowerCase()) {
|
|
55
|
+
try {
|
|
56
|
+
res = await whoisQuery(referral, domain, opts);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// keep original
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// If TLD registry returns no match and there was no referral, try multi-label public suffix candidates
|
|
64
|
+
if (publicSuffix.includes(".") &&
|
|
65
|
+
/no match|not found/i.test(res.text) &&
|
|
66
|
+
opts?.followWhoisReferral !== false) {
|
|
67
|
+
const candidates = [];
|
|
68
|
+
const ps = publicSuffix.toLowerCase();
|
|
69
|
+
// Prefer explicit exceptions when known
|
|
70
|
+
const exception = WHOIS_TLD_EXCEPTIONS[ps];
|
|
71
|
+
if (exception)
|
|
72
|
+
candidates.push(exception);
|
|
73
|
+
for (const server of candidates) {
|
|
74
|
+
try {
|
|
75
|
+
const alt = await whoisQuery(server, domain, opts);
|
|
76
|
+
if (alt.text && !/error/i.test(alt.text)) {
|
|
77
|
+
res = alt;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// try next
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const record = normalizeWhois(domain, tld, res.text, res.serverQueried, now, !!opts?.includeRaw);
|
|
87
|
+
return { ok: true, record };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
91
|
+
return { ok: false, error: message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Determine if a domain appears available (not registered).
|
|
95
|
+
* Performs a lookup and resolves to a boolean. Rejects on lookup error. */
|
|
96
|
+
export async function isAvailable(domain, opts) {
|
|
97
|
+
const res = await lookupDomain(domain, opts);
|
|
98
|
+
if (!res.ok || !res.record)
|
|
99
|
+
throw new Error(res.error || "Lookup failed");
|
|
100
|
+
return res.record.isRegistered === false;
|
|
101
|
+
}
|
|
102
|
+
/** Determine if a domain appears registered.
|
|
103
|
+
* Performs a lookup and resolves to a boolean. Rejects on lookup error. */
|
|
104
|
+
export async function isRegistered(domain, opts) {
|
|
105
|
+
const res = await lookupDomain(domain, opts);
|
|
106
|
+
if (!res.ok || !res.record)
|
|
107
|
+
throw new Error(res.error || "Lookup failed");
|
|
108
|
+
return res.record.isRegistered === true;
|
|
109
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function withTimeout(promise, timeoutMs, reason = "Timeout") {
|
|
2
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
3
|
+
return promise;
|
|
4
|
+
let timer;
|
|
5
|
+
const timeout = new Promise((_, reject) => {
|
|
6
|
+
timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
|
|
7
|
+
});
|
|
8
|
+
return Promise.race([
|
|
9
|
+
promise.finally(() => {
|
|
10
|
+
if (timer !== undefined)
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
}),
|
|
13
|
+
timeout,
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
export function sleep(ms) {
|
|
17
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DEFAULT_TIMEOUT_MS = 15000;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_TIMEOUT_MS = 15000;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function toISO(dateLike: string | number | Date | undefined | null): string | undefined;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Lightweight date parsing helpers to avoid external dependencies.
|
|
2
|
+
// We aim to parse common RDAP and WHOIS date representations and return a UTC ISO string.
|
|
3
|
+
export function toISO(dateLike) {
|
|
4
|
+
if (dateLike == null)
|
|
5
|
+
return undefined;
|
|
6
|
+
if (dateLike instanceof Date)
|
|
7
|
+
return toIsoFromDate(dateLike);
|
|
8
|
+
if (typeof dateLike === "number")
|
|
9
|
+
return toIsoFromDate(new Date(dateLike));
|
|
10
|
+
const raw = String(dateLike).trim();
|
|
11
|
+
if (!raw)
|
|
12
|
+
return undefined;
|
|
13
|
+
// Try several structured formats seen in WHOIS outputs (treat as UTC when no TZ provided)
|
|
14
|
+
const tryFormats = [
|
|
15
|
+
// 2023-01-02 03:04:05Z or without Z
|
|
16
|
+
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z)?$/,
|
|
17
|
+
// 2023/01/02 03:04:05
|
|
18
|
+
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/,
|
|
19
|
+
// 02-Jan-2023
|
|
20
|
+
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
|
|
21
|
+
// Jan 02 2023
|
|
22
|
+
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/,
|
|
23
|
+
];
|
|
24
|
+
for (const re of tryFormats) {
|
|
25
|
+
const m = raw.match(re);
|
|
26
|
+
if (!m)
|
|
27
|
+
continue;
|
|
28
|
+
const d = parseWithRegex(m, re);
|
|
29
|
+
if (d)
|
|
30
|
+
return toIsoFromDate(d);
|
|
31
|
+
}
|
|
32
|
+
// Fallback to native Date parsing (handles ISO and RFC2822 with TZ)
|
|
33
|
+
const native = new Date(raw);
|
|
34
|
+
if (!Number.isNaN(native.getTime()))
|
|
35
|
+
return toIsoFromDate(native);
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function toIsoFromDate(d) {
|
|
39
|
+
try {
|
|
40
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), 0))
|
|
41
|
+
.toISOString()
|
|
42
|
+
.replace(/\.\d{3}Z$/, "Z");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function parseWithRegex(m, _re) {
|
|
49
|
+
const monthMap = {
|
|
50
|
+
jan: 0,
|
|
51
|
+
feb: 1,
|
|
52
|
+
mar: 2,
|
|
53
|
+
apr: 3,
|
|
54
|
+
may: 4,
|
|
55
|
+
jun: 5,
|
|
56
|
+
jul: 6,
|
|
57
|
+
aug: 7,
|
|
58
|
+
sep: 8,
|
|
59
|
+
oct: 9,
|
|
60
|
+
nov: 10,
|
|
61
|
+
dec: 11,
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
// If the matched string contains time components, parse as Y-M-D H:M:S
|
|
65
|
+
if (m[0].includes(":")) {
|
|
66
|
+
const [_, y, mo, d, hh, mm, ss] = m;
|
|
67
|
+
return new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss)));
|
|
68
|
+
}
|
|
69
|
+
// If the matched string contains hyphens, treat as DD-MMM-YYYY
|
|
70
|
+
if (m[0].includes("-")) {
|
|
71
|
+
const [_, dd, monStr, yyyy] = m;
|
|
72
|
+
const mon = monthMap[monStr.toLowerCase()];
|
|
73
|
+
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
|
|
74
|
+
}
|
|
75
|
+
// Otherwise treat as MMM DD YYYY
|
|
76
|
+
const [_, monStr, dd, yyyy] = m;
|
|
77
|
+
const mon = monthMap[monStr.toLowerCase()];
|
|
78
|
+
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// fall through to undefined
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function extractTld(domain: string): string;
|
|
2
|
+
export declare function getDomainParts(domain: string): {
|
|
3
|
+
publicSuffix: string;
|
|
4
|
+
tld: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function isLikelyDomain(input: string): boolean;
|
|
7
|
+
export declare function punyToUnicode(domain: string): string;
|
|
8
|
+
export declare function isWhoisAvailable(text: string | undefined): boolean;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import psl from "psl";
|
|
2
|
+
export function extractTld(domain) {
|
|
3
|
+
const lower = domain.trim().toLowerCase();
|
|
4
|
+
try {
|
|
5
|
+
const parsed = psl.parse?.(lower);
|
|
6
|
+
const suffix = parsed?.tld;
|
|
7
|
+
if (suffix) {
|
|
8
|
+
const labels = String(suffix).split(".").filter(Boolean);
|
|
9
|
+
if (labels.length)
|
|
10
|
+
return labels[labels.length - 1];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// ignore and fall back
|
|
15
|
+
}
|
|
16
|
+
const parts = lower.split(".").filter(Boolean);
|
|
17
|
+
return parts[parts.length - 1] ?? lower;
|
|
18
|
+
}
|
|
19
|
+
export function getDomainParts(domain) {
|
|
20
|
+
const lower = domain.toLowerCase().trim();
|
|
21
|
+
let publicSuffix;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = psl.parse?.(lower);
|
|
24
|
+
publicSuffix = parsed?.tld;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
if (!publicSuffix) {
|
|
30
|
+
const parts = lower.split(".").filter(Boolean);
|
|
31
|
+
publicSuffix = parts.length ? parts[parts.length - 1] : lower;
|
|
32
|
+
}
|
|
33
|
+
const labels = publicSuffix.split(".").filter(Boolean);
|
|
34
|
+
const tld = labels.length ? labels[labels.length - 1] : publicSuffix;
|
|
35
|
+
return { publicSuffix, tld };
|
|
36
|
+
}
|
|
37
|
+
export function isLikelyDomain(input) {
|
|
38
|
+
return /^[a-z0-9.-]+$/i.test(input) && input.includes(".");
|
|
39
|
+
}
|
|
40
|
+
export function punyToUnicode(domain) {
|
|
41
|
+
try {
|
|
42
|
+
return domain.normalize("NFC");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return domain;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Common WHOIS availability phrases seen across registries/registrars
|
|
49
|
+
const WHOIS_AVAILABLE_PATTERNS = [
|
|
50
|
+
/\bno match\b/i,
|
|
51
|
+
/\bnot found\b/i,
|
|
52
|
+
/\bno entries found\b/i,
|
|
53
|
+
/\bno data found\b/i,
|
|
54
|
+
/\bavailable for registration\b/i,
|
|
55
|
+
/\bdomain\s+available\b/i,
|
|
56
|
+
/\bdomain status[:\s]+available\b/i,
|
|
57
|
+
/\bobject does not exist\b/i,
|
|
58
|
+
/\bthe queried object does not exist\b/i,
|
|
59
|
+
];
|
|
60
|
+
export function isWhoisAvailable(text) {
|
|
61
|
+
if (!text)
|
|
62
|
+
return false;
|
|
63
|
+
return WHOIS_AVAILABLE_PATTERNS.some((re) => re.test(text));
|
|
64
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function uniq<T>(arr: T[] | undefined | null): T[] | undefined;
|
|
2
|
+
export declare function parseKeyValueLines(text: string): Record<string, string[]>;
|
|
3
|
+
export declare function parseCsv(value: string | undefined): string[] | undefined;
|
|
4
|
+
export declare function asString(value: unknown): string | undefined;
|
|
5
|
+
export declare function asStringArray(value: unknown): string[] | undefined;
|
|
6
|
+
export declare function asDateLike(value: unknown): string | number | Date | undefined;
|
package/dist/lib/text.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export function uniq(arr) {
|
|
2
|
+
if (!arr)
|
|
3
|
+
return undefined;
|
|
4
|
+
return Array.from(new Set(arr));
|
|
5
|
+
}
|
|
6
|
+
export function parseKeyValueLines(text) {
|
|
7
|
+
const map = new Map();
|
|
8
|
+
const lines = text.split(/\r?\n/);
|
|
9
|
+
let lastKey;
|
|
10
|
+
for (const rawLine of lines) {
|
|
11
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
12
|
+
if (!line.trim())
|
|
13
|
+
continue;
|
|
14
|
+
// Bracketed form: [Key] value (common in .jp and some ccTLDs)
|
|
15
|
+
const bracket = line.match(/^\s*\[([^\]]+)\]\s*(.*)$/);
|
|
16
|
+
if (bracket) {
|
|
17
|
+
const key = bracket[1].trim().toLowerCase();
|
|
18
|
+
const value = bracket[2].trim();
|
|
19
|
+
const list = map.get(key) ?? [];
|
|
20
|
+
if (value)
|
|
21
|
+
list.push(value);
|
|
22
|
+
map.set(key, list);
|
|
23
|
+
lastKey = key;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Colon form: Key: value
|
|
27
|
+
const idx = line.indexOf(":");
|
|
28
|
+
if (idx !== -1) {
|
|
29
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
30
|
+
const value = line.slice(idx + 1).trim();
|
|
31
|
+
if (!key) {
|
|
32
|
+
lastKey = undefined;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const list = map.get(key) ?? [];
|
|
36
|
+
if (value)
|
|
37
|
+
list.push(value);
|
|
38
|
+
map.set(key, list);
|
|
39
|
+
lastKey = key;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Continuation line: starts with indentation after a key appeared
|
|
43
|
+
if (lastKey && /^\s+/.test(line)) {
|
|
44
|
+
const value = line.trim();
|
|
45
|
+
if (value) {
|
|
46
|
+
const list = map.get(lastKey) ?? [];
|
|
47
|
+
list.push(value);
|
|
48
|
+
map.set(lastKey, list);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Otherwise ignore non key-value lines
|
|
52
|
+
}
|
|
53
|
+
return Object.fromEntries(map);
|
|
54
|
+
}
|
|
55
|
+
export function parseCsv(value) {
|
|
56
|
+
if (!value)
|
|
57
|
+
return undefined;
|
|
58
|
+
return value
|
|
59
|
+
.split(/[,\s]+/)
|
|
60
|
+
.map((s) => s.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
export function asString(value) {
|
|
64
|
+
return typeof value === "string" ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
export function asStringArray(value) {
|
|
67
|
+
return Array.isArray(value)
|
|
68
|
+
? value.filter((x) => typeof x === "string")
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
export function asDateLike(value) {
|
|
72
|
+
if (typeof value === "string" ||
|
|
73
|
+
typeof value === "number" ||
|
|
74
|
+
value instanceof Date)
|
|
75
|
+
return value;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LookupOptions } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve RDAP base URLs for a given TLD using IANA's bootstrap registry.
|
|
4
|
+
* Returns zero or more base URLs (always suffixed with a trailing slash).
|
|
5
|
+
*/
|
|
6
|
+
export declare function getRdapBaseUrlsForTld(tld: string, options?: LookupOptions): Promise<string[]>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { withTimeout } from "../lib/async.js";
|
|
2
|
+
import { DEFAULT_TIMEOUT_MS } from "../lib/constants.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve RDAP base URLs for a given TLD using IANA's bootstrap registry.
|
|
5
|
+
* Returns zero or more base URLs (always suffixed with a trailing slash).
|
|
6
|
+
*/
|
|
7
|
+
export async function getRdapBaseUrlsForTld(tld, options) {
|
|
8
|
+
const bootstrapUrl = options?.customBootstrapUrl ?? "https://data.iana.org/rdap/dns.json";
|
|
9
|
+
const res = await withTimeout(fetch(bootstrapUrl, {
|
|
10
|
+
method: "GET",
|
|
11
|
+
headers: { accept: "application/json" },
|
|
12
|
+
signal: options?.signal,
|
|
13
|
+
}), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, "RDAP bootstrap timeout");
|
|
14
|
+
if (!res.ok)
|
|
15
|
+
return [];
|
|
16
|
+
const data = (await res.json());
|
|
17
|
+
const target = tld.toLowerCase();
|
|
18
|
+
const bases = [];
|
|
19
|
+
for (const svc of data.services) {
|
|
20
|
+
const tlds = svc[0];
|
|
21
|
+
const urls = svc[1];
|
|
22
|
+
if (tlds.map((x) => x.toLowerCase()).includes(target)) {
|
|
23
|
+
for (const u of urls) {
|
|
24
|
+
const base = u.endsWith("/") ? u : `${u}/`;
|
|
25
|
+
bases.push(base);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Array.from(new Set(bases));
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LookupOptions } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fetch RDAP JSON for a domain from a specific RDAP base URL.
|
|
4
|
+
* Throws on HTTP >= 400 (includes RDAP error JSON payloads).
|
|
5
|
+
*/
|
|
6
|
+
export declare function fetchRdapDomain(domain: string, baseUrl: string, options?: LookupOptions): Promise<{
|
|
7
|
+
url: string;
|
|
8
|
+
json: unknown;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { withTimeout } from "../lib/async.js";
|
|
2
|
+
import { DEFAULT_TIMEOUT_MS } from "../lib/constants.js";
|
|
3
|
+
// Use global fetch (Node 18+). For large JSON we keep it simple.
|
|
4
|
+
/**
|
|
5
|
+
* Fetch RDAP JSON for a domain from a specific RDAP base URL.
|
|
6
|
+
* Throws on HTTP >= 400 (includes RDAP error JSON payloads).
|
|
7
|
+
*/
|
|
8
|
+
export async function fetchRdapDomain(domain, baseUrl, options) {
|
|
9
|
+
const url = new URL(`domain/${encodeURIComponent(domain)}`, baseUrl).toString();
|
|
10
|
+
const res = await withTimeout(fetch(url, {
|
|
11
|
+
method: "GET",
|
|
12
|
+
headers: { accept: "application/rdap+json, application/json" },
|
|
13
|
+
signal: options?.signal,
|
|
14
|
+
}), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, "RDAP lookup timeout");
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const bodyText = await res.text();
|
|
17
|
+
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
|
18
|
+
}
|
|
19
|
+
const json = await res.json();
|
|
20
|
+
return { url, json };
|
|
21
|
+
}
|