diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 1416082a..b21832cb 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -44,7 +44,7 @@ import { type BookingImportMode, } from '@trek/shared' import { getSocketId } from './websocket' -import { isReachable, probeNow } from '../sync/connectivity' +import { probeNow } from '../sync/connectivity' /** * Validate a response payload against its @trek/shared Zod schema — but only in @@ -176,13 +176,17 @@ apiClient.interceptors.response.use( // distinguish a proxy auth challenge from a genuine outage. If the server // is reachable, a top-level reload lets the edge proxy run its auth flow. if (!error.response && navigator.onLine) { - await probeNow() - // Both the original request and the health probe failed while the device - // has a network interface. This matches the proxy-auth-challenge pattern - // (CF Access / Pangolin intercept all requests and CORS-block XHR). - // Guard with sessionStorage to prevent reload loops (server genuinely - // down would also land here, but only reloads once). - if (!isReachable()) { + // Only an actual edge-proxy auth wall warrants tearing down the SW to + // reauth: a reachable proxy (CF Access / Pangolin) that intercepts /api + // with a cross-origin redirect or an HTML login page. A genuine offline + // boot ALSO lands here — navigator.onLine reflects a network interface, + // not reachability, and is routinely true on mobile while offline. So + // gate strictly on a positive proxy signal; on plain offline do nothing + // and let the request reject so the cached shell + IndexedDB serve the + // app. Unregistering the SW here reloaded into a dead network and broke + // PWA offline mode (#1346). + const state = await probeNow() + if (state === 'proxy-wall') { const { pathname } = window.location if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) { sessionStorage.setItem('proxy_reauth_attempted', '1') diff --git a/client/src/sync/connectivity.ts b/client/src/sync/connectivity.ts index dbfc0ebd..71391999 100644 --- a/client/src/sync/connectivity.ts +++ b/client/src/sync/connectivity.ts @@ -1,6 +1,12 @@ const PROBE_INTERVAL_MS = 30_000 const PROBE_TIMEOUT_MS = 1_500 +// Three distinct outcomes so callers can tell a genuine offline (the request +// never reached a server — never tear down offline infrastructure) apart from an +// edge-proxy auth wall (CF Access / Pangolin intercept /api — a top-level reload +// is needed so the proxy can run its auth flow). +export type ProbeState = 'online' | 'offline' | 'proxy-wall' + let reachable = true const listeners = new Set<(v: boolean) => void>() @@ -10,8 +16,8 @@ function setReachable(v: boolean): void { listeners.forEach(fn => fn(v)) } -async function probe(): Promise { - if (!navigator.onLine) { setReachable(false); return } +async function probe(): Promise { + if (!navigator.onLine) { setReachable(false); return 'offline' } try { const ctrl = new AbortController() const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS) @@ -19,28 +25,36 @@ async function probe(): Promise { method: 'GET', credentials: 'include', cache: 'no-store', + // Surface a cross-origin auth redirect (CF Access) as an opaque redirect + // instead of letting it throw — that's a positive proxy signal, not offline. + redirect: 'manual', signal: ctrl.signal, }) clearTimeout(t) - // /api/health returns JSON. CF Access / Pangolin will either return HTML - // (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws - // below. Both proxy-auth scenarios resolve to reachable = false. + if (res.type === 'opaqueredirect') { setReachable(false); return 'proxy-wall' } const ct = res.headers.get('content-type') || '' - setReachable(res.ok && ct.includes('application/json')) - } catch { + if (res.ok && ct.includes('application/json')) { setReachable(true); return 'online' } + // A real HTTP response that isn't our health JSON (e.g. Pangolin's HTML 200 + // auth wall, or an edge 401/403) means the proxy is reachable but gating us. setReachable(false) + return 'proxy-wall' + } catch { + // fetch threw → the request never completed: genuinely offline (or the server + // is down). Must NOT be treated as a proxy wall — that would unregister the SW. + setReachable(false) + return 'offline' } } export function startConnectivityProbe(): void { - probe() - setInterval(probe, PROBE_INTERVAL_MS) - window.addEventListener('online', probe) + void probe() + setInterval(() => { void probe() }, PROBE_INTERVAL_MS) + window.addEventListener('online', () => { void probe() }) window.addEventListener('offline', () => setReachable(false)) } export function isReachable(): boolean { return reachable } -export function probeNow(): Promise { return probe() } +export function probeNow(): Promise { return probe() } export function onChange(fn: (v: boolean) => void): () => void { listeners.add(fn) return () => listeners.delete(fn) diff --git a/client/tests/unit/sync/connectivity.test.ts b/client/tests/unit/sync/connectivity.test.ts new file mode 100644 index 00000000..0dbfa154 --- /dev/null +++ b/client/tests/unit/sync/connectivity.test.ts @@ -0,0 +1,55 @@ +// FE-SYNC-CONNECTIVITY: probeNow must tell a genuine offline (network error — +// never tear down the SW) apart from an edge-proxy auth wall (#1346). +import { describe, it, expect, vi, afterEach } from 'vitest' +import { probeNow } from '../../../src/sync/connectivity' + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, configurable: true }) +} + +function fetchReturns(res: Partial): void { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res as Response)) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + setOnline(true) +}) + +describe('FE-SYNC-CONNECTIVITY: probeNow offline vs proxy-wall (#1346)', () => { + it('FE-SYNC-CONNECTIVITY-001: navigator.onLine false → "offline"', async () => { + setOnline(false) + expect(await probeNow()).toBe('offline') + }) + + it('FE-SYNC-CONNECTIVITY-002: fetch throws (network error) → "offline" (must NOT unregister the SW)', async () => { + setOnline(true) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + expect(await probeNow()).toBe('offline') + }) + + it('FE-SYNC-CONNECTIVITY-003: ok JSON health → "online"', async () => { + setOnline(true) + fetchReturns({ type: 'basic', ok: true, headers: new Headers({ 'content-type': 'application/json' }) }) + expect(await probeNow()).toBe('online') + }) + + it('FE-SYNC-CONNECTIVITY-004: cross-origin auth redirect (CF Access) → "proxy-wall"', async () => { + setOnline(true) + fetchReturns({ type: 'opaqueredirect', ok: false, headers: new Headers() }) + expect(await probeNow()).toBe('proxy-wall') + }) + + it('FE-SYNC-CONNECTIVITY-005: HTML auth wall (Pangolin 200) → "proxy-wall"', async () => { + setOnline(true) + fetchReturns({ type: 'basic', ok: true, headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }) }) + expect(await probeNow()).toBe('proxy-wall') + }) + + it('FE-SYNC-CONNECTIVITY-006: edge 401/403 (non-JSON) → "proxy-wall"', async () => { + setOnline(true) + fetchReturns({ type: 'basic', ok: false, headers: new Headers({ 'content-type': 'text/html' }) }) + expect(await probeNow()).toBe('proxy-wall') + }) +})