diff --git a/.changeset/wild-tigers-pump.md b/.changeset/wild-tigers-pump.md new file mode 100644 index 0000000..f991d94 --- /dev/null +++ b/.changeset/wild-tigers-pump.md @@ -0,0 +1,5 @@ +--- +'offline-detector': minor +--- + +Adds the support for network polling to detect user's connection state diff --git a/.github/workflows/changeset-pr.yml b/.github/workflows/changeset-pr.yml index 7245516..ebe0ec9 100644 --- a/.github/workflows/changeset-pr.yml +++ b/.github/workflows/changeset-pr.yml @@ -14,6 +14,9 @@ jobs: with: fetch-depth: 0 + - name: Fetch main branch + run: git fetch origin main:main + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/examples/site/src/components/HeroSection.tsx b/examples/site/src/components/HeroSection.tsx index 86fe581..ca416d5 100644 --- a/examples/site/src/components/HeroSection.tsx +++ b/examples/site/src/components/HeroSection.tsx @@ -1,13 +1,13 @@ -import React from 'react' -import EthernetCable from './EthernetCable' -import { useOfflineDetector } from '@/hooks/useOfflineDetector' +import React from 'react'; +import EthernetCable from './EthernetCable'; +import { useOfflineDetector } from '@/hooks/useOfflineDetector'; const scrollToFeatures = () => { const featuresSection = document.getElementById('features'); if (featuresSection) { - featuresSection.scrollIntoView({ + featuresSection.scrollIntoView({ behavior: 'smooth', - block: 'start' + block: 'start', }); } }; @@ -16,55 +16,74 @@ const HeroSection = () => { const { isOnline } = useOfflineDetector(); return ( -
-
-

- +
+
+

+ Offline Detector

- -

+ +

Real-time network connectivity detection for modern web applications

- -
- + +
+
- -
-
- -
+ +
- ) -} + ); +}; -export default HeroSection \ No newline at end of file +export default HeroSection; diff --git a/examples/site/src/components/InstallationSection.tsx b/examples/site/src/components/InstallationSection.tsx index 25788c1..8aa74d1 100644 --- a/examples/site/src/components/InstallationSection.tsx +++ b/examples/site/src/components/InstallationSection.tsx @@ -1,57 +1,218 @@ -import React from 'react' -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' -import { nightOwl } from 'react-syntax-highlighter/dist/esm/styles/prism' -import Card from './ui/CardComponent' +import React, { useState } from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { nightOwl } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import Card from './ui/CardComponent'; -const code = `import { createOfflineDetector } from 'offline-detector'; +const CodeBlock = ({ + code, + language = 'javascript', + title, +}: { + code: string; + language?: string; + title?: string; +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy code: ', err); + } + }; + + return ( +
+ {title && ( +
+ {title} +
+ )} +
+ + + {code} + +
+
+ ); +}; + +const basicCode = `import { createOfflineDetector } from 'offline-detector'; const detector = createOfflineDetector({ - onOnline: () => console.log('Online'), - onOffline: () => console.log('Offline') + onOnline: () => console.log('Back online!'), + onOffline: () => console.log('Gone offline!') }); -detector.start();` -const InstallationSection = () => { +detector.start();`; + +const advancedCode = `import { createOfflineDetector } from 'offline-detector'; + +const detector = createOfflineDetector({ + onOnline: () => { + // Sync pending data + }, + onOffline: () => { + // Show offline indicator + }, + stateChangeDebounceDelay: 1000, + networkVerification: { + enabled: true, + url: 'https://api.example.com/health', + requestTimeout: 5000, + interval: 30000, + maxFailures: 3 + }, + nativeEvents: { + enabled: true + } +}); + +detector.start();`; + +const reactCode = `import { useEffect, useState } from 'react'; +import { createOfflineDetector } from 'offline-detector'; + +function useOfflineDetector() { + const [isOnline, setIsOnline] = useState(true); + + useEffect(() => { + const detector = createOfflineDetector({ + onOnline: () => setIsOnline(true), + onOffline: () => setIsOnline(false), + networkVerification: { + enabled: true, + interval: 5000, + maxFailures: 2 + } + }); + + detector.start(); + setIsOnline(detector.isOnline()); + + return () => detector.destroy(); + }, []); + + return isOnline; +} + +// Usage in component +function App() { + const isOnline = useOfflineDetector(); + return ( -
-
-

Get Started

- -
-
-

Install the package

- - npm install offline-detector - -
- -
-

Basic usage

- - {code} - -
-
-
+
+

My App

+ {!isOnline && ( +
+ You're currently offline
+ )} +
+ ); +}`; +const InstallationSection = () => { + return ( +
+
+

Get Started

+ +
+
+

+ Install the package +

+ +
+
+
+ +
+
+

+ Basic Usage +

+

+ Simple setup with default configuration +

+ +
+
+
+ +
+
+

+ Advanced Configuration +

+

+ Custom network verification, debouncing, and native events +

+ +
+
+
+ +
+
+

+ React Hook Example +

+

+ Custom hook for React applications with automatic cleanup +

+ +
+
+
+
- ) -} + ); +}; -export default InstallationSection \ No newline at end of file +export default InstallationSection; diff --git a/examples/site/src/hooks/useOfflineDetector.ts b/examples/site/src/hooks/useOfflineDetector.ts index 43db690..a45218e 100644 --- a/examples/site/src/hooks/useOfflineDetector.ts +++ b/examples/site/src/hooks/useOfflineDetector.ts @@ -32,6 +32,13 @@ export function useOfflineDetector( setIsOnline(false); onOffline?.(); }, + networkVerification: { + enabled: true, + url: '/offline-detector/favicon.ico', + requestTimeout: 5000, + interval: 2000, + maxFailures: 3, + }, }); detector.current = newDetector; @@ -45,7 +52,7 @@ export function useOfflineDetector( } catch (error) { console.error('Failed to create offline detector:', error); } - }, []); + }, [onOnline, onOffline]); return { isOnline, diff --git a/packages/core/README.md b/packages/core/README.md index 1f241ab..b251e5a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,14 +1,17 @@ # Offline Detector -A lightweight TypeScript library for detecting online/offline status in browsers with modern bundler support. +A lightweight TypeScript library for detecting online/offline status in browsers with modern bundler support. Features intelligent network verification, debounced state changes, and flexible configuration options. ## Features - 🌐 **Browser-focused**: Designed specifically for browser environments - 📦 **Bundler agnostic**: Works with any modern bundler (Webpack, Vite, Rollup, etc.) - 🔧 **TypeScript**: Full TypeScript support with type definitions -- 🚀 **Lightweight**: Minimal bundle size +- 🚀 **Lightweight**: Minimal bundle size with tree-shaking support - 📱 **Cross-platform**: Works across all modern browsers +- 🔍 **Smart Detection**: Combines native events with network verification +- ⚡ **Debounced**: Prevents rapid state changes with configurable debouncing +- 🎯 **Configurable**: Extensive options for customization ## Installation @@ -16,9 +19,260 @@ A lightweight TypeScript library for detecting online/offline status in browsers npm install offline-detector ``` -## Usage +## Quick Start -_Coming soon - API documentation will be available once the library is implemented._ +```typescript +import { createOfflineDetector } from 'offline-detector'; + +const detector = createOfflineDetector({ + onOnline: () => console.log('Back online!'), + onOffline: () => console.log('Gone offline!'), +}); + +// Start monitoring +detector.start(); + +// Check current status +console.log(detector.isOnline()); // true or false + +// Stop monitoring +detector.stop(); +``` + +## API Reference + +### `createOfflineDetector(options?)` + +Creates a new offline detector instance. + +#### Parameters + +- `options` (optional): `OfflineDetectorOptions` - Configuration object + +#### Returns + +`OfflineDetector` - An object with methods to control the detector + +### `OfflineDetectorOptions` + +```typescript +interface OfflineDetectorOptions { + /** Callback function called when the device comes online */ + onOnline?: () => void; + /** Callback function called when the device goes offline */ + onOffline?: () => void; + /** Debounce delay for state changes in milliseconds. Defaults to 1000ms */ + stateChangeDebounceDelay?: number; + /** Network verification and polling configuration */ + networkVerification?: { + /** Whether to perform actual network requests for verification. Defaults to true */ + enabled?: boolean; + /** URL to test connectivity against. Defaults to a reliable endpoint */ + url?: string; + /** Request timeout for connectivity tests in milliseconds. Defaults to 5000ms */ + requestTimeout?: number; + /** Interval between connectivity checks in milliseconds. Defaults to 60000ms */ + interval?: number; + /** Maximum consecutive failures before considering offline. Defaults to 3 */ + maxFailures?: number; + }; + /** Native events configuration */ + nativeEvents?: { + /** Whether to enable browser's native online/offline events as primary detection. Defaults to true */ + enabled?: boolean; + }; +} +``` + +### `OfflineDetector` + +```typescript +interface OfflineDetector { + /** Start monitoring network status */ + start(): void; + /** Stop monitoring network status */ + stop(): void; + /** Get current online status - returns true if online, false if offline */ + isOnline(): boolean; + /** Destroy the detector and clean up resources */ + destroy(): void; +} +``` + +## Usage Examples + +### Basic Usage + +```typescript +import { createOfflineDetector } from 'offline-detector'; + +const detector = createOfflineDetector({ + onOnline: () => { + console.log('Connection restored!'); + // Show success notification + }, + onOffline: () => { + console.log('Connection lost!'); + // Show offline indicator + }, +}); + +detector.start(); +``` + +### Advanced Configuration + +```typescript +import { createOfflineDetector } from 'offline-detector'; + +const detector = createOfflineDetector({ + onOnline: () => { + // Sync data when back online + syncPendingData(); + }, + onOffline: () => { + // Show offline banner + showOfflineBanner(); + }, + stateChangeDebounceDelay: 2000, // Wait 2 seconds before triggering callbacks + networkVerification: { + enabled: true, + url: 'https://api.example.com/health', // Your own endpoint + requestTimeout: 10000, // 10 second timeout + interval: 30000, // Check every 30 seconds + maxFailures: 2, // Go offline after 2 consecutive failures + }, + nativeEvents: { + enabled: true, // Use browser's native events as primary detection + }, +}); + +detector.start(); +``` + +### React Hook Example + +```typescript +import { useEffect, useState } from 'react'; +import { createOfflineDetector } from 'offline-detector'; + +function useOfflineDetector() { + const [isOnline, setIsOnline] = useState(true); + + useEffect(() => { + const detector = createOfflineDetector({ + onOnline: () => setIsOnline(true), + onOffline: () => setIsOnline(false), + networkVerification: { + enabled: true, + interval: 5000, // Check every 5 seconds + maxFailures: 2 + } + }); + + detector.start(); + setIsOnline(detector.isOnline()); + + return () => detector.destroy(); + }, []); + + return isOnline; +} + +// Usage in component +function App() { + const isOnline = useOfflineDetector(); + + return ( +
+

My App

+ {!isOnline && ( +
+ You're currently offline +
+ )} +
+ ); +} +``` + +### Vanilla JavaScript Example + +```javascript +import { createOfflineDetector } from 'offline-detector'; + +// Create detector with custom configuration +const detector = createOfflineDetector({ + onOnline: () => { + document.body.classList.remove('offline'); + document.getElementById('status').textContent = 'Online'; + }, + onOffline: () => { + document.body.classList.add('offline'); + document.getElementById('status').textContent = 'Offline'; + }, + stateChangeDebounceDelay: 1500, + networkVerification: { + enabled: true, + url: '/api/health', + requestTimeout: 3000, + interval: 10000, + maxFailures: 3, + }, +}); + +// Start monitoring +detector.start(); + +// Check status programmatically +function checkStatus() { + const status = detector.isOnline() ? 'Online' : 'Offline'; + console.log(`Current status: ${status}`); +} + +// Clean up when done +window.addEventListener('beforeunload', () => { + detector.destroy(); +}); +``` + +### Module Bundlers + +#### ES Modules + +```javascript +import { createOfflineDetector } from 'offline-detector'; +``` + +#### CommonJS + +```javascript +const { createOfflineDetector } = require('offline-detector'); +``` + +## Configuration Options + +### Network Verification + +The library can perform actual network requests to verify connectivity: + +- **enabled**: Enable/disable network verification (default: `true`) +- **url**: Endpoint to test against (default: `'https://www.google.com/favicon.ico'`) +- **requestTimeout**: Request timeout in milliseconds (default: `5000`) +- **interval**: Check interval in milliseconds (default: `60000`) +- **maxFailures**: Consecutive failures before going offline (default: `3`) + +### Native Events + +Uses browser's built-in `online`/`offline` events: + +- **enabled**: Enable/disable native events (default: `true`) + +### Debouncing + +Prevents rapid state changes: + +- **stateChangeDebounceDelay**: Delay in milliseconds before triggering callbacks (default: `1000`) ## License diff --git a/packages/core/package.json b/packages/core/package.json index ca5208d..df86423 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,7 +27,9 @@ "build": "npm run build:js && npm run build:types", "build:js": "rollup -c", "build:types": "tsc --declaration --declarationMap --emitDeclarationOnly --outDir dist/types", - "dev": "rollup -c -w", + "dev": "npm run dev:js & npm run dev:types", + "dev:js": "rollup -c -w", + "dev:types": "tsc --declaration --declarationMap --emitDeclarationOnly --outDir dist/types --watch", "type-check": "tsc --noEmit", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 01808a9..48e1c31 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,11 +1,35 @@ import { OfflineDetector, OfflineDetectorOptions } from './types'; +import { + createDebounce, + createNativeEventHandlers, + createPolling, + testConnectivity, +} from './utils'; export function createOfflineDetector( options: OfflineDetectorOptions = {} ): OfflineDetector { - const { onOnline, onOffline } = options; + const { + onOnline, + onOffline, + stateChangeDebounceDelay = 1000, + networkVerification = {}, + nativeEvents: nativeEventsConfig = {}, + } = options; + + const { + enabled: useNetworkTest = true, + url: testUrl = 'https://www.google.com/favicon.ico', + requestTimeout: timeout = 5000, + interval: checkInterval = 60000, + maxFailures: failureThreshold = 3, + } = networkVerification; + + const { enabled: useNativeEvents = true } = nativeEventsConfig; let isListening = false; + let isOnline = true; + let consecutiveFailures = 0; const isBrowser = typeof window !== 'undefined' && typeof navigator !== 'undefined'; @@ -14,22 +38,124 @@ export function createOfflineDetector( throw new Error('OfflineDetector can only be used in browser environments'); } - const handleOnline = (): void => { - onOnline?.(); + const debounceAction = createDebounce({ + delay: stateChangeDebounceDelay, + callback: () => { + if (isOnline) { + onOnline?.(); + } else { + onOffline?.(); + } + }, + }); + + const handleStateChange = (newState: boolean): void => { + if (isOnline === newState) return; + + const wasOnline = isOnline; + isOnline = newState; + if (useNetworkTest && isListening) { + if (newState && !wasOnline) { + offlinePolling.stop(); + polling.start(); + } else if (!newState && wasOnline) { + polling.stop(); + offlinePolling.start(); + } + } + + debounceAction.call(); }; - const handleOffline = (): void => { - onOffline?.(); + const verifyConnectivity = async (): Promise => { + if (!useNetworkTest) { + return navigator.onLine; + } + + try { + const isConnected = await testConnectivity({ + testUrl, + timeout, + }); + return isConnected; + } catch { + return false; + } + }; + + const performConnectivityCheck = async (): Promise => { + if (!isListening) return; + + const isConnected = await verifyConnectivity(); + + if (isConnected) { + consecutiveFailures = 0; + if (!isOnline) { + handleStateChange(true); + } + } else { + if (isOnline) { + consecutiveFailures++; + if (consecutiveFailures >= failureThreshold) { + handleStateChange(false); + } + } + } }; + const nativeEvents = createNativeEventHandlers({ + onOnline: () => { + if (!useNativeEvents) return; + + if (useNetworkTest) { + verifyConnectivity().then(isActuallyOnline => { + if (isActuallyOnline) { + consecutiveFailures = 0; + handleStateChange(true); + } + }); + } else { + consecutiveFailures = 0; + handleStateChange(true); + } + }, + + onOffline: () => { + if (!useNativeEvents) return; + handleStateChange(false); + }, + }); + + const polling = createPolling({ + interval: checkInterval, + callback: performConnectivityCheck, + }); + + const offlinePolling = createPolling({ + interval: Math.min(checkInterval / 4, 15000), + callback: performConnectivityCheck, + }); + return { start(): void { if (isListening) return; isListening = true; - window.addEventListener('online', handleOnline); - window.addEventListener('offline', handleOffline); + isOnline = navigator.onLine; + + if (useNativeEvents) { + nativeEvents.addListeners(); + } + + if (useNetworkTest) { + if (isOnline) { + polling.start(); + } else { + offlinePolling.start(); + } + performConnectivityCheck(); + } }, stop(): void { @@ -37,12 +163,17 @@ export function createOfflineDetector( isListening = false; - window.removeEventListener('online', handleOnline); - window.removeEventListener('offline', handleOffline); + if (useNativeEvents) { + nativeEvents.removeListeners(); + } + + polling.stop(); + offlinePolling.stop(); + debounceAction.cancel(); }, isOnline(): boolean { - return navigator.onLine; + return isOnline; }, destroy(): void { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b13ad3f..e4160b6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,11 +1,37 @@ export interface OfflineDetectorOptions { + /** Callback function called when the device comes online */ onOnline?: () => void; + /** Callback function called when the device goes offline */ onOffline?: () => void; + /** Debounce delay for state changes in milliseconds. Defaults to 1000ms */ + stateChangeDebounceDelay?: number; + /** Network verification and polling configuration */ + networkVerification?: { + /** Whether to perform actual network requests for verification. Defaults to true */ + enabled?: boolean; + /** URL to test connectivity against. Defaults to a reliable endpoint */ + url?: string; + /** Request timeout for connectivity tests in milliseconds. Defaults to 5000ms */ + requestTimeout?: number; + /** Interval between connectivity checks in milliseconds. Defaults to 60000ms */ + interval?: number; + /** Maximum consecutive failures before considering offline. Defaults to 3 */ + maxFailures?: number; + }; + /** Native events configuration */ + nativeEvents?: { + /** Whether to enable browser's native online/offline events as primary detection. Defaults to true */ + enabled?: boolean; + }; } export interface OfflineDetector { + /** Start monitoring network status */ start(): void; + /** Stop monitoring network status */ stop(): void; + /** Get current online status - returns true if online, false if offline */ isOnline(): boolean; + /** Destroy the detector and clean up resources */ destroy(): void; } diff --git a/packages/core/src/utils/debounce.ts b/packages/core/src/utils/debounce.ts new file mode 100644 index 0000000..54c6ef7 --- /dev/null +++ b/packages/core/src/utils/debounce.ts @@ -0,0 +1,29 @@ +export interface DebounceOptions { + delay: number; + callback: () => void; +} + +export function createDebounce(options: DebounceOptions): { + call: () => void; + cancel: () => void; +} { + const { delay, callback } = options; + let timer: ReturnType | null = null; + + return { + call(): void { + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(callback, delay); + }, + + cancel(): void { + if (timer) { + clearTimeout(timer); + timer = null; + } + }, + }; +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 0000000..f666eeb --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './debounce'; +export * from './native-events'; +export * from './network-test'; +export * from './polling'; diff --git a/packages/core/src/utils/native-events.ts b/packages/core/src/utils/native-events.ts new file mode 100644 index 0000000..8be2b27 --- /dev/null +++ b/packages/core/src/utils/native-events.ts @@ -0,0 +1,31 @@ +export interface NativeEventHandlers { + onOnline: () => void; + onOffline: () => void; +} + +export function createNativeEventHandlers(handlers: NativeEventHandlers): { + addListeners: () => void; + removeListeners: () => void; +} { + const { onOnline, onOffline } = handlers; + + const handleOnline = (): void => { + onOnline(); + }; + + const handleOffline = (): void => { + onOffline(); + }; + + return { + addListeners(): void { + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + }, + + removeListeners(): void { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }, + }; +} diff --git a/packages/core/src/utils/network-test.ts b/packages/core/src/utils/network-test.ts new file mode 100644 index 0000000..fd6d4e8 --- /dev/null +++ b/packages/core/src/utils/network-test.ts @@ -0,0 +1,27 @@ +export interface NetworkTestOptions { + testUrl: string; + timeout: number; +} + +export async function testConnectivity( + options: NetworkTestOptions +): Promise { + const { testUrl, timeout } = options; + + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timeout')), timeout); + }); + + const fetchPromise = fetch(testUrl, { + method: 'HEAD', + mode: 'no-cors', + cache: 'no-cache', + }); + + await Promise.race([fetchPromise, timeoutPromise]); + return true; + } catch { + return false; + } +} diff --git a/packages/core/src/utils/polling.ts b/packages/core/src/utils/polling.ts new file mode 100644 index 0000000..8c53bb8 --- /dev/null +++ b/packages/core/src/utils/polling.ts @@ -0,0 +1,27 @@ +export interface PollingOptions { + interval: number; + callback: () => void | Promise; +} + +export function createPolling(options: PollingOptions): { + start: () => void; + stop: () => void; +} { + const { interval, callback } = options; + let timer: ReturnType | null = null; + + return { + start(): void { + if (timer) return; + + timer = setInterval(callback, interval); + }, + + stop(): void { + if (timer) { + clearInterval(timer); + timer = null; + } + }, + }; +}