Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [7.0.0]
### Added
- 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.
- 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.
- `parseUserAgents` accepts an array (`client.parseUserAgents([ua1, ua2])`) in addition to the deprecated variadic form.
- Generic cache typing: `IpregistryCache<V>` and the new `IpregistryCacheValue` type.

### Changed
- The library is now compiled with full TypeScript strict mode (`noImplicitAny` enabled).

### Deprecated
- `IpregistryConfigBuilder`: pass an `IpregistryClientOptions` object to the constructor instead.
- `IpregistryOption`, `FilterOption`, `HostnameOption`, `IpregistryOptions` and the variadic lookup signatures: pass a `LookupOptions` object instead.
- The variadic `parseUserAgents(...userAgents)` form: pass an array instead.

All deprecated forms keep working in 7.x and behave identically (including cache
key compatibility between legacy options and their `LookupOptions` equivalents);
they will be removed in a future major version.

### Migration
```typescript
// Before (6.x) // After (7.x)
new IpregistryClient( new IpregistryClient({
new IpregistryConfigBuilder('KEY') apiKey: 'KEY',
.withEuBaseUrl() baseUrl: 'eu',
.withTimeout(10000) cache: new InMemoryCache(),
.build(), timeout: 10000,
new InMemoryCache()) })

client.lookupIp(ip, client.lookupIp(ip, {
IpregistryOptions.filter('location'), fields: 'location',
IpregistryOptions.hostname(true)) hostname: true,
})
```

## [6.2.0] - 2026-07-05
### Added
- 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).
Expand Down
61 changes: 36 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ runtime:
- **Bun** — verified continuously.
- **Cloudflare Workers** — verified continuously against the real Workers
runtime (workerd). Note that retry backoff waits count toward a Worker's
wall-clock duration; on latency-sensitive Workers consider
`withMaxRetries(1)` or a lower `withRetryInterval`.
wall-clock duration; on latency-sensitive Workers consider a lower
`maxRetries` or `retryInterval`.
- **Deno** and other web-standard runtimes are expected to work through the
same APIs.
- **Browsers** — through a bundler (the ESM build is tree-shakable) or a
Expand All @@ -146,15 +146,15 @@ in subsequent invocations.
Caching up to 16384 entries:

```typescript
const client = new IpregistryClient('YOUR_API_KEY', new InMemoryCache(16384));
const client = new IpregistryClient({apiKey: 'YOUR_API_KEY', cache: new InMemoryCache(16384)});
```

### Configuring cache max age

Caching up to 16384 entries for at most 6 hours:

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

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

```typescript
const client = new IpregistryClient('YOUR_API_KEY', new NoCache());
const client = new IpregistryClient('YOUR_API_KEY');
```

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

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

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

### Cancelling requests

Every lookup accepts an `AbortSignal`. Aborting cancels the in-flight request,
any pending retries and their backoff waits, and pending batch chunks:

```typescript
const controller = new AbortController();

const promise = client.lookupIp('73.2.2.2', {signal: controller.signal});

// Later, e.g. when the user navigates away:
controller.abort();
```

## Batch lookups

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

```typescript
const client = new IpregistryClient(
new IpregistryConfigBuilder('YOUR_API_KEY')
.withMaxBatchSize(256) // values per request, capped at 1024
.withBatchConcurrency(1) // sequential dispatch, gentler on rate-limited keys
.build()
);
const client = new IpregistryClient({
apiKey: 'YOUR_API_KEY',
batchConcurrency: 1, // sequential dispatch, gentler on rate-limited keys
maxBatchSize: 256, // values per request, capped at 1024
});
```

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

```typescript
const ipInfo = await client.lookupIp('73.2.2.2', IpregistryOptions.hostname(true));
const ipInfo = await client.lookupIp('73.2.2.2', {hostname: true});
```

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

```typescript
const response = await client.lookupIp('73.2.2.2', IpregistryOptions.filter('hostname,location.country.name'));
const response = await client.lookupIp('73.2.2.2', {fields: 'hostname,location.country.name'});
```

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

For clients operating within the European Union or for those who prefer to route their requests through our EU
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:
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:

```typescript
const config = new IpregistryConfigBuilder('tryout').withEuBaseUrl().build()
const client = new IpregistryClient(config)
const client = new IpregistryClient({apiKey: 'tryout', baseUrl: 'eu'})
```

# Other Libraries
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ipregistry/client",
"description": "Official Ipregistry Javascript Library.",
"version": "6.2.0",
"version": "7.0.0",
"browser": "./dist/index.global.js",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down Expand Up @@ -70,6 +70,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@swc/core": "^1.15.43",
"@types/chai": "^5.2.3",
"@types/node": "^26.1.0",
"chai": "^6.2.2",
"eslint": "^10.6.0",
Expand Down
33 changes: 20 additions & 13 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,26 @@
* limitations under the License.
*/

import { IpInfo } from './model.js'
import { AutonomousSystem, IpInfo } from './model.js'

export interface IpregistryCache {
get(key: string): any | undefined
/**
* The union of value types the Ipregistry client stores in its cache.
*/
export type IpregistryCacheValue = IpInfo | AutonomousSystem

export interface IpregistryCache<V = IpregistryCacheValue> {
get(key: string): V | undefined

put(key: string, data: any): void
put(key: string, data: V): void

invalidate(key: string): void

invalidateAll(): void
}

interface CacheEntry {
interface CacheEntry<V> {
expiresAt: number
value: IpInfo
value: V
}

/**
Expand All @@ -37,14 +42,16 @@ interface CacheEntry {
* after insertion; reading an entry refreshes its recency for eviction
* purposes but does not extend its lifetime.
*/
export class InMemoryCache implements IpregistryCache {
export class InMemoryCache<
V = IpregistryCacheValue,
> implements IpregistryCache<V> {
private readonly maximumSize: number

private readonly expireAfter: number

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

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

get(key: string): IpInfo | undefined {
get(key: string): V | undefined {
const entry = this.cache.get(key)

if (!entry) {
Expand All @@ -80,7 +87,7 @@ export class InMemoryCache implements IpregistryCache {
this.cache.clear()
}

put(key: string, data: IpInfo): void {
put(key: string, data: V): void {
this.cache.delete(key)
this.cache.set(key, {
expiresAt: Date.now() + this.expireAfter,
Expand All @@ -96,9 +103,9 @@ export class InMemoryCache implements IpregistryCache {
}
}

export class NoCache implements IpregistryCache {
export class NoCache<V = IpregistryCacheValue> implements IpregistryCache<V> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
get(key: string): IpInfo | undefined {
get(key: string): V | undefined {
return undefined
}

Expand All @@ -112,7 +119,7 @@ export class NoCache implements IpregistryCache {
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
put(key: string, data: IpInfo): void {
put(key: string, data: V): void {
// do nothing
}
}
66 changes: 61 additions & 5 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,30 @@ export async function customFetch(
),
} as Required<Pick<Options, keyof typeof DEFAULT_OPTIONS>> & Options

const callerSignal = providedOptions.signal ?? undefined

for (let attempt = 0; ; attempt++) {
if (callerSignal?.aborted) {
throw new ClientError('Request cancelled')
}

const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), options.timeout)

let response: Response
try {
response = await fetch(url, {
...options,
signal: controller.signal,
signal: combineSignals(controller.signal, callerSignal),
})
} catch {
if (callerSignal?.aborted) {
throw new ClientError('Request cancelled')
}
// Transport errors are retried up to maxRetries regardless of the
// retry-on-status flags, matching the other Ipregistry clients.
if (attempt < options.maxRetries) {
await backoff(options.retryInterval, attempt, 0)
await backoff(options.retryInterval, attempt, 0, callerSignal)
continue
}
throw new ClientError(
Expand All @@ -87,7 +96,12 @@ export async function customFetch(
const retryAfter = parseRetryAfter(
response.headers.get('retry-after'),
)
await backoff(options.retryInterval, attempt, retryAfter)
await backoff(
options.retryInterval,
attempt,
retryAfter,
callerSignal,
)
continue
}

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

/**
* Combines the internal timeout signal with an optional caller-supplied
* signal so that either can abort the request.
*/
function combineSignals(
timeoutSignal: AbortSignal,
callerSignal?: AbortSignal,
): AbortSignal {
if (!callerSignal) {
return timeoutSignal
}

if (typeof AbortSignal.any === 'function') {
return AbortSignal.any([timeoutSignal, callerSignal])
}

// Fallback for runtimes without AbortSignal.any (Node.js < 20.3).
const controller = new AbortController()
const abort = () => controller.abort()

if (timeoutSignal.aborted || callerSignal.aborted) {
controller.abort()
} else {
timeoutSignal.addEventListener('abort', abort, { once: true })
callerSignal.addEventListener('abort', abort, { once: true })
}

return controller.signal
}

/**
* Waits before the next retry attempt, honoring an explicit Retry-After delay
* when positive and otherwise using exponential backoff
* (retryInterval * 2^attempt).
* (retryInterval * 2^attempt). Rejects with a `ClientError` if the caller
* signal aborts while waiting.
*/
async function backoff(
retryInterval: number,
attempt: number,
retryAfter: number,
callerSignal?: AbortSignal,
): Promise<void> {
let delay = retryAfter

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

await new Promise(resolve => setTimeout(resolve, delay))
await new Promise<void>((resolve, reject) => {
const cancel = () => {
clearTimeout(timer)
reject(new ClientError('Request cancelled during retry backoff'))
}
const timer = setTimeout(() => {
callerSignal?.removeEventListener('abort', cancel)
resolve()
}, delay)
callerSignal?.addEventListener('abort', cancel, { once: true })
})
}
Loading