@connectivity-kit/svelte
v0.1.3
Published
Minimal, SSR-safe browser connectivity tracking (online + real reachability) for Svelte 5.
Maintainers
Readme
@connectivity-kit/svelte
Minimal, SSR-safe connectivity tracking for Svelte 5. Tracks two distinct facts and combines them:
online— the OS/browser network interface is up (navigator.onLineplus theonline/offlinewindow events)reachable— the last reachability probe actually succeeded — real internet access, not just an active interface. Connected to wifi with no upstream isonline: true, reachable: false.
Browser-only by design: no adapter interface, no pluggable transport abstraction, no native-platform portability layer. There's exactly one source of truth (the browser), so there's nothing to normalize across.
Install
npm install @connectivity-kit/sveltesvelte (^5.0.0) is a peer dependency — this package doesn't bundle its own copy.
Quick start
<script lang="ts">
import { ConnectivityManager } from '@connectivity-kit/svelte';
const connectivity = new ConnectivityManager();
// The manager never ties its own lifecycle to a component automatically
// — tear it down explicitly on unmount.
$effect(() => () => connectivity.destroy());
</script>
{#if connectivity.isConnected}
<span class="badge badge-success">Online</span>
{:else if connectivity.online}
<span class="badge badge-warning">Limited connectivity</span>
{:else}
<span class="badge badge-error">Offline</span>
{/if}If you only need one instance for the whole app, construct it once in a
shared module instead of per-component and skip the destroy() call.
API
class ConnectivityManager {
constructor(options?: ConnectivityManagerOptions);
get online(): boolean; // network interface is up
get reachable(): boolean; // last probe succeeded — real internet access
get isConnected(): boolean; // online && reachable
get checking(): boolean; // a probe is currently in flight
readonly ready: Promise<void>; // resolves after the first probe settles
check: ConnectivityCheck; // an arrow-function field — see "Injecting check()" below
destroy(): void; // release all listeners/timers — idempotent
}
interface ConnectivityManagerOptions {
reachabilityUrl?: string | null; // default '/favicon.ico'; null disables probing
reachabilityIntervalMs?: number; // default 30_000
reachabilityTimeoutMs?: number; // default 5_000
checkCooldownMs?: number; // default 1_000 — see "check()" below
isBrowser?: boolean; // override environment detection (mainly for tests)
}
interface ConnectivityCheckOptions {
force?: boolean; // bypass the cooldown for this call
}
interface ConnectivityStatus {
readonly online: boolean;
readonly reachable: boolean;
readonly isConnected: boolean;
readonly checkedAt: string; // ISO-8601, of the last *completed* probe
}
type ConnectivityCheck = (options?: ConnectivityCheckOptions) => Promise<ConnectivityStatus>;All exported types are prefixed Connectivity* deliberately — this is a
published package, and generic names like StatusResult or CheckOptions
are exactly the kind of thing likely to collide with another library's (or
your own app's) exports in a consumer's autocomplete.
Injecting check()
check is defined as an arrow-function class field, not a prototype method
— it stays correctly bound to its instance even when detached and passed
around on its own:
const { check } = connectivity;
await check(); // works — no .bind(connectivity) needed
function RetryButton({ check }: { check: ConnectivityCheck }) {
// ...
}A plain method wouldn't survive this (calling it detached would throw,
since it reads private instance fields internally) — the same reason
the manager's internal online/offline event handlers are also
arrow-function fields rather than methods.
check(): cooldown and in-flight coalescing
Calling check() repeatedly in a short window (a user mashing "Retry", or
two components each calling it on mount) is handled two ways:
- In-flight coalescing. If a probe is already running — from
check(), theonlineevent, or the background interval — a newcheck()call joins that same probe instead of starting a secondfetch. This isn't configurable; overlapping probes are never useful. - Post-completion cooldown. After a probe finishes, a non-
forcecheck()call withincheckCooldownMs(default 1000ms) returns the last completedConnectivityStatusimmediately, with no new request. Pass{ force: true }to bypass this for a deliberate action, like a real "pull to refresh" gesture.
The cooldown only applies to check() — the automatic online-event and
background-interval probes are never throttled by it.
Usage scenarios
Waiting for the first check before rendering anything
<script lang="ts">
import { ConnectivityManager } from '@connectivity-kit/svelte';
const connectivity = new ConnectivityManager();
$effect(() => () => connectivity.destroy());
</script>
{#await connectivity.ready}
<Spinner />
{:then}
{#if connectivity.isConnected}
<slot />
{:else}
<OfflineBanner />
{/if}
{/await}A "Retry" button
<script lang="ts">
import { ConnectivityManager } from '@connectivity-kit/svelte';
const connectivity = new ConnectivityManager();
$effect(() => () => connectivity.destroy());
</script>
{#if !connectivity.isConnected}
<button
onclick={() => connectivity.check({ force: true })}
disabled={connectivity.checking}
>
{connectivity.checking ? 'Checking…' : 'Retry'}
</button>
{/if}force: true here is deliberate: a user pressing "Retry" expects it to
actually retry, not silently no-op because it happened to land inside the
default cooldown window.
Reading a snapshot outside a component
const result = await connectivity.check();
// result: { online, reachable, isConnected, checkedAt }
sendAnalyticsEvent('connectivity_checked', result);Custom reachability target
const connectivity = new ConnectivityManager({
reachabilityUrl: '/api/health',
reachabilityIntervalMs: 15_000
});Disabling the reachability probe entirely
const connectivity = new ConnectivityManager({ reachabilityUrl: null });connectivity.reachable will stay false and connectivity.isConnected
will always equal connectivity.online in this mode.
One instance monitoring two targets
const apiHealth = new ConnectivityManager({ reachabilityUrl: '/api/health' });
const paymentsHealth = new ConnectivityManager({ reachabilityUrl: 'https://status.payment-provider.com/ping' });Testing with isBrowser
import { describe, it, expect } from 'vitest';
import { ConnectivityManager } from '@connectivity-kit/svelte';
it('reports offline outside a browser context', async () => {
const manager = new ConnectivityManager({ isBrowser: false });
await manager.ready;
expect(manager.online).toBe(false);
manager.destroy();
});SSR safety
Outside a browser context (typeof window === 'undefined'), the manager
never touches window/navigator, never calls fetch, and never schedules
the background probe timer — all of which would otherwise be permanently
leaked on the server, since nothing calls destroy() on a server-rendered
instance. It reports online: false / reachable: false and resolves
ready immediately.
This safety net is keyed off the resolved isBrowser option, not a fresh
environment check on every use — so passing isBrowser: true explicitly on
the server (there's no legitimate reason to) reintroduces that leak. Only
pass isBrowser in tests that need to simulate one environment or the
other.
Reachability semantics
Any HTTP response — including a 404 — counts as reachable; only a
network-level failure (including our own timeout) counts as unreachable.
This means reachabilityUrl doesn't need to be a dedicated health-check
endpoint; most apps already serve something at their default
/favicon.ico.
