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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ jobs:
node-version: ${{ matrix.version }}
- run: npm ci
- run: npm test
env:
IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }}
- run: npm run build
- run: cd test-app && npm install && node index.js
env:
Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ You'll need an IPinfo API access token, which you can get by signing up for a fr

The free plan is limited to 50,000 requests per month, and doesn't include some of the data fields such as IP type and company data. To enable all the data fields and additional request volumes see [https://ipinfo.io/pricing](https://ipinfo.io/pricing).

⚠️ Note: This library does not currently support our newest free API https://ipinfo.io/lite. If you’d like to use IPinfo Lite, you can call the [endpoint directly](https://ipinfo.io/developers/lite-api) using your preferred HTTP client. Developers are also welcome to contribute support for Lite by submitting a pull request.
The library also supports the Lite API, see the [Lite API section](#lite-api) for more info.

### Installation

Expand Down Expand Up @@ -57,7 +57,7 @@ const ipinfo = await ipinfoWrapper.lookupIp("1.1.1.1");

<details><summary>Standalone example</summary>

1. Create `ipinfo.js` with the following code, then replace `MY_TOKEN` with
1. Create `ipinfo.js` with the following code, then replace `MY_TOKEN` with
[your token](https://ipinfo.io/account/token).

```typescript
Expand All @@ -79,7 +79,7 @@ node ipinfo.js
// ...
```

3. Run `ipinfo.js` with an IP to lookup, like `2.2.2.2` `8.8.8.8` or
3. Run `ipinfo.js` with an IP to lookup, like `2.2.2.2` `8.8.8.8` or
[your IP](https://ipinfo.io/what-is-my-ip).

```shell
Expand All @@ -95,7 +95,7 @@ node ipinfo.js 2.2.2.2

Each `lookup` method will throw an error when the lookup does not complete
successfully. A program that performs a lookup should catch errors unless it is
desirable for the error to bubble up. For example, if your program is performing
desirable for the error to bubble up. For example, if your program is performing
a lookup to find the country code of an IP you can return "N/A" when catching an
error.

Expand All @@ -106,6 +106,23 @@ const countryCode = ipinfoWrapper
.catch((error) => "N/A");
```

### Lite API

The library gives the possibility to use the [Lite API](https://ipinfo.io/developers/lite-api) too, authentication with your token is still required.

The returned details are slightly different from the Core API.

```typescript
import IPinfoLiteWrapper from "node-ipinfo";

const ipinfoWrapper = new IPinfoLiteWrapper("MY_TOKEN");
const ipinfo = await ipinfoWrapper.lookupIp("8.8.8.8");
console.log(ipinfo.countryCode)
// US
console.log(ipinfo.country)
// United States
```

### Caching

This library uses an LRU cache (deletes the least-recently-used items).
Expand Down Expand Up @@ -224,7 +241,7 @@ const { IPinfoWrapper } = require("node-ipinfo");

const ipinfoWrapper = new IPinfoWrapper("MY_TOKEN");

const ips = ["1.1.1.1", "8.8.8.8", "1.2.3.4"];
const ips = ["1.1.1.1", "8.8.8.8", "1.2.3.4"];
ipinfoWrapper.getMap(ips).then(response => {
console.log(response);
});
Expand All @@ -239,7 +256,7 @@ const { IPinfoWrapper } = require("node-ipinfo");

const ipinfoWrapper = new IPinfoWrapper("MY_TOKEN");

const ips = ["1.1.1.1", "8.8.8.8", "1.2.3.4/country"];
const ips = ["1.1.1.1", "8.8.8.8", "1.2.3.4/country"];

ipinfoWrapper
.getBatch(ips)
Expand Down
65 changes: 65 additions & 0 deletions __tests__/ipinfoLiteWrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as dotenv from "dotenv";
import { IPBogon, IPinfoLite } from "../src/common";
import IPinfoLiteWrapper from "../src/ipinfoLiteWrapper";

const testIfTokenIsSet = process.env.IPINFO_TOKEN ? test : test.skip;

beforeAll(() => {
dotenv.config();
});

describe("IPinfoLiteWrapper", () => {
testIfTokenIsSet("lookupIp", async () => {
const ipinfoWrapper = new IPinfoLiteWrapper(process.env.IPINFO_TOKEN!);

// test multiple times for cache.
for (let i = 0; i < 5; i++) {
const data = (await ipinfoWrapper.lookupIp(
"8.8.8.8"
)) as IPinfoLite;
expect(data.ip).toEqual("8.8.8.8");
expect(data.asn).toEqual("AS15169");
expect(data.asName).toEqual("Google LLC");
expect(data.asDomain).toEqual("google.com");
expect(data.countryCode).toEqual("US");
expect(data.country).toEqual("United States");
expect(data.continentCode).toEqual("NA");
expect(data.continent).toEqual("North America");
expect(data.isEU).toEqual(false);
}
});

testIfTokenIsSet("isBogon", async () => {
const ipinfoWrapper = new IPinfoLiteWrapper(process.env.IPINFO_TOKEN!);

const data = (await ipinfoWrapper.lookupIp("198.51.100.1")) as IPBogon;
expect(data.ip).toEqual("198.51.100.1");
expect(data.bogon).toEqual(true);
});

test("Error is thrown for invalid token", async () => {
const ipinfo = new IPinfoLiteWrapper("invalid-token");
await expect(ipinfo.lookupIp("1.2.3.4")).rejects.toThrow();
});

test("Error is thrown when response cannot be parsed as JSON", async () => {
const baseUrlWithUnparseableResponse = "https://ipinfo.io/developers#";

const ipinfo = new IPinfoLiteWrapper(
"token",
undefined,
undefined,
undefined,
baseUrlWithUnparseableResponse
);

await expect(ipinfo.lookupIp("1.2.3.4")).rejects.toThrow();

const result = await ipinfo
.lookupIp("1.2.3.4")
.then((_) => "parseable")
.catch((_) => "unparseable");

expect(result).toEqual("unparseable");
});
});
18 changes: 18 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const HOST: string = "ipinfo.io";
export const HOST_LITE: string = "api.ipinfo.io/lite";

// cache version
export const CACHE_VSN: string = "1";
Expand Down Expand Up @@ -98,6 +99,23 @@ export interface IPinfo {
domains: Domains;
}

export interface IPBogon {
ip: string;
bogon: boolean;
}

export interface IPinfoLite {
ip: string;
asn: string;
asName: string;
asDomain: string;
countryCode: string;
country: string;
continentCode: string;
continent: string;
isEU: boolean;
}

export interface Prefix {
netblock: string;
id: string;
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import IPinfoWrapper from "./ipinfoWrapper";
import IPinfoLiteWrapper from "./ipinfoLiteWrapper";
import Cache from "./cache/cache";
import LruCache from "./cache/lruCache";
import ApiLimitError from "./errors/apiLimitError";

export { Options } from "lru-cache";

export { Cache, LruCache, IPinfoWrapper, ApiLimitError };
export { Cache, LruCache, IPinfoWrapper, IPinfoLiteWrapper, ApiLimitError };
export {
Asn,
Company,
Expand Down
167 changes: 167 additions & 0 deletions src/ipinfoLiteWrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import fetch from "node-fetch";
import type { RequestInit, Response } from "node-fetch";
import { defaultEuCountries } from "../config/utils";
import Cache from "./cache/cache";
import LruCache from "./cache/lruCache";
import ApiLimitError from "./errors/apiLimitError";
import { isInSubnet } from "subnet-check";
import {
REQUEST_TIMEOUT_DEFAULT,
CACHE_VSN,
HOST_LITE,
BOGON_NETWORKS,
IPinfoLite,
IPBogon
} from "./common";
import VERSION from "./version";

const clientUserAgent = `IPinfoClient/nodejs/${VERSION}`;

export default class IPinfoLiteWrapper {
private token: string;
private baseUrl: string;
private euCountries: Array<string>;
private cache: Cache;
private timeout: number;

/**
* Creates IPinfoWrapper object to communicate with the [IPinfo](https://ipinfo.io/) API.
*
* @param token Token string provided by IPinfo for registered user.
* @param cache An implementation of IPCache interface. If it is not provided
* then LruCache is used as default.
* @param timeout Timeout in milliseconds that controls the timeout of requests.
* It defaults to 5000 i.e. 5 seconds. A timeout of 0 disables the timeout feature.
* @param i18nData Internationalization data for customizing countries-related information.
* @param i18nData.countries Custom countries data. If not provided, default countries data will be used.
* @param i18nData.countriesFlags Custom countries flags data. If not provided, default countries flags data will be used.
* @param i18nData.countriesCurrencies Custom countries currencies data. If not provided, default countries currencies data will be used.
* @param i18nData.continents Custom continents data. If not provided, default continents data will be used.
* @param i18nData.euCountries Custom EU countries data. If not provided or an empty array, default EU countries data will be used.
*/
constructor(
token: string,
cache?: Cache,
timeout?: number,
i18nData?: {
euCountries?: Array<string>;
},
baseUrl?: string
) {
this.token = token;
this.euCountries =
i18nData?.euCountries && i18nData?.euCountries.length !== 0
? i18nData.euCountries
: defaultEuCountries;
this.cache = cache ? cache : new LruCache();
this.timeout =
timeout === null || timeout === undefined
? REQUEST_TIMEOUT_DEFAULT
: timeout;
this.baseUrl = baseUrl || `https://${HOST_LITE}`;
}

public static cacheKey(k: string) {
return `${k}:${CACHE_VSN}`;
}

public async fetchApi(
path: string,
init: RequestInit = {}
): Promise<Response> {
const headers = {
Accept: "application/json",
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
"User-Agent": clientUserAgent
};

const request = Object.assign(
{
timeout: this.timeout,
method: "GET",
compress: false
},
init,
{ headers: Object.assign(headers, init.headers) }
);

const url = [this.baseUrl, path].join(
!this.baseUrl.endsWith("/") && !path.startsWith("/") ? "/" : ""
);

return fetch(url, request).then((response: Response) => {
if (response.status === 429) {
throw new ApiLimitError();
}

if (response.status >= 400) {
throw new Error(
`Received an error from the IPinfo API ` +
`(using authorization ${headers["Authorization"]}) ` +
`${response.status} ${response.statusText} ${response.url}`
);
}

return response;
});
}

/**
* Lookup IP information using the IP.
*
* @param ip IP address against which the location information is required.
* @return Response containing location information.
*/
public async lookupIp(
ip: string | undefined = undefined
): Promise<IPinfoLite | IPBogon> {
if (ip && this.isBogon(ip)) {
return {
ip,
bogon: true
};
}

if (!ip) {
ip = "me";
}

const data = await this.cache.get(IPinfoLiteWrapper.cacheKey(ip));

if (data) {
return data;
}

return this.fetchApi(ip).then(async (response) => {
const data = await response.json();

const ipinfo = {
ip: data.ip,
asn: data.asn,
asName: data.as_name,
asDomain: data.as_domain,
countryCode: data.country_code,
country: data.country,
continentCode: data.continent_code,
continent: data.continent,
isEU: this.euCountries.includes(data.country_code)
};

this.cache.set(IPinfoLiteWrapper.cacheKey(ip), ipinfo);

return ipinfo;
});
}

private isBogon(ip: string): boolean {
if (ip != "") {
for (var network of BOGON_NETWORKS) {
if (isInSubnet(ip, network)) {
return true;
}
}
}
return false;
}
}