Last active
October 11, 2024 16:30
-
-
Save MaxMonteil/3b3c0c7aa50104a6c3d595830f7946b6 to your computer and use it in GitHub Desktop.
A more reliable check for network availability in the browser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const NetworkStatus = { | |
ONLINE: 'online', | |
OFFLINE: 'offline', | |
PENDING: 'pending', | |
} as const | |
type NetworkStatusTypeOnline = 'online' | |
type NetworkStatusTypeOffline = 'offline' | |
type NetworkStatusTypePending = 'pending' | |
type NetworkStatusType = | |
| NetworkStatusTypeOnline | |
| NetworkStatusTypeOffline | |
| NetworkStatusTypePending | |
const ONE_HOUR = 60 * 60 * 1000 | |
/** Create some random (non secure) string value. */ | |
const getRandomString: () => string = () => | |
Math.random().toString(36).substring(2, 15) | |
interface IsOnlineConfig { | |
/* By default the client's origin is queried to prevent CORS errors, but you can pass your own. */ | |
url?: string | |
/* You can choose to omit the random query param added to the url. */ | |
addRandomQuery?: true | |
/* Pass your own random value. */ | |
randomValue?: string | |
/* Default value is 'rand' */ | |
paramName?: 'rand' | |
} | |
/** | |
* Pings the client's own url origin with a HEAD request to verify if there is a connection to the internet. | |
* | |
* @param options Additional configuration options | |
* @returns true if online and connected to the internet, false otherwise. | |
*/ | |
async function isOnline(options: IsOnlineConfig = {}): Promise<boolean> { | |
const { | |
url = window.location.origin, | |
addRandomQuery = true, | |
randomValue = getRandomString(), | |
paramName = 'rand', | |
} = options | |
// window.location.origin is string and it can be null if the browser is on an error page | |
if (!window.navigator.onLine) return false | |
try { | |
// client is connected to the network, check for internet access | |
const origin = new URL(url) | |
if (addRandomQuery) { | |
// the random value param is to help prevent response caching | |
origin.searchParams.set(paramName, randomValue) | |
} | |
const response = await fetch(url.toString(), { method: 'HEAD' }) | |
return response.ok | |
} catch { | |
return false | |
} | |
} | |
class NetworkObserver { | |
static status: NetworkStatusType | null = null | |
private static _pendingPromise: Promise<boolean> | null = null | |
private static async _initNetworkStatus(): Promise<void> { | |
this.status = 'pending' | |
this._pendingPromise = isOnline() | |
this.status = (await this._pendingPromise) | |
? NetworkStatus.ONLINE | |
: NetworkStatus.OFFLINE | |
} | |
/** Returns true if the client is online. */ | |
static async online(): Promise<boolean> { | |
if (this.status === NetworkStatus.PENDING) await this._pendingPromise | |
return NetworkObserver.status === NetworkStatus.ONLINE | |
} | |
private _listeners: Record< | |
string, | |
(status: NetworkStatusTypeOnline | NetworkStatusTypeOffline) => void | |
> | |
private _boundHandler: (e: Event) => void | |
private _options?: boolean | AddEventListenerOptions | |
private _isInit: boolean | |
/** Observe and get notified of network changes. */ | |
constructor(options?: boolean | AddEventListenerOptions) { | |
this._listeners = {} | |
this._options = options | |
this._boundHandler = (e) => this._onChange(e) | |
this._isInit = this._init(this._boundHandler, this._options) | |
} | |
/** Initialize the 'online' and 'offline' window event listeners. */ | |
private _init( | |
handler: (this: Window, ev: Event) => void, | |
options?: boolean | AddEventListenerOptions | |
) { | |
window.addEventListener('online', handler, options) | |
window.addEventListener('offline', handler, options) | |
if (NetworkObserver.status === null) { | |
NetworkObserver._initNetworkStatus() | |
this.addListener((status: NetworkStatusType) => { | |
NetworkObserver.status = status | |
}) | |
} | |
return true | |
} | |
/** | |
* @param e Native browser event. | |
*/ | |
private async _onChange(e: Event) { | |
let status = { | |
online: NetworkStatus.ONLINE, | |
offline: NetworkStatus.OFFLINE, | |
}[e.type as NetworkStatusTypeOffline | NetworkStatusTypeOnline] | |
if (status === NetworkStatus.OFFLINE) { | |
Object.values(this._listeners).forEach((listener) => listener(status)) | |
return | |
} | |
// verify online status | |
try { | |
const result = await retry(isOnline, { | |
retries: 50, | |
maximumBackoff: ONE_HOUR, | |
check: (v) => Boolean(v), | |
}) | |
if (!result) status = NetworkStatus.OFFLINE | |
} catch { | |
status = NetworkStatus.OFFLINE | |
} finally { | |
Object.values(this._listeners).forEach((listener) => listener(status)) | |
} | |
} | |
/** | |
* Add a callback that will run whenever the network state changes. | |
* | |
* @param listener The callback to run on network changes, receives the current network state ('online' or 'offline') | |
* @returns listener id | |
*/ | |
addListener( | |
listener: ( | |
status: NetworkStatusTypeOnline | NetworkStatusTypeOffline | |
) => void | |
) { | |
if (!this._isInit) { | |
this._isInit = this._init(this._boundHandler, this._options) | |
} | |
const id = getRandomString() | |
this._listeners[id] = listener | |
return id | |
} | |
/** Remove event listeners and added callbacks. */ | |
cleanup() { | |
this._listeners = {} | |
window.removeEventListener('online', this._boundHandler) | |
window.removeEventListener('offline', this._boundHandler) | |
this._isInit = false | |
} | |
} | |
interface RetryOptions<T> { | |
/* Number of time to retry the function. */ | |
retries?: number | |
/* Maximum time in milliseconds to wait in between retries. */ | |
maximumBackoff?: number | |
/* Maximum time in milliseconds to wait in between retries. */ | |
backoffRate?: number | |
/* Function ran on the result of func to decide if should retry */ | |
check?: (v: T) => boolean | |
/* Error raised in previous try */ | |
lastError?: unknown | Error | |
/* Last result obtained */ | |
lastResult?: T | |
} | |
/** | |
* @param func The callback to keep retrying. | |
* @param options Additional options to modify the retry method. | |
* @returns The value of the callback if a retry was successful. | |
*/ | |
async function retry<T>( | |
func: () => Promise<T>, | |
options: RetryOptions<T> = {}, | |
count = 0 | |
): Promise<T> { | |
const { | |
retries = 5, | |
maximumBackoff = 5000, | |
backoffRate = 10, | |
check = undefined, | |
lastError = undefined, | |
lastResult = undefined, | |
} = options | |
if (count > retries) { | |
if (lastError) throw lastError | |
if (lastResult) return lastResult | |
throw new Error( | |
'Reached maximum number of retries without passing the check.' | |
) | |
} | |
try { | |
const result = await func() | |
if (typeof check === 'function' && !check(result)) { | |
await sleep(Math.min(backoffRate ** count, maximumBackoff)) | |
return retry(func, { ...options, lastResult: result }, count++) | |
} | |
return result | |
} catch (err: unknown) { | |
await sleep(Math.min(backoffRate ** count, maximumBackoff)) | |
return retry(func, { ...options, lastError: err }, count++) | |
} | |
} | |
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) | |
export default NetworkObserver | |
export { isOnline, NetworkStatus as networkStatusType } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment