@aoxborrow/registrar-client 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/src/http.ts ADDED
@@ -0,0 +1,230 @@
1
+ import { USER_AGENT } from './constants';
2
+ import {
3
+ AbortError,
4
+ AuthenticationError,
5
+ AuthorizationError,
6
+ ConnectionError,
7
+ InvalidResponseError,
8
+ NotFoundError,
9
+ ParsingError,
10
+ RateLimitError,
11
+ RegistrarError,
12
+ TimeoutError,
13
+ toRegistrarError,
14
+ } from './errors';
15
+ import type { RegistrarClientOptions } from './types';
16
+ import { backoffDelay, sleep } from './utils';
17
+
18
+ // configuration for an HttpClient instance
19
+ export interface HttpClientConfig {
20
+ // base URL for all requests, e.g. "https://api.example.com/v2"
21
+ baseUrl: string;
22
+ // default headers applied to every request (e.g. Authorization) — never logged
23
+ headers?: Record<string, string>;
24
+ // request/retry behavior
25
+ options: RegistrarClientOptions;
26
+ }
27
+
28
+ // options for a single request
29
+ export interface RequestConfig {
30
+ method?: string;
31
+ // path appended to baseUrl, or an absolute URL
32
+ path: string;
33
+ // query parameters (never used for secrets)
34
+ query?: Record<string, string | number | boolean | undefined>;
35
+ // JSON body; serialized with JSON.stringify
36
+ body?: unknown;
37
+ headers?: Record<string, string>;
38
+ // per-request overrides
39
+ timeout?: number;
40
+ retries?: number;
41
+ backoff?: number;
42
+ signal?: AbortSignal;
43
+ }
44
+
45
+ // a small, browser/edge-safe fetch wrapper with timeout, abort linking,
46
+ // retry-with-backoff, and registrar error mapping. No Node built-ins.
47
+ export class HttpClient {
48
+ constructor(protected config: HttpClientConfig) {}
49
+
50
+ // perform a request and parse a JSON response body
51
+ async request<T = unknown>(req: RequestConfig): Promise<T> {
52
+ return this.withRetries(req, async () => {
53
+ const text = await this.send(req);
54
+ if (text.length === 0) return undefined as T;
55
+ try {
56
+ return JSON.parse(text) as T;
57
+ } catch (error) {
58
+ throw new ParsingError(
59
+ `Failed to parse JSON response from '${this.buildUrl(req)}': ${errorMessage(error)}`
60
+ );
61
+ }
62
+ });
63
+ }
64
+
65
+ // perform a request and return the raw response body text (e.g. XML APIs)
66
+ async requestText(req: RequestConfig): Promise<string> {
67
+ return this.withRetries(req, () => this.send(req));
68
+ }
69
+
70
+ // shared retry-with-backoff wrapper around a single-attempt operation
71
+ protected async withRetries<T>(req: RequestConfig, attemptFn: () => Promise<T>): Promise<T> {
72
+ const retries = req.retries ?? this.config.options.retries;
73
+ const backoff = req.backoff ?? this.config.options.backoff;
74
+
75
+ let lastError: RegistrarError | undefined;
76
+ for (let attempt = 0; attempt <= retries; attempt++) {
77
+ try {
78
+ return await attemptFn();
79
+ } catch (error) {
80
+ lastError = toRegistrarError(error);
81
+
82
+ // out of attempts, or the error is not retryable
83
+ if (attempt >= retries || !lastError.shouldRetry()) {
84
+ throw lastError;
85
+ }
86
+
87
+ // honor Retry-After for rate limits when present, else exponential backoff
88
+ const delay =
89
+ lastError instanceof RateLimitError && lastError.retryAfter != null
90
+ ? lastError.retryAfter * 1000
91
+ : backoffDelay(backoff, attempt);
92
+ await sleep(delay);
93
+ }
94
+ }
95
+
96
+ // unreachable: the loop always returns or throws
97
+ throw lastError ?? new RegistrarError('Request failed');
98
+ }
99
+
100
+ // build the full URL for a request, applying query parameters
101
+ protected buildUrl(req: RequestConfig): string {
102
+ let base: string;
103
+ if (/^https?:\/\//.test(req.path)) {
104
+ // absolute URL
105
+ base = req.path;
106
+ } else if (req.path === '') {
107
+ // empty path: the baseUrl is itself the full endpoint (e.g. Namecheap/Dynadot)
108
+ base = this.config.baseUrl;
109
+ } else {
110
+ base = `${this.config.baseUrl.replace(/\/$/, '')}/${req.path.replace(/^\//, '')}`;
111
+ }
112
+ const url = new URL(base);
113
+ if (req.query) {
114
+ for (const [key, value] of Object.entries(req.query)) {
115
+ if (value !== undefined) url.searchParams.set(key, String(value));
116
+ }
117
+ }
118
+ return url.toString();
119
+ }
120
+
121
+ // send a single request (no retries), map errors, and return the raw body text
122
+ protected async send(req: RequestConfig): Promise<string> {
123
+ const timeout = req.timeout ?? this.config.options.timeout;
124
+ const externalSignal = req.signal ?? this.config.options.signal;
125
+
126
+ // abort controller for this request, linked to any external signal
127
+ const controller = new AbortController();
128
+ if (externalSignal?.aborted) {
129
+ throw new AbortError('Request was aborted');
130
+ }
131
+ const onAbort = () => controller.abort();
132
+ externalSignal?.addEventListener('abort', onAbort);
133
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
134
+
135
+ const url = this.buildUrl(req);
136
+ const headers: Record<string, string> = {
137
+ 'User-Agent': USER_AGENT,
138
+ 'Accept': 'application/json',
139
+ ...this.config.headers,
140
+ ...req.headers,
141
+ };
142
+ if (req.body !== undefined && headers['Content-Type'] === undefined) {
143
+ headers['Content-Type'] = 'application/json';
144
+ }
145
+
146
+ try {
147
+ let response: Response;
148
+ try {
149
+ response = await fetch(url, {
150
+ method: req.method ?? 'GET',
151
+ headers,
152
+ body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
153
+ signal: controller.signal,
154
+ });
155
+ } catch (error) {
156
+ // distinguish abort/timeout from a genuine network failure
157
+ if (isAbortError(error)) {
158
+ if (externalSignal?.aborted) throw new AbortError('Request was aborted');
159
+ throw new TimeoutError(`Request to '${url}' timed out after ${timeout}ms`);
160
+ }
161
+ throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}`);
162
+ }
163
+
164
+ if (!response.ok) {
165
+ throw await this.toStatusError(response, url);
166
+ }
167
+
168
+ // 204 No Content yields an empty string
169
+ if (response.status === 204) {
170
+ return '';
171
+ }
172
+ return await response.text();
173
+ } finally {
174
+ clearTimeout(timeoutId);
175
+ externalSignal?.removeEventListener('abort', onAbort);
176
+ }
177
+ }
178
+
179
+ // map a non-2xx response to a typed RegistrarError
180
+ protected async toStatusError(response: Response, url: string): Promise<RegistrarError> {
181
+ // read the body defensively; it may aid debugging but must not be assumed JSON
182
+ let detail = '';
183
+ try {
184
+ detail = (await response.text()).slice(0, 500);
185
+ } catch {
186
+ // ignore body read failures
187
+ }
188
+ const message = `Request to '${url}' failed with ${response.status} ${response.statusText}${
189
+ detail ? `: ${detail}` : ''
190
+ }`;
191
+
192
+ switch (response.status) {
193
+ case 401:
194
+ return new AuthenticationError(message);
195
+ case 403:
196
+ return new AuthorizationError(message);
197
+ case 404:
198
+ return new NotFoundError(message);
199
+ case 429: {
200
+ const retryAfter = Number(response.headers.get('retry-after'));
201
+ return new RateLimitError(message, Number.isFinite(retryAfter) ? retryAfter : undefined);
202
+ }
203
+ default: {
204
+ if (response.status >= 500) {
205
+ return new InvalidResponseError(message);
206
+ }
207
+ // preserve the real status for 4xx codes without a dedicated class
208
+ // (400, 402, 409, 422, …) so callers can branch on it
209
+ const err = new RegistrarError(message);
210
+ err.status = response.status;
211
+ return err;
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // detect an AbortController/fetch abort across environments
218
+ function isAbortError(error: unknown): boolean {
219
+ return (
220
+ typeof error === 'object' &&
221
+ error !== null &&
222
+ 'name' in error &&
223
+ (error as { name?: string }).name === 'AbortError'
224
+ );
225
+ }
226
+
227
+ // safely extract a message from an unknown thrown value
228
+ function errorMessage(error: unknown): string {
229
+ return error instanceof Error ? error.message : String(error);
230
+ }
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Public API surface.
2
+
3
+ export { RegistrarClient } from './client';
4
+ export { listPortfolio } from './portfolio';
5
+ export type { DomainSource, PortfolioError, PortfolioResult } from './portfolio';
6
+ export { HttpClient } from './http';
7
+ export type { HttpClientConfig, RequestConfig } from './http';
8
+
9
+ // core registrar abstraction (the `Registrar` interface itself is exported via
10
+ // `export * from './types'` below)
11
+ export { BaseRegistrar, selectBaseUrl } from './registrar';
12
+
13
+ // built-in providers lookup + factory
14
+ export { registrars, createRegistrar } from './registrars/index';
15
+ export type { RegistrarName } from './registrars/index';
16
+
17
+ // capability model: feature constants, the core contract, and helpers
18
+ export { Feature, CORE_FEATURES, EXTENDED_FEATURES, ALL_FEATURES, isCoreFeature } from './features';
19
+ export type { RegistrarFeature } from './features';
20
+
21
+ // bundled providers
22
+ export { CloudflareRegistrar } from './registrars/cloudflare';
23
+ export { DynadotRegistrar } from './registrars/dynadot';
24
+ export { GandiRegistrar } from './registrars/gandi';
25
+ export { GoDaddyRegistrar } from './registrars/godaddy';
26
+ export { NameBrightRegistrar } from './registrars/namebright';
27
+ export { NamecheapRegistrar } from './registrars/namecheap';
28
+ export { NameSiloRegistrar } from './registrars/namesilo';
29
+ export { PorkbunRegistrar } from './registrars/porkbun';
30
+ export { SpaceshipRegistrar } from './registrars/spaceship';
31
+
32
+ // XML parsing helpers for XML-based registrar APIs
33
+ export { parseXml, ensureArray } from './xml';
34
+
35
+ // shared types, constants, utils, and errors
36
+ export * from './types';
37
+ export * from './constants';
38
+ export * from './utils';
39
+ export * from './errors';
@@ -0,0 +1,65 @@
1
+ // Aggregate domains across multiple registrars into one combined portfolio.
2
+ //
3
+ // Each source is queried concurrently and failures are isolated per-registrar
4
+ // (one provider being down never sinks the whole view). Every returned Domain
5
+ // already carries its `registrar`, so the flattened list stays attributable.
6
+
7
+ import type { RegistrarClient } from './client';
8
+ import type { Domain, ListDomainsOptions, Registrar } from './types';
9
+ import { toRegistrarError } from './errors';
10
+
11
+ // anything that can list domains: a raw Registrar or the RegistrarClient facade
12
+ export type DomainSource = Registrar | RegistrarClient;
13
+
14
+ // a per-registrar failure from a portfolio fetch
15
+ export interface PortfolioError {
16
+ // the registrar id that failed, e.g. "godaddy"
17
+ registrar: string;
18
+ error: Error;
19
+ }
20
+
21
+ // the outcome of a portfolio fetch: all domains that loaded, plus any errors
22
+ export interface PortfolioResult {
23
+ domains: Domain[];
24
+ errors: PortfolioError[];
25
+ }
26
+
27
+ // resolve a source's registrar id, whether it's a client facade or a provider
28
+ function sourceName(source: DomainSource): string {
29
+ return 'provider' in source ? source.provider.name : source.name;
30
+ }
31
+
32
+ /**
33
+ * List domains across many registrars at once. Queries every source in parallel
34
+ * and collects results with per-registrar error isolation.
35
+ *
36
+ * `opts` (including `search`) is passed to EACH source. Every source returns its
37
+ * full account; order of the returned `domains` follows the order of `sources`.
38
+ *
39
+ * const { domains, errors } = await listPortfolio(
40
+ * [godaddyClient, namecheapClient, gandiClient],
41
+ * { search: 'acme' }
42
+ * );
43
+ */
44
+ export async function listPortfolio(
45
+ sources: DomainSource[],
46
+ opts?: ListDomainsOptions
47
+ ): Promise<PortfolioResult> {
48
+ const settled = await Promise.allSettled(sources.map(source => source.listDomains(opts)));
49
+
50
+ const domains: Domain[] = [];
51
+ const errors: PortfolioError[] = [];
52
+
53
+ settled.forEach((result, i) => {
54
+ if (result.status === 'fulfilled') {
55
+ domains.push(...result.value);
56
+ } else {
57
+ errors.push({
58
+ registrar: sourceName(sources[i]),
59
+ error: toRegistrarError(result.reason),
60
+ });
61
+ }
62
+ });
63
+
64
+ return { domains, errors };
65
+ }
@@ -0,0 +1,240 @@
1
+ import { DEFAULT_OPTIONS } from './constants';
2
+ import { ConfigurationError, NotImplementedError } from './errors';
3
+ import { HttpClient, type HttpClientConfig } from './http';
4
+ import { CORE_FEATURES, type RegistrarFeature } from './features';
5
+ import type {
6
+ ConnectionResult,
7
+ ContactSet,
8
+ DnsRecord,
9
+ Domain,
10
+ DomainAvailability,
11
+ DomainForward,
12
+ DnssecStatus,
13
+ EmailForward,
14
+ ListDomainsOptions,
15
+ OperationResult,
16
+ Registrar,
17
+ RegisterDomainInput,
18
+ RegistrarClientOptions,
19
+ RegistrarCredentials,
20
+ RegistrarEnvironment,
21
+ RegistrarOptions,
22
+ RequestOptions,
23
+ TldPricing,
24
+ TransferDomainInput,
25
+ } from './types';
26
+
27
+ // pick the base URL for the requested environment. Throws if `sandbox` is
28
+ // requested but the provider defines no sandbox URL — so integration tests
29
+ // fail loudly rather than silently hitting production.
30
+ export function selectBaseUrl(
31
+ registrar: string,
32
+ environment: RegistrarEnvironment | undefined,
33
+ urls: { production: string; sandbox?: string }
34
+ ): string {
35
+ if (environment === 'sandbox') {
36
+ if (!urls.sandbox) {
37
+ throw new ConfigurationError(`${registrar} does not provide a sandbox environment`);
38
+ }
39
+ return urls.sandbox;
40
+ }
41
+ return urls.production;
42
+ }
43
+
44
+ // Abstract base for registrar providers. Owns the shared HTTP/auth/retry
45
+ // plumbing (via `HttpClient`) and provides `NotImplementedError`-rejecting
46
+ // defaults for every operation. Concrete providers extend this, build their
47
+ // base URL + auth from the credentials passed to `super()`, and override the
48
+ // operations their API supports.
49
+ export abstract class BaseRegistrar implements Registrar {
50
+ abstract readonly name: string;
51
+
52
+ protected http: HttpClient;
53
+ protected credentials: RegistrarCredentials;
54
+ protected options: RegistrarClientOptions;
55
+ readonly environment: RegistrarEnvironment;
56
+
57
+ // Extended capabilities this provider opts into, beyond the core contract.
58
+ // Subclasses override this; core features are inherited and not re-declared.
59
+ static readonly extendedFeatures: readonly RegistrarFeature[] = [];
60
+
61
+ // The provider's full capability set: the core contract plus its extended
62
+ // features. Available statically (no instance/credentials needed) for catalog
63
+ // UIs and MCP tooling.
64
+ static get features(): readonly RegistrarFeature[] {
65
+ return [...CORE_FEATURES, ...this.extendedFeatures];
66
+ }
67
+
68
+ // instance mirror of the static `features`
69
+ get features(): readonly RegistrarFeature[] {
70
+ return (this.constructor as typeof BaseRegistrar).features;
71
+ }
72
+
73
+ // whether this provider supports a given capability
74
+ supports(feature: RegistrarFeature): boolean {
75
+ return this.features.includes(feature);
76
+ }
77
+
78
+ constructor(
79
+ credentials: RegistrarCredentials,
80
+ httpConfig: Omit<HttpClientConfig, 'options'>,
81
+ options?: RegistrarOptions
82
+ ) {
83
+ const { environment = 'production', ...clientOptions } = options ?? {};
84
+ this.credentials = credentials;
85
+ this.environment = environment;
86
+ this.options = { ...DEFAULT_OPTIONS, ...clientOptions };
87
+ this.http = new HttpClient({ ...httpConfig, options: this.options });
88
+ }
89
+
90
+ testConnection(_opts?: RequestOptions): Promise<ConnectionResult> {
91
+ return Promise.reject(
92
+ new NotImplementedError(`${this.name}: testConnection is not implemented`)
93
+ );
94
+ }
95
+
96
+ listDomains(_opts?: ListDomainsOptions): Promise<Domain[]> {
97
+ return Promise.reject(new NotImplementedError(`${this.name}: listDomains is not implemented`));
98
+ }
99
+
100
+ renewDomain(
101
+ _domainName: string,
102
+ _years?: number,
103
+ _opts?: RequestOptions
104
+ ): Promise<OperationResult> {
105
+ return Promise.reject(new NotImplementedError(`${this.name}: renewDomain is not implemented`));
106
+ }
107
+
108
+ updateNameservers(
109
+ _domainName: string,
110
+ _nameservers: string[],
111
+ _opts?: RequestOptions
112
+ ): Promise<OperationResult> {
113
+ return Promise.reject(
114
+ new NotImplementedError(`${this.name}: updateNameservers is not implemented`)
115
+ );
116
+ }
117
+
118
+ lockDomain(_domainName: string, _opts?: RequestOptions): Promise<OperationResult> {
119
+ return Promise.reject(new NotImplementedError(`${this.name}: lockDomain is not implemented`));
120
+ }
121
+
122
+ unlockDomain(_domainName: string, _opts?: RequestOptions): Promise<OperationResult> {
123
+ return Promise.reject(new NotImplementedError(`${this.name}: unlockDomain is not implemented`));
124
+ }
125
+
126
+ getDomain(_domainName: string, _opts?: RequestOptions): Promise<Domain> {
127
+ return this.notImplemented('getDomain');
128
+ }
129
+
130
+ checkAvailability(_domainNames: string[], _opts?: RequestOptions): Promise<DomainAvailability[]> {
131
+ return this.notImplemented('checkAvailability');
132
+ }
133
+
134
+ getPricing(_tldOrDomain: string, _opts?: RequestOptions): Promise<TldPricing> {
135
+ return this.notImplemented('getPricing');
136
+ }
137
+
138
+ registerDomain(
139
+ _domainName: string,
140
+ _input: RegisterDomainInput,
141
+ _opts?: RequestOptions
142
+ ): Promise<OperationResult> {
143
+ return this.notImplemented('registerDomain');
144
+ }
145
+
146
+ setAutoRenew(
147
+ _domainName: string,
148
+ _enabled: boolean,
149
+ _opts?: RequestOptions
150
+ ): Promise<OperationResult> {
151
+ return this.notImplemented('setAutoRenew');
152
+ }
153
+
154
+ transferIn(
155
+ _domainName: string,
156
+ _input: TransferDomainInput,
157
+ _opts?: RequestOptions
158
+ ): Promise<OperationResult> {
159
+ return this.notImplemented('transferIn');
160
+ }
161
+
162
+ getNameservers(_domainName: string, _opts?: RequestOptions): Promise<string[]> {
163
+ return this.notImplemented('getNameservers');
164
+ }
165
+
166
+ setPrivacy(
167
+ _domainName: string,
168
+ _enabled: boolean,
169
+ _opts?: RequestOptions
170
+ ): Promise<OperationResult> {
171
+ return this.notImplemented('setPrivacy');
172
+ }
173
+
174
+ getContacts(_domainName: string, _opts?: RequestOptions): Promise<ContactSet> {
175
+ return this.notImplemented('getContacts');
176
+ }
177
+
178
+ updateContacts(
179
+ _domainName: string,
180
+ _contacts: ContactSet,
181
+ _opts?: RequestOptions
182
+ ): Promise<OperationResult> {
183
+ return this.notImplemented('updateContacts');
184
+ }
185
+
186
+ getDnsRecords(_domainName: string, _opts?: RequestOptions): Promise<DnsRecord[]> {
187
+ return this.notImplemented('getDnsRecords');
188
+ }
189
+
190
+ setDnsRecords(
191
+ _domainName: string,
192
+ _records: DnsRecord[],
193
+ _opts?: RequestOptions
194
+ ): Promise<OperationResult> {
195
+ return this.notImplemented('setDnsRecords');
196
+ }
197
+
198
+ // --- extended capabilities (opt-in; default to NotImplementedError) ---
199
+
200
+ getEmailForwarding(_domainName: string, _opts?: RequestOptions): Promise<EmailForward[]> {
201
+ return this.notImplemented('getEmailForwarding');
202
+ }
203
+
204
+ setEmailForwarding(
205
+ _domainName: string,
206
+ _forwards: EmailForward[],
207
+ _opts?: RequestOptions
208
+ ): Promise<OperationResult> {
209
+ return this.notImplemented('setEmailForwarding');
210
+ }
211
+
212
+ getDomainForwarding(_domainName: string, _opts?: RequestOptions): Promise<DomainForward[]> {
213
+ return this.notImplemented('getDomainForwarding');
214
+ }
215
+
216
+ setDomainForwarding(
217
+ _domainName: string,
218
+ _forwards: DomainForward[],
219
+ _opts?: RequestOptions
220
+ ): Promise<OperationResult> {
221
+ return this.notImplemented('setDomainForwarding');
222
+ }
223
+
224
+ getAuthCode(_domainName: string, _opts?: RequestOptions): Promise<string> {
225
+ return this.notImplemented('getAuthCode');
226
+ }
227
+
228
+ getDnssec(_domainName: string, _opts?: RequestOptions): Promise<DnssecStatus> {
229
+ return this.notImplemented('getDnssec');
230
+ }
231
+
232
+ disableDnssec(_domainName: string, _opts?: RequestOptions): Promise<OperationResult> {
233
+ return this.notImplemented('disableDnssec');
234
+ }
235
+
236
+ // shared rejection for a capability a provider hasn't wired up yet
237
+ protected notImplemented(method: string): Promise<never> {
238
+ return Promise.reject(new NotImplementedError(`${this.name}: ${method} is not implemented`));
239
+ }
240
+ }