@actuate-media/cms-admin 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,10 +5,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
5
5
  import {
6
6
  type Notification,
7
7
  type NotificationOutcome,
8
+ type NotificationStreamHandle,
9
+ type NotificationStreamOptions,
8
10
  listNotifications as defaultListNotifications,
9
11
  markAllNotificationsRead as defaultMarkAllRead,
10
12
  markNotificationRead as defaultMarkRead,
11
13
  notificationSummary,
14
+ subscribeNotificationStream as defaultSubscribeStream,
12
15
  unreadCount as defaultUnreadCount,
13
16
  } from '../lib/notifications-client.js'
14
17
 
@@ -20,25 +23,41 @@ import {
20
23
  *
21
24
  * Design choices:
22
25
  *
23
- * - **Polling, not SSE.** Phase 3 already ships an SSE stream for live
24
- * previews (Phase 2 / `/preview/stream`), but extending it to
25
- * per-user notifications adds a non-trivial fan-out path and a fresh
26
- * security surface (privacy-leak risk on misconfigured streams). The
27
- * bell polls every `pollIntervalMs` (default 30s) and on dropdown
28
- * open. A push-based extension is queued as a Phase 4 follow-up.
26
+ * - **SSE push, with poll fallback (Phase 7).** A persistent
27
+ * `EventSource` subscribes to `/api/cms/notifications/stream` for
28
+ * instant delivery; new notifications bump the badge immediately
29
+ * instead of after the next 30 s poll tick. The poll loop is kept
30
+ * alive at a back-pressure-friendly cadence (default 60 s twice
31
+ * the legacy 30 s value because SSE handles the common case) and
32
+ * acts as a backstop for:
33
+ * 1. multi-node deployments where the in-process bus only
34
+ * publishes to subscribers on the same node, and
35
+ * 2. proxies / browsers that silently drop the EventSource
36
+ * without firing `onerror`.
29
37
  * - **Optimistic read state.** When the user clicks a notification we
30
38
  * mark it read locally before the server confirms — so the bell
31
39
  * counter feels instant. Server failures roll the state back via
32
40
  * the `onError` toast.
33
41
  * - **API injection.** The dependency-injection seam matches the rest
34
42
  * of the admin (`CommentSidePanel.api`) so unit tests can substitute
35
- * in-memory fakes.
43
+ * in-memory fakes — including the EventSource subscriber, which
44
+ * isn't available in JSDOM by default.
36
45
  */
37
46
  export interface NotificationsApi {
38
47
  list: (opts: { includeRead?: boolean }) => Promise<NotificationOutcome<Notification[]>>
39
48
  unread: () => Promise<NotificationOutcome<number>>
40
49
  markRead: (id: string) => Promise<NotificationOutcome<Notification>>
41
50
  markAllRead: () => Promise<NotificationOutcome<number>>
51
+ /**
52
+ * Open a per-user SSE subscription. Implementations MUST always
53
+ * return a handle — even in environments without `EventSource`
54
+ * (SSR, JSDOM without a polyfill), where a no-op `{ close() {} }`
55
+ * is the right answer. The bell's effect calls `handle.close()`
56
+ * unconditionally on unmount; a `null` return would crash.
57
+ * Omitting the member entirely is the supported way to opt out of
58
+ * SSE (the bell then runs poll-only).
59
+ */
60
+ subscribe?: (opts: NotificationStreamOptions) => NotificationStreamHandle
42
61
  }
43
62
 
44
63
  const defaultApi: NotificationsApi = {
@@ -46,10 +65,16 @@ const defaultApi: NotificationsApi = {
46
65
  unread: () => defaultUnreadCount(),
47
66
  markRead: (id) => defaultMarkRead(id),
48
67
  markAllRead: () => defaultMarkAllRead(),
68
+ subscribe: (opts) => defaultSubscribeStream(opts),
49
69
  }
50
70
 
51
71
  export interface NotificationBellProps {
52
- /** Polling cadence in milliseconds. Default 30,000. Pass 0 to disable. */
72
+ /**
73
+ * Polling cadence in milliseconds for the fallback loop. Default
74
+ * 60,000 (twice the pre-SSE value — push handles the common case).
75
+ * Pass 0 to disable polling entirely (relies on SSE only — only
76
+ * safe on single-node deployments).
77
+ */
53
78
  pollIntervalMs?: number
54
79
  /** Optional click hook — called with the notification when a row is clicked. */
55
80
  onSelect?: (notification: Notification) => void
@@ -61,7 +86,7 @@ export interface NotificationBellProps {
61
86
  }
62
87
 
63
88
  export function NotificationBell({
64
- pollIntervalMs = 30_000,
89
+ pollIntervalMs = 60_000,
65
90
  onSelect,
66
91
  onError,
67
92
  api = defaultApi,
@@ -72,6 +97,15 @@ export function NotificationBell({
72
97
  const [items, setItems] = useState<Notification[]>([])
73
98
  const [loading, setLoading] = useState(false)
74
99
  const mountedRef = useRef(true)
100
+ // Set of notification IDs we've already rendered. The SSE handler
101
+ // consults this synchronously so duplicate deliveries (REST refresh
102
+ // + SSE push, or two SSE retries) don't double-bump the badge.
103
+ // Kept in sync with `items` via an effect — the items state is the
104
+ // source of truth; this ref is a derived O(1) lookup index.
105
+ const seenIdsRef = useRef<Set<string>>(new Set())
106
+ useEffect(() => {
107
+ seenIdsRef.current = new Set(items.map((n) => n.id))
108
+ }, [items])
75
109
  useEffect(() => {
76
110
  return () => {
77
111
  mountedRef.current = false
@@ -111,6 +145,34 @@ export function NotificationBell({
111
145
  return () => clearInterval(id)
112
146
  }, [refreshUnread, pollIntervalMs])
113
147
 
148
+ // SSE push subscription. Bumps the badge + prepends the new row to
149
+ // the open dropdown the instant the server publishes. Falls back
150
+ // silently when the api/runtime doesn't support EventSource — the
151
+ // poll loop above keeps the bell correct in that case.
152
+ useEffect(() => {
153
+ if (!api.subscribe) return
154
+ const handle = api.subscribe({
155
+ onNotification: (n) => {
156
+ if (!mountedRef.current) return
157
+ // Defensive: SSE events can race with REST refreshes (or with
158
+ // their own retries). The seen-id ref is updated
159
+ // synchronously so a second delivery of the same row is a
160
+ // no-op for BOTH `items` and the unread badge — guarding only
161
+ // setItems would still let the counter drift.
162
+ if (seenIdsRef.current.has(n.id)) return
163
+ seenIdsRef.current.add(n.id)
164
+ setItems((arr) => [n, ...arr])
165
+ if (!n.readAt) setUnread((v) => v + 1)
166
+ },
167
+ onError: () => {
168
+ // Don't surface every transient SSE disconnect as a UI error —
169
+ // the browser auto-reconnects, and the poll loop keeps state
170
+ // converging. We deliberately stay quiet here.
171
+ },
172
+ })
173
+ return () => handle.close()
174
+ }, [api])
175
+
114
176
  useEffect(() => {
115
177
  if (!open) return
116
178
  void refreshList(false)
@@ -138,14 +200,24 @@ export function NotificationBell({
138
200
  )
139
201
 
140
202
  const handleMarkAll = useCallback(async () => {
141
- const previous = items.slice()
203
+ // Snapshot ONLY the previously-unread IDs (and their original
204
+ // readAt values, which are null by definition). On rollback we
205
+ // restore those specific rows and leave anything that arrived
206
+ // via SSE while the request was in flight alone — otherwise a
207
+ // failed mark-all-read would drop fresh notifications.
208
+ const previouslyUnreadIds = items.filter((n) => n.readAt === null).map((n) => n.id)
209
+ if (previouslyUnreadIds.length === 0) return
210
+ const ids = new Set(previouslyUnreadIds)
142
211
  const now = new Date().toISOString()
143
- setItems(items.map((n) => ({ ...n, readAt: n.readAt ?? now })))
212
+ setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: n.readAt ?? now } : n)))
144
213
  setUnread(0)
145
214
  const out = await api.markAllRead()
146
215
  if (!out.ok) {
147
216
  onError?.(out.error)
148
- setItems(previous)
217
+ // Functional rollback: only revert the rows we optimistically
218
+ // marked. Anything mid-flight (SSE arrivals, locally-added) is
219
+ // untouched.
220
+ setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: null } : n)))
149
221
  await refreshUnread()
150
222
  }
151
223
  }, [api, items, onError, refreshUnread])
package/src/lib/api.ts CHANGED
@@ -6,6 +6,17 @@ export function setApiBase(path: string) {
6
6
  basePath = path
7
7
  }
8
8
 
9
+ /**
10
+ * Current API base path. Exposed so non-`fetch` consumers (in
11
+ * particular `EventSource` for SSE channels) can resolve URLs against
12
+ * the same prefix configured via `setApiBase()`. Keep this read-only
13
+ * — mutation must always flow through `setApiBase()` so we never have
14
+ * two callers with diverging views of the base.
15
+ */
16
+ export function getApiBase(): string {
17
+ return basePath
18
+ }
19
+
9
20
  function getCsrfToken(): string {
10
21
  if (typeof document === 'undefined') return ''
11
22
  const match = document.cookie.match(/(?:^|;\s*)actuate_csrf=([^;]*)/)
@@ -11,7 +11,7 @@
11
11
  * (which carries Prisma types that don't belong in the admin bundle).
12
12
  */
13
13
 
14
- import { cmsApi } from './api.js'
14
+ import { cmsApi, getApiBase } from './api.js'
15
15
 
16
16
  export type NotificationKind = 'comment_reply' | 'comment_resolved' | 'comment_mention'
17
17
 
@@ -125,6 +125,152 @@ export async function markAllNotificationsRead(): Promise<NotificationOutcome<nu
125
125
  return { ok: true, result: res.data.count }
126
126
  }
127
127
 
128
+ /**
129
+ * Subscribe to the per-user SSE push channel that ships notifications
130
+ * the instant they're persisted server-side. Replaces the 30-second
131
+ * poll loop in the notification bell.
132
+ *
133
+ * The browser's native `EventSource` handles reconnection for us — on
134
+ * a server-initiated close (the stream caps at ~4 minutes by design),
135
+ * the connection re-opens automatically. We DO NOT abort the poll
136
+ * fallback in callers: SSE is a latency-win, the poll is what keeps
137
+ * multi-node deployments correct (each node only pushes to its own
138
+ * subscribers).
139
+ *
140
+ * Returns a `close()` function that callers MUST invoke on unmount —
141
+ * otherwise the browser keeps the connection open and we leak event
142
+ * listeners.
143
+ *
144
+ * `EventSource` itself isn't available in the JSDOM test environment;
145
+ * callers can inject a `factory` to substitute a test double. In
146
+ * production we fall back to the global `EventSource` when no factory
147
+ * is supplied.
148
+ */
149
+ export interface NotificationStreamOptions {
150
+ /** Called for every `event: notification` frame. */
151
+ onNotification: (n: Notification) => void
152
+ /** Called once on the `event: ready` frame. */
153
+ onReady?: (payload: { userId: string }) => void
154
+ /** Called on connection errors. Useful for falling back to the poll loop. */
155
+ onError?: (event: Event) => void
156
+ /**
157
+ * Optional factory so unit tests can inject a fake `EventSource`,
158
+ * or a null sentinel when the environment lacks one (SSR, JSDOM
159
+ * without a polyfill). Production code can rely on the default
160
+ * (global `EventSource`).
161
+ */
162
+ factory?: (url: string) => EventSourceLike | null
163
+ }
164
+
165
+ /**
166
+ * Minimal subset of the `EventSource` interface our subscriber relies
167
+ * on. Browsers ship the full DOM type; the admin only needs add/remove
168
+ * event listeners plus `close()`.
169
+ */
170
+ export interface EventSourceLike {
171
+ addEventListener: (type: string, listener: (event: MessageEvent | Event) => void) => void
172
+ removeEventListener?: (type: string, listener: (event: MessageEvent | Event) => void) => void
173
+ close: () => void
174
+ }
175
+
176
+ export interface NotificationStreamHandle {
177
+ /** Tear down the EventSource and stop receiving events. Idempotent. */
178
+ close: () => void
179
+ }
180
+
181
+ export function subscribeNotificationStream(
182
+ opts: NotificationStreamOptions,
183
+ ): NotificationStreamHandle {
184
+ // The `cmsApi()` helper handles base-path / origin resolution for
185
+ // fetch calls, but EventSource takes a raw URL. We mirror its
186
+ // logic locally — `/api/cms/notifications/stream` is correct for
187
+ // both the local dev server and any production deploy that mounts
188
+ // the CMS API at the default prefix. Custom prefixes are handled by
189
+ // the consumer override (factory).
190
+ // Resolve against `setApiBase()` so a consumer mounting the CMS at
191
+ // a non-default prefix (e.g. `/cms-api`) still hits the right SSE
192
+ // endpoint — `EventSource` doesn't go through `cmsApi()`, so we
193
+ // can't rely on its url-construction logic.
194
+ const url = `${getApiBase()}/notifications/stream`
195
+ const make = opts.factory ?? defaultEventSourceFactory
196
+ const source = make(url)
197
+ if (!source) {
198
+ // No EventSource available (SSR? JSDOM without polyfill? Or the
199
+ // injected factory deliberately returns null in tests). Return a
200
+ // no-op handle so callers can compose without conditional logic.
201
+ return { close: () => {} }
202
+ }
203
+ let closed = false
204
+
205
+ const onReady = (event: MessageEvent | Event) => {
206
+ if (!opts.onReady) return
207
+ const data = parseEventData(event)
208
+ if (data && typeof (data as { userId?: unknown }).userId === 'string') {
209
+ opts.onReady({ userId: (data as { userId: string }).userId })
210
+ }
211
+ }
212
+ const onNotification = (event: MessageEvent | Event) => {
213
+ const data = parseEventData(event)
214
+ if (data && isNotification(data)) {
215
+ opts.onNotification(data)
216
+ }
217
+ }
218
+ const onError = (event: Event) => {
219
+ opts.onError?.(event)
220
+ }
221
+
222
+ source.addEventListener('ready', onReady)
223
+ source.addEventListener('notification', onNotification)
224
+ source.addEventListener('error', onError)
225
+
226
+ return {
227
+ close: () => {
228
+ if (closed) return
229
+ closed = true
230
+ source.removeEventListener?.('ready', onReady)
231
+ source.removeEventListener?.('notification', onNotification)
232
+ source.removeEventListener?.('error', onError)
233
+ source.close()
234
+ },
235
+ }
236
+ }
237
+
238
+ function defaultEventSourceFactory(url: string): EventSourceLike | null {
239
+ if (typeof EventSource === 'undefined') return null
240
+ // `withCredentials: true` so the admin session cookie travels with
241
+ // the connection upgrade — every notification stream requires auth
242
+ // and the same-origin cookie is the only credential we trust here.
243
+ return new EventSource(url, { withCredentials: true }) as unknown as EventSourceLike
244
+ }
245
+
246
+ function parseEventData(event: MessageEvent | Event): unknown {
247
+ // EventSource emits MessageEvents with `data: string`. Plain
248
+ // `Event` (e.g. the `error` event) has no payload; ignore.
249
+ const data = (event as MessageEvent).data
250
+ if (typeof data !== 'string') return null
251
+ try {
252
+ return JSON.parse(data)
253
+ } catch {
254
+ return null
255
+ }
256
+ }
257
+
258
+ function isNotification(value: unknown): value is Notification {
259
+ if (!value || typeof value !== 'object') return false
260
+ const v = value as Record<string, unknown>
261
+ // Defensive: don't trust the wire blindly — verify the discriminant
262
+ // properties our consumers will read. A drift in the server-side
263
+ // wire format must surface as "missing fields" in the test suite,
264
+ // not as runtime errors deep in the bell's render path.
265
+ return (
266
+ typeof v.id === 'string' &&
267
+ typeof v.userId === 'string' &&
268
+ typeof v.createdAt === 'string' &&
269
+ !!v.payload &&
270
+ typeof (v.payload as { kind?: unknown }).kind === 'string'
271
+ )
272
+ }
273
+
128
274
  /**
129
275
  * Cheap single-line summary for the bell list — the server gives us a
130
276
  * structured payload, but the dropdown wants one human-readable string