@aoxborrow/registrar-client 0.5.0 → 0.7.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/README.md +181 -33
- package/dist/index.cjs +742 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +85 -3
- package/dist/index.d.ts +85 -3
- package/dist/index.js +736 -57
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +69 -1
- package/src/features.ts +42 -0
- package/src/http.ts +76 -5
- package/src/index.ts +10 -2
- package/src/registrar.ts +47 -2
- package/src/registrars/cloudflare.ts +3 -0
- package/src/registrars/dynadot.ts +3 -0
- package/src/registrars/gandi.ts +3 -0
- package/src/registrars/godaddy.ts +3 -0
- package/src/registrars/index.ts +2 -0
- package/src/registrars/namebright.ts +6 -2
- package/src/registrars/namecheap.ts +6 -1
- package/src/registrars/namecom.ts +708 -0
- package/src/registrars/namesilo.ts +4 -4
- package/src/registrars/porkbun.ts +3 -3
- package/src/registrars/spaceship.ts +3 -0
- package/src/types.ts +30 -1
- package/src/utils.ts +16 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aoxborrow/registrar-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "One consistent, fully-typed TypeScript interface to many domain registrar APIs (GoDaddy, Cloudflare, Namecheap, Spaceship, Dynadot, Porkbun, and more) behind a single pluggable multi-provider client",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Aaron Oxborrow",
|
package/src/errors.ts
CHANGED
|
@@ -75,11 +75,79 @@ export class TimeoutError extends RetryableRegistrarError {
|
|
|
75
75
|
// network/connection failure reaching the registrar API
|
|
76
76
|
export class ConnectionError extends RetryableRegistrarError {
|
|
77
77
|
public status = 503; // Service Unavailable
|
|
78
|
+
// true when the failure is known to have happened before the request left:
|
|
79
|
+
// DNS, connection refused, TLS handshake, or a proxy that never opened its
|
|
80
|
+
// tunnel. The registrar cannot have acted on it, so it is safe to re-send
|
|
81
|
+
// even for a write.
|
|
82
|
+
public notSent: boolean;
|
|
78
83
|
|
|
79
|
-
constructor(message: string) {
|
|
84
|
+
constructor(message: string, details: { notSent?: boolean } = {}) {
|
|
80
85
|
super(message);
|
|
81
86
|
this.name = 'ConnectionError';
|
|
87
|
+
this.notSent = details.notSent ?? false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A write failed in a way that leaves its result unknown: the request timed
|
|
92
|
+
// out, the connection dropped mid-response, or the registrar answered 5xx. It
|
|
93
|
+
// may have been applied. Never retried by the library; re-read the domain
|
|
94
|
+
// before trying again.
|
|
95
|
+
export class OutcomeUnknownError extends RegistrarError {
|
|
96
|
+
public status = 504; // Gateway Timeout
|
|
97
|
+
public feature?: string;
|
|
98
|
+
public override cause?: unknown;
|
|
99
|
+
|
|
100
|
+
constructor(message: string, details: { feature?: string; cause?: unknown } = {}) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.name = 'OutcomeUnknownError';
|
|
103
|
+
this.feature = details.feature;
|
|
104
|
+
this.cause = details.cause;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const NOT_SENT = Symbol.for('registrar-client.notSent');
|
|
109
|
+
|
|
110
|
+
// For custom `fetch` transports: mark an error as having occurred before the
|
|
111
|
+
// request was sent (proxy unreachable, proxy auth refused, tunnel TLS failed).
|
|
112
|
+
// Returns the same error for `throw markNotSent(err)`.
|
|
113
|
+
export function markNotSent<T>(error: T): T {
|
|
114
|
+
if (typeof error === 'object' && error !== null) {
|
|
115
|
+
Object.defineProperty(error, NOT_SENT, { value: true, enumerable: false });
|
|
116
|
+
}
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// error codes Node's fetch (undici) reports, via `error.cause.code`, for
|
|
121
|
+
// failures that happen before any request bytes are written
|
|
122
|
+
const PRE_SEND_CODES = new Set([
|
|
123
|
+
'ENOTFOUND',
|
|
124
|
+
'EAI_AGAIN',
|
|
125
|
+
'ECONNREFUSED',
|
|
126
|
+
'EHOSTUNREACH',
|
|
127
|
+
'ENETUNREACH',
|
|
128
|
+
'EADDRNOTAVAIL',
|
|
129
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
130
|
+
'CERT_HAS_EXPIRED',
|
|
131
|
+
'CERT_NOT_YET_VALID',
|
|
132
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
133
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
134
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
135
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
136
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
// Whether a fetch rejection is known to have happened before the request was
|
|
140
|
+
// sent. Only positive evidence counts: an explicit `markNotSent`, or a
|
|
141
|
+
// pre-connection error code. Runtimes that expose no code (browsers, Workers)
|
|
142
|
+
// yield false, which is the safe answer.
|
|
143
|
+
export function wasNotSent(error: unknown): boolean {
|
|
144
|
+
for (let e: unknown = error, depth = 0; e && typeof e === 'object' && depth < 5; depth++) {
|
|
145
|
+
if ((e as Record<symbol, unknown>)[NOT_SENT] === true) return true;
|
|
146
|
+
const code = (e as { code?: unknown }).code;
|
|
147
|
+
if (typeof code === 'string' && PRE_SEND_CODES.has(code)) return true;
|
|
148
|
+
e = (e as { cause?: unknown }).cause;
|
|
82
149
|
}
|
|
150
|
+
return false;
|
|
83
151
|
}
|
|
84
152
|
|
|
85
153
|
// registrar API rejected the credentials
|
package/src/features.ts
CHANGED
|
@@ -102,3 +102,45 @@ const CORE_FEATURE_SET: ReadonlySet<RegistrarFeature> = new Set(CORE_FEATURES);
|
|
|
102
102
|
export function isCoreFeature(feature: RegistrarFeature): boolean {
|
|
103
103
|
return CORE_FEATURE_SET.has(feature);
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
// How each feature method is served: whether it only reads registrar state or
|
|
107
|
+
// may change it, and which positional argument carries its `RequestOptions`.
|
|
108
|
+
//
|
|
109
|
+
// The intent decides what may be re-sent after a failure (see `HttpClient`). It
|
|
110
|
+
// is keyed by feature rather than by HTTP method because the method says
|
|
111
|
+
// nothing reliable: Namecheap sends every command, writes included, as a GET,
|
|
112
|
+
// and Porkbun sends reads as POSTs.
|
|
113
|
+
//
|
|
114
|
+
// Typed as a total record so adding a feature without classifying it fails to
|
|
115
|
+
// compile. `getAuthCode` is a write: several registrars regenerate the code,
|
|
116
|
+
// invalidating the previous one.
|
|
117
|
+
export const FEATURE_CALLS: Record<
|
|
118
|
+
RegistrarFeature,
|
|
119
|
+
{ intent: 'read' | 'write'; optsIndex: number }
|
|
120
|
+
> = {
|
|
121
|
+
testConnection: { intent: 'read', optsIndex: 0 },
|
|
122
|
+
listDomains: { intent: 'read', optsIndex: 0 },
|
|
123
|
+
getDomain: { intent: 'read', optsIndex: 1 },
|
|
124
|
+
checkAvailability: { intent: 'read', optsIndex: 1 },
|
|
125
|
+
getPricing: { intent: 'read', optsIndex: 1 },
|
|
126
|
+
registerDomain: { intent: 'write', optsIndex: 2 },
|
|
127
|
+
renewDomain: { intent: 'write', optsIndex: 2 },
|
|
128
|
+
setAutoRenew: { intent: 'write', optsIndex: 2 },
|
|
129
|
+
transferIn: { intent: 'write', optsIndex: 2 },
|
|
130
|
+
updateNameservers: { intent: 'write', optsIndex: 2 },
|
|
131
|
+
getNameservers: { intent: 'read', optsIndex: 1 },
|
|
132
|
+
lockDomain: { intent: 'write', optsIndex: 1 },
|
|
133
|
+
unlockDomain: { intent: 'write', optsIndex: 1 },
|
|
134
|
+
setPrivacy: { intent: 'write', optsIndex: 2 },
|
|
135
|
+
getContacts: { intent: 'read', optsIndex: 1 },
|
|
136
|
+
updateContacts: { intent: 'write', optsIndex: 2 },
|
|
137
|
+
getDnsRecords: { intent: 'read', optsIndex: 1 },
|
|
138
|
+
setDnsRecords: { intent: 'write', optsIndex: 2 },
|
|
139
|
+
getAuthCode: { intent: 'write', optsIndex: 1 },
|
|
140
|
+
getDnssec: { intent: 'read', optsIndex: 1 },
|
|
141
|
+
disableDnssec: { intent: 'write', optsIndex: 1 },
|
|
142
|
+
getEmailForwarding: { intent: 'read', optsIndex: 1 },
|
|
143
|
+
setEmailForwarding: { intent: 'write', optsIndex: 2 },
|
|
144
|
+
getDomainForwarding: { intent: 'read', optsIndex: 1 },
|
|
145
|
+
setDomainForwarding: { intent: 'write', optsIndex: 2 },
|
|
146
|
+
};
|
package/src/http.ts
CHANGED
|
@@ -6,13 +6,15 @@ import {
|
|
|
6
6
|
ConnectionError,
|
|
7
7
|
InvalidResponseError,
|
|
8
8
|
NotFoundError,
|
|
9
|
+
OutcomeUnknownError,
|
|
9
10
|
ParsingError,
|
|
10
11
|
RateLimitError,
|
|
11
12
|
RegistrarError,
|
|
12
13
|
TimeoutError,
|
|
13
14
|
toRegistrarError,
|
|
15
|
+
wasNotSent,
|
|
14
16
|
} from './errors';
|
|
15
|
-
import type { RegistrarClientOptions } from './types';
|
|
17
|
+
import type { FeatureCall, RegistrarClientOptions } from './types';
|
|
16
18
|
import { backoffDelay, sleep } from './utils';
|
|
17
19
|
|
|
18
20
|
// configuration for an HttpClient instance
|
|
@@ -23,8 +25,16 @@ export interface HttpClientConfig {
|
|
|
23
25
|
headers?: Record<string, string>;
|
|
24
26
|
// request/retry behavior
|
|
25
27
|
options: RegistrarClientOptions;
|
|
28
|
+
// HTTP methods this API reserves for requests that never change state. A
|
|
29
|
+
// REST API sets `REST_SAFE_METHODS`, so a lookup made in the middle of a
|
|
30
|
+
// write (a zone list, a price check) is still retried like any read. Leave
|
|
31
|
+
// unset for APIs where the method means nothing: Namecheap and NameSilo send
|
|
32
|
+
// writes as GET. Those mark their nested reads with `asRead` instead.
|
|
33
|
+
safeMethods?: readonly string[];
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
export const REST_SAFE_METHODS: readonly string[] = ['GET', 'HEAD'];
|
|
37
|
+
|
|
28
38
|
// options for a single request
|
|
29
39
|
export interface RequestConfig {
|
|
30
40
|
method?: string;
|
|
@@ -40,6 +50,11 @@ export interface RequestConfig {
|
|
|
40
50
|
retries?: number;
|
|
41
51
|
backoff?: number;
|
|
42
52
|
signal?: AbortSignal;
|
|
53
|
+
fetch?: typeof globalThis.fetch;
|
|
54
|
+
// which feature this request serves and whether it reads or writes; arrives
|
|
55
|
+
// with the caller's `RequestOptions`. A request without one is treated as a
|
|
56
|
+
// write, the safe assumption.
|
|
57
|
+
call?: FeatureCall;
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
// a small, browser/edge-safe fetch wrapper with timeout, abort linking,
|
|
@@ -67,7 +82,20 @@ export class HttpClient {
|
|
|
67
82
|
return this.withRetries(req, () => this.send(req));
|
|
68
83
|
}
|
|
69
84
|
|
|
70
|
-
//
|
|
85
|
+
// Shared retry-with-backoff wrapper around a single-attempt operation.
|
|
86
|
+
//
|
|
87
|
+
// What may be re-sent depends on where the failure happened and on whether
|
|
88
|
+
// the call reads or writes:
|
|
89
|
+
//
|
|
90
|
+
// failure read write
|
|
91
|
+
// never sent (DNS, refused, TLS, proxy stage) retry retry
|
|
92
|
+
// 429 retry retry
|
|
93
|
+
// unknown (timeout, dropped mid-response, 5xx) retry OutcomeUnknownError
|
|
94
|
+
// any other response fail fail
|
|
95
|
+
//
|
|
96
|
+
// A write whose outcome is unknown may have been applied, so it is never
|
|
97
|
+
// re-sent: a second renewal charges twice, a second record-create leaves a
|
|
98
|
+
// duplicate. The caller re-reads the domain instead.
|
|
71
99
|
protected async withRetries<T>(req: RequestConfig, attemptFn: () => Promise<T>): Promise<T> {
|
|
72
100
|
const retries = req.retries ?? this.config.options.retries;
|
|
73
101
|
const backoff = req.backoff ?? this.config.options.backoff;
|
|
@@ -79,6 +107,16 @@ export class HttpClient {
|
|
|
79
107
|
} catch (error) {
|
|
80
108
|
lastError = toRegistrarError(error);
|
|
81
109
|
|
|
110
|
+
if (this.intentOf(req) === 'write' && isOutcomeUnknown(lastError)) {
|
|
111
|
+
const unknown = new OutcomeUnknownError(
|
|
112
|
+
`The registrar did not confirm this change, so it may or may not have been applied. ` +
|
|
113
|
+
`Check the domain before trying again. (${lastError.message})`,
|
|
114
|
+
{ feature: req.call?.feature, cause: lastError }
|
|
115
|
+
);
|
|
116
|
+
if (req.call) req.call.outcomeUnknown = unknown;
|
|
117
|
+
throw unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
82
120
|
// out of attempts, or the error is not retryable
|
|
83
121
|
if (attempt >= retries || !lastError.shouldRetry()) {
|
|
84
122
|
throw lastError;
|
|
@@ -97,6 +135,15 @@ export class HttpClient {
|
|
|
97
135
|
throw lastError ?? new RegistrarError('Request failed');
|
|
98
136
|
}
|
|
99
137
|
|
|
138
|
+
// Whether a request only reads. A method the API reserves for reads wins;
|
|
139
|
+
// otherwise it is whatever the feature being served is, and a request that
|
|
140
|
+
// says nothing is assumed to write.
|
|
141
|
+
protected intentOf(req: RequestConfig): 'read' | 'write' {
|
|
142
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
143
|
+
if (this.config.safeMethods?.includes(method)) return 'read';
|
|
144
|
+
return req.call?.intent ?? 'write';
|
|
145
|
+
}
|
|
146
|
+
|
|
100
147
|
// build the full URL for a request, applying query parameters
|
|
101
148
|
protected buildUrl(req: RequestConfig): string {
|
|
102
149
|
let base: string;
|
|
@@ -146,7 +193,8 @@ export class HttpClient {
|
|
|
146
193
|
try {
|
|
147
194
|
let response: Response;
|
|
148
195
|
try {
|
|
149
|
-
|
|
196
|
+
const doFetch = req.fetch ?? this.config.options.fetch ?? fetch;
|
|
197
|
+
response = await doFetch(url, {
|
|
150
198
|
method: req.method ?? 'GET',
|
|
151
199
|
headers,
|
|
152
200
|
body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
|
|
@@ -158,7 +206,9 @@ export class HttpClient {
|
|
|
158
206
|
if (externalSignal?.aborted) throw new AbortError('Request was aborted');
|
|
159
207
|
throw new TimeoutError(`Request to '${url}' timed out after ${timeout}ms`);
|
|
160
208
|
}
|
|
161
|
-
throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}
|
|
209
|
+
throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}`, {
|
|
210
|
+
notSent: wasNotSent(error),
|
|
211
|
+
});
|
|
162
212
|
}
|
|
163
213
|
|
|
164
214
|
if (!response.ok) {
|
|
@@ -169,7 +219,20 @@ export class HttpClient {
|
|
|
169
219
|
if (response.status === 204) {
|
|
170
220
|
return '';
|
|
171
221
|
}
|
|
172
|
-
|
|
222
|
+
try {
|
|
223
|
+
return await response.text();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// The registrar answered, then the body was cut off. The request was
|
|
226
|
+
// certainly sent, so for a write the outcome is unknown; a read can
|
|
227
|
+
// simply be retried.
|
|
228
|
+
if (isAbortError(error)) {
|
|
229
|
+
if (externalSignal?.aborted) throw new AbortError('Request was aborted');
|
|
230
|
+
throw new TimeoutError(`Response from '${url}' timed out after ${timeout}ms`);
|
|
231
|
+
}
|
|
232
|
+
throw new ConnectionError(
|
|
233
|
+
`Connection to '${url}' was lost while reading the response: ${errorMessage(error)}`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
173
236
|
} finally {
|
|
174
237
|
clearTimeout(timeoutId);
|
|
175
238
|
externalSignal?.removeEventListener('abort', onAbort);
|
|
@@ -214,6 +277,14 @@ export class HttpClient {
|
|
|
214
277
|
}
|
|
215
278
|
}
|
|
216
279
|
|
|
280
|
+
// A retryable failure that leaves a write's result unknown: the request may
|
|
281
|
+
// have reached the registrar. A rate limit is an explicit rejection, and a
|
|
282
|
+
// connection error known to precede sending never reached it.
|
|
283
|
+
function isOutcomeUnknown(error: RegistrarError): boolean {
|
|
284
|
+
if (error instanceof TimeoutError || error instanceof InvalidResponseError) return true;
|
|
285
|
+
return error instanceof ConnectionError && !error.notSent;
|
|
286
|
+
}
|
|
287
|
+
|
|
217
288
|
// detect an AbortController/fetch abort across environments
|
|
218
289
|
function isAbortError(error: unknown): boolean {
|
|
219
290
|
return (
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export { RegistrarClient } from './client';
|
|
4
4
|
export { listPortfolio } from './portfolio';
|
|
5
5
|
export type { DomainSource, PortfolioError, PortfolioResult } from './portfolio';
|
|
6
|
-
export { HttpClient } from './http';
|
|
6
|
+
export { HttpClient, REST_SAFE_METHODS } from './http';
|
|
7
7
|
export type { HttpClientConfig, RequestConfig } from './http';
|
|
8
8
|
|
|
9
9
|
// core registrar abstraction (the `Registrar` interface itself is exported via
|
|
@@ -15,7 +15,14 @@ export { registrars, createRegistrar } from './registrars/index';
|
|
|
15
15
|
export type { RegistrarName } from './registrars/index';
|
|
16
16
|
|
|
17
17
|
// capability model: feature constants, the core contract, and helpers
|
|
18
|
-
export {
|
|
18
|
+
export {
|
|
19
|
+
Feature,
|
|
20
|
+
CORE_FEATURES,
|
|
21
|
+
EXTENDED_FEATURES,
|
|
22
|
+
ALL_FEATURES,
|
|
23
|
+
FEATURE_CALLS,
|
|
24
|
+
isCoreFeature,
|
|
25
|
+
} from './features';
|
|
19
26
|
export type { RegistrarFeature } from './features';
|
|
20
27
|
|
|
21
28
|
// bundled providers
|
|
@@ -25,6 +32,7 @@ export { GandiRegistrar } from './registrars/gandi';
|
|
|
25
32
|
export { GoDaddyRegistrar } from './registrars/godaddy';
|
|
26
33
|
export { NameBrightRegistrar } from './registrars/namebright';
|
|
27
34
|
export { NamecheapRegistrar } from './registrars/namecheap';
|
|
35
|
+
export { NamecomRegistrar } from './registrars/namecom';
|
|
28
36
|
export { NameSiloRegistrar } from './registrars/namesilo';
|
|
29
37
|
export { PorkbunRegistrar } from './registrars/porkbun';
|
|
30
38
|
export { SpaceshipRegistrar } from './registrars/spaceship';
|
package/src/registrar.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_OPTIONS } from './constants';
|
|
2
2
|
import { ConfigurationError, NotImplementedError } from './errors';
|
|
3
3
|
import { HttpClient, type HttpClientConfig } from './http';
|
|
4
|
-
import { CORE_FEATURES, type RegistrarFeature } from './features';
|
|
4
|
+
import { CORE_FEATURES, FEATURE_CALLS, type RegistrarFeature } from './features';
|
|
5
5
|
import type {
|
|
6
6
|
ConnectionResult,
|
|
7
7
|
ContactSet,
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
RegistrarClientOptions,
|
|
19
19
|
RegistrarCredentials,
|
|
20
20
|
RegistrarEnvironment,
|
|
21
|
+
FeatureCall,
|
|
21
22
|
RegistrarOptions,
|
|
22
23
|
RequestOptions,
|
|
23
24
|
TldPricing,
|
|
@@ -65,6 +66,10 @@ export abstract class BaseRegistrar implements Registrar {
|
|
|
65
66
|
return [...CORE_FEATURES, ...this.extendedFeatures];
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// HTTP methods this provider's API reserves for reads; see
|
|
70
|
+
// `HttpClientConfig.safeMethods`. REST providers set `REST_SAFE_METHODS`.
|
|
71
|
+
static readonly safeMethods: readonly string[] = [];
|
|
72
|
+
|
|
68
73
|
// instance mirror of the static `features`
|
|
69
74
|
get features(): readonly RegistrarFeature[] {
|
|
70
75
|
return (this.constructor as typeof BaseRegistrar).features;
|
|
@@ -90,7 +95,47 @@ export abstract class BaseRegistrar implements Registrar {
|
|
|
90
95
|
this.credentials = credentials;
|
|
91
96
|
this.environment = environment;
|
|
92
97
|
this.options = { ...DEFAULT_OPTIONS, ...clientOptions };
|
|
93
|
-
this.http = new HttpClient({
|
|
98
|
+
this.http = new HttpClient({
|
|
99
|
+
safeMethods: (this.constructor as typeof BaseRegistrar).safeMethods,
|
|
100
|
+
...httpConfig,
|
|
101
|
+
options: this.options,
|
|
102
|
+
});
|
|
103
|
+
this.threadFeatureCalls();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Wrap each feature method so its `RequestOptions` carry a `FeatureCall`:
|
|
107
|
+
// the read/write intent the HTTP layer needs to decide what may be re-sent,
|
|
108
|
+
// and a slot where it reports a write whose outcome is unknown. Providers
|
|
109
|
+
// already forward their options into every request, so nothing else changes.
|
|
110
|
+
//
|
|
111
|
+
// A feature calling another feature (a write re-reading the domain, say)
|
|
112
|
+
// goes through that feature's own wrapper, so the inner read is still a read.
|
|
113
|
+
// Providers fold most failures into an `OperationResult`; the unknown outcome
|
|
114
|
+
// is copied onto that result so it is not lost along with the error.
|
|
115
|
+
private threadFeatureCalls(): void {
|
|
116
|
+
const self = this as unknown as Record<string, unknown>;
|
|
117
|
+
for (const [feature, { intent, optsIndex }] of Object.entries(FEATURE_CALLS)) {
|
|
118
|
+
const original = self[feature];
|
|
119
|
+
if (typeof original !== 'function') continue;
|
|
120
|
+
// Not `async`: a provider that validates its arguments by throwing
|
|
121
|
+
// synchronously keeps doing so.
|
|
122
|
+
self[feature] = (...args: unknown[]): unknown => {
|
|
123
|
+
const call: FeatureCall = { intent, feature };
|
|
124
|
+
const next = [...args];
|
|
125
|
+
while (next.length <= optsIndex) next.push(undefined);
|
|
126
|
+
next[optsIndex] = { ...(next[optsIndex] as RequestOptions | undefined), call };
|
|
127
|
+
const result: unknown = (original as (...a: unknown[]) => unknown).apply(this, next);
|
|
128
|
+
if (!(result instanceof Promise)) return result;
|
|
129
|
+
return result.then((value: unknown) =>
|
|
130
|
+
call.outcomeUnknown &&
|
|
131
|
+
value !== null &&
|
|
132
|
+
typeof value === 'object' &&
|
|
133
|
+
(value as OperationResult).success === false
|
|
134
|
+
? { ...value, outcome: 'unknown' }
|
|
135
|
+
: value
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
94
139
|
}
|
|
95
140
|
|
|
96
141
|
testConnection(_opts?: RequestOptions): Promise<ConnectionResult> {
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
RequestOptions,
|
|
16
16
|
TldPricing,
|
|
17
17
|
} from '../types';
|
|
18
|
+
import { REST_SAFE_METHODS } from '../http';
|
|
18
19
|
import { createDomain, filterDomains, normalizeDomain, settableForwards } from '../utils';
|
|
19
20
|
import { NotImplementedError, toRegistrarError } from '../errors';
|
|
20
21
|
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
@@ -173,6 +174,8 @@ interface CfDnsRecord {
|
|
|
173
174
|
*/
|
|
174
175
|
export class CloudflareRegistrar extends BaseRegistrar {
|
|
175
176
|
readonly name = 'cloudflare';
|
|
177
|
+
// a REST API: GET and HEAD never change state
|
|
178
|
+
static override readonly safeMethods = REST_SAFE_METHODS;
|
|
176
179
|
|
|
177
180
|
static readonly displayName = 'Cloudflare';
|
|
178
181
|
static readonly website = 'cloudflare.com';
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
TldPricing,
|
|
18
18
|
TransferDomainInput,
|
|
19
19
|
} from '../types';
|
|
20
|
+
import { REST_SAFE_METHODS } from '../http';
|
|
20
21
|
import { createDomain, filterDomains, requireConsent, settableForwards } from '../utils';
|
|
21
22
|
import { NotFoundError, NotImplementedError, toRegistrarError } from '../errors';
|
|
22
23
|
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
@@ -178,6 +179,8 @@ const SUPPORTED_DNS_TYPES = new Set(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'S
|
|
|
178
179
|
*/
|
|
179
180
|
export class DynadotRegistrar extends BaseRegistrar {
|
|
180
181
|
readonly name = 'dynadot';
|
|
182
|
+
// a REST API: GET and HEAD never change state
|
|
183
|
+
static override readonly safeMethods = REST_SAFE_METHODS;
|
|
181
184
|
|
|
182
185
|
static readonly displayName = 'Dynadot';
|
|
183
186
|
static readonly website = 'dynadot.com';
|
package/src/registrars/gandi.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
TldPricing,
|
|
20
20
|
TransferDomainInput,
|
|
21
21
|
} from '../types';
|
|
22
|
+
import { REST_SAFE_METHODS } from '../http';
|
|
22
23
|
import { createDomain, filterDomains, settableForwards } from '../utils';
|
|
23
24
|
import { toRegistrarError } from '../errors';
|
|
24
25
|
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
@@ -159,6 +160,8 @@ const GANDI_TYPE_TO_FORWARD: Record<string, DomainForwardType> = {
|
|
|
159
160
|
*/
|
|
160
161
|
export class GandiRegistrar extends BaseRegistrar {
|
|
161
162
|
readonly name = 'gandi';
|
|
163
|
+
// a REST API: GET and HEAD never change state
|
|
164
|
+
static override readonly safeMethods = REST_SAFE_METHODS;
|
|
162
165
|
|
|
163
166
|
static readonly displayName = 'Gandi.net';
|
|
164
167
|
static readonly website = 'gandi.net';
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
TldPricing,
|
|
18
18
|
TransferDomainInput,
|
|
19
19
|
} from '../types';
|
|
20
|
+
import { REST_SAFE_METHODS } from '../http';
|
|
20
21
|
import { createDomain, filterDomains, settableForwards } from '../utils';
|
|
21
22
|
import {
|
|
22
23
|
ConfigurationError,
|
|
@@ -267,6 +268,8 @@ interface GdV3Operation {
|
|
|
267
268
|
*/
|
|
268
269
|
export class GoDaddyRegistrar extends BaseRegistrar {
|
|
269
270
|
readonly name = 'godaddy';
|
|
271
|
+
// a REST API: GET and HEAD never change state
|
|
272
|
+
static override readonly safeMethods = REST_SAFE_METHODS;
|
|
270
273
|
|
|
271
274
|
static readonly displayName = 'GoDaddy';
|
|
272
275
|
static readonly website = 'godaddy.com';
|
package/src/registrars/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { GandiRegistrar } from './gandi';
|
|
|
10
10
|
import { GoDaddyRegistrar } from './godaddy';
|
|
11
11
|
import { NameBrightRegistrar } from './namebright';
|
|
12
12
|
import { NamecheapRegistrar } from './namecheap';
|
|
13
|
+
import { NamecomRegistrar } from './namecom';
|
|
13
14
|
import { NameSiloRegistrar } from './namesilo';
|
|
14
15
|
import { PorkbunRegistrar } from './porkbun';
|
|
15
16
|
import { SpaceshipRegistrar } from './spaceship';
|
|
@@ -22,6 +23,7 @@ export const registrars = {
|
|
|
22
23
|
godaddy: GoDaddyRegistrar,
|
|
23
24
|
namebright: NameBrightRegistrar,
|
|
24
25
|
namecheap: NamecheapRegistrar,
|
|
26
|
+
namecom: NamecomRegistrar,
|
|
25
27
|
namesilo: NameSiloRegistrar,
|
|
26
28
|
porkbun: PorkbunRegistrar,
|
|
27
29
|
spaceship: SpaceshipRegistrar,
|
|
@@ -13,12 +13,13 @@ import type {
|
|
|
13
13
|
RequestOptions,
|
|
14
14
|
TldPricing,
|
|
15
15
|
} from '../types';
|
|
16
|
-
import { createDomain, filterDomains } from '../utils';
|
|
16
|
+
import { asRead, createDomain, filterDomains } from '../utils';
|
|
17
17
|
import { AuthenticationError, NotImplementedError, toRegistrarError } from '../errors';
|
|
18
18
|
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
19
19
|
import { Feature, type RegistrarFeature } from '../features';
|
|
20
20
|
import type { RegistrarCredentials } from '../types';
|
|
21
21
|
import type { RequestConfig } from '../http';
|
|
22
|
+
import { REST_SAFE_METHODS } from '../http';
|
|
22
23
|
|
|
23
24
|
const TOKEN_URL = 'https://api.namebright.com/auth/token';
|
|
24
25
|
|
|
@@ -196,6 +197,8 @@ interface NbAvailability {
|
|
|
196
197
|
*/
|
|
197
198
|
export class NameBrightRegistrar extends BaseRegistrar {
|
|
198
199
|
readonly name = 'namebright';
|
|
200
|
+
// a REST API: GET and HEAD never change state
|
|
201
|
+
static override readonly safeMethods = REST_SAFE_METHODS;
|
|
199
202
|
|
|
200
203
|
static readonly displayName = 'NameBright';
|
|
201
204
|
static readonly website = 'namebright.com';
|
|
@@ -243,7 +246,8 @@ export class NameBrightRegistrar extends BaseRegistrar {
|
|
|
243
246
|
client_id: this.credentials.clientId,
|
|
244
247
|
client_secret: this.credentials.clientSecret,
|
|
245
248
|
},
|
|
246
|
-
|
|
249
|
+
// issuing a token changes nothing on the account, whatever it precedes
|
|
250
|
+
...asRead(opts),
|
|
247
251
|
});
|
|
248
252
|
if (!res.access_token) {
|
|
249
253
|
throw new AuthenticationError('NameBright: token endpoint returned no access_token');
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
TransferDomainInput,
|
|
19
19
|
} from '../types';
|
|
20
20
|
import {
|
|
21
|
+
asRead,
|
|
21
22
|
createDomain,
|
|
22
23
|
filterDomains,
|
|
23
24
|
normalizeDomain,
|
|
@@ -580,7 +581,11 @@ export class NamecheapRegistrar extends BaseRegistrar {
|
|
|
580
581
|
enabled: boolean,
|
|
581
582
|
opts?: RequestOptions
|
|
582
583
|
): Promise<OperationResult> {
|
|
583
|
-
const cr = await this.command(
|
|
584
|
+
const cr = await this.command(
|
|
585
|
+
'namecheap.domains.getInfo',
|
|
586
|
+
{ DomainName: domainName },
|
|
587
|
+
asRead(opts)
|
|
588
|
+
);
|
|
584
589
|
const id = cr.DomainGetInfoResult?.Whoisguard?.ID;
|
|
585
590
|
if (id == null || String(id) === '0') {
|
|
586
591
|
throw new Error(`${this.name}: ${domainName} has no WhoisGuard subscription to toggle`);
|