Skip to content

Instantly share code, notes, and snippets.

@MaxMonteil
Last active July 14, 2023 15:48
Show Gist options
  • Save MaxMonteil/3b3c0c7aa50104a6c3d595830f7946b6 to your computer and use it in GitHub Desktop.
Save MaxMonteil/3b3c0c7aa50104a6c3d595830f7946b6 to your computer and use it in GitHub Desktop.
A more reliable check for network availability in the browser
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