// The reactive net-state store (O2): the single source of truth for the detected // connectivity/version state, running the pure reducer (netstate.ts, O1) over a `$state` snapshot and // owning the side-effects the reducer asks for — the probe/recovery watcher, the OS-signal listeners // and the offline/online toast. connection.svelte.ts and offline.svelte.ts are now thin derived shims // over this module, so their ~40 consumers keep reading `connection.online` / `offlineMode.active` // unchanged while a single machine drives them underneath. // // The store is a leaf module: it never imports app.svelte.ts. The pieces that need app context — the // smart recovery (reconcile-or-reachability) and the toast text (i18n) — are injected via // register*() hooks the bootstrap wires, which also breaks what would otherwise be an import cycle. import { INITIAL, reduce, type Effect, type NetConfig, type NetEvent, type NetSnapshot } from './netstate'; import { backoffMs } from './retry'; const isMock = import.meta.env.MODE === 'mock'; // Production hysteresis tuning (owner-confirmed). k = 3 consecutive probe failures ⇒ offline (a single // blip is one failure, so it rides out in `connecting`); debounceMs is the wall-clock backstop for a // slow, spaced-out failure sequence. Both sit on top of the transport's own retry/backoff. const cfg: NetConfig = { k: 3, debounceMs: 15000 }; // While offline, re-check reachability on this cadence (mirrors the old app.svelte.ts recovery poll). const OFFLINE_POLL_MS = 4000; let snap = $state({ ...INITIAL }); /** netState exposes the reactive detected state; read the getters in markup / `$derived`. */ export const netState = { /** state is the raw machine state. */ get state() { return snap.state; }, /** online is true only when the gateway is reachable and the version accepted (full features). */ get online() { return snap.state === 'online'; }, /** connecting is true while a call/probe is failing but the anti-flap window has not elapsed. */ get connecting() { return snap.state === 'connecting'; }, /** offline is true in either offline state (the kill switch / blue chrome / local-only lobby). */ get offline() { return snap.state === 'offlineNoNetwork' || snap.state === 'offlineVersionLocked'; }, /** versionLocked is true when the client is below the hard minimum version (the Update notice). */ get versionLocked() { return snap.state === 'offlineVersionLocked'; }, }; // Injected hooks (dependency inversion — see the module comment). let probeFn: (() => Promise) | null = null; let recoverFn: (() => Promise) | null = null; let toastFn: ((toast: 'offline' | 'online') => void) | null = null; let nudgeFn: (() => void) | null = null; /** registerProbe installs the low-level reachability probe (a cheap authenticated read); the transport * wires it, and checkReachable / the smart recovery use it. It should reject when there is no session. */ export function registerProbe(fn: () => Promise): void { probeFn = fn; } /** registerRecovery installs the smart recovery the watcher runs while connecting/offline: reconcile a * server guest when there is no session (native), otherwise a reachability check. It resolves when the * gateway is reachable and rejects to stay offline. The bootstrap wires it. */ export function registerRecovery(fn: () => Promise): void { recoverFn = fn; } /** registerNetToast installs the offline/online toast renderer (i18n text lives in app scope). */ export function registerNetToast(fn: (toast: 'offline' | 'online') => void): void { toastFn = fn; } /** registerNetNudge installs the soft "update available" latch the setNudge effect fires. It runs only * when the machine accepts a versionRecommended from online (O1), so the nudge never shows offline. */ export function registerNetNudge(fn: () => void): void { nudgeFn = fn; } // The probe/recovery watcher: one self-rescheduling timer whose cadence follows the current state — // exponential backoff while `connecting`, a fixed poll while `offlineNoNetwork`, and idle otherwise. It // is inert under the mock (no real transport); the e2e drives transitions through the window hooks. let timer: ReturnType | null = null; let attempt = 0; let probing = false; function stopTimer(): void { if (timer) { clearTimeout(timer); timer = null; } } function arm(delayMs: number): void { if (isMock) return; stopTimer(); timer = setTimeout(runProbe, delayMs); } async function runProbe(): Promise { timer = null; const st = snap.state; // Nothing to check when online, and the version lock only exits on an app update (a fresh boot). if (st === 'online' || st === 'offlineVersionLocked') return; if (probing || !recoverFn) return; probing = true; try { await recoverFn(); emit('probeOk'); } catch { emit('probeFailed'); // Reschedule by the (possibly just-changed) state: keep backing off while connecting, poll while // offline; stop once online / version-locked (the guard at the top of the next run). if (snap.state === 'connecting') arm(backoffMs(Math.min(++attempt, 6))); else if (snap.state === 'offlineNoNetwork') arm(OFFLINE_POLL_MS); } finally { probing = false; } } /** * checkReachable runs the registered reachability probe once, bounded by timeoutMs, and reports whether * the gateway answered — a single attempt for the cold-start / recovery decision. It reports false when * no probe is registered or the timeout wins. */ export async function checkReachable(timeoutMs: number): Promise { if (!probeFn) return false; try { await Promise.race([ probeFn(), new Promise((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)), ]); return true; } catch { return false; } } /** emit applies one event to the machine (stamping it with the current time — the reducer chose * time-on-the-event) and runs the resulting effects. It is the single entry point every signal source * funnels through. */ export function emit(type: NetEvent): void { const { next, effects } = reduce(snap, { type, at: Date.now() }, cfg); snap = next; for (const effect of effects) applyEffect(effect); } function applyEffect(effect: Effect): void { switch (effect.kind) { case 'toast': toastFn?.(effect.toast); break; case 'startProbe': // Kick the watcher from the top of the backoff (entering connecting, or an osOnline hint). attempt = 0; arm(backoffMs(1)); break; case 'showVersionNotice': // The Update/Play-offline notice (UpdateOverlay.svelte) reads netState.versionLocked directly, so // this effect needs no side-effect here — the state transition is the signal. break; case 'setNudge': // Latch the soft "update available" nudge (update.svelte.ts, wired via registerNetNudge). Fires // only from online (O1), so the nudge is never raised while offline / version-locked. nudgeFn?.(); break; } } /** * bootOffline drives the machine straight into offlineNoNetwork for a known-offline launch — a native * device-local guest with no server session, or a web cached-session launch the reachability check * could not confirm — and arms the recovery poll. It bypasses the connecting hysteresis on purpose: * boot is not a mid-session flap, so there is nothing to ride out. Pass kickNow to start the recovery * poll immediately (the native reconcile) rather than after the first interval. */ export function bootOffline(kickNow = false): void { // provisional: this offline is an assumption, not an observation — the first probe result confirms // it (silent heal if reachable, so a first launch that had connectivity does not flash a spurious // "back online" toast) or turns it into a real offline (see the reducer). snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true }; arm(kickNow ? 0 : OFFLINE_POLL_MS); } /** initNetSignals wires the web connectivity hints (navigator online/offline) to the machine. The * native @capacitor/network hint is wired separately in native.ts. A no-op under SSR and the mock (the * e2e drives connectivity through the window hooks). */ export function initNetSignals(): void { if (typeof window === 'undefined' || isMock) return; window.addEventListener('offline', () => emit('osOffline')); window.addEventListener('online', () => emit('osOnline')); } /** resetNetState returns the machine to online and stops the watcher (e.g. on logout). */ export function resetNetState(): void { stopTimer(); attempt = 0; snap = { state: 'online', fails: 0, connectingSince: 0 }; }