Skip to content

Commit 68eebe7

Browse files
Merge pull request #325 from ipregistry/v7
v7: options-object API, request cancellation, strict TypeScript
2 parents 456e141 + cc3209c commit 68eebe7

11 files changed

Lines changed: 704 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [7.0.0]
11+
### Added
12+
- Options-object API: construct the client with `new IpregistryClient({ apiKey, baseUrl, timeout, maxRetries, cache, ... })` and pass per-call options as a plain object, e.g. `client.lookupIp(ip, { fields: 'location', hostname: true })`. The `baseUrl` option accepts the shorthand `'eu'` for the European Union endpoint.
13+
- Request cancellation: every lookup accepts an `AbortSignal` via `{ signal }`. Aborting cancels the in-flight request, pending retries and their backoff waits, and pending batch chunks.
14+
- `parseUserAgents` accepts an array (`client.parseUserAgents([ua1, ua2])`) in addition to the deprecated variadic form.
15+
- Generic cache typing: `IpregistryCache<V>` and the new `IpregistryCacheValue` type.
16+
17+
### Changed
18+
- The library is now compiled with full TypeScript strict mode (`noImplicitAny` enabled).
19+
20+
### Deprecated
21+
- `IpregistryConfigBuilder`: pass an `IpregistryClientOptions` object to the constructor instead.
22+
- `IpregistryOption`, `FilterOption`, `HostnameOption`, `IpregistryOptions` and the variadic lookup signatures: pass a `LookupOptions` object instead.
23+
- The variadic `parseUserAgents(...userAgents)` form: pass an array instead.
24+
25+
All deprecated forms keep working in 7.x and behave identically (including cache
26+
key compatibility between legacy options and their `LookupOptions` equivalents);
27+
they will be removed in a future major version.
28+
29+
### Migration
30+
```typescript
31+
// Before (6.x) // After (7.x)
32+
new IpregistryClient( new IpregistryClient({
33+
new IpregistryConfigBuilder('KEY') apiKey: 'KEY',
34+
.withEuBaseUrl() baseUrl: 'eu',
35+
.withTimeout(10000) cache: new InMemoryCache(),
36+
.build(), timeout: 10000,
37+
new InMemoryCache()) })
38+
39+
client.lookupIp(ip, client.lookupIp(ip, {
40+
IpregistryOptions.filter('location'), fields: 'location',
41+
IpregistryOptions.hostname(true)) hostname: true,
42+
})
43+
```
44+
1045
## [6.2.0] - 2026-07-05
1146
### Added
1247
- Automatic splitting of large batch lookups, aligned with the Go client: inputs beyond the API limit (1024 values) are chunked and dispatched with bounded concurrency, preserving input order. Configurable via `withMaxBatchSize` and `withBatchConcurrency` (default 4, set 1 for sequential dispatch).

README.md

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,8 @@ runtime:
121121
- **Bun** — verified continuously.
122122
- **Cloudflare Workers** — verified continuously against the real Workers
123123
runtime (workerd). Note that retry backoff waits count toward a Worker's
124-
wall-clock duration; on latency-sensitive Workers consider
125-
`withMaxRetries(1)` or a lower `withRetryInterval`.
124+
wall-clock duration; on latency-sensitive Workers consider a lower
125+
`maxRetries` or `retryInterval`.
126126
- **Deno** and other web-standard runtimes are expected to work through the
127127
same APIs.
128128
- **Browsers** — through a bundler (the ESM build is tree-shakable) or a
@@ -146,15 +146,15 @@ in subsequent invocations.
146146
Caching up to 16384 entries:
147147

148148
```typescript
149-
const client = new IpregistryClient('YOUR_API_KEY', new InMemoryCache(16384));
149+
const client = new IpregistryClient({apiKey: 'YOUR_API_KEY', cache: new InMemoryCache(16384)});
150150
```
151151

152152
### Configuring cache max age
153153

154154
Caching up to 16384 entries for at most 6 hours:
155155

156156
```typescript
157-
const client = new IpregistryClient('YOUR_API_KEY', new InMemoryCache(16384, 3600 * 6 * 1000));
157+
const client = new IpregistryClient({apiKey: 'YOUR_API_KEY', cache: new InMemoryCache(16384, 3600 * 6 * 1000)});
158158
```
159159

160160
If your purpose is to re-use a same Ipregistry client instance (and thus share the same cache) for different API keys,
@@ -167,7 +167,7 @@ client.config.apiKey = 'YOUR_NEW_API_KEY';
167167
### Disabling caching
168168

169169
```typescript
170-
const client = new IpregistryClient('YOUR_API_KEY', new NoCache());
170+
const client = new IpregistryClient('YOUR_API_KEY');
171171
```
172172

173173
## Timeouts and retries
@@ -180,21 +180,34 @@ retried unless you opt in; when enabled, a `Retry-After` response header takes
180180
precedence over the computed backoff. All of it is configurable:
181181

182182
```typescript
183-
const client = new IpregistryClient(
184-
new IpregistryConfigBuilder('YOUR_API_KEY')
185-
.withTimeout(10000) // per-attempt timeout, in milliseconds
186-
.withMaxRetries(2) // 0 disables retries
187-
.withRetryInterval(500) // backoff base, in milliseconds
188-
.withRetryOnServerError(true) // retry 5xx responses (default: true)
189-
.withRetryOnTooManyRequests(true) // retry 429 responses (default: false)
190-
.build()
191-
);
183+
const client = new IpregistryClient({
184+
apiKey: 'YOUR_API_KEY',
185+
maxRetries: 2, // 0 disables retries
186+
retryInterval: 500, // backoff base, in milliseconds
187+
retryOnServerError: true, // retry 5xx responses (default: true)
188+
retryOnTooManyRequests: true, // retry 429 responses (default: false)
189+
timeout: 10000, // per-attempt timeout, in milliseconds
190+
});
192191
```
193192

194193
Rate limiting is opt-in per API key on Ipregistry, which is why
195194
`retryOnTooManyRequests` defaults to false. Enable it if your key is
196195
rate limited.
197196

197+
### Cancelling requests
198+
199+
Every lookup accepts an `AbortSignal`. Aborting cancels the in-flight request,
200+
any pending retries and their backoff waits, and pending batch chunks:
201+
202+
```typescript
203+
const controller = new AbortController();
204+
205+
const promise = client.lookupIp('73.2.2.2', {signal: controller.signal});
206+
207+
// Later, e.g. when the user navigates away:
208+
controller.abort();
209+
```
210+
198211
## Batch lookups
199212

200213
`batchLookupIps` and `batchLookupAsns` look up many values in a single call:
@@ -210,12 +223,11 @@ Cached entries are served locally; only the remainder is requested. Both knobs
210223
are configurable:
211224

212225
```typescript
213-
const client = new IpregistryClient(
214-
new IpregistryConfigBuilder('YOUR_API_KEY')
215-
.withMaxBatchSize(256) // values per request, capped at 1024
216-
.withBatchConcurrency(1) // sequential dispatch, gentler on rate-limited keys
217-
.build()
218-
);
226+
const client = new IpregistryClient({
227+
apiKey: 'YOUR_API_KEY',
228+
batchConcurrency: 1, // sequential dispatch, gentler on rate-limited keys
229+
maxBatchSize: 256, // values per request, capped at 1024
230+
});
219231
```
220232

221233
## Enabling hostname lookup
@@ -224,7 +236,7 @@ By default, the Ipregistry API does not return information about the hostname a
224236
In order to include the hostname value in your API result, you need to enable the feature explicitly:
225237

226238
```typescript
227-
const ipInfo = await client.lookupIp('73.2.2.2', IpregistryOptions.hostname(true));
239+
const ipInfo = await client.lookupIp('73.2.2.2', {hostname: true});
228240
```
229241

230242
## Errors
@@ -254,7 +266,7 @@ the cache and no request is made to the Ipregistry API.
254266
To save bandwidth and speed up response times, the API allows selecting fields to return:
255267

256268
```typescript
257-
const response = await client.lookupIp('73.2.2.2', IpregistryOptions.filter('hostname,location.country.name'));
269+
const response = await client.lookupIp('73.2.2.2', {fields: 'hostname,location.country.name'});
258270
```
259271

260272
## Usage data
@@ -278,11 +290,10 @@ console.log(response.throttling.reset);
278290
## European Union base URL
279291

280292
For clients operating within the European Union or for those who prefer to route their requests through our EU
281-
servers, the Ipregistry client library provides an easy way to configure this preference using the `withEuBaseURL` option. This ensures that your requests are handled by our EU-based infrastructure, potentially reducing latency and aligning with local data handling regulations:
293+
servers, the Ipregistry client library provides an easy way to configure this preference using the `baseUrl` option. This ensures that your requests are handled by our EU-based infrastructure, potentially reducing latency and aligning with local data handling regulations:
282294

283295
```typescript
284-
const config = new IpregistryConfigBuilder('tryout').withEuBaseUrl().build()
285-
const client = new IpregistryClient(config)
296+
const client = new IpregistryClient({apiKey: 'tryout', baseUrl: 'eu'})
286297
```
287298

288299
# Other Libraries

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@ipregistry/client",
33
"description": "Official Ipregistry Javascript Library.",
4-
"version": "6.2.0",
4+
"version": "7.0.0",
55
"browser": "./dist/index.global.js",
66
"main": "./dist/index.js",
77
"module": "./dist/index.mjs",
@@ -70,6 +70,7 @@
7070
"devDependencies": {
7171
"@eslint/js": "^10.0.1",
7272
"@swc/core": "^1.15.43",
73+
"@types/chai": "^5.2.3",
7374
"@types/node": "^26.1.0",
7475
"chai": "^6.2.2",
7576
"eslint": "^10.6.0",

src/cache.ts

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,26 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { IpInfo } from './model.js'
17+
import { AutonomousSystem, IpInfo } from './model.js'
1818

19-
export interface IpregistryCache {
20-
get(key: string): any | undefined
19+
/**
20+
* The union of value types the Ipregistry client stores in its cache.
21+
*/
22+
export type IpregistryCacheValue = IpInfo | AutonomousSystem
23+
24+
export interface IpregistryCache<V = IpregistryCacheValue> {
25+
get(key: string): V | undefined
2126

22-
put(key: string, data: any): void
27+
put(key: string, data: V): void
2328

2429
invalidate(key: string): void
2530

2631
invalidateAll(): void
2732
}
2833

29-
interface CacheEntry {
34+
interface CacheEntry<V> {
3035
expiresAt: number
31-
value: IpInfo
36+
value: V
3237
}
3338

3439
/**
@@ -37,14 +42,16 @@ interface CacheEntry {
3742
* after insertion; reading an entry refreshes its recency for eviction
3843
* purposes but does not extend its lifetime.
3944
*/
40-
export class InMemoryCache implements IpregistryCache {
45+
export class InMemoryCache<
46+
V = IpregistryCacheValue,
47+
> implements IpregistryCache<V> {
4148
private readonly maximumSize: number
4249

4350
private readonly expireAfter: number
4451

4552
// Iteration order of a Map is insertion order; the first key is therefore
4653
// the least recently used, since reads re-insert their entry.
47-
private readonly cache: Map<string, CacheEntry> = new Map()
54+
private readonly cache: Map<string, CacheEntry<V>> = new Map()
4855

4956
constructor(
5057
maximumSize: number = typeof window !== 'undefined' ? 16 : 2048,
@@ -54,7 +61,7 @@ export class InMemoryCache implements IpregistryCache {
5461
this.expireAfter = expireAfter
5562
}
5663

57-
get(key: string): IpInfo | undefined {
64+
get(key: string): V | undefined {
5865
const entry = this.cache.get(key)
5966

6067
if (!entry) {
@@ -80,7 +87,7 @@ export class InMemoryCache implements IpregistryCache {
8087
this.cache.clear()
8188
}
8289

83-
put(key: string, data: IpInfo): void {
90+
put(key: string, data: V): void {
8491
this.cache.delete(key)
8592
this.cache.set(key, {
8693
expiresAt: Date.now() + this.expireAfter,
@@ -96,9 +103,9 @@ export class InMemoryCache implements IpregistryCache {
96103
}
97104
}
98105

99-
export class NoCache implements IpregistryCache {
106+
export class NoCache<V = IpregistryCacheValue> implements IpregistryCache<V> {
100107
// eslint-disable-next-line @typescript-eslint/no-unused-vars
101-
get(key: string): IpInfo | undefined {
108+
get(key: string): V | undefined {
102109
return undefined
103110
}
104111

@@ -112,7 +119,7 @@ export class NoCache implements IpregistryCache {
112119
}
113120

114121
// eslint-disable-next-line @typescript-eslint/no-unused-vars
115-
put(key: string, data: IpInfo): void {
122+
put(key: string, data: V): void {
116123
// do nothing
117124
}
118125
}

src/fetch.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,30 @@ export async function customFetch(
5252
),
5353
} as Required<Pick<Options, keyof typeof DEFAULT_OPTIONS>> & Options
5454

55+
const callerSignal = providedOptions.signal ?? undefined
56+
5557
for (let attempt = 0; ; attempt++) {
58+
if (callerSignal?.aborted) {
59+
throw new ClientError('Request cancelled')
60+
}
61+
5662
const controller = new AbortController()
5763
const timeoutId = setTimeout(() => controller.abort(), options.timeout)
5864

5965
let response: Response
6066
try {
6167
response = await fetch(url, {
6268
...options,
63-
signal: controller.signal,
69+
signal: combineSignals(controller.signal, callerSignal),
6470
})
6571
} catch {
72+
if (callerSignal?.aborted) {
73+
throw new ClientError('Request cancelled')
74+
}
6675
// Transport errors are retried up to maxRetries regardless of the
6776
// retry-on-status flags, matching the other Ipregistry clients.
6877
if (attempt < options.maxRetries) {
69-
await backoff(options.retryInterval, attempt, 0)
78+
await backoff(options.retryInterval, attempt, 0, callerSignal)
7079
continue
7180
}
7281
throw new ClientError(
@@ -87,7 +96,12 @@ export async function customFetch(
8796
const retryAfter = parseRetryAfter(
8897
response.headers.get('retry-after'),
8998
)
90-
await backoff(options.retryInterval, attempt, retryAfter)
99+
await backoff(
100+
options.retryInterval,
101+
attempt,
102+
retryAfter,
103+
callerSignal,
104+
)
91105
continue
92106
}
93107

@@ -139,21 +153,63 @@ function parseRetryAfter(value: string | null): number {
139153
return parseInt(value) * 1000
140154
}
141155

156+
/**
157+
* Combines the internal timeout signal with an optional caller-supplied
158+
* signal so that either can abort the request.
159+
*/
160+
function combineSignals(
161+
timeoutSignal: AbortSignal,
162+
callerSignal?: AbortSignal,
163+
): AbortSignal {
164+
if (!callerSignal) {
165+
return timeoutSignal
166+
}
167+
168+
if (typeof AbortSignal.any === 'function') {
169+
return AbortSignal.any([timeoutSignal, callerSignal])
170+
}
171+
172+
// Fallback for runtimes without AbortSignal.any (Node.js < 20.3).
173+
const controller = new AbortController()
174+
const abort = () => controller.abort()
175+
176+
if (timeoutSignal.aborted || callerSignal.aborted) {
177+
controller.abort()
178+
} else {
179+
timeoutSignal.addEventListener('abort', abort, { once: true })
180+
callerSignal.addEventListener('abort', abort, { once: true })
181+
}
182+
183+
return controller.signal
184+
}
185+
142186
/**
143187
* Waits before the next retry attempt, honoring an explicit Retry-After delay
144188
* when positive and otherwise using exponential backoff
145-
* (retryInterval * 2^attempt).
189+
* (retryInterval * 2^attempt). Rejects with a `ClientError` if the caller
190+
* signal aborts while waiting.
146191
*/
147192
async function backoff(
148193
retryInterval: number,
149194
attempt: number,
150195
retryAfter: number,
196+
callerSignal?: AbortSignal,
151197
): Promise<void> {
152198
let delay = retryAfter
153199

154200
if (delay <= 0) {
155201
delay = retryInterval * 2 ** Math.min(attempt, 30)
156202
}
157203

158-
await new Promise(resolve => setTimeout(resolve, delay))
204+
await new Promise<void>((resolve, reject) => {
205+
const cancel = () => {
206+
clearTimeout(timer)
207+
reject(new ClientError('Request cancelled during retry backoff'))
208+
}
209+
const timer = setTimeout(() => {
210+
callerSignal?.removeEventListener('abort', cancel)
211+
resolve()
212+
}, delay)
213+
callerSignal?.addEventListener('abort', cancel, { once: true })
214+
})
159215
}

0 commit comments

Comments
 (0)