From 1c6ae498f4aca3062b4dc386157f7ea0e515dede Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:38:46 +0000 Subject: [PATCH 1/2] Reuse HTTPS connections through an undici Agent and negotiate HTTP/2 Every lookup used to call the global fetch with its default dispatcher, so a client had no connection pool of its own to control or close. IPData now creates an undici Agent together with the instance (allowH2 for HTTP/2 via ALPN with HTTP/1.1 fallback, 60 s idle timeout, 30 s HTTP/2 PING and TCP keepalive probes) and passes it to every fetch call, so TCP and TLS are negotiated once per client instead of once per request. A trailing, optional httpConfig constructor argument exposes http2, keepAliveTimeout and dispatcher (to inject a ProxyAgent, EnvHttpProxyAgent or MockAgent). close() releases an Agent the instance created and leaves an injected dispatcher alone. A request that fails before any response byte arrived because a pooled connection was reset or closed is retried exactly once; HTTP statuses are never retried. The bulk POST now also sends Accept: application/json. Tests replace the global.fetch patch with an undici MockAgent injected through httpConfig.dispatcher, and a loopback HTTP server proves that consecutive requests reuse one connection and that close() releases it. CI runs on pull requests and on Node 20, 22 and 24. undici ^7.22.0 needs Node >= 20.18.1 (lru-cache 11 already excluded Node 18), the version becomes 3.1.0 and the lock file is regenerated. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- .github/workflows/default.yml | 6 +- README.md | 55 +++++- package-lock.json | 18 +- package.json | 7 +- src/ipdata.test.ts | 325 ++++++++++++++++++++++++++++++---- src/ipdata.ts | 112 +++++++++++- 6 files changed, 471 insertions(+), 52 deletions(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index f03f090..ee222f3 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -4,6 +4,7 @@ on: push: branches: - '**' + pull_request: jobs: lint: @@ -22,13 +23,16 @@ jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22, 24] steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install node uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm ci - name: Test diff --git a/README.md b/README.md index ce370d5..c26be50 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Node.js library for the [ipdata](https://ipdata.co) API. Look up IP addresses for geolocation, threat intelligence, company data, and more. -Requires **Node.js 18** or later. Uses the native `fetch` API with zero HTTP dependencies. +Requires **Node.js 20.18.1** or later. HTTP requests go through [undici](https://undici.nodejs.org), the HTTP client behind Node's built-in `fetch`, so that connections are pooled and reused across lookups and HTTP/2 is negotiated where available. **Table of Contents** @@ -15,6 +15,7 @@ Requires **Node.js 18** or later. Uses the native `fetch` API with zero HTTP dep - [EU Endpoint](#eu-endpoint) - [Lookup](#lookup) - [Bulk Lookup](#bulk-lookup) +- [Connection reuse and HTTP/2](#connection-reuse-and-http2) - [Response Fields](#response-fields) - [TypeScript](#typescript) @@ -140,6 +141,56 @@ With field filtering: const info = await ipdata.bulkLookup(['1.1.1.1', '1.0.0.1'], ['ip', 'city']); ``` +## Connection reuse and HTTP/2 + +An `IPData` instance keeps a persistent HTTPS connection pool (an [undici](https://undici.nodejs.org) `Agent`) for as long as it lives. Create one client per process (or one per API key or endpoint), keep it, and reuse it for every lookup: each new client pays a fresh TLS handshake. Requests to api.ipdata.co are multiplexed over HTTP/2 when the platform negotiates it, and fall back to HTTP/1.1 keep-alive otherwise. + +Node.js runs your code on one thread, so a single instance can be shared by every request handler in the process; `worker_threads` and `cluster` workers each need their own instance, because connections cannot be shared across threads or processes. Idle HTTP/1.1 connections are closed after 60 seconds, HTTP/2 sessions are kept alive with a PING every 30 seconds, and neither keeps the event loop alive, so a script exits normally without any extra call. Call `await ipdata.close()` at shutdown when you want the pooled connections released immediately. If you pass your own `dispatcher`, you own it and `close()` leaves it open. + +```js +const ipdata = new IPData(''); + +// ... lookups ... + +await ipdata.close(); +``` + +### HTTP options + +The optional fourth constructor argument configures the transport: + +```js +const ipdata = new IPData('', undefined, undefined, { + http2: true, // negotiate HTTP/2 via ALPN and fall back to HTTP/1.1 (default: true) + keepAliveTimeout: 60000, // milliseconds an idle connection is kept before it is closed (default: 60000) +}); +``` + +Use `dispatcher` to send requests through your own [undici dispatcher](https://undici.nodejs.org/#/docs/api/Dispatcher), for example to reach the API through a proxy or to intercept requests in tests. `http2` and `keepAliveTimeout` only apply to the pool the library creates itself. + +```js +import { EnvHttpProxyAgent, MockAgent, ProxyAgent } from 'undici'; + +// Explicit proxy +const ipdata = new IPData('', undefined, undefined, { + dispatcher: new ProxyAgent('http://proxy.example.com:8080'), +}); + +// HTTP_PROXY, HTTPS_PROXY and NO_PROXY from the environment +const ipdata = new IPData('', undefined, undefined, { dispatcher: new EnvHttpProxyAgent() }); + +// Tests: canned responses, no network +const mockAgent = new MockAgent(); +mockAgent.disableNetConnect(); +mockAgent + .get('https://api.ipdata.co') + .intercept({ path: '/1.1.1.1?api-key=', method: 'GET' }) + .reply(200, { ip: '1.1.1.1', is_eu: false }); +const ipdata = new IPData('', undefined, undefined, { dispatcher: mockAgent }); +``` + +A request that fails because the server closed a pooled connection before any response byte arrived (connection reset or closed) is sent once more on a fresh connection; this also covers the `POST` made by `bulkLookup()`. HTTP error statuses are never retried. + ## Response Fields The following fields are available for use with `selectField` and `fields`: @@ -159,5 +210,5 @@ The following fields are available for use with `selectField` and `fields`: The library is written in TypeScript and exports the following types: ```ts -import IPData, { EU_BASE_URL, CacheConfig, LookupParams, LookupResponse } from 'ipdata'; +import IPData, { EU_BASE_URL, CacheConfig, HttpConfig, LookupParams, LookupResponse } from 'ipdata'; ``` diff --git a/package-lock.json b/package-lock.json index fdcaf1c..5aa695b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "ipdata", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ipdata", - "version": "3.0.0", + "version": "3.1.0", "license": "MIT", "dependencies": { "lru-cache": "^11.2.6", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "undici": "^7.22.0" }, "devDependencies": { "@types/jest": "^29.5.0", @@ -31,7 +32,7 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=18" + "node": ">=20.18.1" } }, "node_modules/@babel/code-frame": { @@ -7734,6 +7735,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index e7f0195..57e2fac 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "ipdata", - "version": "3.0.0", + "version": "3.1.0", "description": "JavaScript library to gather information for an ip using https://ipdata.co.", "main": "./lib/ipdata.js", "types": "./lib/ipdata.d.ts", "engines": { - "node": ">=18" + "node": ">=20.18.1" }, "scripts": { "prebuild": "del lib", @@ -25,7 +25,8 @@ "homepage": "https://github.com/ipdata/node#readme", "dependencies": { "lru-cache": "^11.2.6", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "undici": "^7.22.0" }, "devDependencies": { "@types/jest": "^29.5.0", diff --git a/src/ipdata.test.ts b/src/ipdata.test.ts index fdb3442..55bbfb5 100644 --- a/src/ipdata.test.ts +++ b/src/ipdata.test.ts @@ -1,8 +1,13 @@ +import { createServer, type Server } from 'http'; +import type { AddressInfo, Socket } from 'net'; +import { once } from 'events'; +import { Agent, MockAgent, errors } from 'undici'; import IPData, { EU_BASE_URL, LookupResponse } from './ipdata'; const TEST_API_KEY = 'test-api-key'; const TEST_IP = '1.1.1.1'; const DEFAULT_IP_KEY = 'DEFAULT_IP'; +const API_ORIGIN = 'https://api.ipdata.co'; const MOCK_IP1_DATA = { ip: '1.1.1.1', @@ -66,16 +71,49 @@ const MOCK_DEFAULT_IP_DATA = { ip: '203.0.113.1', }; -function mockFetchResponse(data: unknown, status = 200) { - return Promise.resolve({ - ok: status >= 200 && status < 300, - status, - json: () => Promise.resolve(data), +// Every request in this file goes through an undici MockAgent injected via httpConfig.dispatcher, or through a +// loopback server; nothing reaches the network. +const mockAgent = new MockAgent(); +mockAgent.disableNetConnect(); +const mockPool = mockAgent.get(API_ORIGIN); + +function apiPath(path: string, params: Record = {}): string { + const url = new URL(path, API_ORIGIN); + url.searchParams.set('api-key', TEST_API_KEY); + Object.keys(params).forEach((name) => { + url.searchParams.set(name, params[name]); }); + return `${url.pathname}${url.search}`; } -const mockFetch = jest.fn() as jest.Mock; -global.fetch = mockFetch; +interface MockOptions { + status?: number; + params?: Record; +} + +function mockLookup(path: string, data: unknown, { status = 200, params }: MockOptions = {}): void { + mockPool + .intercept({ path: apiPath(path, params), method: 'GET' }) + .reply(status, JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); +} + +function mockBulkLookup(ips: string[], data: unknown, { status = 200, params }: MockOptions = {}): void { + mockPool + .intercept({ path: apiPath('/bulk', params), method: 'POST', body: JSON.stringify(ips) }) + .reply(status, JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); +} + +afterEach(() => { + try { + mockAgent.assertNoPendingInterceptors(); + } finally { + mockPool.cleanMocks(); + } +}); + +afterAll(async () => { + await mockAgent.close(); +}); function keyBy(items: LookupResponse[]): Record { return items.reduce>((acc, item) => { @@ -121,38 +159,248 @@ describe('constructor()', () => { }); }); +describe('httpConfig', () => { + it('should create an undici Agent by default', async () => { + const ipdata = new IPData(TEST_API_KEY); + expect(ipdata.dispatcher).toBeInstanceOf(Agent); + await ipdata.close(); + }); + + it('should create an undici Agent when http2 and keepAliveTimeout are configured', async () => { + const ipdata = new IPData(TEST_API_KEY, undefined, undefined, { http2: false, keepAliveTimeout: 1000 }); + expect(ipdata.dispatcher).toBeInstanceOf(Agent); + await ipdata.close(); + }); + + it('should use the injected dispatcher for lookup()', async () => { + const ipdata = new IPData(TEST_API_KEY, undefined, undefined, { dispatcher: mockAgent }); + const dispatch = jest.spyOn(mockAgent, 'dispatch'); + expect(ipdata.dispatcher).toBe(mockAgent); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); + const info = await ipdata.lookup(TEST_IP); + expect(info).toHaveProperty('ip', TEST_IP); + expect(dispatch).toHaveBeenCalledTimes(1); + dispatch.mockRestore(); + }); + + it('should use the injected dispatcher for bulkLookup()', async () => { + const ipdata = new IPData(TEST_API_KEY, undefined, undefined, { dispatcher: mockAgent }); + const dispatch = jest.spyOn(mockAgent, 'dispatch'); + const ips = [TEST_IP, '8.8.8.8']; + mockBulkLookup(ips, [MOCK_IP1_DATA, MOCK_IP2_DATA]); + const result = await ipdata.bulkLookup(ips); + expect(result).toHaveLength(2); + expect(dispatch).toHaveBeenCalledTimes(1); + dispatch.mockRestore(); + }); + + it('should send sequential lookups through the same dispatcher', async () => { + const ipdata = new IPData(TEST_API_KEY, undefined, undefined, { dispatcher: mockAgent }); + const dispatch = jest.spyOn(mockAgent, 'dispatch'); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); + mockLookup('/8.8.8.8', MOCK_IP2_DATA); + await ipdata.lookup('1.1.1.1'); + await ipdata.lookup('8.8.8.8'); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch.mock.calls[0][0]).toHaveProperty('origin', API_ORIGIN); + expect(dispatch.mock.calls[1][0]).toHaveProperty('origin', API_ORIGIN); + dispatch.mockRestore(); + }); + + describe('close()', () => { + it('should close the Agent the instance created', async () => { + const ipdata = new IPData(TEST_API_KEY); + const agent = ipdata.dispatcher as Agent; + expect(agent.closed).toBe(false); + await ipdata.close(); + expect(agent.closed).toBe(true); + await expect(ipdata.close()).resolves.toBeUndefined(); + }); + + it('should not close an injected dispatcher', async () => { + const agent = new Agent(); + const ipdata = new IPData(TEST_API_KEY, undefined, undefined, { dispatcher: agent }); + const close = jest.spyOn(agent, 'close'); + await ipdata.close(); + expect(close).not.toHaveBeenCalled(); + expect(agent.closed).toBe(false); + close.mockRestore(); + await agent.close(); + }); + }); +}); + +describe('connection reuse', () => { + let server: Server; + let baseUrl: string; + const sockets: Socket[] = []; + const requests: string[] = []; + + function nextTurn(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); + } + + beforeAll(async () => { + server = createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk: string) => { + body += chunk; + }); + req.on('end', () => { + requests.push(`${req.method} ${req.url} ${body}`.trim()); + res.setHeader('content-type', 'application/json'); + if (req.method === 'POST') { + const ips = JSON.parse(body) as string[]; + res.end(JSON.stringify(ips.map((ip) => ({ ...MOCK_IP1_DATA, ip })))); + } else { + const ip = new URL(req.url!, baseUrl).pathname.slice(1) || MOCK_DEFAULT_IP_DATA.ip; + res.end(JSON.stringify({ ...MOCK_IP1_DATA, ip })); + } + }); + }); + server.on('connection', (socket) => { + sockets.push(socket); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; + }); + + beforeEach(() => { + sockets.length = 0; + requests.length = 0; + }); + + afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('should send consecutive requests over one connection and release it on close()', async () => { + const ipdata = new IPData(TEST_API_KEY, undefined, baseUrl); + await ipdata.lookup('1.1.1.1'); + await nextTurn(); + await ipdata.lookup('8.8.8.8'); + await nextTurn(); + await ipdata.bulkLookup(['1.0.0.1', '9.9.9.9']); + expect(requests).toEqual([ + `GET /1.1.1.1?api-key=${TEST_API_KEY}`, + `GET /8.8.8.8?api-key=${TEST_API_KEY}`, + `POST /bulk?api-key=${TEST_API_KEY} ["1.0.0.1","9.9.9.9"]`, + ]); + expect(sockets).toHaveLength(1); + await ipdata.close(); + if (!sockets[0].destroyed) { + await once(sockets[0], 'close'); + } + expect(sockets[0].destroyed).toBe(true); + }); + + it('should not open a connection per request for back-to-back lookups', async () => { + const ipdata = new IPData(TEST_API_KEY, undefined, baseUrl); + // undici marks a connection reusable on the event-loop turn after its response completed, so requests awaited + // strictly back-to-back alternate between two pooled connections; the pool never grows beyond that. + await ipdata.lookup('1.1.1.1'); + await ipdata.lookup('8.8.8.8'); + await ipdata.lookup('1.0.0.1'); + await ipdata.lookup('9.9.9.9'); + await ipdata.bulkLookup(['4.4.4.4', '5.5.5.5']); + expect(requests).toHaveLength(5); + expect(sockets.length).toBeLessThanOrEqual(2); + await ipdata.close(); + }); +}); + +describe('stale pooled connection', () => { + const ipdata = new IPData(TEST_API_KEY, undefined, undefined, { dispatcher: mockAgent }); + const path = apiPath('/1.1.1.1'); + + afterEach(() => { + ipdata.cache.clear(); + }); + + it('should retry a lookup once when the connection closed before any response arrived', async () => { + mockPool.intercept({ path, method: 'GET' }).replyWithError(new errors.SocketError('other side closed')); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); + const info = await ipdata.lookup(TEST_IP); + expect(info).toHaveProperty('ip', TEST_IP); + expect(info).toHaveProperty('status', 200); + }); + + it('should retry a bulk lookup once when the connection was reset before any response arrived', async () => { + const ips = [TEST_IP, '8.8.8.8']; + mockPool + .intercept({ path: apiPath('/bulk'), method: 'POST', body: JSON.stringify(ips) }) + .replyWithError(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' })); + mockBulkLookup(ips, [MOCK_IP1_DATA, MOCK_IP2_DATA]); + const result = await ipdata.bulkLookup(ips); + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('status', 200); + }); + + it('should not retry more than once', async () => { + mockPool.intercept({ path, method: 'GET' }).replyWithError(new errors.SocketError('other side closed')).times(2); + await expect(ipdata.lookup(TEST_IP)).rejects.toMatchObject({ + message: 'fetch failed', + cause: { code: 'UND_ERR_SOCKET' }, + }); + }); + + it('should not retry other transport errors', async () => { + mockPool + .intercept({ path, method: 'GET' }) + .replyWithError(Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' })); + await expect(ipdata.lookup(TEST_IP)).rejects.toMatchObject({ + message: 'fetch failed', + cause: { code: 'ECONNREFUSED' }, + }); + }); + + it('should not retry an HTTP error status', async () => { + mockLookup('/1.1.1.1', { message: 'Internal error' }, { status: 500 }); + const info = await ipdata.lookup(TEST_IP); + expect(info).toHaveProperty('status', 500); + expect(info).toHaveProperty('message', 'Internal error'); + }); +}); + describe('lookup()', () => { - const ipdata = new IPData(TEST_API_KEY); + const ipdata = new IPData(TEST_API_KEY, undefined, undefined, { dispatcher: mockAgent }); afterEach(() => { ipdata.cache.clear(); - mockFetch.mockReset(); }); describe('default IP address', () => { it('should return information', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_DEFAULT_IP_DATA)); + mockLookup('/', MOCK_DEFAULT_IP_DATA); const info = await ipdata.lookup(); expect(info).toHaveProperty('ip'); expect(info).toHaveProperty('status'); }); it('should return information when null is provided', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_DEFAULT_IP_DATA)); + mockLookup('/', MOCK_DEFAULT_IP_DATA); const info = await ipdata.lookup(undefined); expect(info).toHaveProperty('ip'); expect(info).toHaveProperty('status'); }); it('should return information when undefined is provided', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_DEFAULT_IP_DATA)); + mockLookup('/', MOCK_DEFAULT_IP_DATA); const info = await ipdata.lookup(undefined); expect(info).toHaveProperty('ip'); expect(info).toHaveProperty('status'); }); it('should cache the information', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_DEFAULT_IP_DATA)); + mockLookup('/', MOCK_DEFAULT_IP_DATA); await ipdata.lookup(); const info = ipdata.cache.get(DEFAULT_IP_KEY); expect(info).toHaveProperty('ip'); @@ -160,8 +408,8 @@ describe('lookup()', () => { }); it('should not cache the information', async () => { - const customIPData = new IPData(TEST_API_KEY, { ttl: 1 }); - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_DEFAULT_IP_DATA)); + const customIPData = new IPData(TEST_API_KEY, { ttl: 1 }, undefined, { dispatcher: mockAgent }); + mockLookup('/', MOCK_DEFAULT_IP_DATA); await customIPData.lookup(); const info = ipdata.cache.get(DEFAULT_IP_KEY); expect(info).toBeUndefined(); @@ -175,14 +423,14 @@ describe('lookup()', () => { }); it('should return information', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_IP1_DATA)); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); const info = await ipdata.lookup(TEST_IP); expect(info).toHaveProperty('ip', TEST_IP); expect(info).toHaveProperty('status'); }); it('should cache the information', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_IP1_DATA)); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); await ipdata.lookup(TEST_IP); const info = ipdata.cache.get(TEST_IP); expect(info).toHaveProperty('ip', TEST_IP); @@ -190,8 +438,8 @@ describe('lookup()', () => { }); it('should not cache the information', async () => { - const customIPData = new IPData(TEST_API_KEY, { ttl: 1 }); - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_IP1_DATA)); + const customIPData = new IPData(TEST_API_KEY, { ttl: 1 }, undefined, { dispatcher: mockAgent }); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); await customIPData.lookup(TEST_IP); const info = ipdata.cache.get(TEST_IP); expect(info).toBeUndefined(); @@ -212,7 +460,7 @@ describe('lookup()', () => { it('should return a response with only the field', async () => { const field = 'is_eu'; - mockFetch.mockReturnValueOnce(mockFetchResponse(false)); + mockLookup('/is_eu', false); const info = await ipdata.lookup(undefined, field); expect(info).toHaveProperty(field, false); expect(info).toHaveProperty('status'); @@ -230,7 +478,7 @@ describe('lookup()', () => { const field1 = 'ip'; const field2 = 'is_eu'; const fields = [field1, field2]; - mockFetch.mockReturnValueOnce(mockFetchResponse({ ip: TEST_IP, is_eu: false })); + mockLookup('/1.1.1.1', { ip: TEST_IP, is_eu: false }, { params: { fields: fields.join(',') } }); const info = await ipdata.lookup(TEST_IP, undefined, fields); expect(info).toHaveProperty(field1, TEST_IP); expect(info).toHaveProperty(field2, false); @@ -246,7 +494,7 @@ describe('lookup()', () => { network: '1.1.1.0/24', type: 'hosting', }; - mockFetch.mockReturnValueOnce(mockFetchResponse(companyData)); + mockLookup('/1.1.1.1/company', companyData); const info = await ipdata.lookup(TEST_IP, 'company'); expect(info).toHaveProperty('company'); expect(info).toHaveProperty('status'); @@ -254,7 +502,7 @@ describe('lookup()', () => { it('should accept company in fields array', async () => { const fields = ['ip', 'company']; - mockFetch.mockReturnValueOnce(mockFetchResponse({ ip: TEST_IP, company: MOCK_IP1_DATA.company })); + mockLookup('/1.1.1.1', { ip: TEST_IP, company: MOCK_IP1_DATA.company }, { params: { fields: fields.join(',') } }); const info = await ipdata.lookup(TEST_IP, undefined, fields); expect(info).toHaveProperty('ip', TEST_IP); expect(info).toHaveProperty('status'); @@ -263,28 +511,28 @@ describe('lookup()', () => { describe('named params', () => { it('should return information with no params', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_DEFAULT_IP_DATA)); + mockLookup('/', MOCK_DEFAULT_IP_DATA); const info = await ipdata.lookup({}); expect(info).toHaveProperty('ip'); expect(info).toHaveProperty('status'); }); it('should return information with ip param', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_IP1_DATA)); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); const info = await ipdata.lookup({ ip: TEST_IP }); expect(info).toHaveProperty('ip', TEST_IP); expect(info).toHaveProperty('status'); }); it('should return a selectField response', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(false)); + mockLookup('/is_eu', false); const info = await ipdata.lookup({ selectField: 'is_eu' }); expect(info).toHaveProperty('is_eu', false); expect(info).toHaveProperty('status'); }); it('should return a fields response', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse({ ip: TEST_IP, is_eu: false })); + mockLookup('/1.1.1.1', { ip: TEST_IP, is_eu: false }, { params: { fields: 'ip,is_eu' } }); const info = await ipdata.lookup({ ip: TEST_IP, fields: ['ip', 'is_eu'] }); expect(info).toHaveProperty('ip', TEST_IP); expect(info).toHaveProperty('is_eu', false); @@ -300,7 +548,7 @@ describe('lookup()', () => { describe('new API fields', () => { it('should return threat object with new fields', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_IP1_DATA)); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); const info = await ipdata.lookup(TEST_IP); expect(info).toHaveProperty('threat'); expect(info.threat).toHaveProperty('is_icloud_relay'); @@ -309,7 +557,7 @@ describe('lookup()', () => { }); it('should return languages with code field', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_IP1_DATA)); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); const info = await ipdata.lookup(TEST_IP); expect(info.languages.length).toBeGreaterThan(0); expect(info.languages[0]).toHaveProperty('code'); @@ -318,13 +566,12 @@ describe('lookup()', () => { }); describe('bulkLookup()', () => { - const ipdata = new IPData(TEST_API_KEY); + const ipdata = new IPData(TEST_API_KEY, undefined, undefined, { dispatcher: mockAgent }); const IP1 = TEST_IP; const IP2 = '8.8.8.8'; afterEach(() => { ipdata.cache.clear(); - mockFetch.mockReset(); }); it('should throw an error if less than 2 ip addresses are provided', async () => { @@ -339,7 +586,7 @@ describe('bulkLookup()', () => { }); it('should return info for the ip addresses', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse([MOCK_IP1_DATA, MOCK_IP2_DATA])); + mockBulkLookup([IP1, IP2], [MOCK_IP1_DATA, MOCK_IP2_DATA]); const result = await ipdata.bulkLookup([IP1, IP2]); const info = keyBy(result); expect(info[IP1]).toHaveProperty('ip', IP1); @@ -349,7 +596,7 @@ describe('bulkLookup()', () => { }); it('should cache the info', async () => { - mockFetch.mockReturnValueOnce(mockFetchResponse([MOCK_IP1_DATA, MOCK_IP2_DATA])); + mockBulkLookup([IP1, IP2], [MOCK_IP1_DATA, MOCK_IP2_DATA]); await ipdata.bulkLookup([IP1, IP2]); const ip1Info = ipdata.cache.get(IP1); const ip2Info = ipdata.cache.get(IP2); @@ -361,10 +608,10 @@ describe('bulkLookup()', () => { it('should use existing cached ips and merge ones that do not exist', async () => { const IP3 = '1.0.0.1'; - mockFetch.mockReturnValueOnce(mockFetchResponse(MOCK_IP1_DATA)); + mockLookup('/1.1.1.1', MOCK_IP1_DATA); await ipdata.lookup(IP1); expect(ipdata.cache.get(IP1)).toBeDefined(); - mockFetch.mockReturnValueOnce(mockFetchResponse([MOCK_IP2_DATA, MOCK_IP3_DATA])); + mockBulkLookup([IP2, IP3], [MOCK_IP2_DATA, MOCK_IP3_DATA]); const result = await ipdata.bulkLookup([IP1, IP2, IP3]); const info = keyBy(result); expect(info[IP1]).toHaveProperty('ip', IP1); @@ -386,11 +633,13 @@ describe('bulkLookup()', () => { const field1 = 'ip'; const field2 = 'is_eu'; const fields = [field1, field2]; - mockFetch.mockReturnValueOnce( - mockFetchResponse([ + mockBulkLookup( + [IP1, IP2], + [ { ip: IP1, is_eu: false }, { ip: IP2, is_eu: true }, - ]), + ], + { params: { fields: fields.join(',') } }, ); const result = await ipdata.bulkLookup([IP1, IP2], fields); const info = keyBy(result); diff --git a/src/ipdata.ts b/src/ipdata.ts index f6c4637..616df1f 100644 --- a/src/ipdata.ts +++ b/src/ipdata.ts @@ -1,8 +1,12 @@ import { isIP as netIsIP } from 'net'; import { LRUCache } from 'lru-cache'; +import { fetch, Agent, type Dispatcher, type RequestInit, type Response } from 'undici'; const CACHE_MAX = 4096; // max number of items const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours +const KEEP_ALIVE_TIMEOUT = 1000 * 60; // close a pooled connection after 60 seconds idle +const KEEP_ALIVE_MAX_TIMEOUT = 1000 * 60 * 10; // upper bound when the server sends a larger keep-alive hint +const KEEP_ALIVE_PROBE_INTERVAL = 1000 * 30; // HTTP/2 PING and TCP keepalive probe interval const DEFAULT_IP = 'DEFAULT_IP'; const VALID_FIELDS = [ 'ip', @@ -64,11 +68,66 @@ function isValidFields(fields: string[]): boolean { return true; } +interface TransportError { + code: string; + message: string; +} + +// fetch() rejects with a TypeError whose cause is the undici or socket error; find the first error carrying a code. +function findTransportError(error: unknown): TransportError | undefined { + let current: unknown = error; + + for (let depth = 0; depth < 5 && typeof current === 'object' && current !== null; depth += 1) { + const { code, message, cause } = current as { code?: unknown; message?: unknown; cause?: unknown }; + + if (typeof code === 'string') { + return { code, message: typeof message === 'string' ? message : '' }; + } + + current = cause; + } + + return undefined; +} + +// True for a connection that was reset, closed (EOF) or broken while the request was in flight and before any +// response byte arrived: the usual symptom of a pooled connection the server dropped while it was idle. +function isStaleConnectionError(error: unknown): boolean { + const transportError = findTransportError(error); + + if (!transportError) { + return false; + } + + const { code, message } = transportError; + + if (code === 'ECONNRESET' || code === 'EPIPE') { + return true; + } + + return ( + code === 'UND_ERR_SOCKET' && + (message === 'other side closed' || message === 'closed' || message.startsWith('HTTP/2: "GOAWAY"')) + ); +} + export interface CacheConfig { max?: number; ttl?: number; } +export interface HttpConfig { + /** + * undici dispatcher to send requests through (for example a ProxyAgent, an EnvHttpProxyAgent or a MockAgent). + * The caller owns it: close() leaves it open. http2 and keepAliveTimeout are ignored when it is set. + */ + dispatcher?: Dispatcher; + /** Negotiate HTTP/2 via ALPN, falling back to HTTP/1.1 when the server does not offer it. Default: true. */ + http2?: boolean; + /** Milliseconds a pooled connection may stay idle before it is closed. Default: 60000. */ + keepAliveTimeout?: number; +} + export interface LookupParams { ip?: string; selectField?: string; @@ -145,8 +204,11 @@ export class IPData { apiKey: string; baseUrl: string; cache: LRUCache; + /** The dispatcher every request goes through: the injected one, or the Agent this instance created and owns. */ + readonly dispatcher: Dispatcher; + private readonly ownedAgent?: Agent; - constructor(apiKey: string, cacheConfig?: CacheConfig, baseUrl?: string) { + constructor(apiKey: string, cacheConfig?: CacheConfig, baseUrl?: string, httpConfig?: HttpConfig) { if (typeof apiKey !== 'string') { throw new Error('An API key is required.'); } @@ -154,6 +216,48 @@ export class IPData { this.apiKey = apiKey; this.baseUrl = baseUrl || BASE_URL; this.cache = new LRUCache({ max: CACHE_MAX, ttl: CACHE_TTL, ...cacheConfig }); + + if (httpConfig?.dispatcher) { + this.dispatcher = httpConfig.dispatcher; + } else { + // One connection pool per instance, kept for its whole lifetime: TCP + TLS are negotiated once and every + // request is multiplexed over HTTP/2 or sent on a kept-alive HTTP/1.1 connection. + this.ownedAgent = new Agent({ + allowH2: httpConfig?.http2 ?? true, + keepAliveTimeout: httpConfig?.keepAliveTimeout ?? KEEP_ALIVE_TIMEOUT, + keepAliveMaxTimeout: KEEP_ALIVE_MAX_TIMEOUT, + pingInterval: KEEP_ALIVE_PROBE_INTERVAL, + connect: { keepAliveInitialDelay: KEEP_ALIVE_PROBE_INTERVAL }, + }); + this.dispatcher = this.ownedAgent; + } + } + + /** + * Closes the connection pool this instance created. Call it once at shutdown; a dispatcher supplied through + * httpConfig is not touched. Idle pooled connections never keep the event loop alive, so this is optional. + */ + async close(): Promise { + if (this.ownedAgent && !this.ownedAgent.closed) { + await this.ownedAgent.close(); + } + } + + private async request(url: URL, init?: RequestInit): Promise { + const options: RequestInit = { ...init, dispatcher: this.dispatcher }; + + try { + return await fetch(url.toString(), options); + } catch (error) { + // undici does not retry a request that failed before any response byte arrived, for example because the + // server closed the pooled connection while it was idle. That connection has already been discarded, so + // send the request exactly once more. HTTP error statuses are responses, not failures, and never get here. + if (!isStaleConnectionError(error)) { + throw error; + } + + return fetch(url.toString(), options); + } } private buildUrl(...pathSegments: string[]): URL { @@ -210,7 +314,7 @@ export class IPData { url.searchParams.set('fields', fields.join(',')); } - const response = await fetch(url.toString()); + const response = await this.request(url); if (!response.ok) { const errorData = (await response.json().catch(() => ({}))) as object; @@ -261,9 +365,9 @@ export class IPData { url.searchParams.set('fields', fields.join(',')); } - const response = await fetch(url.toString(), { + const response = await this.request(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(bulk), }); From ca9d09a220e3a01e5f6424cbc96bd3e2a8377632 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:42:03 +0000 Subject: [PATCH 2/2] Add is_satellite, is_anycast and blocklist date fields The API now reports two more top-level booleans next to is_eu, is_satellite and is_anycast, each included only when it is true, and each threat.blocklists entry may carry first_seen and last_seen (YYYY-MM-DD strings, UTC) plus days_seen. Some feeds omit the three blocklist fields, so all of them are optional. LookupResponse declares the new keys as optional (undefined reads as false for the flags), the dates stay strings, and both flag names are accepted by the selectField and fields validation of lookup() and bulkLookup(). Responses are parsed JSON, so unknown keys already pass through unchanged. Tests add fixtures built from the API samples (a satellite ISP, an anycast address with dated blocklist entries, a VPN exit with an undated entry) for single and bulk lookups, and the README documents the five names. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- README.md | 14 ++- src/ipdata.test.ts | 267 +++++++++++++++++++++++++++++++++++++++++++++ src/ipdata.ts | 18 ++- 3 files changed, 297 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c26be50..a1c2ef7 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ A request that fails because the server closed a pooled connection before any re The following fields are available for use with `selectField` and `fields`: -`ip`, `is_eu`, `city`, `region`, `region_code`, `country_name`, `country_code`, `continent_name`, `continent_code`, `latitude`, `longitude`, `asn`, `company`, `organisation`, `postal`, `calling_code`, `flag`, `emoji_flag`, `emoji_unicode`, `carrier`, `languages`, `currency`, `time_zone`, `threat`, `count` +`ip`, `is_eu`, `is_satellite`, `is_anycast`, `city`, `region`, `region_code`, `country_name`, `country_code`, `continent_name`, `continent_code`, `latitude`, `longitude`, `asn`, `company`, `organisation`, `postal`, `calling_code`, `flag`, `emoji_flag`, `emoji_unicode`, `carrier`, `languages`, `currency`, `time_zone`, `threat`, `count` **`asn`** — `{ asn, name, domain, route, type }` @@ -203,8 +203,20 @@ The following fields are available for use with `selectField` and `fields`: **`carrier`** — `{ name, mcc, mnc }` +**`is_satellite`** — `true` when the address belongs to a satellite internet provider. The API includes the key only when it is `true` and omits it otherwise, so treat a missing key as `false` (`info.is_satellite ?? false`). + +**`is_anycast`** — `true` when the address is anycast. Also included only when `true`. + **`threat`** — `{ is_tor, is_icloud_relay, is_proxy, is_datacenter, is_anonymous, is_known_attacker, is_known_abuser, is_threat, is_bogon, blocklists }` +Each entry of `threat.blocklists` is `{ name, site, type, first_seen, last_seen, days_seen }`: + +- **`first_seen`** — first day the address appeared on that blocklist, as a `YYYY-MM-DD` string (UTC) +- **`last_seen`** — most recent day the address appeared on that blocklist, same format +- **`days_seen`** — number of days the address has appeared on that blocklist (integer) + +Some feeds, for example ipdata's own `vpn` list, do not report these three, so an entry may contain only `name`, `site` and `type`. The dates are passed through as strings. + ## TypeScript The library is written in TypeScript and exports the following types: diff --git a/src/ipdata.test.ts b/src/ipdata.test.ts index 55bbfb5..b737cea 100644 --- a/src/ipdata.test.ts +++ b/src/ipdata.test.ts @@ -71,6 +71,159 @@ const MOCK_DEFAULT_IP_DATA = { ip: '203.0.113.1', }; +// Satellite ISP: is_satellite is present, is_anycast is not. +const SAMPLE_SATELLITE_DATA = { + ip: '98.97.34.0', + is_satellite: true, + is_eu: false, + city: 'Seattle', + country_code: 'US', + asn: { + asn: 'AS14593', + name: 'Space Exploration Technologies Corporation', + domain: 'spacex.com', + route: '98.97.32.0/21', + type: 'business', + }, + threat: { + is_tor: false, + is_vpn: false, + is_icloud_relay: false, + is_proxy: false, + is_datacenter: false, + is_anonymous: false, + is_known_attacker: false, + is_known_abuser: false, + is_threat: false, + is_bogon: false, + blocklists: [], + scores: { vpn_score: 0, proxy_score: 1, threat_score: 8, trust_score: 97 }, + }, + count: '0', +}; + +// Anycast address with dated blocklist entries: is_anycast is present, is_satellite is not. +const SAMPLE_ANYCAST_DATA = { + ip: '8.8.8.8', + is_anycast: true, + is_eu: false, + city: null, + region: null, + region_code: null, + region_type: null, + country_name: 'United States', + country_code: 'US', + continent_name: 'North America', + continent_code: 'NA', + latitude: 39.7599983215332, + longitude: -98.5, + postal: null, + calling_code: '1', + flag: 'https://ipdata.co/flags/us.png', + emoji_flag: '\u{1F1FA}\u{1F1F8}', + emoji_unicode: 'U+1F1FA U+1F1F8', + asn: { asn: 'AS15169', name: 'Google LLC', domain: 'about.google', route: '8.8.8.0/24', type: 'hosting' }, + company: { name: 'Google LLC', domain: 'about.google', network: '8.8.8.0/24', type: 'hosting' }, + languages: [{ name: 'English', native: 'English', code: 'en' }], + currency: { name: 'US Dollar', code: 'USD', symbol: '$', native: '$', plural: 'US dollars' }, + time_zone: { name: null, abbr: null, offset: null, is_dst: null, current_time: null }, + threat: { + is_tor: false, + is_vpn: false, + is_icloud_relay: false, + is_proxy: false, + is_datacenter: true, + is_anonymous: false, + is_known_attacker: false, + is_known_abuser: false, + is_threat: false, + is_bogon: false, + blocklists: [ + { + days_seen: 1, + first_seen: '2026-11-02', + last_seen: '2026-11-02', + name: 'ipdata', + site: 'https://ipdata.co', + type: 'cloud', + }, + { + days_seen: 1, + first_seen: '2026-11-02', + last_seen: '2026-11-02', + name: 'ipdata', + site: 'https://ipdata.co', + type: 'open_resolvers', + }, + ], + scores: { vpn_score: 100, proxy_score: 93, threat_score: 100, trust_score: 2 }, + }, + count: '0', +}; + +// VPN exit: neither top-level flag is present and the third blocklist entry has no dates. +const SAMPLE_VPN_DATA = { + ip: '187.13.250.1', + is_eu: false, + threat: { + is_tor: false, + is_vpn: true, + is_icloud_relay: false, + is_proxy: false, + is_datacenter: true, + is_anonymous: true, + is_known_attacker: true, + is_known_abuser: true, + is_threat: true, + is_bogon: false, + blocklists: [ + { + days_seen: 2, + first_seen: '2026-10-31', + last_seen: '2026-11-02', + name: 'Gluetun', + site: 'https://github.com/qdm12/gluetun', + type: 'vpn', + }, + { + days_seen: 1, + first_seen: '2026-11-02', + last_seen: '2026-11-02', + name: 'ipdata', + site: 'https://ipdata.co', + type: 'cloud', + }, + { name: 'ipdata', site: 'https://ipdata.co', type: 'vpn' }, + { + days_seen: 2, + first_seen: '2026-10-31', + last_seen: '2026-11-02', + name: 'NordVPN', + site: 'https://nordvpn.com', + type: 'vpn', + }, + { + days_seen: 2, + first_seen: '2026-10-31', + last_seen: '2026-11-02', + name: 'Wikimedia Global Blocklist', + site: 'https://wikimedia.org', + type: 'general', + }, + { + days_seen: 2, + first_seen: '2026-10-31', + last_seen: '2026-11-02', + name: 'X4BNet', + site: 'https://github.com/X4BNet/lists_vpn', + type: 'vpn', + }, + ], + scores: { vpn_score: 100, proxy_score: 99, threat_score: 100, trust_score: 0 }, + }, + count: '0', +}; + // Every request in this file goes through an undici MockAgent injected via httpConfig.dispatcher, or through a // loopback server; nothing reaches the network. const mockAgent = new MockAgent(); @@ -563,6 +716,80 @@ describe('lookup()', () => { expect(info.languages[0]).toHaveProperty('code'); }); }); + + describe('is_satellite, is_anycast and blocklist dates', () => { + it('should expose is_satellite for a satellite ISP and leave is_anycast undefined', async () => { + mockLookup('/98.97.34.0', SAMPLE_SATELLITE_DATA); + const info = await ipdata.lookup('98.97.34.0'); + expect(info.is_satellite).toBe(true); + expect(info.is_anycast).toBeUndefined(); + expect(info.is_anycast ?? false).toBe(false); + expect(info.threat.blocklists).toEqual([]); + }); + + it('should expose is_anycast and dated blocklist entries for an anycast address', async () => { + mockLookup('/8.8.8.8', SAMPLE_ANYCAST_DATA); + const info = await ipdata.lookup('8.8.8.8'); + expect(info.is_anycast).toBe(true); + expect(info.is_satellite).toBeUndefined(); + expect(info.is_satellite ?? false).toBe(false); + expect(info.threat.blocklists).toHaveLength(2); + expect(info.threat.blocklists![0]).toEqual({ + days_seen: 1, + first_seen: '2026-11-02', + last_seen: '2026-11-02', + name: 'ipdata', + site: 'https://ipdata.co', + type: 'cloud', + }); + expect(typeof info.threat.blocklists![1].first_seen).toBe('string'); + expect(typeof info.threat.blocklists![1].days_seen).toBe('number'); + }); + + it('should leave both flags undefined and tolerate a blocklist entry without dates', async () => { + mockLookup('/187.13.250.1', SAMPLE_VPN_DATA); + const info = await ipdata.lookup('187.13.250.1'); + expect(info.is_satellite).toBeUndefined(); + expect(info.is_anycast).toBeUndefined(); + expect(info.threat.blocklists).toHaveLength(6); + const undated = info.threat.blocklists![2]; + expect(undated).toEqual({ name: 'ipdata', site: 'https://ipdata.co', type: 'vpn' }); + expect(undated.first_seen).toBeUndefined(); + expect(undated.last_seen).toBeUndefined(); + expect(undated.days_seen).toBeUndefined(); + expect(info.threat.blocklists![0]).toMatchObject({ + name: 'Gluetun', + days_seen: 2, + first_seen: '2026-10-31', + last_seen: '2026-11-02', + }); + }); + + it('should pass unknown keys through unchanged', async () => { + mockLookup('/98.97.34.0', { ...SAMPLE_SATELLITE_DATA, future_field: 'kept' }); + const info = await ipdata.lookup('98.97.34.0'); + expect(info).toHaveProperty('future_field', 'kept'); + expect(info).toHaveProperty('is_satellite', true); + }); + + it('should accept is_satellite and is_anycast as select fields', async () => { + mockLookup('/98.97.34.0/is_satellite', true); + const satellite = await ipdata.lookup('98.97.34.0', 'is_satellite'); + expect(satellite).toHaveProperty('is_satellite', true); + mockLookup('/8.8.8.8/is_anycast', true); + const anycast = await ipdata.lookup({ ip: '8.8.8.8', selectField: 'is_anycast' }); + expect(anycast).toHaveProperty('is_anycast', true); + }); + + it('should accept is_satellite and is_anycast in the fields array', async () => { + const fields = ['ip', 'is_satellite', 'is_anycast']; + mockLookup('/8.8.8.8', { ip: '8.8.8.8', is_anycast: true }, { params: { fields: fields.join(',') } }); + const info = await ipdata.lookup('8.8.8.8', undefined, fields); + expect(info).toHaveProperty('ip', '8.8.8.8'); + expect(info).toHaveProperty('is_anycast', true); + expect(info.is_satellite).toBeUndefined(); + }); + }); }); describe('bulkLookup()', () => { @@ -651,4 +878,44 @@ describe('bulkLookup()', () => { expect(info[IP2]).toHaveProperty('status'); }); }); + + describe('is_satellite, is_anycast and blocklist dates', () => { + const SATELLITE_IP = '98.97.34.0'; + const ANYCAST_IP = '8.8.8.8'; + + it('should return the new fields per item', async () => { + const ips = [SATELLITE_IP, ANYCAST_IP]; + mockBulkLookup(ips, [SAMPLE_SATELLITE_DATA, SAMPLE_ANYCAST_DATA]); + const info = keyBy(await ipdata.bulkLookup(ips)); + expect(info[SATELLITE_IP].is_satellite).toBe(true); + expect(info[SATELLITE_IP].is_anycast).toBeUndefined(); + expect(info[SATELLITE_IP].threat.blocklists).toEqual([]); + expect(info[ANYCAST_IP].is_anycast).toBe(true); + expect(info[ANYCAST_IP].is_satellite).toBeUndefined(); + expect(info[ANYCAST_IP].threat.blocklists![1]).toMatchObject({ + type: 'open_resolvers', + days_seen: 1, + first_seen: '2026-11-02', + last_seen: '2026-11-02', + }); + }); + + it('should accept is_satellite and is_anycast in the fields array', async () => { + const ips = [SATELLITE_IP, ANYCAST_IP]; + const fields = ['ip', 'is_satellite', 'is_anycast']; + mockBulkLookup( + ips, + [ + { ip: SATELLITE_IP, is_satellite: true }, + { ip: ANYCAST_IP, is_anycast: true }, + ], + { params: { fields: fields.join(',') } }, + ); + const info = keyBy(await ipdata.bulkLookup(ips, fields)); + expect(info[SATELLITE_IP]).toHaveProperty('is_satellite', true); + expect(info[SATELLITE_IP].is_anycast).toBeUndefined(); + expect(info[ANYCAST_IP]).toHaveProperty('is_anycast', true); + expect(info[ANYCAST_IP].is_satellite).toBeUndefined(); + }); + }); }); diff --git a/src/ipdata.ts b/src/ipdata.ts index 616df1f..dae0455 100644 --- a/src/ipdata.ts +++ b/src/ipdata.ts @@ -11,6 +11,8 @@ const DEFAULT_IP = 'DEFAULT_IP'; const VALID_FIELDS = [ 'ip', 'is_eu', + 'is_satellite', + 'is_anycast', 'city', 'region', 'region_code', @@ -137,6 +139,10 @@ export interface LookupParams { export interface LookupResponse { ip: string; is_eu: boolean; + /** true for satellite ISP addresses. The API includes the key only when it is true and omits it otherwise. */ + is_satellite?: boolean; + /** true for anycast addresses. The API includes the key only when it is true and omits it otherwise. */ + is_anycast?: boolean; city?: string; region?: string; region_code?: string; @@ -194,7 +200,17 @@ export interface LookupResponse { is_bogon: boolean; is_icloud_relay?: boolean; is_datacenter?: boolean; - blocklists?: { name: string; site: string; type: string }[]; + blocklists?: { + name: string; + site: string; + type: string; + /** First day the address was seen on this list, as a YYYY-MM-DD string (UTC). Some feeds omit the dates. */ + first_seen?: string; + /** Most recent day the address was seen on this list, as a YYYY-MM-DD string (UTC). */ + last_seen?: string; + /** Number of days the address has been seen on this list. Omitted together with the dates. */ + days_seen?: number; + }[]; }; count: number; status: number;