marmar-context / web / context / src / api / events.ts
events.ts
Raw
// Live check-in updates: server-sent events with replay from the last event id,
// falling back to polling every 3 seconds when the stream is unavailable.
import { eventFromWire } from "./adapt-checkins";
import { clinicianApi } from "./client";
import type { CheckInEvent } from "./types";
import type { CheckInEvent as WireEvent } from "./wire";

export type StreamMode = "stream" | "polling" | "closed";
const TYPES = ["status", "note", "note_updated", "transcript", "summary", "urgent", "alert"] as const;
export const POLL_MS = 3_000;

export type Subscription = { close: () => void };

/**
 * Follow one check-in from `after` (the snapshot's lastEventId). EventSource resends
 * Last-Event-ID on its own reconnects; the first connection passes it as a query value.
 * Two stream failures within a minute switch to polling for the rest of the view.
 */
export function followCheckIn(
  checkInId: string,
  after: number,
  onEvents: (events: CheckInEvent[]) => void,
  onMode: (mode: StreamMode) => void,
  options: { forcePolling?: boolean } = {},
): Subscription {
  let last = after;
  let closed = false;
  let source: EventSource | null = null;
  let timer: ReturnType<typeof setTimeout> | null = null;
  let controller: AbortController | null = null;
  const failures: number[] = [];

  const deliver = (events: CheckInEvent[]) => {
    const fresh = events.filter((e) => e.id > last).sort((a, b) => a.id - b.id);
    if (!fresh.length) return;
    last = fresh[fresh.length - 1].id;
    onEvents(fresh);
  };

  const poll = async () => {
    if (closed) return;
    controller = new AbortController();
    try {
      const result = await clinicianApi.updates(checkInId, last, controller.signal);
      deliver(result.events);
    } catch {
      // A failed poll is retried on the next tick; the snapshot stays as it was.
    }
    if (!closed) timer = setTimeout(poll, POLL_MS);
  };

  const startPolling = () => {
    source?.close();
    source = null;
    onMode("polling");
    void poll();
  };

  if (options.forcePolling || typeof EventSource === "undefined") startPolling();
  else {
    source = new EventSource(clinicianApi.eventsUrl(checkInId, last), { withCredentials: true });
    source.onopen = () => onMode("stream");
    for (const type of TYPES)
      source.addEventListener(type, (message) => {
        try {
          deliver([eventFromWire(JSON.parse((message as MessageEvent<string>).data) as WireEvent)]);
        } catch {
          // An unreadable event is skipped; the next poll or replay brings it back.
        }
      });
    source.onerror = () => {
      const now = Date.now();
      failures.push(now);
      while (failures.length && now - failures[0] > 60_000) failures.shift();
      if (source?.readyState === EventSource.CLOSED || failures.length >= 2) startPolling();
    };
  }

  return {
    close() {
      closed = true;
      source?.close();
      controller?.abort();
      if (timer) clearTimeout(timer);
      onMode("closed");
    },
  };
}