From 300906933a4455dd8a14fdbe20a2e06fb9523f51 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 24 Jun 2026 19:56:43 +0000 Subject: [PATCH 01/10] Preserve underlying cause on DB error classes Give AddressNotFoundError, BadMethodCallError, InvalidDbBufferError, and ValueError an optional `cause` option and forward it to the Error constructor, matching the WebServiceError behavior. Reader.openBuffer previously threw `new InvalidDbBufferError(e)`, passing the underlying Error as the message and discarding its stack and cause. It now passes the error message and preserves the original error as `cause`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/errors.spec.ts | 37 ++++++++++++++++++++++++++++++++++++- src/errors.ts | 16 ++++++++-------- src/reader.spec.ts | 13 +++++++++++++ src/reader.ts | 6 +++--- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/errors.spec.ts b/src/errors.spec.ts index 183dc97d..c8d432f8 100644 --- a/src/errors.spec.ts +++ b/src/errors.spec.ts @@ -1,4 +1,39 @@ -import { WebServiceError } from './errors.js'; +import { + AddressNotFoundError, + BadMethodCallError, + InvalidDbBufferError, + ValueError, + WebServiceError, +} from './errors.js'; + +describe.each([ + ['AddressNotFoundError', AddressNotFoundError], + ['BadMethodCallError', BadMethodCallError], + ['InvalidDbBufferError', InvalidDbBufferError], + ['ValueError', ValueError], +] as const)('%s', (name, ErrorClass) => { + it('uses the message and sets the name', () => { + const err = new ErrorClass('boom'); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ErrorClass); + expect(err.message).toBe('boom'); + expect(err.name).toBe(name); + }); + + it('preserves the underlying cause when provided', () => { + const cause = new TypeError('underlying'); + const err = new ErrorClass('boom', { cause }); + + expect(err.cause).toBe(cause); + }); + + it('leaves cause undefined when not provided', () => { + const err = new ErrorClass('boom'); + + expect(err.cause).toBeUndefined(); + }); +}); describe('WebServiceError', () => { it('is an Error instance', () => { diff --git a/src/errors.ts b/src/errors.ts index 5dd50b39..bb2c38df 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -66,8 +66,8 @@ WebServiceError.prototype.name = 'WebServiceError'; * This generally means that the address was a private or reserved address. */ export class AddressNotFoundError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = this.constructor.name; } } @@ -77,8 +77,8 @@ export class AddressNotFoundError extends Error { * e.g. `reader.city` is used with a Country database */ export class BadMethodCallError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = this.constructor.name; } } @@ -87,8 +87,8 @@ export class BadMethodCallError extends Error { * This error is thrown if a database buffer is not a valid database */ export class InvalidDbBufferError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = this.constructor.name; } } @@ -97,8 +97,8 @@ export class InvalidDbBufferError extends Error { * This error is thrown if the IP address provided is not valid. */ export class ValueError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = this.constructor.name; } } diff --git a/src/reader.spec.ts b/src/reader.spec.ts index b5eeaddb..2a59e4c2 100644 --- a/src/reader.spec.ts +++ b/src/reader.spec.ts @@ -47,5 +47,18 @@ describe('Reader', () => { InvalidDbBufferError ); }); + + it('preserves the underlying error as the cause', () => { + expect.assertions(3); + + try { + Reader.openBuffer(Buffer.from('foo')); + } catch (e) { + expect(e).toBeInstanceOf(InvalidDbBufferError); + const err = e as InvalidDbBufferError; + expect(typeof err.message).toBe('string'); + expect(err.cause).toBeInstanceOf(Error); + } + }); }); }); diff --git a/src/reader.ts b/src/reader.ts index 997a11ba..8c68691e 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -34,9 +34,9 @@ export default class Reader { let reader; try { reader = new mmdb.Reader(buffer); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - throw new InvalidDbBufferError(e); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + throw new InvalidDbBufferError(error.message, { cause: error }); } return new ReaderModel(reader); From 34d4ed459e8e26671c129ceb1dc50e7aa1353136 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 24 Jun 2026 19:58:30 +0000 Subject: [PATCH 02/10] Type enterprise() as Enterprise rather than City The method constructs a models.Enterprise but was annotated as the wider models.City. Enterprise extends City and adds no fields today, so the two are structurally identical and the change is not yet observable, but the annotation now reflects the actual return type and will expose any Enterprise-specific fields added in the future. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/readerModel.spec.ts | 4 +++- src/readerModel.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/readerModel.spec.ts b/src/readerModel.spec.ts index a6fe58cf..4277b12f 100644 --- a/src/readerModel.spec.ts +++ b/src/readerModel.spec.ts @@ -3,6 +3,7 @@ import { BadMethodCallError, ValueError, } from './errors.js'; +import * as models from './models/index.js'; import Reader from './reader.js'; describe('ReaderModel', () => { @@ -600,7 +601,8 @@ describe('ReaderModel', () => { './test/data/test-data/GeoIP2-Enterprise-Test.mmdb' ); - const model = reader.enterprise('2.125.160.216'); + // enterprise() should be typed as Enterprise, not the wider City. + const model: models.Enterprise = reader.enterprise('2.125.160.216'); const expected = { city: { diff --git a/src/readerModel.ts b/src/readerModel.ts index 134f3519..287faf3d 100644 --- a/src/readerModel.ts +++ b/src/readerModel.ts @@ -150,7 +150,7 @@ export default class ReaderModel { * @throws {AddressNotFoundError} Throws an error when the IP address isn't found in the database * @throws {ValueError} Throws an error when the IP address isn't valid */ - public enterprise(ipAddress: string): models.City { + public enterprise(ipAddress: string): models.Enterprise { return this.modelFor( models.Enterprise, 'Enterprise', From 5f618e15c015182f186529a6d7803dd547cdfb84 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 24 Jun 2026 20:01:41 +0000 Subject: [PATCH 03/10] Type WebServiceError.code with an open ClientErrorCode union Replace the plain `string` type on `code` with `WebServiceErrorCode`, an open `ClientErrorCode | (string & {})` union. This offers autocompletion for the six client-generated codes while still accepting any code the web service may return. The `ClientErrorCode` and `WebServiceErrorCode` types are exported from the package. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/errors.ts | 6 +++--- src/index.ts | 6 +++++- src/types.ts | 36 +++++++++++++++++++++++++++++++++++- src/webServiceClient.ts | 37 +++++++++++++++++++++++++------------ 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index bb2c38df..c5dcdb3f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import { WebServiceClientError } from './types.js'; +import { WebServiceClientError, WebServiceErrorCode } from './types.js'; /* tslint:disable:max-classes-per-file */ @@ -15,7 +15,7 @@ export class WebServiceError extends Error implements WebServiceClientError { /** * The error code returned by the web service or generated by this client. */ - public readonly code: string; + public readonly code: WebServiceErrorCode; /** * A human-readable description of the error. This is an alias of `message`, * retained for backward compatibility. @@ -36,7 +36,7 @@ export class WebServiceError extends Error implements WebServiceClientError { constructor( properties: { - code: string; + code: WebServiceErrorCode; error: string; status?: number; url: string; diff --git a/src/index.ts b/src/index.ts index 174d1196..31b1c23d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,4 +6,8 @@ export { Reader, ReaderModel, WebServiceClient }; export * from './records.js'; export * from './models/index.js'; export * from './errors.js'; -export type { WebServiceClientError } from './types.js'; +export type { + ClientErrorCode, + WebServiceClientError, + WebServiceErrorCode, +} from './types.js'; diff --git a/src/types.ts b/src/types.ts index e28740cc..8d0d6524 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,10 +9,44 @@ export interface CityResponse extends mmdb.CityResponse { maxmind?: MaxMindRecord; } +/** + * The error codes that this client generates itself, as opposed to those + * returned by the web service. + */ +export type ClientErrorCode = + | 'FETCH_ERROR' + | 'HTTP_STATUS_CODE_ERROR' + | 'INVALID_RESPONSE_BODY' + | 'IP_ADDRESS_INVALID' + | 'NETWORK_TIMEOUT' + | 'SERVER_ERROR'; + +/** + * The `code` exposed on a {@link WebServiceError}. This is one of the + * client-generated {@link ClientErrorCode} values or any other string returned + * by the web service. The `& {}` keeps the union open to arbitrary strings + * while still offering autocompletion for the known client codes. + */ +export type WebServiceErrorCode = ClientErrorCode | (string & {}); + export interface WebServiceClientError { - code: string; + /** + * The error code returned by the web service or generated by this client. + */ + code: WebServiceErrorCode; + /** + * A human-readable description of the error. This is an alias of the standard + * `Error` `message`. + */ error: string; + /** + * The HTTP status code, when the error originated from an HTTP response. + * Absent for network-level errors. + */ status?: number; + /** + * The URL that was being requested when the error occurred. + */ url: string; /** * The underlying error that caused this one, when available (for example, diff --git a/src/webServiceClient.ts b/src/webServiceClient.ts index 139f3e5f..db19ce9a 100644 --- a/src/webServiceClient.ts +++ b/src/webServiceClient.ts @@ -2,6 +2,7 @@ import * as mmdb from 'maxmind'; import packageInfo from '../package.json' with { type: 'json' }; import { WebServiceError } from './errors.js'; import * as models from './models/index.js'; +import { ClientErrorCode } from './types.js'; /** Option for the WebServiceClient constructor */ interface Options { @@ -21,7 +22,22 @@ type servicePath = 'city' | 'country' | 'insights'; const invalidResponseBody = { code: 'INVALID_RESPONSE_BODY', error: 'Received an invalid or unparseable response body', -}; +} satisfies { code: ClientErrorCode; error: string }; + +// Builds a WebServiceError for a client-generated failure. Typing `code` as the +// closed `ClientErrorCode` (rather than the open `WebServiceErrorCode` the +// WebServiceError constructor accepts) makes a typo at a throw site a compile +// error and keeps the `ClientErrorCode` union in sync with what the client +// actually emits. +const clientError = ( + properties: { + code: ClientErrorCode; + error: string; + status?: number; + url: string; + }, + options?: { cause?: unknown } +): WebServiceError => new WebServiceError(properties, options); const isErrorBody = (data: unknown): data is { code: string; error: string } => typeof data === 'object' && @@ -125,7 +141,7 @@ export default class WebServiceClient { const url = `https://${this.host}${parsedPath}`; if (!mmdb.validate(ipAddress)) { - throw new WebServiceError({ + throw clientError({ code: 'IP_ADDRESS_INVALID', error: 'The IP address provided is invalid', url, @@ -151,7 +167,7 @@ export default class WebServiceClient { ? err : new Error(String(err)); if (error.name === 'TimeoutError') { - throw new WebServiceError( + throw clientError( { code: 'NETWORK_TIMEOUT', error: 'The request timed out', @@ -165,7 +181,7 @@ export default class WebServiceClient { // only log `code`/`error`, not just available via `cause`. const causeDetail = error.cause instanceof Error ? `: ${error.cause.message}` : ''; - throw new WebServiceError( + throw clientError( { code: 'FETCH_ERROR', error: `${error.name} - ${error.message}${causeDetail}`, @@ -183,10 +199,7 @@ export default class WebServiceClient { try { data = await response.json(); } catch (err) { - throw new WebServiceError( - { ...invalidResponseBody, url }, - { cause: err } - ); + throw clientError({ ...invalidResponseBody, url }, { cause: err }); } return new modelClass(data); @@ -199,7 +212,7 @@ export default class WebServiceClient { const status = response.status; if (status && status >= 500 && status < 600) { - return new WebServiceError({ + return clientError({ code: 'SERVER_ERROR', error: `Received a server error with HTTP status code: ${status}`, status, @@ -208,7 +221,7 @@ export default class WebServiceClient { } if (status && (status < 400 || status >= 600)) { - return new WebServiceError({ + return clientError({ code: 'HTTP_STATUS_CODE_ERROR', error: `Received an unexpected HTTP status code: ${status}`, status, @@ -220,14 +233,14 @@ export default class WebServiceClient { try { data = await response.json(); } catch (err) { - return new WebServiceError( + return clientError( { ...invalidResponseBody, status, url }, { cause: err } ); } if (!isErrorBody(data)) { - return new WebServiceError({ ...invalidResponseBody, status, url }); + return clientError({ ...invalidResponseBody, status, url }); } return new WebServiceError({ From ae733f1d68017a5a92e937746f64f66204b27f93 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 24 Jun 2026 21:18:12 +0000 Subject: [PATCH 04/10] Fix inaccurate Names doc comment The `Names` interface comment referred to "the locales list passed to the `WebServiceClient` constructor", but no such argument exists in this library; the `names` object always contains every available locale. Reword the comment to describe the actual behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/records.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/records.ts b/src/records.ts index 28f6e084..8cfdeadb 100644 --- a/src/records.ts +++ b/src/records.ts @@ -1,9 +1,8 @@ import { ConnectionType } from './types.js'; /** - * The name of the place based on the locales list passed to the - * `WebServiceClient` constructor. Don't use any of these names as a database or - * dictionary key. Use the ID or relevant code instead. + * The name of the place in each available locale. Don't use any of these names + * as a database or dictionary key. Use the ID or relevant code instead. */ export interface Names { readonly de?: string; From 6a718a608d389771c874382d0a5f67f0f64fe404 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 24 Jun 2026 21:19:20 +0000 Subject: [PATCH 05/10] Add an injectable fetcher to the web service client Add an optional `fetcher` to the WebServiceClient options so a custom `fetch` implementation can be supplied (useful for proxies, custom dispatchers, and testing); it defaults to the global `fetch`. Also record the cause-preservation and code-union changes from earlier in this branch in the CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++++++++++++++ README.md | 4 +++- src/webServiceClient.spec.ts | 24 ++++++++++++++++++++++++ src/webServiceClient.ts | 11 ++++++++++- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c053540..80112862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ CHANGELOG property (for example, the network error behind a `FETCH_ERROR`). The `WebServiceError` class and the `WebServiceClientError` type are now exported from the package. +* The `code` property on `WebServiceError` and the `WebServiceClientError` + interface is now typed as `WebServiceErrorCode` + (`ClientErrorCode | (string & {})`) instead of `string`, providing + autocompletion for the client-generated codes while still accepting any + code returned by the web service. The `ClientErrorCode` and + `WebServiceErrorCode` types are exported from the package. +* The `AddressNotFoundError`, `BadMethodCallError`, `InvalidDbBufferError`, + and `ValueError` classes now accept an optional `cause` and forward it to + `Error`. `Reader.openBuffer()` now preserves the underlying parsing error as + the `cause` of the `InvalidDbBufferError` it throws. +* Added a `fetcher` option to the `WebServiceClient` options, allowing a + custom `fetch` implementation to be supplied (for example, to route requests + through a custom dispatcher or proxy, or for testing). It defaults to the + global `fetch`. 6.3.4 (2025-11-25) ------------------ diff --git a/README.md b/README.md index 7578821c..d834ecdd 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ your MaxMind `accountID` and `licenseKey` as parameters. The third argument is an object holding additional option. The `timeout` option defaults to `3000`. The `host` option defaults to `geoip.maxmind.com`. Set `host` to `geolite.info` to use the GeoLite web service instead of GeoIP. Set `host` to -`sandbox.maxmind.com` to use the Sandbox environment. +`sandbox.maxmind.com` to use the Sandbox environment. The `fetcher` option lets +you supply a custom `fetch` implementation (for example, to route requests +through a proxy or custom dispatcher); it defaults to the global `fetch`. You may then call the function corresponding to a specific end point, passing it the IP address you want to lookup. diff --git a/src/webServiceClient.spec.ts b/src/webServiceClient.spec.ts index c7d4369c..46c4b2f5 100644 --- a/src/webServiceClient.spec.ts +++ b/src/webServiceClient.spec.ts @@ -56,6 +56,30 @@ describe('WebServiceClient', () => { const client = new Client(auth.user, auth.pass); + describe('fetcher option', () => { + const ip = '8.8.8.8'; + + it('uses an injected fetcher instead of the global fetch', async () => { + const calls: { init?: RequestInit; url: RequestInfo | URL }[] = []; + const fetcher = ((url: RequestInfo | URL, init?: RequestInit) => { + calls.push({ init, url }); + return Promise.resolve( + new Response(JSON.stringify(geoip2Fixture), { + headers: { 'content-type': 'application/json' }, + status: 200, + }) + ); + }) as typeof fetch; + const localClient = new Client(auth.user, auth.pass, { fetcher }); + + const got = await localClient.city(ip); + + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe(`${baseUrl}${fullPath('city', ip)}`); + expect(got.country!.isoCode).toEqual('US'); + }); + }); + describe('city()', () => { const testFixture = { city: geoip2Fixture.city, diff --git a/src/webServiceClient.ts b/src/webServiceClient.ts index db19ce9a..d554c387 100644 --- a/src/webServiceClient.ts +++ b/src/webServiceClient.ts @@ -6,6 +6,10 @@ import { ClientErrorCode } from './types.js'; /** Option for the WebServiceClient constructor */ interface Options { + /** A custom `fetch` implementation to use for requests. Defaults to the + * global `fetch`. This is primarily useful for testing or for routing + * requests through a custom dispatcher or proxy. */ + fetcher?: typeof fetch; /** The host to use when connecting to the web service. The default is * "geoip.maxmind.com". To call the GeoLite web service instead of the * GeoIP web service, set this to "geolite.info". To call the Sandbox @@ -53,6 +57,7 @@ export default class WebServiceClient { private licenseKey: string; private timeout = 3000; private host = 'geoip.maxmind.com'; + private fetcher: typeof fetch = fetch; /** * Instantiates a WebServiceClient @@ -79,6 +84,10 @@ export default class WebServiceClient { } if (typeof options === 'object') { + if (options.fetcher !== undefined) { + this.fetcher = options.fetcher; + } + if (options.host !== undefined) { this.host = options.host; } @@ -160,7 +169,7 @@ export default class WebServiceClient { let response; try { - response = await fetch(url, options); + response = await this.fetcher(url, options); } catch (err) { const error = err instanceof Error || err instanceof DOMException From c4aee2f8d026391ff4115cf4c7c861319d86f22a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 24 Jun 2026 21:29:45 +0000 Subject: [PATCH 06/10] Replace nock with the injectable fetcher in web service tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebServiceClient tests now drive requests through an injected `fetcher` that returns canned `Response`s and records the requests made, instead of mocking at the HTTP layer with nock. Request shape (method, path, auth header) is asserted directly, error/status cases return the corresponding `Response`, and the timeout case rejects when the request signal aborts — exercising the real timeout signal deterministically rather than via a wall-clock delay. This removes the `nock` dev dependency (which was on a beta release). Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 97 ----------------- package.json | 1 - src/webServiceClient.spec.ts | 196 +++++++++++++++++++---------------- 3 files changed, 109 insertions(+), 185 deletions(-) diff --git a/package-lock.json b/package-lock.json index 193b240a..672cab69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "eslint-config-prettier": "^10.0.1", "globals": "^17.0.0", "jest": "^30.0.0", - "nock": "^14.0.0-beta.15", "prettier": "^3.0.0", "ts-jest": "^29.4.0", "typedoc": "^0.28.1", @@ -1309,24 +1308,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mswjs/interceptors": { - "version": "0.41.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", - "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -1346,31 +1327,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3598,13 +3554,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4405,13 +4354,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4718,21 +4660,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nock": { - "version": "14.0.15", - "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.15.tgz", - "integrity": "sha512-S0a47C9pLvcYx/Ugf0H30BVBEcUgMMBDk9VJIDlJ8XGrfH2QDUD4Tgdp45qDIiHttokBG+IbsOtsvIjGR/j3bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@mswjs/interceptors": "^0.41.0", - "json-stringify-safe": "^5.0.1", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">=18.20.0 <20 || >=20.12.1" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4814,13 +4741,6 @@ "node": ">= 0.8.0" } }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5097,16 +5017,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5300,13 +5210,6 @@ "node": ">=8" } }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/package.json b/package.json index 22c6fefd..01730e89 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "eslint-config-prettier": "^10.0.1", "globals": "^17.0.0", "jest": "^30.0.0", - "nock": "^14.0.0-beta.15", "prettier": "^3.0.0", "ts-jest": "^29.4.0", "typedoc": "^0.28.1", diff --git a/src/webServiceClient.spec.ts b/src/webServiceClient.spec.ts index 46c4b2f5..2a53dfeb 100644 --- a/src/webServiceClient.spec.ts +++ b/src/webServiceClient.spec.ts @@ -1,11 +1,9 @@ -import nock from 'nock'; import geoip2Fixture from '../fixtures/geoip2.json' with { type: 'json' }; import { WebServiceError } from './errors.js'; import Client from './webServiceClient.js'; import * as models from './models/index.js'; const baseUrl = 'https://geoip.maxmind.com'; -const nockInstance = nock(baseUrl); const fullPath = (path: string, ipAddress: string) => `/geoip/v2.1/${path}/${ipAddress}`; const auth = { @@ -13,6 +11,35 @@ const auth = { user: '123', }; +interface CapturedRequest { + init?: RequestInit; + url: RequestInfo | URL; +} + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + status, + }); + +// Builds a client backed by an injected fetcher driven by `handler`, and +// captures the requests the client makes so they can be asserted on. This +// replaces HTTP-level mocking: the handler returns the `Response` (or rejects) +// for each request. +const clientWith = ( + handler: (request: CapturedRequest) => Response | Promise, + options: { host?: string; timeout?: number } = {} +) => { + const requests: CapturedRequest[] = []; + const fetcher = (async (url: RequestInfo | URL, init?: RequestInit) => { + const request = { init, url }; + requests.push(request); + return handler(request); + }) as typeof fetch; + const client = new Client(auth.user, auth.pass, { fetcher, ...options }); + return { client, requests }; +}; + const expectError = async ( promise: Promise, expected: { @@ -49,33 +76,23 @@ const expectError = async ( }; describe('WebServiceClient', () => { - afterEach(() => { - nock.cleanAll(); - nock.abortPendingRequests(); - }); - - const client = new Client(auth.user, auth.pass); - - describe('fetcher option', () => { - const ip = '8.8.8.8'; - - it('uses an injected fetcher instead of the global fetch', async () => { - const calls: { init?: RequestInit; url: RequestInfo | URL }[] = []; - const fetcher = ((url: RequestInfo | URL, init?: RequestInit) => { - calls.push({ init, url }); - return Promise.resolve( - new Response(JSON.stringify(geoip2Fixture), { - headers: { 'content-type': 'application/json' }, - status: 200, - }) - ); - }) as typeof fetch; - const localClient = new Client(auth.user, auth.pass, { fetcher }); + describe('request', () => { + it('uses the injected fetcher with the correct method, path, and auth', async () => { + const ip = '8.8.8.8'; + const { client, requests } = clientWith(() => + jsonResponse(200, geoip2Fixture) + ); - const got = await localClient.city(ip); + const got = await client.city(ip); - expect(calls).toHaveLength(1); - expect(calls[0].url).toBe(`${baseUrl}${fullPath('city', ip)}`); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(`${baseUrl}${fullPath('city', ip)}`); + expect(requests[0].init!.method).toBe('GET'); + const headers = requests[0].init!.headers as Record; + expect(headers.Authorization).toBe( + 'Basic ' + btoa(`${auth.user}:${auth.pass}`) + ); + expect(headers['User-Agent']).toMatch(/^GeoIP2-node\//); expect(got.country!.isoCode).toEqual('US'); }); }); @@ -96,15 +113,16 @@ describe('WebServiceClient', () => { it('returns a city class', async () => { const ip = '8.8.8.8'; - expect.assertions(96); + expect.assertions(97); - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(200, testFixture); + const { client, requests } = clientWith(() => + jsonResponse(200, testFixture) + ); const got: models.City = await client.city(ip); + expect(requests[0].url).toBe(`${baseUrl}${fullPath('city', ip)}`); + expect(got.city!.confidence).toEqual(25); expect(got.city!.geonameId).toEqual(54321); expect(got.city!.names.de).toEqual('Los Angeles'); @@ -227,15 +245,16 @@ describe('WebServiceClient', () => { it('returns a country class', async () => { const ip = '8.8.8.8'; - expect.assertions(64); + expect.assertions(65); - nockInstance - .get(fullPath('country', ip)) - .basicAuth(auth) - .reply(200, testFixture); + const { client, requests } = clientWith(() => + jsonResponse(200, testFixture) + ); const got: models.Country = await client.country(ip); + expect(requests[0].url).toBe(`${baseUrl}${fullPath('country', ip)}`); + expect(got.continent!.code).toEqual('NA'); expect(got.continent!.geonameId).toEqual(123456); expect(got.continent!.names.de).toEqual('Nordamerika'); @@ -327,15 +346,16 @@ describe('WebServiceClient', () => { it('returns an insight class', async () => { const ip = '8.8.8.8'; - expect.assertions(106); + expect.assertions(107); - nockInstance - .get(fullPath('insights', ip)) - .basicAuth(auth) - .reply(200, testFixture); + const { client, requests } = clientWith(() => + jsonResponse(200, testFixture) + ); const got: models.Insights = await client.insights(ip); + expect(requests[0].url).toBe(`${baseUrl}${fullPath('insights', ip)}`); + expect(got.city!.confidence).toEqual(25); expect(got.city!.geonameId).toEqual(54321); expect(got.city!.names.de).toEqual('Los Angeles'); @@ -461,20 +481,25 @@ describe('WebServiceClient', () => { it('should time out if the request takes too long', async () => { const ip = '8.8.8.8'; - const client = new Client(auth.user, auth.pass, { - timeout: 10, - }); - - nock('https://geoip.maxmind.com') - .get(`/geoip/v2.1/city/${ip}`) - .delay(200) // Delay the response to trigger the timeout - .reply(200, geoip2Fixture); + // The handler never resolves on its own; it rejects only when the + // request signal aborts, which the client's timeout triggers. This + // exercises the real timeout signal and the NETWORK_TIMEOUT mapping + // without depending on wall-clock response delays. + const { client } = clientWith( + (request) => + new Promise((_resolve, reject) => { + request.init?.signal?.addEventListener('abort', () => + reject((request.init!.signal as AbortSignal).reason) + ); + }), + { timeout: 10 } + ); // The underlying abort/timeout error is preserved as the cause. await expectError(client.city(ip), { code: 'NETWORK_TIMEOUT', error: 'The request timed out', - url: `https://geoip.maxmind.com/geoip/v2.1/city/${ip}`, + url: `${baseUrl}${fullPath('city', ip)}`, cause: 'defined', }); }); @@ -483,6 +508,9 @@ describe('WebServiceClient', () => { describe('error handling', () => { it('rejects if the IP address is invalid', async () => { const ip = 'foo'; + const { client, requests } = clientWith(() => + jsonResponse(200, geoip2Fixture) + ); await expectError(client.city(ip), { code: 'IP_ADDRESS_INVALID', @@ -490,12 +518,13 @@ describe('WebServiceClient', () => { url: baseUrl + fullPath('city', ip), cause: 'undefined', }); + // The request is rejected before any fetch is attempted. + expect(requests).toHaveLength(0); }); it('handles 5xx level errors', async () => { const ip = '8.8.8.8'; - - nockInstance.get(fullPath('city', ip)).basicAuth(auth).reply(500); + const { client } = clientWith(() => new Response(null, { status: 500 })); await expectError(client.city(ip), { code: 'SERVER_ERROR', @@ -508,8 +537,7 @@ describe('WebServiceClient', () => { it('handles 3xx level errors', async () => { const ip = '8.8.8.8'; - - nockInstance.get(fullPath('city', ip)).basicAuth(auth).reply(300); + const { client } = clientWith(() => new Response(null, { status: 300 })); await expectError(client.city(ip), { code: 'HTTP_STATUS_CODE_ERROR', @@ -522,11 +550,7 @@ describe('WebServiceClient', () => { it('handles errors with unknown payload', async () => { const ip = '8.8.8.8'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(401, { foo: 'bar' }); + const { client } = clientWith(() => jsonResponse(401, { foo: 'bar' })); await expectError(client.city(ip), { code: 'INVALID_RESPONSE_BODY', @@ -546,11 +570,7 @@ describe('WebServiceClient', () => { 'treats $description as an invalid response body', async ({ payload }) => { const ip = '8.8.8.8'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(400, payload); + const { client } = clientWith(() => jsonResponse(400, payload)); await expectError(client.city(ip), { code: 'INVALID_RESPONSE_BODY', @@ -564,8 +584,7 @@ describe('WebServiceClient', () => { it('handles 200s with bad json', async () => { const ip = '8.8.8.8'; - - nockInstance.get(fullPath('city', ip)).basicAuth(auth).reply(200, 'foo'); + const { client } = clientWith(() => new Response('foo', { status: 200 })); const err = await expectError(client.city(ip), { code: 'INVALID_RESPONSE_BODY', @@ -581,11 +600,9 @@ describe('WebServiceClient', () => { it('preserves the cause when an error response body is not JSON', async () => { const ip = '8.8.8.8'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(401, 'this is not json'); + const { client } = clientWith( + () => new Response('this is not json', { status: 401 }) + ); const err = await expectError(client.city(ip), { code: 'INVALID_RESPONSE_BODY', @@ -601,11 +618,7 @@ describe('WebServiceClient', () => { it('handles general network errors', async () => { const ip = '8.8.8.8'; const error = 'Network Error'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .replyWithError(error); + const { client } = clientWith(() => Promise.reject(new Error(error))); const err = await expectError(client.city(ip), { code: 'FETCH_ERROR', @@ -617,16 +630,27 @@ describe('WebServiceClient', () => { expect((err.cause as Error).message).toBe(error); }); + it('wraps a non-Error fetcher rejection', async () => { + const ip = '8.8.8.8'; + // A custom fetcher (e.g. a proxy/dispatcher) may reject with a non-Error + // value; it should still be normalized into a FETCH_ERROR. + const { client } = clientWith(() => Promise.reject('boom')); + + const err = await expectError(client.city(ip), { + code: 'FETCH_ERROR', + error: 'Error - boom', + url: baseUrl + fullPath('city', ip), + cause: 'defined', + }); + expect((err.cause as Error).message).toBe('boom'); + }); + it('includes the underlying cause in the FETCH_ERROR message', async () => { const ip = '8.8.8.8'; const fetchError = Object.assign(new TypeError('fetch failed'), { cause: new Error('connect ECONNREFUSED 1.2.3.4:443'), }); - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .replyWithError(fetchError); + const { client } = clientWith(() => Promise.reject(fetchError)); const err = await expectError(client.city(ip), { code: 'FETCH_ERROR', @@ -651,11 +675,9 @@ describe('WebServiceClient', () => { ${403} | ${'PERMISSION_REQUIRED'} | ${'permission required'} `('handles $code error', async ({ code, error, status }) => { const ip = '8.8.8.8'; - - nockInstance - .get(fullPath('city', ip)) - .basicAuth(auth) - .reply(status, { code, error }); + const { client } = clientWith(() => + jsonResponse(status, { code, error }) + ); await expectError(client.city(ip), { code, From e278c6d1bfd0b8170dd1800db242c6f2c84411fb Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 25 Jun 2026 20:43:22 +0000 Subject: [PATCH 07/10] Guard the WebServiceClient constructor against null options `typeof null === 'object'`, so `new Client(id, key, null)` from a JS caller entered the options-object branch and threw a TypeError on `options.fetcher`. Treat null like undefined. The buggy branch predates this PR, so this is a standalone fix rather than a fixup. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/webServiceClient.spec.ts | 19 +++++++++++++++++++ src/webServiceClient.ts | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/webServiceClient.spec.ts b/src/webServiceClient.spec.ts index 2a53dfeb..f3393c04 100644 --- a/src/webServiceClient.spec.ts +++ b/src/webServiceClient.spec.ts @@ -727,6 +727,25 @@ describe('WebServiceClient with empty options', () => { }); }); +describe('WebServiceClient with null options', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let client: any; + + beforeAll(() => { + // A JS caller may pass an explicit null; typeof null === 'object', so the + // constructor must not treat it as an options object. + client = new Client(auth.user, auth.pass, null as unknown as undefined); + }); + + it('sets host', () => { + expect(client.host).toEqual('geoip.maxmind.com'); + }); + + it('sets timeout', () => { + expect(client.timeout).toEqual(3000); + }); +}); + describe('WebServiceClient with timeout', () => { it('sets timeout', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/webServiceClient.ts b/src/webServiceClient.ts index d554c387..1b821160 100644 --- a/src/webServiceClient.ts +++ b/src/webServiceClient.ts @@ -79,7 +79,9 @@ export default class WebServiceClient { ) { this.accountID = accountID; this.licenseKey = licenseKey; - if (options === undefined) { + // `typeof null === 'object'`, so guard null alongside undefined to avoid + // dereferencing it in the options branch below. + if (options === undefined || options === null) { return; } From c7ab9da46695bbb047aa5b8859586cb6b8a89fc1 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 25 Jun 2026 20:50:33 +0000 Subject: [PATCH 08/10] Return non-object input unchanged from camelcaseKeys Guard against primitive input (e.g. from a JS caller) the way the minfraud-api-node port does: a non-object now passes through unchanged rather than being turned into an index-keyed object by Object.entries. Keeps the two libraries' shared utility consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils.spec.ts | 8 ++++++++ src/utils.ts | 10 ++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 9f19f04a..c9334d6a 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -119,6 +119,14 @@ describe('src/Utils', () => { }); }); describe('camelcaseKeys()', () => { + it('returns non-object input unchanged', () => { + expect(camelcaseKeys('foo')).toBe('foo'); + expect(camelcaseKeys(42)).toBe(42); + expect(camelcaseKeys(true)).toBe(true); + expect(camelcaseKeys(null)).toBeNull(); + expect(camelcaseKeys(undefined)).toBeUndefined(); + }); + it("converts an object's keys from snake_case to camelCase", () => { const cases = [ { input: { snake_case: 1 }, expected: { snakeCase: 1 } }, diff --git a/src/utils.ts b/src/utils.ts index bc0527a7..9fe370f0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -194,16 +194,18 @@ const processArray = (arr: Array): unknown[] => * @param input - object with some snake_case keys * @returns - object with camelCase keys */ -export function camelcaseKeys( - input: Record | unknown[] -): Record | unknown[] { +export function camelcaseKeys(input: unknown): unknown { if (Array.isArray(input)) { return processArray(input); } + // Leave primitives (and null/undefined) untouched. + if (!isObject(input)) { + return input; + } const output: Record = {}; - for (const [key, value] of Object.entries(input)) { + for (const [key, value] of Object.entries(input as Record)) { if (Array.isArray(value)) { output[snakeToCamelCase(key)] = processArray(value); } else if (isObject(value)) { From 9266176442dd444d5be8e550212735017cc135ff Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 25 Jun 2026 21:28:59 +0000 Subject: [PATCH 09/10] Export the WebServiceClientOptions type Rename the constructor's `Options` interface to `WebServiceClientOptions` and export it so consumers can name the options object. Keeps the public surface consistent with minfraud-api-node. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 1 + src/webServiceClient.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 31b1c23d..dd0ecbe0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,4 @@ export type { WebServiceClientError, WebServiceErrorCode, } from './types.js'; +export type { WebServiceClientOptions } from './webServiceClient.js'; diff --git a/src/webServiceClient.ts b/src/webServiceClient.ts index 1b821160..efec80d6 100644 --- a/src/webServiceClient.ts +++ b/src/webServiceClient.ts @@ -4,8 +4,8 @@ import { WebServiceError } from './errors.js'; import * as models from './models/index.js'; import { ClientErrorCode } from './types.js'; -/** Option for the WebServiceClient constructor */ -interface Options { +/** Options for the WebServiceClient constructor */ +export interface WebServiceClientOptions { /** A custom `fetch` implementation to use for requests. Defaults to the * global `fetch`. This is primarily useful for testing or for routing * requests through a custom dispatcher or proxy. */ @@ -75,7 +75,7 @@ export default class WebServiceClient { licenseKey: string, // We support a number, which will be treated as the timeout for historical // reasons. - options?: Options | number + options?: WebServiceClientOptions | number ) { this.accountID = accountID; this.licenseKey = licenseKey; From 4ce7d11a94384f521292021f104e1b56ea579f0c Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 26 Jun 2026 20:11:53 +0000 Subject: [PATCH 10/10] Mark the 7.0.0 CHANGELOG section as unreleased Use the `7.0.0 (unreleased)` heading until release time, per the changelog convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80112862..cdceb918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ CHANGELOG ========= -7.0.0 +7.0.0 (unreleased) ------------------ * **Breaking** Dropped support for Node.js 18 and 20. Node.js 22 or greater is now