@aoxborrow/registrar-client 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aoxborrow/registrar-client",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -89,20 +89,20 @@
89
89
  },
90
90
  "devDependencies": {
91
91
  "@eslint/js": "^10.0.1",
92
- "@types/node": "^26.2.0",
93
- "@typescript-eslint/eslint-plugin": "^8.56.0",
94
- "@typescript-eslint/parser": "^8.56.0",
92
+ "@types/node": "^26.6.2",
93
+ "@typescript-eslint/eslint-plugin": "^8.71.1",
94
+ "@typescript-eslint/parser": "^8.71.1",
95
95
  "@vitest/coverage-v8": "^4.1.11",
96
- "dotenv": "^17.4.2",
97
- "eslint": "^10.9.0",
96
+ "dotenv": "^18.0.1",
97
+ "eslint": "^10.11.0",
98
98
  "eslint-config-prettier": "^10.1.8",
99
99
  "eslint-import-resolver-typescript": "^4.4.4",
100
100
  "eslint-plugin-import-x": "^4.17.1",
101
- "globals": "^17.11.0",
102
- "prettier": "^3.6.2",
101
+ "globals": "^17.12.0",
102
+ "prettier": "^3.9.8",
103
103
  "tsup": "^8.5.0",
104
104
  "typescript": "^5.9.3",
105
- "typescript-eslint": "^8.56.0",
105
+ "typescript-eslint": "^8.71.1",
106
106
  "vitest": "^4.1.11"
107
107
  },
108
108
  "dependencies": {
package/src/http.ts CHANGED
@@ -31,6 +31,9 @@ export interface HttpClientConfig {
31
31
  // unset for APIs where the method means nothing: Namecheap and NameSilo send
32
32
  // writes as GET. Those mark their nested reads with `asRead` instead.
33
33
  safeMethods?: readonly string[];
34
+ // A provider-specific delay for HTTP 429 without a usable Retry-After.
35
+ // Otherwise the normal exponential backoff applies.
36
+ rateLimitFallbackSeconds?: number;
34
37
  }
35
38
 
36
39
  export const REST_SAFE_METHODS: readonly string[] = ['GET', 'HEAD'];
@@ -260,8 +263,10 @@ export class HttpClient {
260
263
  case 404:
261
264
  return new NotFoundError(message);
262
265
  case 429: {
263
- const retryAfter = Number(response.headers.get('retry-after'));
264
- return new RateLimitError(message, Number.isFinite(retryAfter) ? retryAfter : undefined);
266
+ const retryAfter =
267
+ parseRetryAfter(response.headers.get('retry-after')) ??
268
+ this.config.rateLimitFallbackSeconds;
269
+ return new RateLimitError(message, retryAfter);
265
270
  }
266
271
  default: {
267
272
  if (response.status >= 500) {
@@ -277,6 +282,25 @@ export class HttpClient {
277
282
  }
278
283
  }
279
284
 
285
+ // RFC 9110 Retry-After is delay-seconds or an HTTP date. Absence must stay
286
+ // undefined: Number(null) is zero and would bypass the configured backoff.
287
+ function parseRetryAfter(value: string | null): number | undefined {
288
+ const text = value?.trim();
289
+ if (!text) return undefined;
290
+ if (/^\d+$/.test(text)) {
291
+ const seconds = Number(text);
292
+ return Number.isSafeInteger(seconds) ? seconds : undefined;
293
+ }
294
+ // Accept the HTTP-date formats from RFC 9110, not Date.parse's permissive
295
+ // numeric/calendar inputs. The obsolete asctime form has implicit UTC.
296
+ const zonedDate =
297
+ /^(?:[a-z]{3}, \d{2} [a-z]{3} \d{4}|[a-z]+, \d{2}-[a-z]{3}-\d{2}) \d{2}:\d{2}:\d{2} GMT$/i;
298
+ const asctimeDate = /^[a-z]{3} [a-z]{3} (?: \d|\d{2}) \d{2}:\d{2}:\d{2} \d{4}$/i;
299
+ if (!zonedDate.test(text) && !asctimeDate.test(text)) return undefined;
300
+ const date = Date.parse(asctimeDate.test(text) ? `${text} GMT` : text);
301
+ return Number.isFinite(date) ? Math.max(0, Math.ceil((date - Date.now()) / 1000)) : undefined;
302
+ }
303
+
280
304
  // A retryable failure that leaves a write's result unknown: the request may
281
305
  // have reached the registrar. A rate limit is an explicit rejection, and a
282
306
  // connection error known to precede sending never reached it.
@@ -216,6 +216,9 @@ export class DynadotRegistrar extends BaseRegistrar {
216
216
  sandbox: 'https://api-sandbox.dynadot.com',
217
217
  }),
218
218
  headers: { Authorization: `Bearer ${credentials.apiKey}` },
219
+ // HTTP 429 can omit Retry-After. Avoid exhausting all attempts inside
220
+ // Dynadot's regular-tier minute window in that case.
221
+ rateLimitFallbackSeconds: 60,
219
222
  },
220
223
  options
221
224
  );
@@ -60,6 +60,7 @@ interface NcCheckEl {
60
60
  '@_Available'?: string;
61
61
  '@_IsPremiumName'?: string;
62
62
  '@_PremiumRegistrationPrice'?: string;
63
+ '@_PremiumRenewalPrice'?: string;
63
64
  }
64
65
 
65
66
  // a contact group in namecheap.domains.getContacts (children are text elements)
@@ -367,9 +368,10 @@ export class NamecheapRegistrar extends BaseRegistrar {
367
368
 
368
369
  /**
369
370
  * Availability via domains.check (batched at 50 per request, Namecheap's cap).
370
- * The check response only carries a price for *premium* names
371
- * (`PremiumRegistrationPrice`); regular registration pricing comes from
372
- * `getPricing`, so `price` is set only for premium results.
371
+ * The check response only carries prices for *premium* names
372
+ * (`PremiumRegistrationPrice`, `PremiumRenewalPrice`); regular pricing comes
373
+ * from `getPricing`, so `price` and `renewalPrice` are set only for premium
374
+ * results.
373
375
  */
374
376
  override async checkAvailability(
375
377
  domainNames: string[],
@@ -385,12 +387,16 @@ export class NamecheapRegistrar extends BaseRegistrar {
385
387
  );
386
388
  for (const el of ensureArray(cr.DomainCheckResult)) {
387
389
  const premium = el['@_IsPremiumName'] === 'true';
388
- const premiumPrice = premium ? Number(el['@_PremiumRegistrationPrice']) : NaN;
390
+ const premiumPrice = (attr: string | undefined) => {
391
+ const n = premium ? Number(attr) : NaN;
392
+ return Number.isFinite(n) && n > 0 ? n : undefined;
393
+ };
389
394
  results.push({
390
395
  domainName: el['@_Domain'] ?? '',
391
396
  available: el['@_Available'] === 'true',
392
397
  premium,
393
- price: Number.isFinite(premiumPrice) && premiumPrice > 0 ? premiumPrice : undefined,
398
+ price: premiumPrice(el['@_PremiumRegistrationPrice']),
399
+ renewalPrice: premiumPrice(el['@_PremiumRenewalPrice']),
394
400
  });
395
401
  }
396
402
  }
@@ -108,18 +108,14 @@ class NamecomHttpClient extends HttpClient {
108
108
  }),
109
109
  url
110
110
  );
111
- if (error instanceof RateLimitError) {
112
- const retryAfter = response.headers.get('retry-after');
113
- const seconds = retryAfter == null ? NaN : Number(retryAfter);
114
- const date = retryAfter == null ? NaN : Date.parse(retryAfter);
115
- const reset = response.headers.get('x-ratelimit-reset');
116
- error.retryAfter = Number.isFinite(seconds)
117
- ? Math.max(0, seconds)
118
- : Number.isFinite(date)
119
- ? Math.max(0, (date - Date.now()) / 1000)
120
- : reset != null && Number.isFinite(Number(reset))
121
- ? Math.max(0, Number(reset) - Date.now() / 1000)
122
- : undefined;
111
+ // The shared client has already read Retry-After. Without a usable one,
112
+ // fall back to X-RateLimit-Reset (Unix seconds); otherwise leave it unset
113
+ // so the configured backoff applies.
114
+ if (error instanceof RateLimitError && error.retryAfter === undefined) {
115
+ const reset = response.headers.get('x-ratelimit-reset')?.trim();
116
+ if (reset && /^\d+(?:\.\d+)?$/.test(reset)) {
117
+ error.retryAfter = Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000));
118
+ }
123
119
  }
124
120
  return error;
125
121
  }
package/src/types.ts CHANGED
@@ -194,6 +194,9 @@ export interface DomainAvailability {
194
194
  premium?: boolean;
195
195
  // registration price in major currency units (e.g. 11.99), when the API reports it
196
196
  price?: number;
197
+ // annual renewal price in major currency units, when the API reports one for
198
+ // this specific name (e.g. a premium name's renewal)
199
+ renewalPrice?: number;
197
200
  // ISO 4217 currency code, e.g. "USD"
198
201
  currency?: string;
199
202
  // the registration period the price covers, in years