+74
-3
| /* eslint-env browser */ | ||
| import {publicIpv4, publicIpv6} from 'public-ip'; | ||
| export default async function isOnline(options) { | ||
| const urlCheck = async (url, options, signal) => { | ||
| // Validate URL | ||
| const urlObject = new URL(url); | ||
| // Only allow HTTP and HTTPS | ||
| if (!['http:', 'https:'].includes(urlObject.protocol)) { | ||
| throw new Error(`Unsupported protocol: ${urlObject.protocol}`); | ||
| } | ||
| // Use fetch with timeout | ||
| const controller = new AbortController(); | ||
| const timeoutId = setTimeout(() => controller.abort(), options.timeout); | ||
| if (signal) { | ||
| signal.addEventListener('abort', () => controller.abort(), {once: true}); | ||
| } | ||
| try { | ||
| // Use HEAD request when possible to minimize data transfer | ||
| const response = await fetch(url, {method: 'HEAD', signal: controller.signal}); | ||
| if (!response.ok && response.status === 405) { | ||
| // If HEAD fails with 405, try GET as fallback (some servers don't support HEAD) | ||
| const getResponse = await fetch(url, {method: 'GET', signal: controller.signal}); | ||
| if (!getResponse.ok) { | ||
| throw new Error(`HTTP ${getResponse.status}`); | ||
| } | ||
| } else if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}`); | ||
| } | ||
| } catch (error) { | ||
| // If HEAD fails, try GET as fallback (some servers don't support HEAD) | ||
| if (error.message?.includes('Method Not Allowed')) { | ||
| const getResponse = await fetch(url, {method: 'GET', signal: controller.signal}); | ||
| if (!getResponse.ok) { | ||
| throw new Error(`HTTP ${getResponse.status}`); | ||
| } | ||
| } else { | ||
| throw error; | ||
| } | ||
| } finally { | ||
| clearTimeout(timeoutId); | ||
| } | ||
| }; | ||
| const checkUrls = async (urls, options, signal) => { | ||
| if (!urls?.length) { | ||
| throw new Error('No URLs to check'); | ||
| } | ||
| // Try all URLs in parallel and return true if any succeeds | ||
| const promises = urls.map(url => urlCheck(url, options, signal)); | ||
| // Wait for the first successful result | ||
| return Promise.any(promises); | ||
| }; | ||
| export default async function isOnline(options = {}) { | ||
| options = { | ||
@@ -11,3 +67,2 @@ timeout: 5000, | ||
| // eslint-disable-next-line n/no-unsupported-features/node-builtins | ||
| if (!navigator?.onLine) { | ||
@@ -19,8 +74,24 @@ return false; | ||
| // Run multiple checks in parallel for better resilience | ||
| const checks = [ | ||
| // Main check using public-ip (which includes icanhazip and ipify fallback) | ||
| publicIpFunction({...options, signal: options.signal}), | ||
| // Additional check using Cloudflare (CORS-enabled) | ||
| // This helps when Brave blocks icanhazip (works for both IPv4 and IPv6) | ||
| urlCheck('https://1.1.1.1/', options, options.signal), | ||
| ]; | ||
| // Add any user-provided fallback URLs to parallel checks | ||
| if (options.fallbackUrls?.length > 0) { | ||
| checks.push(checkUrls(options.fallbackUrls, options, options.signal)); | ||
| } | ||
| try { | ||
| await publicIpFunction(options); | ||
| // Use Promise.any to return true if ANY check succeeds | ||
| await Promise.any(checks); | ||
| return true; | ||
| } catch { | ||
| // All checks failed | ||
| return false; | ||
| } | ||
| } |
+69
-3
@@ -10,2 +10,24 @@ export type Options = { | ||
| /** | ||
| AbortSignal to cancel the operation. | ||
| When aborted, the promise will resolve to `false`. | ||
| @example | ||
| ``` | ||
| import isOnline from 'is-online'; | ||
| const controller = new AbortController(); | ||
| setTimeout(() => { | ||
| controller.abort(); | ||
| }, 500); | ||
| const result = await isOnline({signal: controller.signal}); | ||
| console.log(result); | ||
| //=> false | ||
| ``` | ||
| */ | ||
| readonly signal?: AbortSignal; | ||
| /** | ||
| [Internet Protocol version](https://en.wikipedia.org/wiki/Internet_Protocol#Version_history) to use. | ||
@@ -18,2 +40,24 @@ | ||
| readonly ipVersion?: 4 | 6; | ||
| /** | ||
| Fallback URLs to check for connectivity. | ||
| Only HTTP and HTTPS URLs are supported. In Node.js, these URLs are checked only if all default connectivity checks fail. In the browser, these URLs are checked in parallel with the default checks for better resilience against ad blockers. | ||
| @example | ||
| ``` | ||
| import isOnline from 'is-online'; | ||
| const result = await isOnline({ | ||
| fallbackUrls: [ | ||
| 'https://www.google.com', | ||
| 'https://www.github.com', | ||
| 'http://example.com' | ||
| ] | ||
| }); | ||
| console.log(result); | ||
| //=> true | ||
| ``` | ||
| */ | ||
| readonly fallbackUrls?: readonly string[]; | ||
| }; | ||
@@ -25,8 +69,17 @@ | ||
| The following checks are run in parallel: | ||
| - Retrieve [icanhazip.com](https://github.com/major/icanhaz) via HTTPS | ||
| - Query `myip.opendns.com` on OpenDNS (Node.js only) | ||
| - Retrieve Apple's Captive Portal test page (Node.js only) | ||
| **Node.js:** | ||
| - Retrieve [icanhazip.com](https://github.com/major/icanhaz) (or [ipify.org](https://www.ipify.org) as fallback) via HTTPS. | ||
| - Query `myip.opendns.com` and `o-o.myaddr.l.google.com` DNS entries. | ||
| - Retrieve Apple's Captive Portal test page (this is what iOS does). | ||
| - Check Cloudflare's website via HTTPS. | ||
| **Browser:** | ||
| - Retrieve [icanhazip.com](https://github.com/major/icanhaz) (or [ipify.org](https://www.ipify.org) as fallback) via HTTPS. | ||
| - Check Cloudflare's 1.1.1.1 service via HTTPS (helps when ad blockers block icanhazip.com). | ||
| When any check succeeds, the returned Promise is resolved to `true`. | ||
| @returns A promise that resolves to `true` if the internet connection is up, `false` otherwise. | ||
| @example | ||
@@ -39,3 +92,16 @@ ``` | ||
| ``` | ||
| @example | ||
| ``` | ||
| import isOnline from 'is-online'; | ||
| // With timeout | ||
| console.log(await isOnline({timeout: 10_000})); | ||
| //=> true | ||
| // With IPv6 | ||
| console.log(await isOnline({ipVersion: 6})); | ||
| //=> true | ||
| ``` | ||
| */ | ||
| export default function isOnline(options?: Options): Promise<boolean>; |
+169
-61
| import os from 'node:os'; | ||
| import got, {CancelError} from 'got'; | ||
| import {channel} from 'node:diagnostics_channel'; | ||
| import {withHttpError, withTimeout} from 'fetch-extras'; | ||
| import {publicIpv4, publicIpv6} from 'public-ip'; | ||
@@ -7,33 +8,133 @@ import pAny from 'p-any'; | ||
| const appleCheck = options => { | ||
| const gotPromise = got('https://captive.apple.com/hotspot-detect.html', { | ||
| timeout: { | ||
| request: options.timeout, | ||
| }, | ||
| dnsLookupIpVersion: options.ipVersion, | ||
| headers: { | ||
| 'user-agent': 'CaptiveNetworkSupport/1.0 wispr', | ||
| }, | ||
| }); | ||
| const diagnosticsChannel = channel('is-online:connectivity-check'); | ||
| const promise = (async () => { | ||
| try { | ||
| const {body} = await gotPromise; | ||
| if (!body?.includes('Success')) { | ||
| throw new Error('Apple check failed'); | ||
| } | ||
| } catch (error) { | ||
| if (!(error instanceof CancelError)) { | ||
| throw error; | ||
| } | ||
| const publishFailure = (url, error) => { | ||
| if (!diagnosticsChannel.hasSubscribers) { | ||
| return; | ||
| } | ||
| try { | ||
| diagnosticsChannel.publish({ | ||
| timestamp: Date.now(), | ||
| url, | ||
| error: { | ||
| name: error.constructor.name, | ||
| message: error.message, | ||
| code: error.code, | ||
| }, | ||
| }); | ||
| } catch { | ||
| // Ignore diagnostics errors - never affect main functionality | ||
| } | ||
| }; | ||
| const fetchUrl = async (url, options, signal, fetchOptions = {}) => { | ||
| const fetchWithTimeout = withHttpError(withTimeout(globalThis.fetch, options.timeout)); | ||
| return fetchWithTimeout(url, {signal, ...fetchOptions}); | ||
| }; | ||
| const appleCheck = async (options, signal) => { | ||
| const url = 'https://captive.apple.com/hotspot-detect.html'; | ||
| try { | ||
| const response = await fetchUrl(url, options, signal, { | ||
| method: 'GET', // Apple captive portal requires GET to return body content | ||
| headers: { | ||
| 'user-agent': 'CaptiveNetworkSupport/1.0 wispr', | ||
| }, | ||
| }); | ||
| const body = await response.text(); | ||
| if (!body?.includes('Success')) { | ||
| throw new Error('Apple check failed'); | ||
| } | ||
| })(); | ||
| } catch (error) { | ||
| publishFailure(url, error); | ||
| throw error; | ||
| } | ||
| }; | ||
| promise.cancel = gotPromise.cancel; | ||
| const urlCheck = async (url, options, signal) => { | ||
| // Validate URL | ||
| let urlObject; | ||
| try { | ||
| urlObject = new URL(url); | ||
| } catch (error) { | ||
| // Invalid URL format | ||
| publishFailure(url, error); | ||
| throw error; | ||
| } | ||
| return promise; | ||
| // Only allow HTTP and HTTPS | ||
| if (!['http:', 'https:'].includes(urlObject.protocol)) { | ||
| const error = new Error(`Unsupported protocol: ${urlObject.protocol}`); | ||
| publishFailure(url, error); | ||
| throw error; | ||
| } | ||
| try { | ||
| // Use HEAD request when possible to minimize data transfer | ||
| await fetchUrl(url, options, signal, {method: 'HEAD'}); | ||
| } catch (error) { | ||
| // If HEAD fails, try GET as fallback (some servers don't support HEAD) | ||
| if (error.status === 405 || error.message?.includes('Method Not Allowed')) { | ||
| await fetchUrl(url, options, signal, {method: 'GET'}); | ||
| } else { | ||
| // Publish failure for this specific URL | ||
| publishFailure(url, error); | ||
| throw error; | ||
| } | ||
| } | ||
| }; | ||
| // Note: It cannot be `async`` as then it looses the `.cancel()` method. | ||
| export default function isOnline(options) { | ||
| const createAbortPromise = signal => new Promise((resolve, reject) => { | ||
| if (signal.aborted) { | ||
| reject(new Error('Aborted')); | ||
| } else { | ||
| signal.addEventListener('abort', () => { | ||
| reject(new Error('Aborted')); | ||
| }, {once: true}); | ||
| } | ||
| }); | ||
| const tryFallbackUrls = async options => { | ||
| if (!options.fallbackUrls?.length) { | ||
| return false; | ||
| } | ||
| if (options.signal?.aborted) { | ||
| return false; | ||
| } | ||
| try { | ||
| const urlPromise = checkUrls(options.fallbackUrls, options, options.signal); | ||
| if (options.signal) { | ||
| const abortPromise = createAbortPromise(options.signal); | ||
| await pTimeout(Promise.race([urlPromise, abortPromise]), {milliseconds: options.timeout}); | ||
| } else { | ||
| await pTimeout(urlPromise, {milliseconds: options.timeout}); | ||
| } | ||
| return true; | ||
| } catch { | ||
| // Individual URL failures are already published by urlCheck | ||
| return false; | ||
| } | ||
| }; | ||
| const checkUrls = async (urls, options, signal) => { | ||
| if (!urls?.length) { | ||
| throw new Error('No URLs to check'); | ||
| } | ||
| const promises = urls.map(async url => { | ||
| await urlCheck(url, options, signal); | ||
| return true; | ||
| }); | ||
| return pAny(promises); | ||
| }; | ||
| export default async function isOnline(options = {}) { | ||
| options = { | ||
@@ -53,44 +154,51 @@ timeout: 5000, | ||
| if (options.signal?.aborted) { | ||
| return false; | ||
| } | ||
| const publicIpFunction = options.ipVersion === 4 ? publicIpv4 : publicIpv6; | ||
| const queries = []; | ||
| const promise = pAny([ | ||
| (async () => { | ||
| const query = publicIpFunction(options); | ||
| queries.push(query); | ||
| await query; | ||
| const publicIpCheck = async (onlyHttps = false) => { | ||
| const serviceName = onlyHttps ? 'https://api.ipify.org' : 'https://icanhazip.com'; | ||
| try { | ||
| await publicIpFunction({...options, onlyHttps, signal: options.signal}); | ||
| } catch (error) { | ||
| publishFailure(serviceName, error); | ||
| throw error; | ||
| } | ||
| }; | ||
| const promise = (async () => { | ||
| const promises = [ | ||
| publicIpCheck(false), | ||
| publicIpCheck(true), | ||
| appleCheck(options, options.signal), | ||
| // Cloudflare as additional fallback | ||
| urlCheck('https://cloudflare.com/', options, options.signal), | ||
| ].map(async promise => { | ||
| await promise; | ||
| return true; | ||
| })(), | ||
| (async () => { | ||
| const query = publicIpFunction({...options, onlyHttps: true}); | ||
| queries.push(query); | ||
| await query; | ||
| return true; | ||
| })(), | ||
| (async () => { | ||
| const query = appleCheck(options); | ||
| queries.push(query); | ||
| await query; | ||
| return true; | ||
| })(), | ||
| ]); | ||
| }); | ||
| return pTimeout(promise, {milliseconds: options.timeout}).catch(() => { // eslint-disable-line promise/prefer-await-to-then | ||
| for (const query of queries) { | ||
| query.cancel(); | ||
| return pAny(promises); | ||
| })(); | ||
| // Try main checks first | ||
| // eslint-disable-next-line no-warning-comments | ||
| // TODO: Use AbortSignal.timeout() instead of pTimeout when it's widely supported | ||
| const tryMainChecks = async () => { | ||
| if (options.signal) { | ||
| const abortPromise = createAbortPromise(options.signal); | ||
| return pTimeout(Promise.race([promise, abortPromise]), {milliseconds: options.timeout}); | ||
| } | ||
| return false; | ||
| }); | ||
| return pTimeout(promise, {milliseconds: options.timeout}); | ||
| }; | ||
| // TODO: Use this instead when supporting AbortController. | ||
| // try { | ||
| // return await pTimeout(promise, options.timeout); | ||
| // } catch { | ||
| // for (const query of queries) { | ||
| // query.cancel(); | ||
| // } | ||
| // return false; | ||
| // } | ||
| try { | ||
| return await tryMainChecks(); | ||
| } catch { | ||
| // Individual check failures are already published by each check | ||
| return tryFallbackUrls(options); | ||
| } | ||
| } |
+7
-13
| { | ||
| "name": "is-online", | ||
| "version": "11.0.0", | ||
| "version": "12.0.0", | ||
| "description": "Check if the internet connection is up", | ||
@@ -24,6 +24,6 @@ "license": "MIT", | ||
| "engines": { | ||
| "node": ">=18" | ||
| "node": ">=20" | ||
| }, | ||
| "scripts": { | ||
| "test": "xo && ava && tsd" | ||
| "test": "xo && node --test && tsd" | ||
| }, | ||
@@ -58,17 +58,11 @@ "files": [ | ||
| "dependencies": { | ||
| "got": "^13.0.0", | ||
| "fetch-extras": "^1.0.0", | ||
| "p-any": "^4.0.0", | ||
| "p-timeout": "^6.1.2", | ||
| "p-timeout": "^6.1.4", | ||
| "public-ip": "^7.0.1" | ||
| }, | ||
| "devDependencies": { | ||
| "ava": "^6.1.3", | ||
| "tsd": "^0.31.1", | ||
| "xo": "^0.59.2" | ||
| }, | ||
| "ava": { | ||
| "files": [ | ||
| "test.js" | ||
| ] | ||
| "tsd": "^0.33.0", | ||
| "xo": "^1.2.2" | ||
| } | ||
| } |
+101
-2
@@ -15,2 +15,7 @@ # is-online | ||
| ## Requirements | ||
| - Node.js 20+ | ||
| - Works in modern browsers when bundled (requires `fetch` API support) | ||
| ## Usage | ||
@@ -25,2 +30,48 @@ | ||
| ### With timeout | ||
| ```js | ||
| import isOnline from 'is-online'; | ||
| console.log(await isOnline({timeout: 10_000})); | ||
| //=> true | ||
| ``` | ||
| ### With abort signal | ||
| ```js | ||
| import isOnline from 'is-online'; | ||
| const controller = new AbortController(); | ||
| setTimeout(() => { | ||
| controller.abort(); | ||
| }, 500); | ||
| const result = await isOnline({ | ||
| timeout: 3000, | ||
| signal: controller.signal | ||
| }); | ||
| console.log(result); | ||
| //=> false | ||
| ``` | ||
| ### With fallback URLs | ||
| ```js | ||
| import isOnline from 'is-online'; | ||
| const result = await isOnline({ | ||
| fallbackUrls: [ | ||
| 'https://www.google.com', | ||
| 'https://www.github.com', | ||
| 'http://example.com' | ||
| ] | ||
| }); | ||
| console.log(result); | ||
| //=> true | ||
| ``` | ||
| ## API | ||
@@ -30,2 +81,4 @@ | ||
| Returns a `Promise<boolean>` that resolves to `true` if the internet connection is up, `false` otherwise. | ||
| #### options | ||
@@ -42,2 +95,10 @@ | ||
| ##### signal | ||
| Type: `AbortSignal` | ||
| An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) to abort the operation. | ||
| When the signal is aborted, the promise will resolve to `false`. | ||
| ##### ipVersion | ||
@@ -53,2 +114,10 @@ | ||
| ##### fallbackUrls | ||
| Type: `string[]` | ||
| Fallback URLs to check for connectivity. | ||
| Only HTTP and HTTPS URLs are supported. In Node.js, these URLs are checked only if all default connectivity checks fail. In the browser, these URLs are checked in parallel with the default checks for better resilience against ad blockers. | ||
| ## How it works | ||
@@ -58,8 +127,38 @@ | ||
| **Node.js:** | ||
| - Retrieve [icanhazip.com](https://github.com/major/icanhaz) (or [ipify.org](https://www.ipify.org) as fallback) via HTTPS. | ||
| - Query `myip.opendns.com` and `o-o.myaddr.l.google.com` DNS entries. *(Node.js only)* | ||
| - Retrieve Apple's Captive Portal test page (this is what iOS does). *(Node.js only)* | ||
| - Query `myip.opendns.com` and `o-o.myaddr.l.google.com` DNS entries. | ||
| - Retrieve Apple's Captive Portal test page (this is what iOS does). | ||
| - Check Cloudflare's website via HTTPS. | ||
| **Browser:** | ||
| - Retrieve [icanhazip.com](https://github.com/major/icanhaz) (or [ipify.org](https://www.ipify.org) as fallback) via HTTPS. | ||
| - Check Cloudflare's 1.1.1.1 service via HTTPS (helps when ad blockers block icanhazip.com). | ||
| When any check succeeds, the returned Promise is resolved to `true`. | ||
| If all the above checks fail and you have provided `fallbackUrls`, those will be checked as a fallback. The URLs are checked by making HTTP/HTTPS requests (HEAD requests when possible, with GET as fallback). | ||
| ## Diagnostics | ||
| The package publishes diagnostic information when connectivity checks fail using Node.js [Diagnostics Channel](https://nodejs.org/api/diagnostics_channel.html#diagnostics-channel). This is useful for debugging network issues and is only available in Node.js environments. | ||
| ```js | ||
| import {subscribe} from 'node:diagnostics_channel'; | ||
| import isOnline from 'is-online'; | ||
| // Subscribe to failure events | ||
| subscribe('is-online:connectivity-check', message => { | ||
| console.log('Failed URL:', message.url); | ||
| console.log('Error:', message.error); | ||
| }); | ||
| await isOnline(); | ||
| ``` | ||
| Each failure event includes: | ||
| - `timestamp` - When the failure occurred | ||
| - `url` - The specific URL that failed | ||
| - `error` - Error details (name, message, code) | ||
| ## Proxy support | ||
@@ -66,0 +165,0 @@ |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
17149
123.64%2
-33.33%331
156.59%171
137.5%7
Infinity%+ Added
+ Added
- Removed
Updated