diff --git a/browser.js b/browser.js index 5983c6e..e4b7a07 100644 --- a/browser.js +++ b/browser.js @@ -1,4 +1,5 @@ import isIp from 'is-ip'; +import {createPublicIp, IpNotFoundError} from './core.js'; export class CancelError extends Error { constructor() { @@ -11,12 +12,7 @@ export class CancelError extends Error { } } -export class IpNotFoundError extends Error { - constructor(options) { - super('Could not get the public IP address', options); - this.name = 'IpNotFoundError'; - } -} +export {IpNotFoundError} from './core.js'; const defaults = { timeout: 5000, @@ -100,10 +96,12 @@ const queryHttps = (version, options) => { return promise; }; -const publicIp = {}; - -publicIp.v4 = options => queryHttps('v4', {...defaults, ...options}); +export default createPublicIp(publicIpv4, publicIpv6); -publicIp.v6 = options => queryHttps('v6', {...defaults, ...options}); +export function publicIpv4(options) { + return queryHttps('v4', {...defaults, ...options}); +} -export default publicIp; +export function publicIpv6(options) { + return queryHttps('v6', {...defaults, ...options}); +} diff --git a/core.js b/core.js new file mode 100644 index 0000000..fde3053 --- /dev/null +++ b/core.js @@ -0,0 +1,40 @@ +import AggregateError from 'aggregate-error'; // Use built-in when targeting Node.js 16 + +export class IpNotFoundError extends Error { + constructor(options) { + super('Could not get the public IP address', options); + this.name = 'IpNotFoundError'; + } +} + +export function createPublicIp(publicIpv4, publicIpv6) { + return function publicIp(options) { // eslint-disable-line func-names + const ipv4Promise = publicIpv4(options); + const ipv6Promise = publicIpv6(options); + + const promise = (async () => { + try { + const ipv6 = await ipv6Promise; + ipv4Promise.cancel(); + return ipv6; + } catch (ipv6Error) { + if (!(ipv6Error instanceof IpNotFoundError)) { + throw ipv6Error; + } + + try { + return await ipv4Promise; + } catch (ipv4Error) { + throw new AggregateError([ipv4Error, ipv6Error]); + } + } + })(); + + promise.cancel = () => { + ipv4Promise.cancel(); + ipv6Promise.cancel(); + }; + + return promise; + }; +} diff --git a/index.d.ts b/index.d.ts index c9f8ed6..67ad96c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -29,9 +29,9 @@ export interface Options { @example ``` - import publicIp from 'public-ip'; + import {publicIpv6} from 'public-ip'; - await publicIp.v6({ + await publicIpv6({ fallbackUrls: [ 'https://ifconfig.co/ip' ] @@ -45,44 +45,58 @@ export type CancelablePromise = Promise & { cancel(): void; }; -declare const publicIp: { - /** - Get your public IP address - very fast! +/** +Get your public IP address - very fast! - In Node.js, it queries the DNS records of OpenDNS, Google DNS, and HTTPS services to determine your IP address. In browsers, it uses the excellent [icanhaz](https://github.com/major/icanhaz) and [ipify](https://ipify.org) services through HTTPS. +In Node.js, it queries the DNS records of OpenDNS, Google DNS, and HTTPS services to determine your IP address. In browsers, it uses the excellent [icanhaz](https://github.com/major/icanhaz) and [ipify](https://ipify.org) services through HTTPS. - @returns Your public IPv4 address. A `.cancel()` method is available on the promise, which can be used to cancel the request. - @throws On error or timeout. +@returns Your public IPv6 address or as a fallback, your public IPv4 address. A `.cancel()` method is available on the promise, which can be used to cancel the request. +@throws On error or timeout. - @example - ``` - import publicIp from 'public-ip'; +@example +``` +import publicIp from 'public-ip'; - console.log(await publicIp.v4()); - //=> '46.5.21.123' - ``` - */ - v4(options?: Options): CancelablePromise; +console.log(await publicIp()); // Falls back to IPv4 +//=> 'fe80::200:f8ff:fe21:67cf' +``` +*/ +export function publicIp(options?: Options): CancelablePromise; - /** - Get your public IP address - very fast! +/** +Get your public IP address - very fast! - In Node.js, it queries the DNS records of OpenDNS, Google DNS, and HTTPS services to determine your IP address. In browsers, it uses the excellent [icanhaz](https://github.com/major/icanhaz) and [ipify](https://ipify.org) services through HTTPS. +In Node.js, it queries the DNS records of OpenDNS, Google DNS, and HTTPS services to determine your IP address. In browsers, it uses the excellent [icanhaz](https://github.com/major/icanhaz) and [ipify](https://ipify.org) services through HTTPS. - @returns Your public IPv6 address. A `.cancel()` method is available on the promise, which can be used to cancel the request. - @throws On error or timeout. +@returns Your public IPv4 address. A `.cancel()` method is available on the promise, which can be used to cancel the request. +@throws On error or timeout. - @example - ``` - import publicIp from 'public-ip'; +@example +``` +import {publicIpv4} from 'public-ip'; - console.log(await publicIp.v6()); - //=> 'fe80::200:f8ff:fe21:67cf' - ``` - */ - v6(options?: Options): CancelablePromise; -}; +console.log(await publicIpv4()); +//=> '46.5.21.123' +``` +*/ +export function publicIpv4(options?: Options): CancelablePromise; + +/** +Get your public IP address - very fast! + +In Node.js, it queries the DNS records of OpenDNS, Google DNS, and HTTPS services to determine your IP address. In browsers, it uses the excellent [icanhaz](https://github.com/major/icanhaz) and [ipify](https://ipify.org) services through HTTPS. + +@returns Your public IPv6 address. A `.cancel()` method is available on the promise, which can be used to cancel the request. +@throws On error or timeout. -export default publicIp; +@example +``` +import {publicIpv6} from 'public-ip'; + +console.log(await publicIpv6()); +//=> 'fe80::200:f8ff:fe21:67cf' +``` +*/ +export function publicIpv6(options?: Options): CancelablePromise; export {CancelError} from 'got'; diff --git a/index.js b/index.js index 2f089e5..4323ce1 100644 --- a/index.js +++ b/index.js @@ -3,13 +3,9 @@ import dgram from 'node:dgram'; import dns from 'dns-socket'; import got, {CancelError} from 'got'; import isIp from 'is-ip'; +import {createPublicIp, IpNotFoundError} from './core.js'; -export class IpNotFoundError extends Error { - constructor(options) { - super('Could not get the public IP address', options); - this.name = 'IpNotFoundError'; - } -} +export {IpNotFoundError} from './core.js'; const defaults = { timeout: 5000, @@ -228,9 +224,9 @@ const queryAll = (version, options) => { return promise; }; -const publicIp = {}; +export default createPublicIp(publicIpv4, publicIpv6); -publicIp.v4 = options => { +export function publicIpv4(options) { options = { ...defaults, ...options, @@ -245,9 +241,9 @@ publicIp.v4 = options => { } return queryDns('v4', options); -}; +} -publicIp.v6 = options => { +export function publicIpv6(options) { options = { ...defaults, ...options, @@ -262,8 +258,6 @@ publicIp.v6 = options => { } return queryDns('v6', options); -}; - -export default publicIp; +} export {CancelError} from 'got'; diff --git a/index.test-d.ts b/index.test-d.ts index cc12e2d..c4b4891 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,14 +1,20 @@ import {expectType} from 'tsd'; -import publicIp, {CancelablePromise} from './index.js'; +import {publicIp, publicIpv4, publicIpv6, CancelablePromise} from './index.js'; -expectType>(publicIp.v4()); -expectType>(publicIp.v4({onlyHttps: true})); -expectType>(publicIp.v4({timeout: 10})); -expectType>(publicIp.v4({fallbackUrls: ['https://ifconfig.io']})); -publicIp.v4().cancel(); +expectType>(publicIpv4()); +expectType>(publicIpv4({onlyHttps: true})); +expectType>(publicIpv4({timeout: 10})); +expectType>(publicIpv4({fallbackUrls: ['https://ifconfig.io']})); +publicIpv4().cancel(); -expectType>(publicIp.v6()); -expectType>(publicIp.v6({onlyHttps: true})); -expectType>(publicIp.v6({timeout: 10})); -expectType>(publicIp.v6({fallbackUrls: ['https://ifconfig.io']})); -publicIp.v6().cancel(); +expectType>(publicIpv6()); +expectType>(publicIpv6({onlyHttps: true})); +expectType>(publicIpv6({timeout: 10})); +expectType>(publicIpv6({fallbackUrls: ['https://ifconfig.io']})); +publicIpv6().cancel(); + +expectType>(publicIp()); +expectType>(publicIp({onlyHttps: true})); +expectType>(publicIp({timeout: 10})); +expectType>(publicIp({fallbackUrls: ['https://ifconfig.io']})); +publicIp().cancel(); diff --git a/mocks/stub.js b/mocks/stub.js index 42fb2fb..9f49441 100644 --- a/mocks/stub.js +++ b/mocks/stub.js @@ -21,12 +21,12 @@ export default function stub(objectPath, propertyName, ignoreIndex) { sinon.stub(objectPath, propertyName).callsFake(stub); return { - ignore: _ignoreRegExp => { + ignore(_ignoreRegExp) { ignoreRegExp = _ignoreRegExp; }, ignored: () => ignored.length, called: () => objectPath[propertyName].callCount, - restore: () => { + restore() { ignoreRegExp = undefined; ignored = []; objectPath[propertyName].resetHistory(); diff --git a/package.json b/package.json index 49e72bd..d2c6b35 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "index.js", "index.d.ts", "browser.js", - "browser.d.ts" + "browser.d.ts", + "core.js" ], "keywords": [ "get", @@ -41,15 +42,17 @@ "dns" ], "dependencies": { + "aggregate-error": "^4.0.1", "dns-socket": "^4.2.2", "got": "^12.0.0", "is-ip": "^3.1.0" }, "devDependencies": { - "ava": "^3.15.0", + "ava": "^4.2.0", "sinon": "^12.0.1", - "tsd": "^0.19.0", - "xo": "^0.47.0" + "time-span": "^5.0.0", + "tsd": "^0.20.0", + "xo": "^0.49.0" }, "xo": { "envs": [ diff --git a/readme.md b/readme.md index 05a198c..b941b45 100644 --- a/readme.md +++ b/readme.md @@ -13,19 +13,23 @@ npm install public-ip ## Usage ```js -import publicIp from 'public-ip'; +import publicIp, {publicIpv4, publicIpv6} from 'public-ip'; -console.log(await publicIp.v4()); +console.log(await publicIpv4()); //=> '46.5.21.123' -console.log(await publicIp.v6()); +console.log(await publicIpv6()); +//=> 'fe80::200:f8ff:fe21:67cf' + +console.log(await publicIp()); // Falls back to IPv4 //=> 'fe80::200:f8ff:fe21:67cf' ``` ## API -### publicIp.v4(options?) -### publicIp.v6(options?) +### publicIp(options?) +### publicIpv4(options?) +### publicIpv6(options?) Returns a `Promise` with your public IPv4 or IPv6 address. Rejects on error or timeout. A `.cancel()` method is available on the promise, which can be used to cancel the request. @@ -48,9 +52,9 @@ Default: `[]` Add your own custom HTTPS endpoints to get the public IP from. They will only be used if everything else fails. Any service used as fallback *must* return the IP as a plain string. ```js -import publicIp from 'public-ip'; +import {publicIpv6} from 'public-ip'; -await publicIp.v6({ +await publicIpv6({ fallbackUrls: [ 'https://ifconfig.co/ip' ] diff --git a/test-browser.js b/test-browser.js index f2b9b7a..7923371 100644 --- a/test-browser.js +++ b/test-browser.js @@ -1,9 +1,10 @@ // Comment out the `is-ip` dependency, launch a local server, and then load the HTMl file. -import publicIp from './browser.js'; +import publicIp, {publicIpv4} from './browser.js'; -console.log('IP:', await publicIp.v4()); -console.log('IP:', await publicIp.v4({ +console.log('IP:', await publicIpv4()); +console.log('IP:', await publicIpv4({ fallbackUrls: [ 'https://ifconfig.me', ], })); +console.log('IP:', await publicIp()); diff --git a/test.js b/test.js index bee4554..29831ae 100644 --- a/test.js +++ b/test.js @@ -1,9 +1,10 @@ import process from 'node:process'; import test from 'ava'; import isIp from 'is-ip'; +import timeSpan from 'time-span'; import dnsStub from './mocks/dns-socket.js'; import gotStub from './mocks/got.js'; -import publicIp from './index.js'; +import publicIp, {publicIpv4, publicIpv6} from './index.js'; test.afterEach.always(() => { dnsStub.restore(); @@ -11,7 +12,7 @@ test.afterEach.always(() => { }); test('IPv4 DNS - No HTTPS call', async t => { - t.true(isIp.v4(await publicIp.v4())); + t.true(isIp.v4(await publicIpv4())); t.true(dnsStub.called() > 0); t.is(dnsStub.ignored(), 0); t.is(gotStub.called(), 0); @@ -19,7 +20,7 @@ test('IPv4 DNS - No HTTPS call', async t => { test('IPv4 DNS failure falls back to HTTPS', async t => { dnsStub.ignore(/.*/); - const ip = await publicIp.v4(); + const ip = await publicIpv4(); t.true(isIp.v4(ip)); t.is(dnsStub.ignored(), 8); t.true(gotStub.called() > 0); @@ -27,20 +28,20 @@ test('IPv4 DNS failure falls back to HTTPS', async t => { test('IPv4 DNS failure OpenDNS falls back to Google DNS', async t => { dnsStub.ignore(/^208\./); - const ip = await publicIp.v4(); + const ip = await publicIpv4(); t.true(isIp.v4(ip)); t.is(dnsStub.ignored(), 4); t.is(gotStub.called(), 0); }); test('IPv4 HTTPS - No DNS call', async t => { - t.true(isIp.v4(await publicIp.v4({onlyHttps: true}))); + t.true(isIp.v4(await publicIpv4({onlyHttps: true}))); t.is(dnsStub.called(), 0); }); test('IPv4 HTTPS uses custom URLs', async t => { gotStub.ignore(/com|org/); - t.true(isIp.v4(await publicIp.v4({onlyHttps: true, fallbackUrls: [ + t.true(isIp.v4(await publicIpv4({onlyHttps: true, fallbackUrls: [ 'https://ifconfig.co/ip', 'https://ifconfig.io/ip', ]}))); @@ -49,48 +50,58 @@ test('IPv4 HTTPS uses custom URLs', async t => { }); test('IPv4 DNS timeout', async t => { - t.true(isIp.v4(await publicIp.v4({timeout: 2000}))); + t.true(isIp.v4(await publicIpv4({timeout: 2000}))); }); test('IPv4 HTTPS timeout', async t => { - t.true(isIp.v4(await publicIp.v4({onlyHttps: true, timeout: 4000}))); + t.true(isIp.v4(await publicIpv4({onlyHttps: true, timeout: 4000}))); }); test('IPv4 DNS cancellation', async t => { const timeout = 5000; - const start = process.hrtime(); - const promise = publicIp.v4({timeout}); + const end = timeSpan(); + const promise = publicIpv4({timeout}); promise.cancel(); await promise; - const difference = process.hrtime(start); - const milliseconds = ((difference[0] * 1e9) + difference[1]) / 1e6; - t.true(milliseconds < timeout); + t.true(end() < timeout); }); test('IPv4 HTTPS cancellation', async t => { const timeout = 5000; - const start = process.hrtime(); - const promise = publicIp.v4({timeout, onlyHttps: true}); + const end = timeSpan(); + const promise = publicIpv4({timeout, onlyHttps: true}); promise.cancel(); await promise; - const difference = process.hrtime(start); - const milliseconds = ((difference[0] * 1e9) + difference[1]) / 1e6; - t.true(milliseconds < timeout); + t.true(end() < timeout); }); // Impossible DNS timeouts seems unreliable to test on a working connection // because of caches, so we're only testing HTTPS. test('IPv4 HTTPS impossible timeout', async t => { - await t.throwsAsync(publicIp.v4({onlyHttps: true, timeout: 1})); + await t.throwsAsync(publicIpv4({onlyHttps: true, timeout: 1})); }); if (!process.env.CI) { test('IPv6 DNS', async t => { - t.true(isIp.v6(await publicIp.v6())); + t.true(isIp.v6(await publicIpv6())); }); test('IPv6 HTTPS', async t => { - t.true(isIp.v6(await publicIp.v6({onlyHttps: true}))); + t.true(isIp.v6(await publicIpv6({onlyHttps: true}))); }); } + +test.serial('IPv4 or IPv6', async t => { + const ip = await publicIp({timeout: 500}); + t.true(isIp.v4(ip) || isIp.v6(ip)); +}); + +test.serial('IPv4 or IPv6 cancellation', async t => { + const timeout = 5000; + const end = timeSpan(); + const promise = publicIp({timeout}); + promise.cancel(); + await promise; + t.true(end() < timeout); +});