0007 — Native realtime SSE transport (Capacitor)
Status: Implemented (client + native drop-in) · Date: 2026-07-16
Context
The realtime client (@enode/core/realtime/client.ts) reads Server-Sent Events
with a fetch + ReadableStream reader rather than the browser's native
EventSource, because the stream endpoint requires Authorization: Bearer and
api-key headers that EventSource cannot send.
The tracking app also enables CapacitorHttp: { enabled: true } (see
apps/tracking/capacitor.config.ts) so the REST API works on device: it reroutes
every window.fetch through the native HTTP stack, which ignores browser CORS —
required because the backend doesn't allow the capacitor://localhost /
https://localhost origin the WebView serves from.
These two facts collide. The native HTTP stack buffers the whole response and
cannot stream, so on iOS/Android the SSE fetch either resolves late (hangs) or is
delivered non-incrementally / closes instantly. The client then sees a dead or
churning stream and — via the heartbeat watchdog / drop handler — flips the
connectivity store offline, even though REST calls over the same native stack
keep succeeding. The Android WebView's navigator.onLine is coarse and fires
spurious offline events on top. Net: realtime is broken on native, and it
degrades offline detection into false "no connection" while the server is
reachable. (The failure modes were reproduced against the real modules by
injecting a buffering transport and a spurious offline event.)
Decision
-
Native SSE transport plugin (
EnodeSse), swapped into the existing transport seam.client.tsalready abstracts the connection behindRealtimeTransport; a new publicsetRealtimeTransport()lets the app replace it. On Capacitor,installNativeRealtimeTransport()(called at module load inApiConfigInit, before the firstrealtimeSubscribe) registers the native transport. Web keeps the fetch-stream transport unchanged. -
Dumb transport; framing stays in shared TS. Mirroring
EnodeBle, the native plugin only opens the streaming GET with the required headers and forwards raw UTF-8 lines;createSseParser(shared TS) frames the events. One parser runs on web and native, and the whole reconnect / backoff / watchdog / offline-signal logic inclient.tsis untouched — it just receives real frames again. -
Reuse the existing SSE endpoint — no backend change. Native requests bypass browser CORS (the same reason
CapacitorHttpis on for REST), so the plugin hits/realtime/eventsdirectly. Android: OkHttp withreadTimeout(0)for a long-lived stream. iOS:URLSessionDataDelegateincremental delivery. -
Foreground realtime only — not a Firebase replacement. The stream lives only while the connection is open, and the client already suspends on background. Waking a backgrounded / killed app still needs FCM / APNs and is explicitly out of scope; this decision covers in-app live updates without polling and without a third-party push service.
Consequences
- Realtime works on native, and the offline banner reflects real reachability
again — the two platform-specific false-offline sources (buffered SSE, spurious
navigator.onLine) are removed at the root, so no platform guards are needed in the connectivity store. - One native plugin per platform to maintain (
EnodeSsePlugin.kt/.swift, registered likeEnodeBle), plus anokhttpdependency on Android. This follows the established custom-plugin pattern, not a new one. - The
setRealtimeTransportseam is now public, so a future transport (e.g. a WebSocket path, if the backend adds one) drops in without touching any subscriber. - The TS bridge is unit-tested (
realtime/capacitor-sse.test.ts); the native plugin code needs a device build (npx cap sync+ Android/iOS build) to verify end-to-end — it cannot be exercised by the Node test suite.
See docs/notifications.md for the realtime client it
plugs into, and Troubleshooting for the user-visible
symptom this fixes.