Gå til innholdet
ErmisAI

Realtime streams and polling fallback

Three SSE endpoints, four event types, per-event authorization, and the flag that turns streaming off server-side.

ErmisAI has no push transport. Everything described on this page is server-sent events, and every SSE endpoint in the codebase is a server-side poller over Postgres. There is no pub/sub, no WebSocket, no message broker, and no database change feed. A connected client holds a streaming function open; that function re-reads the same data the REST endpoints read, on a fixed interval, and writes an SSE frame for anything it has not already seen on that connection.

Realtime state is not Redis-backed, whatever older documentation said. Alert history, notifications, and the review and delivery event streams are platform_state rows in Postgres — see Database schema and runtime state. Redis holds no application state and takes no part in any realtime path.

/api/feed/events exists because it replaced a Supabase Realtime postgres_changes subscription on editorial_story_drafts. The reasoning is in the route header at src/app/api/feed/events/route.ts:16-23: tenant scoping is enforced server-side from the Clerk session, so the browser needs no database client and no JWT template.

The three endpoints

RouteEvents emittedServer pollKeepaliveSelf-closeAuth gate
GET /api/notifications/eventsalert.triggered, notification.created, review.status.changed, delivery.failed4 s15 s240 sSession; per-event filtering
GET /api/feed/eventsfeed.changed4 s15 s240 sSession + resolvable tenant scope
GET /api/stories/[storyId]/eventsstory.pipeline.updated7 s15 s290 sSession + canEditStories

All three return the same headers (src/app/api/notifications/events/route.ts:411-418):

Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no

no-transform and X-Accel-Buffering: no are the anti-buffering pair: the first forbids intermediaries from re-encoding the payload, the second disables nginx-style response buffering. Drop either and a proxy may hold frames until the stream closes. Keep them on any new SSE route.

Only the story stream declares a function duration (export const maxDuration = 300, src/app/api/stories/[storyId]/events/route.ts:23). The other two rely on the platform default and simply close themselves at 240 s.

/api/notifications/events

The shared stream for the notification bell, the monitoring workspace, the editorial board, and two admin surfaces. One connection carries all four event types; each consumer subscribes to the subset it cares about.

Event payloads

The emit helpers in src/app/api/notifications/events/route.ts:137-201 are the contract. Client-side they are re-validated by the zod schemas in src/lib/contracts/stream-events.ts.

// alert.triggered
{ id, ruleId: string | null, storyId, title,
  severity: 'info' | 'warning' | 'critical',
  channel: 'in-app' | 'email' | 'webhook',
  deliveredAt, initial: boolean }

// review.status.changed
{ id, storyId, headline,
  action: 'submitted' | 'approved' | 'rejected' | 'returned_to_compose' | 'resynthesized',
  tenantScopeId: string | null, changedAt, initial: boolean }

// delivery.failed
{ id, deliveryId, tenant,
  method: 'webhook' | 'email' | 'feed' | 'cms' | 'wordpress' | 'api',
  responseCode: number, endpoint, createdAt, initial: boolean }

// notification.created
{ id, title, body,
  type: 'alert' | 'system' | 'billing' | 'review' | 'note' | 'publication',
  read: boolean, createdAt, initial: boolean }

parseJsonWithSchema (src/lib/contracts/stream-events.ts:59-68) returns null on malformed or schema-violating data rather than throwing, so a bad frame is dropped silently and the connection keeps running.

Historical replay on connect

Before the poll loop starts, the connection replays the 20 most recent entries of each of alert history, review status events, and failed deliveries, oldest first, each carrying initial: true (route.ts:203-258).

notification.created is not replayed. The connect-time read of listNotifications only seeds the seen-id set (route.ts:96), so a notification is emitted only when it appears on a later poll tick.

Per-event authorization

Filtering happens server-side; the client never sees an event it is not entitled to (route.ts:57-91, repeated for each tick at :283-293).

EventWho receives all tenantsWho receives their own tenant only
review.status.changedcanAccessAdminSurface(appRole, 'review')super_admin, editorcanReviewStories — tenant owner or admin
delivery.failedcanAccessAdminSurface(appRole, 'deliveries')super_admin, opseveryone else
alert.triggeredinherently: listAlertHistory is called with the caller's scope
notification.createdinherently: listNotifications is called with the caller's scope

A caller with neither review capability gets review.status.changed suppressed entirely — the route substitutes Promise.resolve([]) for the read (route.ts:76, :278).

Tenant comparison is normalizeTenantId — trim plus toLocaleLowerCase('en') — on both sides (route.ts:27-40). An event with a blank or missing tenantScopeId never matches a tenant filter.

listDeliveryFailedEvents() is called on every tick regardless of the caller's role (route.ts:77, :279). It reads the global 500-entry array (platform:admin:delivery-failed-events) and the route filters afterwards. Same for listReviewStatusChangedEvents() when the caller can review. Both reads scale with connected clients, not with tenants — there is no coalescing here, unlike the feed watermark.

The notificationsRestPolling short-circuit

If the flag is on, the route returns HTTP 204 with no body before doing any work (route.ts:53-55). The rationale is in the comment above it: EventSource does not reconnect after a 204, so the client's polling fallback takes over cleanly and the flag is authoritative server-side rather than client-advisory.

The route calls isPlatformFeatureEnabled('notificationsRestPolling') with no tenant scope (route.ts:53), so it reads the global default and ignores per-tenant overrides. The client reads the effective value — global default plus per-tenant override — from GET /api/feature-flags (src/app/api/feature-flags/route.ts:38-48). The two can therefore disagree. Behaviour still converges, because a client that opens a stream against a 204 degrades to polling anyway, but a per-tenant override alone does not stop the streaming function from being reachable.

/api/feed/events

Signals that the tenant's draft feed changed. It emits one event name, feed.changed, with the watermark that moved:

{ latestUpdatedAt: string | null, draftCount: number }

The watermark is a single aggregate over editorial_story_drafts for one tenant — max(updated_at) and count(*)::int (src/lib/db/editorial-draft-repository.ts:378-393). The stream compares both fields against the last values it emitted and stays silent when neither moved (route.ts:94-99).

Reads are coalesced per tenant. readSharedDraftFeedWatermark (src/lib/platform/feed/draft-feed-watermark.ts) caches the in-flight promise per tenant scope for WATERMARK_SHARE_WINDOW_MS = 3500 — deliberately just under the 4 s poll interval — so N clients of one tenant on one instance cost one query per poll window, not N. A rejected read is evicted from the cache immediately so an error never sticks for a full window.

If the session has no resolvable tenant scope the route returns 204 (route.ts:34-38): there is no feed to watch, the stream ends permanently, and the feed falls back to its normal fetch-on-demand behaviour.

/api/stories/[storyId]/events

Streams pipeline-phase changes for one story. Gated by canEditStories, which is every tenant role — owner, admin, member — plus the super_admin bypass (src/lib/auth/newsroom-roles.ts:143-145).

Connect sequence (route.ts:47-136):

getTenantStoryDetail(..., { lookupOnly: true }) — a snapshot read that deliberately does not trigger feed synchronisation.

On a miss, getTenantStoryAccess decides whether the caller may see the story at all; a denial is returned through createStoryAccessErrorResponse (see API error and status reference). If access is fine, one full detail lookup hydrates the snapshot. Still nothing → 404.

Writes retry: 1000 as the first frame, telling the browser to back off about a second before reconnecting after a clean close. This is the only one of the three streams that sets a retry hint.

Emits the current phase immediately as story.pipeline.updated, then polls every 7 s and emits only when phase or updatedAt changed.

Two guards are unique to this route:

  • pollInFlight prevents overlapping ticks when a lookup runs longer than the interval (route.ts:139-143).
  • STREAM_MISS_LIMIT = 3 — three consecutive lookups returning nothing close the stream (route.ts:152-158). A story deleted or moved out of scope stops the loop rather than polling forever.

The 290 s self-close exists because the Vercel function cap is 300 s; the header comment records that hitting the cap previously spammed Vercel Runtime Timeout Error into the error feed every 60 s per active viewer (route.ts:17-22).

Client transports

useNotificationStream

src/hooks/use-notification-stream.ts is the single client transport for the notification stream. It owns both modes and hides the difference from consumers.

Egenskap

Type

Status machine: 'connecting' | 'streaming' | 'polling' | 'offline'.

  • restPolling: true → status polling, one immediate onRefresh, then an interval. No EventSource is ever constructed.
  • EventSource undefined (SSR, or an environment without it) → straight to polling.
  • onerror starts the poll timer as a fallback; onopen stops it (:181-198). Note the status expression current === 'streaming' ? 'streaming' : 'polling': once a connection has opened successfully, a later error keeps the badge on the "live" label while polling continues underneath. The 240 s self-close therefore looks like an uninterrupted live connection to the user.
  • The effect re-runs only on eventsKey | restPolling | enabled | pollIntervalMs | skipInitialReplay (:210). Callbacks and the event array go through a latest-ref so a changed callback identity does not tear the stream down and reopen it.

Any new consumer must be correct under onRefresh alone. onEvent never fires in REST mode and never fires during the SSE→poll fallback, so optimistic payload application is an optimisation, not the source of truth.

useFeedRealtime

src/components/features/feed/hooks/use-feed-realtime.ts — 49 lines, deliberately minimal. It opens EventSource('/api/feed/events'), listens for feed.changed, and calls onChange('UPDATE'). The event kind is a stand-in: the watermark signal carries no insert/update/delete distinction and every consumer treats the callback as "re-fetch the feed".

It has no polling fallback, ignores notificationsRestPolling, and does nothing when tenantScopeId is null. StoryFeedWorkspace layers one narrow safety net on top: a single 4 s re-fetch timer that runs only while bootstrapInProgress is true (src/components/features/feed/StoryFeedWorkspace.tsx:201-213).

useStoryStream

src/hooks/use-story-stream.ts — its own transport, independent of the notification hook and of the flag. Falls back to polling fetchStoryDetail every FALLBACK_POLL_INTERVAL_MS = 8000 on onerror, and stops polling permanently on a 401, 403 or 404 (:101-103). Same four-state status type, exported as StoryStreamConnectionStatus from src/lib/contracts/story-workspace.ts:10.

Which surface subscribes to what

SurfaceComponentEventsOptions
Shell bell (desktop + mobile)NotificationBadgeProvideralert.triggered, notification.created, review.status.changed
Settings → NotificationsNotificationsWorkspacesame three
/app/monitoringMonitoringWorkspacealert.triggered, delivery.failedonEvent, enabled: !isBootstrapping
/app/editorialEditorialBoardWorkspacereview.status.changedskipInitialReplay: true
/admin/reviewReviewQueueWorkspacereview.status.changedskipInitialReplay: true
/admin/deliveriesDeliveryLogsWorkspacedelivery.failedskipInitialReplay: true
/app/feedStoryFeedWorkspace via useFeedRealtimefeed.changedseparate transport
Story workspaceuseStoryWorkspaceDatauseStoryStream, badge in PipelineStatusTimelinestory.pipeline.updatedseparate transport

NotificationBadgeProvider exists specifically to own the single stream for the shell. The desktop sidebar and the mobile header each render a bell and one is only CSS-hidden, so subscribing inside the bell component opened two SSE connections per page (src/components/layout/NotificationBell.tsx:20-24). The bells read an unread count from context.

Status labels by surface

Every badge is rendered from the same four-state status but each surface has its own strings.

Surfacestreamingpollingconnectingoffline
/app/monitoringLive alertsPolling fallbackConnectingOffline
Settings → NotificationsLive streamPolling fallbackConnecting streamOffline
/app/editorialLivePollingConnectingOffline
/admin/reviewLivePollingConnectingOffline
/admin/deliveriesLive streamPolling fallbackConnecting streamOffline
Story workspaceLivePollingConnectingOffline

The feed has no transport badge at all. /admin/review uses the key stream.live where every other surface uses stream.streaming — copy that key name wrongly and the badge renders a missing-message error. All of these keys are covered by tests/locale-parity.vitest.ts, so a new one must land in all ten locale files.

Monitoring overrides the displayed status while its initial data loads: isBootstrapping ? 'connecting' : liveStreamStatus (src/components/features/monitoring/MonitoringWorkspace.tsx:209). The underlying hook is offline during that window because enabled is false.

The notificationsRestPolling flag

One of the five flags at /admin/flags, defined in src/lib/contracts/feature-flags.ts:40-44, default false:

Forces the realtime notification/monitoring/review surfaces onto REST polling instead of the default Server-Sent Events stream. Off by default (SSE). Enable to avoid long-lived streaming functions; clients fall back to a 12s poll.

Enforcement is two-sided and both sides matter:

  1. Server — the route returns 204 so no stream can be established.
  2. ClientuseTenantFeatureFlags supplies restPolling, so a client that has already loaded flags never attempts a connection in the first place.

useTenantFeatureFlags (src/hooks/use-tenant-feature-flags.ts) starts from defaultTenantFeatureFlagMap — which has notificationsRestPolling: false — and silently keeps the defaults if the fetch fails. So on first paint, and permanently if /api/feature-flags is failing, clients attempt SSE. With the flag on they get a 204 and settle into polling; the outcome is right, one wasted request per mount is the cost.

The flag does not affect /api/feed/events or /api/stories/[storyId]/events. Turning it on does not eliminate long-lived streaming functions — it eliminates one of three.

See Feature flags and runtime controls for the flag store and the per-tenant override model.

Failure modes and gotchas

A poll tick must never reject unhandled. All three routes wrap the tick body in void (async () => { … })().catch(…) and close the stream on error (notifications/events/route.ts:260-266, 380-383). An unguarded rejection inside a long-lived stream is an unhandled promise rejection, which is process-fatal by default — one transient database error per connected client could take the instance down. Closing rather than continuing is also deliberate: the client reconnects or falls back instead of hammering a known-broken backend every 4 s.

Replay amplifies refetches on two surfaces. NotificationsWorkspace and NotificationBadgeProvider pass neither onEvent nor skipInitialReplay, so each of the up-to-20 replayed alert.triggered frames and up-to-20 replayed review.status.changed frames calls onRefresh — one GET /api/notifications per frame, on every connect and therefore every 240 s reconnect. The three admin and editorial surfaces set skipInitialReplay: true and avoid it.

Every reconnect is a full cold start. The seen* id sets live in the closure of one connection (route.ts:93-96). After the 240 s self-close the next connection rebuilds them from the same reads and replays the same 20 historical entries. Nothing is deduplicated across connections; the client is responsible for that, which is why MonitoringWorkspace dedupes alert history by id before merging.

delivery.failed in the monitoring UI is stream-only. The "Latest delivery failure" card is populated exclusively from live events received while the page is open (MonitoringWorkspace.tsx:54-59); it is never fetched on load, so a reload clears it until the next failure arrives.

The feed stream has no fallback. If EventSource is unavailable or the connection fails permanently, useFeedRealtime simply stops signalling and the feed only updates on user-initiated fetches. There is no timer to catch that, outside the bootstrap window.

Two 204s mean two different things. On /api/notifications/events a 204 means "the operator forced polling". On /api/feed/events it means "this session has no tenant scope". In both cases EventSource fails the connection permanently rather than retrying, which is exactly the intent — but do not read one as the other when debugging.

Streaming functions are billed as open invocations. Three concurrent streams per active tab, each re-reading Postgres on a 4–7 s cadence, is the actual load profile. The feed watermark is the only read that is coalesced.

Adding a new event type

Emit it. Add a typed emit helper next to the existing four in src/app/api/notifications/events/route.ts:137-201, seed a seen* id set at connect, and add the source read to both Promise.all blocks (connect and tick).

Decide the authorization filter server-side, in the route, following the canAccessAdminSurface / tenant-scope pattern at route.ts:57-91. Never ship an event the client is expected to filter.

Add the name to the NotificationStreamEvent union in src/hooks/use-notification-stream.ts:26-30. The consumer arrays use as const satisfies readonly NotificationStreamEvent[], so a typo fails typecheck.

Add a zod schema to src/lib/contracts/stream-events.ts and parse with parseJsonWithSchema in any consumer that uses onEvent. Include initial: z.boolean().optional() if the event participates in replay.

Make the consumer correct under onRefresh alone, then add onEvent if the optimistic path is worth it. If the surface renders a transport badge, add its stream.* keys to messages/en.json and all ten locale files or tests/locale-parity.vitest.ts fails CI.

Route behaviour for the two flag paths and the tenant filters is covered by tests/notification-events-route.vitest.ts; the watermark coalescing and the 204 path are covered by tests/feed-events-route.vitest.ts.

På denne siden