Skip to main content

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

  1. Native SSE transport plugin (EnodeSse), swapped into the existing transport seam. client.ts already abstracts the connection behind RealtimeTransport; a new public setRealtimeTransport() lets the app replace it. On Capacitor, installNativeRealtimeTransport() (called at module load in ApiConfigInit, before the first realtimeSubscribe) registers the native transport. Web keeps the fetch-stream transport unchanged.

  2. 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 in client.ts is untouched — it just receives real frames again.

  3. Reuse the existing SSE endpoint — no backend change. Native requests bypass browser CORS (the same reason CapacitorHttp is on for REST), so the plugin hits /realtime/events directly. Android: OkHttp with readTimeout(0) for a long-lived stream. iOS: URLSessionDataDelegate incremental delivery.

  4. 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 like EnodeBle), plus an okhttp dependency on Android. This follows the established custom-plugin pattern, not a new one.
  • The setRealtimeTransport seam 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.