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..a1c2ef7 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,11 +141,61 @@ 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`: -`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 }` @@ -152,12 +203,24 @@ 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: ```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..b737cea 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,202 @@ 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), +// 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(); +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}`; +} + +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' } }); } -const mockFetch = jest.fn() as jest.Mock; -global.fetch = mockFetch; +afterEach(() => { + try { + mockAgent.assertNoPendingInterceptors(); + } finally { + mockPool.cleanMocks(); + } +}); + +afterAll(async () => { + await mockAgent.close(); +}); function keyBy(items: LookupResponse[]): Record { return items.reduce>((acc, item) => { @@ -121,38 +312,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 +561,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 +576,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 +591,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 +613,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 +631,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 +647,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 +655,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 +664,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 +701,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,22 +710,95 @@ 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'); }); }); + + 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()', () => { - 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 +813,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 +823,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 +835,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 +860,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); @@ -402,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 f6c4637..dae0455 100644 --- a/src/ipdata.ts +++ b/src/ipdata.ts @@ -1,12 +1,18 @@ 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', 'is_eu', + 'is_satellite', + 'is_anycast', 'city', 'region', 'region_code', @@ -64,11 +70,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; @@ -78,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; @@ -135,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; @@ -145,8 +220,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 +232,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 +330,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 +381,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), });