easy-ping 0.7.0 → 0.8.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.
Files changed (66) hide show
  1. package/README.md +4 -0
  2. package/dist/{adapter-BJ8Iejvs.d.cts → adapter-CV5Yt2c6.d.cts} +1 -1
  3. package/dist/{adapter-BJ8Iejvs.d.ts → adapter-CV5Yt2c6.d.ts} +1 -1
  4. package/dist/adapters/drizzle.d.cts +1 -1
  5. package/dist/adapters/drizzle.d.ts +1 -1
  6. package/dist/adapters/mongodb.d.cts +2 -2
  7. package/dist/adapters/mongodb.d.ts +2 -2
  8. package/dist/adapters/mysql.d.cts +2 -2
  9. package/dist/adapters/mysql.d.ts +2 -2
  10. package/dist/adapters/postgres.d.cts +2 -2
  11. package/dist/adapters/postgres.d.ts +2 -2
  12. package/dist/adapters/sqlite.d.cts +2 -2
  13. package/dist/adapters/sqlite.d.ts +2 -2
  14. package/dist/client.cjs +7 -2
  15. package/dist/client.cjs.map +1 -1
  16. package/dist/client.js +7 -2
  17. package/dist/client.js.map +1 -1
  18. package/dist/index.d.cts +4 -4
  19. package/dist/index.d.ts +4 -4
  20. package/dist/{plugin-Wwp1nvo0.d.cts → plugin-6S8tpH0n.d.cts} +1 -1
  21. package/dist/{plugin-CSLKKgXT.d.ts → plugin-C4fikLl7.d.ts} +1 -1
  22. package/dist/plugins/digests.d.cts +2 -2
  23. package/dist/plugins/digests.d.ts +2 -2
  24. package/dist/plugins/mobile-push.cjs +307 -0
  25. package/dist/plugins/mobile-push.cjs.map +1 -0
  26. package/dist/plugins/mobile-push.d.cts +117 -0
  27. package/dist/plugins/mobile-push.d.ts +117 -0
  28. package/dist/plugins/mobile-push.js +261 -0
  29. package/dist/plugins/mobile-push.js.map +1 -0
  30. package/dist/plugins/preferences-client.d.cts +2 -2
  31. package/dist/plugins/preferences-client.d.ts +2 -2
  32. package/dist/plugins/preferences-react.d.cts +1 -1
  33. package/dist/plugins/preferences-react.d.ts +1 -1
  34. package/dist/plugins/preferences.cjs +9 -1
  35. package/dist/plugins/preferences.cjs.map +1 -1
  36. package/dist/plugins/preferences.d.cts +2 -2
  37. package/dist/plugins/preferences.d.ts +2 -2
  38. package/dist/plugins/preferences.js +9 -1
  39. package/dist/plugins/preferences.js.map +1 -1
  40. package/dist/plugins/push.d.cts +2 -2
  41. package/dist/plugins/push.d.ts +2 -2
  42. package/dist/plugins/telegram.d.cts +2 -2
  43. package/dist/plugins/telegram.d.ts +2 -2
  44. package/dist/providers/expo-push.cjs +114 -0
  45. package/dist/providers/expo-push.cjs.map +1 -0
  46. package/dist/providers/expo-push.d.cts +61 -0
  47. package/dist/providers/expo-push.d.ts +61 -0
  48. package/dist/providers/expo-push.js +109 -0
  49. package/dist/providers/expo-push.js.map +1 -0
  50. package/dist/providers/web-push.d.cts +2 -2
  51. package/dist/providers/web-push.d.ts +2 -2
  52. package/dist/react-native.cjs +637 -0
  53. package/dist/react-native.cjs.map +1 -0
  54. package/dist/react-native.d.cts +145 -0
  55. package/dist/react-native.d.ts +145 -0
  56. package/dist/react-native.js +609 -0
  57. package/dist/react-native.js.map +1 -0
  58. package/dist/react.cjs +7 -2
  59. package/dist/react.cjs.map +1 -1
  60. package/dist/react.js +7 -2
  61. package/dist/react.js.map +1 -1
  62. package/dist/schema.d.cts +2 -2
  63. package/dist/schema.d.ts +2 -2
  64. package/dist/testing.d.cts +1 -1
  65. package/dist/testing.d.ts +1 -1
  66. package/package.json +26 -3
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react-native/index.ts","../src/client/index.ts","../src/react/index.ts"],"sourcesContent":["import { useMemo, useRef } from \"react\";\nimport { AppState } from \"react-native\";\nimport { createNotifyClient, type NotifyClient, type NotifyClientOptions } from \"../client\";\nimport { type UseNotificationsResult, useNotifications as useClientNotifications } from \"../react\";\n\n/** The slice of React Native's AppState the client needs; injectable for tests. */\nexport type AppStateLike = {\n currentState: string;\n addEventListener(type: \"change\", handler: (state: string) => void): { remove(): void };\n};\n\nexport type NativeNotifyClientOptions = Omit<\n NotifyClientOptions,\n \"locks\" | \"channel\" | \"serviceWorker\" | \"activityTarget\" | \"isDocumentHidden\"\n> & {\n /** Defaults to React Native's AppState. */\n appState?: AppStateLike | undefined;\n};\n\ntype Listener = (event: { data?: unknown }) => void;\n\n/**\n * Turns AppState into the \"activity target\" the client listens on: coming\n * back to the foreground counts as focus, so the bell refreshes at once and\n * polls at the fast interval again.\n */\nfunction appStateTarget(appState: AppStateLike) {\n const listeners = new Map<string, Set<Listener>>();\n let subscription: { remove(): void } | undefined;\n let last = appState.currentState;\n\n const ensure = () => {\n subscription ??= appState.addEventListener(\"change\", (state) => {\n if (state === \"active\" && last !== \"active\") {\n for (const listener of listeners.get(\"focus\") ?? []) listener({});\n }\n last = state;\n });\n };\n\n return {\n addEventListener(type: string, handler: Listener) {\n listeners.set(type, (listeners.get(type) ?? new Set()).add(handler));\n ensure();\n },\n removeEventListener(type: string, handler: Listener) {\n listeners.get(type)?.delete(handler);\n if ([...listeners.values()].every((set) => set.size === 0)) {\n subscription?.remove();\n subscription = undefined;\n }\n },\n };\n}\n\n/**\n * The web client, wired for a phone: AppState decides hidden vs. active, one\n * app is one user so there is no tab leader to elect, and the event stream is\n * tried first. A fetch that cannot stream (React Native's built-in one) makes\n * the client fall back to polling on the first attempt; pass `fetch` from\n * `expo/fetch` to keep the live stream.\n */\nexport function createNativeNotifyClient(options: NativeNotifyClientOptions = {}): NotifyClient {\n const { appState = AppState, ...rest } = options;\n return createNotifyClient({\n transport: \"sse\",\n ...rest,\n locks: null,\n channel: null,\n serviceWorker: null,\n isDocumentHidden: () => appState.currentState !== \"active\",\n activityTarget: appStateTarget(appState),\n });\n}\n\n/** `useNotifications` for React Native. Same result shape as the web hook. */\nexport function useNotifications(\n options: NativeNotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const {\n client: provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n scope,\n appState,\n } = options;\n\n // Held in a ref so a new fetch identity per render does not rebuild the client.\n const fetchRef = useRef(options.fetch);\n fetchRef.current = options.fetch;\n\n const client = useMemo(\n () =>\n provided ??\n createNativeNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n scope,\n appState,\n fetch: (...args) => (fetchRef.current ?? globalThis.fetch)(...args),\n }),\n [\n provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n scope,\n appState,\n ],\n );\n\n return useClientNotifications({ client });\n}\n\nexport type RegisterMobilePushOptions = {\n /** The token from expo-notifications' getExpoPushTokenAsync(), or your provider's equivalent. */\n token: string;\n platform: \"ios\" | \"android\" | \"web\";\n deviceName?: string | undefined;\n baseUrl?: string | undefined;\n /** Your authenticated fetch. */\n fetch?: typeof globalThis.fetch | undefined;\n};\n\nconst post = async (\n doFetch: typeof globalThis.fetch,\n url: string,\n body: unknown,\n): Promise<Response> =>\n doFetch(url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(body),\n });\n\n/** Registers this device for the mobilePush channel. Idempotent: the same token refreshes its row. */\nexport async function registerMobilePushDevice(options: RegisterMobilePushOptions): Promise<void> {\n const base = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? globalThis.fetch;\n const response = await post(doFetch, `${base}/mobile-push/devices`, {\n token: options.token,\n platform: options.platform,\n deviceName: options.deviceName,\n });\n if (!response.ok) {\n const detail = (await response.json().catch(() => ({}))) as { error?: string };\n throw new Error(detail.error ?? `registering the device failed: ${response.status}`);\n }\n}\n\n/** Removes this device. Resolves false when nothing was registered. */\nexport async function unregisterMobilePushDevice(\n options: Pick<RegisterMobilePushOptions, \"token\" | \"baseUrl\" | \"fetch\">,\n): Promise<boolean> {\n const base = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? globalThis.fetch;\n const response = await post(doFetch, `${base}/mobile-push/devices/remove`, {\n token: options.token,\n });\n if (response.status === 404) return false;\n if (!response.ok) throw new Error(`unregistering the device failed: ${response.status}`);\n return true;\n}\n\nexport type { NotificationView, NotifyClient, NotifyClientOptions, NotifyState } from \"../client\";\nexport type { UseNotificationsResult } from \"../react\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type Transport = \"auto\" | \"sse\" | \"poll\";\n\nexport type TransportStatus = {\n /** \"leader\" holds this browser's single connection; \"follower\" mirrors it; \"solo\" has no Web Locks. */\n role: \"leader\" | \"follower\" | \"solo\";\n /** What the leader is using right now. */\n transport: \"sse\" | \"poll\";\n /** True while the event stream has said `ready` and not since closed. */\n connected: boolean;\n};\n\ntype LockManagerLike = {\n request(\n name: string,\n options: { mode: \"exclusive\"; signal?: AbortSignal },\n callback: () => Promise<void>,\n ): Promise<void>;\n};\n\ntype ChannelLike = {\n postMessage(message: unknown): void;\n addEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n removeEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n close(): void;\n};\n\ntype MessageTargetLike = {\n addEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n removeEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n /** Poll interval while the user is active and no stream is connected. Default 15 s. */\n pollIntervalMs?: number | undefined;\n /** Ceiling the idle backoff grows to. Default 10 min. */\n maxPollIntervalMs?: number | undefined;\n /** Poll interval while the event stream is connected: a safety net. Default 5 min. */\n safetyNetMs?: number | undefined;\n /** How long after user input polling stays at the base interval. Default 60 s. */\n activeWindowMs?: number | undefined;\n /** \"auto\" streams when a DOM is present, \"poll\" never streams, \"sse\" always tries. */\n transport?: Transport | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n /** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */\n locks?: LockManagerLike | null | undefined;\n /** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */\n channel?: (() => ChannelLike) | null | undefined;\n /** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */\n serviceWorker?: MessageTargetLike | null | undefined;\n /** Where user activity is observed. Defaults to window; null disables. */\n activityTarget?: MessageTargetLike | null | undefined;\n now?: (() => number) | undefined;\n /**\n * Separates browsers' tab groups when identity is not a cookie: pass the\n * current user's id (or session id) so tabs signed in as different users\n * never share a leader or mirror each other's inbox.\n */\n scope?: string | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\n/** A stream that dies this fast, repeatedly, is being cut by the platform: stop trying. */\nconst SHORT_STREAM_MS = 15_000;\nconst SHORT_STREAMS_BEFORE_GIVING_UP = 3;\n\ntype TabMessage =\n | { type: \"hello\" }\n | { type: \"state\"; state: NotifyState }\n | { type: \"mutated\" }\n | { type: \"leader\" };\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 10 * 60_000;\n const safetyNetMs = options.safetyNetMs ?? 5 * 60_000;\n const activeWindowMs = options.activeWindowMs ?? 60_000;\n const now = options.now ?? (() => Date.now());\n const groupKey = options.scope ? `${baseUrl}#${options.scope}` : baseUrl;\n const hasDom = typeof window !== \"undefined\" && typeof document !== \"undefined\";\n const transport: Transport = options.transport ?? \"auto\";\n const wantsStream = transport === \"sse\" || (transport === \"auto\" && hasDom);\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n const locks =\n options.locks === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { locks?: LockManagerLike }).locks ?? null)\n : null\n : options.locks;\n const makeChannel =\n options.channel === undefined\n ? typeof BroadcastChannel !== \"undefined\"\n ? () => new BroadcastChannel(`easy-ping:${groupKey}`) as unknown as ChannelLike\n : null\n : options.channel;\n const serviceWorker =\n options.serviceWorker === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { serviceWorker?: MessageTargetLike }).serviceWorker ?? null)\n : null\n : options.serviceWorker;\n const activityTarget =\n options.activityTarget === undefined\n ? hasDom\n ? (window as unknown as MessageTargetLike)\n : null\n : options.activityTarget;\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let role: TransportStatus[\"role\"] = \"solo\";\n let running = false;\n let channel: ChannelLike | undefined;\n let releaseLock: (() => void) | undefined;\n let lockAbort: AbortController | undefined;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n let idleTicks = 0;\n let activeUntil = 0;\n let lastRefreshAt = 0;\n let refreshing: Promise<void> | undefined;\n let refreshAgain = false;\n\n let streamAbort: AbortController | undefined;\n let streamConnected = false;\n let streamDisabled = !wantsStream;\n let shortStreams = 0;\n let streamFailures = 0;\n\n // Bumped per mutation so an in-flight poll cannot revert an optimistic update.\n let generation = 0;\n\n // A follower's own optimistic changes, kept until the leader's state reflects them.\n const localReads = new Set<string>();\n let localSeen = false;\n\n const stamp = () => new Date().toISOString();\n\n function emit() {\n for (const listener of listeners) listener(state);\n }\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n emit();\n if (role === \"leader\") post({ type: \"state\", state });\n }\n\n function post(message: TabMessage) {\n try {\n channel?.postMessage(message);\n } catch {\n // channel closed\n }\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function fetchPage(): Promise<void> {\n const startedAt = generation;\n\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight; the response is stale.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n /** Coalesces overlapping calls: a second request while one is in flight runs once after it. */\n function refresh(): Promise<void> {\n if (refreshing) {\n refreshAgain = true;\n return refreshing;\n }\n refreshing = (async () => {\n try {\n do {\n refreshAgain = false;\n await fetchPage();\n lastRefreshAt = now();\n } while (refreshAgain);\n } finally {\n refreshing = undefined;\n }\n })();\n return refreshing;\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Share the in-flight request so two clicks cannot append the same page twice.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n if (role === \"follower\") post({ type: \"mutated\" });\n } catch (error) {\n generation += 1;\n state = previous;\n emit();\n throw error;\n }\n }\n\n async function markAsRead(id: string): Promise<void> {\n if (role === \"follower\") localReads.add(id);\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n ).catch((error) => {\n localReads.delete(id);\n throw error;\n });\n }\n\n async function markAllRead(): Promise<void> {\n if (role === \"follower\") for (const row of state.notifications) localReads.add(row.id);\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n ).catch((error) => {\n localReads.clear();\n throw error;\n });\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could have seen: no cutoff would mark newer arrivals seen too.\n const newest = state.notifications[0]?.createdAt;\n if (role === \"follower\") localSeen = true;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n ).catch((error) => {\n localSeen = false;\n throw error;\n });\n }\n\n // ---- follower: mirror the leader, keep own optimistic changes until confirmed ----\n\n function applyRemoteState(remote: NotifyState) {\n const notifications = remote.notifications.map((row) => {\n if (!localReads.has(row.id)) return row;\n if (row.readAt) {\n localReads.delete(row.id);\n return row;\n }\n return { ...row, readAt: stamp() };\n });\n if (localSeen && remote.unseenCount === 0) localSeen = false;\n state = {\n ...remote,\n notifications,\n unseenCount: localSeen ? 0 : remote.unseenCount,\n unreadCount: countUnread(notifications),\n };\n emit();\n }\n\n function onTabMessage(event: { data: unknown }) {\n const message = event.data as TabMessage | null;\n if (!message || typeof message !== \"object\") return;\n if (role === \"leader\") {\n if (message.type === \"hello\") post({ type: \"state\", state });\n else if (message.type === \"mutated\") void refresh().catch(() => {});\n return;\n }\n if (message.type === \"state\") applyRemoteState(message.state);\n }\n\n // ---- leader / solo: polling with activity-shaped backoff ----\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n function nextDelay(): number {\n if (consecutiveFailures > 0) {\n return Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs);\n }\n if (streamConnected) return safetyNetMs;\n if (now() < activeUntil) return pollIntervalMs;\n idleTicks += 1;\n return Math.min(pollIntervalMs * 2 ** idleTicks, maxPollIntervalMs);\n }\n\n async function tick() {\n if (!running) return;\n // A hidden tab is not looking at the bell.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n if (running) schedule(nextDelay());\n }\n\n /** User input or a returning tab: poll fast again, and catch up now if the last refresh is stale. */\n function onActivity() {\n if (!running || role === \"follower\") return;\n activeUntil = now() + activeWindowMs;\n idleTicks = 0;\n if (!isHidden() && now() - lastRefreshAt > pollIntervalMs) {\n void refresh().catch(() => {});\n schedule(nextDelay());\n }\n }\n\n const onVisibility = () => {\n if (!isHidden()) onActivity();\n };\n\n const ACTIVITY_EVENTS = [\"focus\", \"pointerdown\", \"keydown\", \"online\"];\n\n // ---- leader / solo: the event stream ----\n\n function changed() {\n if (!running) return;\n void refresh().catch(() => {});\n }\n\n async function readStream(signal: AbortSignal): Promise<void> {\n const openedAt = now();\n const response = await doFetch(`${baseUrl}/events`, {\n headers: { accept: \"text/event-stream\" },\n signal,\n });\n if (!response.ok) throw new Error(`GET /events failed: ${response.status}`);\n if (!response.body) {\n // This fetch cannot stream a body (React Native's built-in one, some polyfills): poll instead.\n streamDisabled = true;\n schedule(nextDelay());\n return;\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let eventName = \"\";\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let newline = buffer.indexOf(\"\\n\");\n while (newline >= 0) {\n const line = buffer.slice(0, newline).replace(/\\r$/, \"\");\n buffer = buffer.slice(newline + 1);\n if (line === \"\") {\n if (eventName === \"ready\") {\n streamConnected = true;\n streamFailures = 0;\n schedule(nextDelay());\n } else if (eventName === \"changed\") changed();\n eventName = \"\";\n } else if (line.startsWith(\"event:\")) eventName = line.slice(6).trim();\n newline = buffer.indexOf(\"\\n\");\n }\n }\n } finally {\n streamConnected = false;\n if (now() - openedAt < SHORT_STREAM_MS) shortStreams += 1;\n else shortStreams = 0;\n }\n }\n\n const isFollower = () => role === \"follower\";\n\n async function streamLoop() {\n while (running && !streamDisabled && !isFollower()) {\n streamAbort = new AbortController();\n try {\n await readStream(streamAbort.signal);\n } catch {\n if (!running) return;\n streamFailures += 1;\n }\n if (!running || isFollower() || streamDisabled) return;\n if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {\n // The platform cuts streams too fast for them to be worth it here.\n streamDisabled = true;\n schedule(nextDelay());\n return;\n }\n await new Promise((resolve) =>\n setTimeout(resolve, Math.min(3_000 * 2 ** streamFailures, 60_000)),\n );\n }\n }\n\n // ---- roles ----\n\n function becomeLeader() {\n role = \"leader\";\n post({ type: \"leader\" });\n post({ type: \"state\", state });\n void tick();\n if (!streamDisabled) void streamLoop();\n }\n\n function becomeFollower() {\n role = \"follower\";\n post({ type: \"hello\" });\n }\n\n function acquireLeadership() {\n if (!locks || !channel) {\n role = \"solo\";\n void tick();\n if (!streamDisabled) void streamLoop();\n return;\n }\n becomeFollower();\n lockAbort = new AbortController();\n locks\n .request(\n `easy-ping:leader:${groupKey}`,\n { mode: \"exclusive\", signal: lockAbort.signal },\n () =>\n new Promise<void>((resolve) => {\n releaseLock = resolve;\n if (running) becomeLeader();\n else resolve();\n }),\n )\n .catch(() => {\n // aborted on stop, or Web Locks refused; either way no leadership here\n });\n }\n\n function start() {\n running = true;\n channel = makeChannel ? makeChannel() : undefined;\n channel?.addEventListener(\"message\", onTabMessage);\n serviceWorker?.addEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.addEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n activeUntil = now() + activeWindowMs;\n acquireLeadership();\n }\n\n function stop() {\n running = false;\n clearTimeout(timer);\n timer = undefined;\n streamAbort?.abort();\n streamAbort = undefined;\n lockAbort?.abort();\n lockAbort = undefined;\n releaseLock?.();\n releaseLock = undefined;\n channel?.removeEventListener(\"message\", onTabMessage);\n channel?.close();\n channel = undefined;\n serviceWorker?.removeEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.removeEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n role = \"solo\";\n }\n\n // ---- signals from outside: the service worker relay and the piggyback header ----\n\n function onRelay(event: { data?: unknown }) {\n const data = event.data as { source?: unknown; type?: unknown } | null;\n if (data?.source === \"easy-ping\" && data.type === \"changed\") changed();\n }\n\n let lastInboxVersion: string | null = null;\n\n // Only the app's own origin may steer the bell; a third-party response cannot.\n const sameOrigin = (response: Response) => {\n if (!response.url || typeof location === \"undefined\") return true;\n try {\n return new URL(response.url).origin === location.origin;\n } catch {\n return false;\n }\n };\n\n /**\n * Wraps your app's fetch so responses carrying the inbox-version header\n * refresh the bell when the version moves. Active users then never need a\n * dedicated poll. RFC 0006 idea 1.\n */\n function instrument(inner: typeof globalThis.fetch): typeof globalThis.fetch {\n return (async (...args: Parameters<typeof globalThis.fetch>) => {\n const response = await inner(...args);\n const version = sameOrigin(response) ? response.headers.get(\"x-easy-ping-inbox\") : null;\n if (version !== null && version !== lastInboxVersion) {\n const first = lastInboxVersion === null;\n lastInboxVersion = version;\n if (!first && role !== \"follower\") changed();\n }\n return response;\n }) as typeof globalThis.fetch;\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One transport regardless of how many components subscribe.\n if (listeners.size === 1) start();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) stop();\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n instrument,\n getState: () => state,\n getTransport: (): TransportStatus => ({\n role,\n transport: streamDisabled ? \"poll\" : \"sse\",\n connected: streamConnected,\n }),\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n","\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/** State lives in the framework-agnostic client; this only wires React's lifecycle. */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const {\n client: provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n } = options;\n\n // Held in a ref so a new callback identity per render does not rebuild the client.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [\n provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n ],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n"],"mappings":";AAAA,SAAS,WAAAA,UAAS,UAAAC,eAAc;AAChC,SAAS,gBAAgB;;;ACsFzB,IAAM,UAAuB;AAAA,EAC3B,eAAe,CAAC;AAAA,EAChB,aAAa;AAAA,EACb,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,OAAO;AACT;AAEA,IAAM,cAAc,CAAC,SACnB,KAAK,OAAO,CAAC,OAAO,QAAS,IAAI,WAAW,OAAO,QAAQ,IAAI,OAAQ,CAAC;AAG1E,IAAM,kBAAkB;AACxB,IAAM,iCAAiC;AAQhC,SAAS,mBAAmB,UAA+B,CAAC,GAAG;AACpE,QAAM,WAAW,QAAQ,WAAW,sBAAsB,QAAQ,OAAO,EAAE;AAC3E,QAAM,UAAU,QAAQ,UAAU,IAAI,SAAS,WAAW,MAAM,GAAG,IAAI;AACvE,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,iBAAiB,QAAQ,kBAAkB;AACjD,QAAM,oBAAoB,QAAQ,qBAAqB,KAAK;AAC5D,QAAM,cAAc,QAAQ,eAAe,IAAI;AAC/C,QAAM,iBAAiB,QAAQ,kBAAkB;AACjD,QAAM,MAAM,QAAQ,QAAQ,MAAM,KAAK,IAAI;AAC3C,QAAM,WAAW,QAAQ,QAAQ,GAAG,OAAO,IAAI,QAAQ,KAAK,KAAK;AACjE,QAAM,SAAS,OAAO,WAAW,eAAe,OAAO,aAAa;AACpE,QAAM,YAAuB,QAAQ,aAAa;AAClD,QAAM,cAAc,cAAc,SAAU,cAAc,UAAU;AACpE,QAAM,WACJ,QAAQ,qBACP,MAAM,OAAO,aAAa,eAAe,SAAS,oBAAoB;AAEzE,QAAM,QACJ,QAAQ,UAAU,SACd,OAAO,cAAc,cACjB,UAA0C,SAAS,OACrD,OACF,QAAQ;AACd,QAAM,cACJ,QAAQ,YAAY,SAChB,OAAO,qBAAqB,cAC1B,MAAM,IAAI,iBAAiB,aAAa,QAAQ,EAAE,IAClD,OACF,QAAQ;AACd,QAAM,gBACJ,QAAQ,kBAAkB,SACtB,OAAO,cAAc,cACjB,UAAoD,iBAAiB,OACvE,OACF,QAAQ;AACd,QAAM,iBACJ,QAAQ,mBAAmB,SACvB,SACG,SACD,OACF,QAAQ;AAEd,MAAI,QAAqB;AACzB,QAAM,YAAY,oBAAI,IAAkC;AAExD,MAAI,OAAgC;AACpC,MAAI,UAAU;AACd,MAAI;AACJ,MAAI;AACJ,MAAI;AAEJ,MAAI;AACJ,MAAI,sBAAsB;AAC1B,MAAI,YAAY;AAChB,MAAI,cAAc;AAClB,MAAI,gBAAgB;AACpB,MAAI;AACJ,MAAI,eAAe;AAEnB,MAAI;AACJ,MAAI,kBAAkB;AACtB,MAAI,iBAAiB,CAAC;AACtB,MAAI,eAAe;AACnB,MAAI,iBAAiB;AAGrB,MAAI,aAAa;AAGjB,QAAM,aAAa,oBAAI,IAAY;AACnC,MAAI,YAAY;AAEhB,QAAM,QAAQ,OAAM,oBAAI,KAAK,GAAE,YAAY;AAE3C,WAAS,OAAO;AACd,eAAW,YAAY,UAAW,UAAS,KAAK;AAAA,EAClD;AAEA,WAAS,IAAI,OAA6B;AACxC,YAAQ,EAAE,GAAG,OAAO,GAAG,MAAM;AAC7B,YAAQ,EAAE,GAAG,OAAO,aAAa,YAAY,MAAM,aAAa,EAAE;AAClE,SAAK;AACL,QAAI,SAAS,SAAU,CAAAC,MAAK,EAAE,MAAM,SAAS,MAAM,CAAC;AAAA,EACtD;AAEA,WAASA,MAAK,SAAqB;AACjC,QAAI;AACF,eAAS,YAAY,OAAO;AAAA,IAC9B,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,iBAAe,QAAW,MAAc,MAAgC;AACtE,UAAM,WAAW,MAAM,QAAQ,GAAG,OAAO,GAAG,IAAI,IAAI;AAAA,MAClD,GAAG;AAAA,MACH,SAAS,EAAE,gBAAgB,oBAAoB,GAAG,MAAM,QAAQ;AAAA,IAClE,CAAC;AACD,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI,MAAM,GAAG,MAAM,UAAU,KAAK,IAAI,IAAI,YAAY,SAAS,MAAM,EAAE;AAC/E,WAAQ,MAAM,SAAS,KAAK;AAAA,EAC9B;AAEA,iBAAe,YAA2B;AACxC,UAAM,YAAY;AAElB,UAAM,OAAO,MAAM,QAIhB,WAAW,KAAK,EAAE;AAErB,UAAM,cAAc,KAAK,gBAAgB,MAAM,QAA4B,QAAQ,GAAG;AAGtF,QAAI,eAAe,UAAW;AAE9B,QAAI;AAAA,MACF,eAAe,KAAK;AAAA,MACpB,YAAY,KAAK;AAAA,MACjB;AAAA,MACA,WAAW;AAAA,MACX,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AAGA,WAAS,UAAyB;AAChC,QAAI,YAAY;AACd,qBAAe;AACf,aAAO;AAAA,IACT;AACA,kBAAc,YAAY;AACxB,UAAI;AACF,WAAG;AACD,yBAAe;AACf,gBAAM,UAAU;AAChB,0BAAgB,IAAI;AAAA,QACtB,SAAS;AAAA,MACX,UAAE;AACA,qBAAa;AAAA,MACf;AAAA,IACF,GAAG;AACH,WAAO;AAAA,EACT;AAEA,MAAI;AAEJ,iBAAe,WAA0B;AACvC,QAAI,CAAC,MAAM,WAAY;AAGvB,QAAI,YAAa,QAAO;AAExB,UAAM,SAAS,MAAM;AAErB,mBAAe,YAAY;AACzB,UAAI;AACF,cAAM,OAAO,MAAM,QAGhB,WAAW,KAAK,WAAW,mBAAmB,MAAM,CAAC,EAAE;AAE1D,cAAM,QAAQ,IAAI,IAAI,MAAM,cAAc,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC;AAE9D,YAAI;AAAA,UACF,eAAe;AAAA,YACb,GAAG,MAAM;AAAA,YACT,GAAG,KAAK,cAAc,OAAO,CAAC,QAAQ,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC;AAAA,UAC1D;AAAA,UACA,YAAY,KAAK;AAAA,QACnB,CAAC;AAAA,MACH,UAAE;AACA,sBAAc;AAAA,MAChB;AAAA,IACF,GAAG;AAEH,WAAO;AAAA,EACT;AAGA,iBAAe,WACb,OACA,OACA,MACe;AACf,UAAM,WAAW;AACjB,kBAAc;AACd,QAAI,EAAE,eAAe,MAAM,MAAM,aAAa,GAAG,GAAG,MAAM,CAAC;AAE3D,QAAI;AACF,YAAM,KAAK;AACX,UAAI,SAAS,WAAY,CAAAA,MAAK,EAAE,MAAM,UAAU,CAAC;AAAA,IACnD,SAAS,OAAO;AACd,oBAAc;AACd,cAAQ;AACR,WAAK;AACL,YAAM;AAAA,IACR;AAAA,EACF;AAEA,iBAAe,WAAW,IAA2B;AACnD,QAAI,SAAS,WAAY,YAAW,IAAI,EAAE;AAC1C,UAAM;AAAA,MACJ,CAAC,SACC,KAAK,IAAI,CAAC,QAAS,IAAI,OAAO,KAAK,EAAE,GAAG,KAAK,QAAQ,IAAI,UAAU,MAAM,EAAE,IAAI,GAAI;AAAA,MACrF,CAAC;AAAA,MACD,MAAM,QAAQ,SAAS,EAAE,QAAQ,QAAQ,MAAM,KAAK,UAAU,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC;AAAA,IAChF,EAAE,MAAM,CAAC,UAAU;AACjB,iBAAW,OAAO,EAAE;AACpB,YAAM;AAAA,IACR,CAAC;AAAA,EACH;AAEA,iBAAe,cAA6B;AAC1C,QAAI,SAAS,WAAY,YAAW,OAAO,MAAM,cAAe,YAAW,IAAI,IAAI,EAAE;AACrF,UAAM;AAAA,MACJ,CAAC,SAAS,KAAK,IAAI,CAAC,SAAS,EAAE,GAAG,KAAK,QAAQ,IAAI,UAAU,MAAM,EAAE,EAAE;AAAA,MACvE,CAAC;AAAA,MACD,MAAM,QAAQ,aAAa,EAAE,QAAQ,OAAO,CAAC;AAAA,IAC/C,EAAE,MAAM,CAAC,UAAU;AACjB,iBAAW,MAAM;AACjB,YAAM;AAAA,IACR,CAAC;AAAA,EACH;AAEA,iBAAe,WAA0B;AAEvC,UAAM,SAAS,MAAM,cAAc,CAAC,GAAG;AACvC,QAAI,SAAS,WAAY,aAAY;AAErC,UAAM;AAAA,MACJ,CAAC,SAAS,KAAK,IAAI,CAAC,SAAS,EAAE,GAAG,KAAK,QAAQ,IAAI,UAAU,MAAM,EAAE,EAAE;AAAA,MACvE,EAAE,aAAa,EAAE;AAAA,MACjB,MACE,QAAQ,SAAS;AAAA,QACf,QAAQ;AAAA,QACR,MAAM,KAAK,UAAU,SAAS,EAAE,QAAQ,OAAO,IAAI,CAAC,CAAC;AAAA,MACvD,CAAC;AAAA,IACL,EAAE,MAAM,CAAC,UAAU;AACjB,kBAAY;AACZ,YAAM;AAAA,IACR,CAAC;AAAA,EACH;AAIA,WAAS,iBAAiB,QAAqB;AAC7C,UAAM,gBAAgB,OAAO,cAAc,IAAI,CAAC,QAAQ;AACtD,UAAI,CAAC,WAAW,IAAI,IAAI,EAAE,EAAG,QAAO;AACpC,UAAI,IAAI,QAAQ;AACd,mBAAW,OAAO,IAAI,EAAE;AACxB,eAAO;AAAA,MACT;AACA,aAAO,EAAE,GAAG,KAAK,QAAQ,MAAM,EAAE;AAAA,IACnC,CAAC;AACD,QAAI,aAAa,OAAO,gBAAgB,EAAG,aAAY;AACvD,YAAQ;AAAA,MACN,GAAG;AAAA,MACH;AAAA,MACA,aAAa,YAAY,IAAI,OAAO;AAAA,MACpC,aAAa,YAAY,aAAa;AAAA,IACxC;AACA,SAAK;AAAA,EACP;AAEA,WAAS,aAAa,OAA0B;AAC9C,UAAM,UAAU,MAAM;AACtB,QAAI,CAAC,WAAW,OAAO,YAAY,SAAU;AAC7C,QAAI,SAAS,UAAU;AACrB,UAAI,QAAQ,SAAS,QAAS,CAAAA,MAAK,EAAE,MAAM,SAAS,MAAM,CAAC;AAAA,eAClD,QAAQ,SAAS,UAAW,MAAK,QAAQ,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAClE;AAAA,IACF;AACA,QAAI,QAAQ,SAAS,QAAS,kBAAiB,QAAQ,KAAK;AAAA,EAC9D;AAIA,WAAS,SAAS,OAAe;AAC/B,iBAAa,KAAK;AAClB,YAAQ,WAAW,MAAM,KAAK;AAAA,EAChC;AAEA,WAAS,YAAoB;AAC3B,QAAI,sBAAsB,GAAG;AAC3B,aAAO,KAAK,IAAI,iBAAiB,KAAK,qBAAqB,iBAAiB;AAAA,IAC9E;AACA,QAAI,gBAAiB,QAAO;AAC5B,QAAI,IAAI,IAAI,YAAa,QAAO;AAChC,iBAAa;AACb,WAAO,KAAK,IAAI,iBAAiB,KAAK,WAAW,iBAAiB;AAAA,EACpE;AAEA,iBAAe,OAAO;AACpB,QAAI,CAAC,QAAS;AAEd,QAAI,SAAS,GAAG;AACd,eAAS,cAAc;AACvB;AAAA,IACF;AAEA,QAAI;AACF,YAAM,QAAQ;AACd,4BAAsB;AAAA,IACxB,SAAS,OAAO;AACd,6BAAuB;AACvB,UAAI,EAAE,WAAW,OAAO,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC,EAAE,CAAC;AAAA,IAC5F;AAEA,QAAI,QAAS,UAAS,UAAU,CAAC;AAAA,EACnC;AAGA,WAAS,aAAa;AACpB,QAAI,CAAC,WAAW,SAAS,WAAY;AACrC,kBAAc,IAAI,IAAI;AACtB,gBAAY;AACZ,QAAI,CAAC,SAAS,KAAK,IAAI,IAAI,gBAAgB,gBAAgB;AACzD,WAAK,QAAQ,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAC7B,eAAS,UAAU,CAAC;AAAA,IACtB;AAAA,EACF;AAEA,QAAM,eAAe,MAAM;AACzB,QAAI,CAAC,SAAS,EAAG,YAAW;AAAA,EAC9B;AAEA,QAAM,kBAAkB,CAAC,SAAS,eAAe,WAAW,QAAQ;AAIpE,WAAS,UAAU;AACjB,QAAI,CAAC,QAAS;AACd,SAAK,QAAQ,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAC/B;AAEA,iBAAe,WAAW,QAAoC;AAC5D,UAAM,WAAW,IAAI;AACrB,UAAM,WAAW,MAAM,QAAQ,GAAG,OAAO,WAAW;AAAA,MAClD,SAAS,EAAE,QAAQ,oBAAoB;AAAA,MACvC;AAAA,IACF,CAAC;AACD,QAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,uBAAuB,SAAS,MAAM,EAAE;AAC1E,QAAI,CAAC,SAAS,MAAM;AAElB,uBAAiB;AACjB,eAAS,UAAU,CAAC;AACpB;AAAA,IACF;AAEA,UAAM,SAAS,SAAS,KAAK,UAAU;AACvC,UAAM,UAAU,IAAI,YAAY;AAChC,QAAI,SAAS;AACb,QAAI,YAAY;AAEhB,QAAI;AACF,iBAAS;AACP,cAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,YAAI,KAAM;AACV,kBAAU,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AAChD,YAAI,UAAU,OAAO,QAAQ,IAAI;AACjC,eAAO,WAAW,GAAG;AACnB,gBAAM,OAAO,OAAO,MAAM,GAAG,OAAO,EAAE,QAAQ,OAAO,EAAE;AACvD,mBAAS,OAAO,MAAM,UAAU,CAAC;AACjC,cAAI,SAAS,IAAI;AACf,gBAAI,cAAc,SAAS;AACzB,gCAAkB;AAClB,+BAAiB;AACjB,uBAAS,UAAU,CAAC;AAAA,YACtB,WAAW,cAAc,UAAW,SAAQ;AAC5C,wBAAY;AAAA,UACd,WAAW,KAAK,WAAW,QAAQ,EAAG,aAAY,KAAK,MAAM,CAAC,EAAE,KAAK;AACrE,oBAAU,OAAO,QAAQ,IAAI;AAAA,QAC/B;AAAA,MACF;AAAA,IACF,UAAE;AACA,wBAAkB;AAClB,UAAI,IAAI,IAAI,WAAW,gBAAiB,iBAAgB;AAAA,UACnD,gBAAe;AAAA,IACtB;AAAA,EACF;AAEA,QAAM,aAAa,MAAM,SAAS;AAElC,iBAAe,aAAa;AAC1B,WAAO,WAAW,CAAC,kBAAkB,CAAC,WAAW,GAAG;AAClD,oBAAc,IAAI,gBAAgB;AAClC,UAAI;AACF,cAAM,WAAW,YAAY,MAAM;AAAA,MACrC,QAAQ;AACN,YAAI,CAAC,QAAS;AACd,0BAAkB;AAAA,MACpB;AACA,UAAI,CAAC,WAAW,WAAW,KAAK,eAAgB;AAChD,UAAI,gBAAgB,gCAAgC;AAElD,yBAAiB;AACjB,iBAAS,UAAU,CAAC;AACpB;AAAA,MACF;AACA,YAAM,IAAI;AAAA,QAAQ,CAAC,YACjB,WAAW,SAAS,KAAK,IAAI,MAAQ,KAAK,gBAAgB,GAAM,CAAC;AAAA,MACnE;AAAA,IACF;AAAA,EACF;AAIA,WAAS,eAAe;AACtB,WAAO;AACP,IAAAA,MAAK,EAAE,MAAM,SAAS,CAAC;AACvB,IAAAA,MAAK,EAAE,MAAM,SAAS,MAAM,CAAC;AAC7B,SAAK,KAAK;AACV,QAAI,CAAC,eAAgB,MAAK,WAAW;AAAA,EACvC;AAEA,WAAS,iBAAiB;AACxB,WAAO;AACP,IAAAA,MAAK,EAAE,MAAM,QAAQ,CAAC;AAAA,EACxB;AAEA,WAAS,oBAAoB;AAC3B,QAAI,CAAC,SAAS,CAAC,SAAS;AACtB,aAAO;AACP,WAAK,KAAK;AACV,UAAI,CAAC,eAAgB,MAAK,WAAW;AACrC;AAAA,IACF;AACA,mBAAe;AACf,gBAAY,IAAI,gBAAgB;AAChC,UACG;AAAA,MACC,oBAAoB,QAAQ;AAAA,MAC5B,EAAE,MAAM,aAAa,QAAQ,UAAU,OAAO;AAAA,MAC9C,MACE,IAAI,QAAc,CAAC,YAAY;AAC7B,sBAAc;AACd,YAAI,QAAS,cAAa;AAAA,YACrB,SAAQ;AAAA,MACf,CAAC;AAAA,IACL,EACC,MAAM,MAAM;AAAA,IAEb,CAAC;AAAA,EACL;AAEA,WAAS,QAAQ;AACf,cAAU;AACV,cAAU,cAAc,YAAY,IAAI;AACxC,aAAS,iBAAiB,WAAW,YAAY;AACjD,mBAAe,iBAAiB,WAAW,OAAO;AAClD,QAAI,gBAAgB;AAClB,iBAAW,QAAQ,gBAAiB,gBAAe,iBAAiB,MAAM,UAAU;AAAA,IACtF;AACA,QAAI,OAAO,aAAa,aAAa;AACnC,eAAS,iBAAiB,oBAAoB,YAAY;AAAA,IAC5D;AACA,kBAAc,IAAI,IAAI;AACtB,sBAAkB;AAAA,EACpB;AAEA,WAAS,OAAO;AACd,cAAU;AACV,iBAAa,KAAK;AAClB,YAAQ;AACR,iBAAa,MAAM;AACnB,kBAAc;AACd,eAAW,MAAM;AACjB,gBAAY;AACZ,kBAAc;AACd,kBAAc;AACd,aAAS,oBAAoB,WAAW,YAAY;AACpD,aAAS,MAAM;AACf,cAAU;AACV,mBAAe,oBAAoB,WAAW,OAAO;AACrD,QAAI,gBAAgB;AAClB,iBAAW,QAAQ,gBAAiB,gBAAe,oBAAoB,MAAM,UAAU;AAAA,IACzF;AACA,QAAI,OAAO,aAAa,aAAa;AACnC,eAAS,oBAAoB,oBAAoB,YAAY;AAAA,IAC/D;AACA,WAAO;AAAA,EACT;AAIA,WAAS,QAAQ,OAA2B;AAC1C,UAAM,OAAO,MAAM;AACnB,QAAI,MAAM,WAAW,eAAe,KAAK,SAAS,UAAW,SAAQ;AAAA,EACvE;AAEA,MAAI,mBAAkC;AAGtC,QAAM,aAAa,CAAC,aAAuB;AACzC,QAAI,CAAC,SAAS,OAAO,OAAO,aAAa,YAAa,QAAO;AAC7D,QAAI;AACF,aAAO,IAAI,IAAI,SAAS,GAAG,EAAE,WAAW,SAAS;AAAA,IACnD,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAOA,WAAS,WAAW,OAAyD;AAC3E,YAAQ,UAAU,SAA8C;AAC9D,YAAM,WAAW,MAAM,MAAM,GAAG,IAAI;AACpC,YAAM,UAAU,WAAW,QAAQ,IAAI,SAAS,QAAQ,IAAI,mBAAmB,IAAI;AACnF,UAAI,YAAY,QAAQ,YAAY,kBAAkB;AACpD,cAAM,QAAQ,qBAAqB;AACnC,2BAAmB;AACnB,YAAI,CAAC,SAAS,SAAS,WAAY,SAAQ;AAAA,MAC7C;AACA,aAAO;AAAA,IACT;AAAA,EACF;AAEA,WAAS,UAAU,UAAoD;AACrE,cAAU,IAAI,QAAQ;AACtB,aAAS,KAAK;AAGd,QAAI,UAAU,SAAS,EAAG,OAAM;AAEhC,WAAO,MAAM;AACX,gBAAU,OAAO,QAAQ;AACzB,UAAI,UAAU,SAAS,EAAG,MAAK;AAAA,IACjC;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,UAAU,MAAM;AAAA,IAChB,cAAc,OAAwB;AAAA,MACpC;AAAA,MACA,WAAW,iBAAiB,SAAS;AAAA,MACrC,WAAW;AAAA,IACb;AAAA,EACF;AACF;;;ACjpBA,SAAS,aAAa,WAAW,SAAS,QAAQ,gBAAgB;AAiB3D,SAAS,iBACd,UAA2D,CAAC,GACpC;AACxB,QAAM;AAAA,IACJ,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AAGJ,QAAM,eAAe,OAAO,OAAO;AACnC,eAAa,UAAU;AAEvB,QAAM,SAAS;AAAA,IACb,MACE,YACA,mBAAmB;AAAA,MACjB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,IAAI,UAAU,aAAa,QAAQ,SAAS,WAAW,OAAO,GAAG,IAAI;AAAA,MAC5E,kBAAkB,MAChB,aAAa,QAAQ,mBAAmB,MACvC,OAAO,aAAa,eAAe,SAAS,oBAAoB;AAAA,IACrE,CAAC;AAAA,IACH;AAAA,MACE;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAsB,MAAM,OAAO,SAAS,CAAC;AAEvE,YAAU,MAAM,OAAO,UAAU,QAAQ,GAAG,CAAC,MAAM,CAAC;AAEpD,SAAO;AAAA,IACL,GAAG;AAAA,IACH,YAAY,YAAY,CAAC,OAAe,OAAO,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC;AAAA,IACvE,aAAa,YAAY,MAAM,OAAO,YAAY,GAAG,CAAC,MAAM,CAAC;AAAA,IAC7D,UAAU,YAAY,MAAM,OAAO,SAAS,GAAG,CAAC,MAAM,CAAC;AAAA,IACvD,UAAU,YAAY,MAAM,OAAO,SAAS,GAAG,CAAC,MAAM,CAAC;AAAA,IACvD,SAAS,YAAY,MAAM,OAAO,QAAQ,GAAG,CAAC,MAAM,CAAC;AAAA,EACvD;AACF;;;AFnDA,SAAS,eAAe,UAAwB;AAC9C,QAAM,YAAY,oBAAI,IAA2B;AACjD,MAAI;AACJ,MAAI,OAAO,SAAS;AAEpB,QAAM,SAAS,MAAM;AACnB,qBAAiB,SAAS,iBAAiB,UAAU,CAAC,UAAU;AAC9D,UAAI,UAAU,YAAY,SAAS,UAAU;AAC3C,mBAAW,YAAY,UAAU,IAAI,OAAO,KAAK,CAAC,EAAG,UAAS,CAAC,CAAC;AAAA,MAClE;AACA,aAAO;AAAA,IACT,CAAC;AAAA,EACH;AAEA,SAAO;AAAA,IACL,iBAAiB,MAAc,SAAmB;AAChD,gBAAU,IAAI,OAAO,UAAU,IAAI,IAAI,KAAK,oBAAI,IAAI,GAAG,IAAI,OAAO,CAAC;AACnE,aAAO;AAAA,IACT;AAAA,IACA,oBAAoB,MAAc,SAAmB;AACnD,gBAAU,IAAI,IAAI,GAAG,OAAO,OAAO;AACnC,UAAI,CAAC,GAAG,UAAU,OAAO,CAAC,EAAE,MAAM,CAAC,QAAQ,IAAI,SAAS,CAAC,GAAG;AAC1D,sBAAc,OAAO;AACrB,uBAAe;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AACF;AASO,SAAS,yBAAyB,UAAqC,CAAC,GAAiB;AAC9F,QAAM,EAAE,WAAW,UAAU,GAAG,KAAK,IAAI;AACzC,SAAO,mBAAmB;AAAA,IACxB,WAAW;AAAA,IACX,GAAG;AAAA,IACH,OAAO;AAAA,IACP,SAAS;AAAA,IACT,eAAe;AAAA,IACf,kBAAkB,MAAM,SAAS,iBAAiB;AAAA,IAClD,gBAAgB,eAAe,QAAQ;AAAA,EACzC,CAAC;AACH;AAGO,SAASC,kBACd,UAAiE,CAAC,GAC1C;AACxB,QAAM;AAAA,IACJ,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AAGJ,QAAM,WAAWC,QAAO,QAAQ,KAAK;AACrC,WAAS,UAAU,QAAQ;AAE3B,QAAM,SAASC;AAAA,IACb,MACE,YACA,yBAAyB;AAAA,MACvB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,IAAI,UAAU,SAAS,WAAW,WAAW,OAAO,GAAG,IAAI;AAAA,IACpE,CAAC;AAAA,IACH;AAAA,MACE;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,SAAO,iBAAuB,EAAE,OAAO,CAAC;AAC1C;AAYA,IAAM,OAAO,OACX,SACA,KACA,SAEA,QAAQ,KAAK;AAAA,EACX,QAAQ;AAAA,EACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,EAC9C,MAAM,KAAK,UAAU,IAAI;AAC3B,CAAC;AAGH,eAAsB,yBAAyB,SAAmD;AAChG,QAAM,QAAQ,QAAQ,WAAW,sBAAsB,QAAQ,OAAO,EAAE;AACxE,QAAM,UAAU,QAAQ,SAAS,WAAW;AAC5C,QAAM,WAAW,MAAM,KAAK,SAAS,GAAG,IAAI,wBAAwB;AAAA,IAClE,OAAO,QAAQ;AAAA,IACf,UAAU,QAAQ;AAAA,IAClB,YAAY,QAAQ;AAAA,EACtB,CAAC;AACD,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,SAAU,MAAM,SAAS,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AACtD,UAAM,IAAI,MAAM,OAAO,SAAS,kCAAkC,SAAS,MAAM,EAAE;AAAA,EACrF;AACF;AAGA,eAAsB,2BACpB,SACkB;AAClB,QAAM,QAAQ,QAAQ,WAAW,sBAAsB,QAAQ,OAAO,EAAE;AACxE,QAAM,UAAU,QAAQ,SAAS,WAAW;AAC5C,QAAM,WAAW,MAAM,KAAK,SAAS,GAAG,IAAI,+BAA+B;AAAA,IACzE,OAAO,QAAQ;AAAA,EACjB,CAAC;AACD,MAAI,SAAS,WAAW,IAAK,QAAO;AACpC,MAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,oCAAoC,SAAS,MAAM,EAAE;AACvF,SAAO;AACT;","names":["useMemo","useRef","post","useNotifications","useRef","useMemo"]}
package/dist/react.cjs CHANGED
@@ -291,7 +291,12 @@ function createNotifyClient(options = {}) {
291
291
  headers: { accept: "text/event-stream" },
292
292
  signal
293
293
  });
294
- if (!response.ok || !response.body) throw new Error(`GET /events failed: ${response.status}`);
294
+ if (!response.ok) throw new Error(`GET /events failed: ${response.status}`);
295
+ if (!response.body) {
296
+ streamDisabled = true;
297
+ schedule(nextDelay());
298
+ return;
299
+ }
295
300
  const reader = response.body.getReader();
296
301
  const decoder = new TextDecoder();
297
302
  let buffer = "";
@@ -332,7 +337,7 @@ function createNotifyClient(options = {}) {
332
337
  if (!running) return;
333
338
  streamFailures += 1;
334
339
  }
335
- if (!running || isFollower()) return;
340
+ if (!running || isFollower() || streamDisabled) return;
336
341
  if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {
337
342
  streamDisabled = true;
338
343
  schedule(nextDelay());
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/react/index.ts","../src/client/index.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/** State lives in the framework-agnostic client; this only wires React's lifecycle. */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const {\n client: provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n } = options;\n\n // Held in a ref so a new callback identity per render does not rebuild the client.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [\n provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n ],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type Transport = \"auto\" | \"sse\" | \"poll\";\n\nexport type TransportStatus = {\n /** \"leader\" holds this browser's single connection; \"follower\" mirrors it; \"solo\" has no Web Locks. */\n role: \"leader\" | \"follower\" | \"solo\";\n /** What the leader is using right now. */\n transport: \"sse\" | \"poll\";\n /** True while the event stream has said `ready` and not since closed. */\n connected: boolean;\n};\n\ntype LockManagerLike = {\n request(\n name: string,\n options: { mode: \"exclusive\"; signal?: AbortSignal },\n callback: () => Promise<void>,\n ): Promise<void>;\n};\n\ntype ChannelLike = {\n postMessage(message: unknown): void;\n addEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n removeEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n close(): void;\n};\n\ntype MessageTargetLike = {\n addEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n removeEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n /** Poll interval while the user is active and no stream is connected. Default 15 s. */\n pollIntervalMs?: number | undefined;\n /** Ceiling the idle backoff grows to. Default 10 min. */\n maxPollIntervalMs?: number | undefined;\n /** Poll interval while the event stream is connected: a safety net. Default 5 min. */\n safetyNetMs?: number | undefined;\n /** How long after user input polling stays at the base interval. Default 60 s. */\n activeWindowMs?: number | undefined;\n /** \"auto\" streams when a DOM is present, \"poll\" never streams, \"sse\" always tries. */\n transport?: Transport | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n /** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */\n locks?: LockManagerLike | null | undefined;\n /** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */\n channel?: (() => ChannelLike) | null | undefined;\n /** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */\n serviceWorker?: MessageTargetLike | null | undefined;\n /** Where user activity is observed. Defaults to window; null disables. */\n activityTarget?: MessageTargetLike | null | undefined;\n now?: (() => number) | undefined;\n /**\n * Separates browsers' tab groups when identity is not a cookie: pass the\n * current user's id (or session id) so tabs signed in as different users\n * never share a leader or mirror each other's inbox.\n */\n scope?: string | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\n/** A stream that dies this fast, repeatedly, is being cut by the platform: stop trying. */\nconst SHORT_STREAM_MS = 15_000;\nconst SHORT_STREAMS_BEFORE_GIVING_UP = 3;\n\ntype TabMessage =\n | { type: \"hello\" }\n | { type: \"state\"; state: NotifyState }\n | { type: \"mutated\" }\n | { type: \"leader\" };\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 10 * 60_000;\n const safetyNetMs = options.safetyNetMs ?? 5 * 60_000;\n const activeWindowMs = options.activeWindowMs ?? 60_000;\n const now = options.now ?? (() => Date.now());\n const groupKey = options.scope ? `${baseUrl}#${options.scope}` : baseUrl;\n const hasDom = typeof window !== \"undefined\" && typeof document !== \"undefined\";\n const transport: Transport = options.transport ?? \"auto\";\n const wantsStream = transport === \"sse\" || (transport === \"auto\" && hasDom);\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n const locks =\n options.locks === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { locks?: LockManagerLike }).locks ?? null)\n : null\n : options.locks;\n const makeChannel =\n options.channel === undefined\n ? typeof BroadcastChannel !== \"undefined\"\n ? () => new BroadcastChannel(`easy-ping:${groupKey}`) as unknown as ChannelLike\n : null\n : options.channel;\n const serviceWorker =\n options.serviceWorker === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { serviceWorker?: MessageTargetLike }).serviceWorker ?? null)\n : null\n : options.serviceWorker;\n const activityTarget =\n options.activityTarget === undefined\n ? hasDom\n ? (window as unknown as MessageTargetLike)\n : null\n : options.activityTarget;\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let role: TransportStatus[\"role\"] = \"solo\";\n let running = false;\n let channel: ChannelLike | undefined;\n let releaseLock: (() => void) | undefined;\n let lockAbort: AbortController | undefined;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n let idleTicks = 0;\n let activeUntil = 0;\n let lastRefreshAt = 0;\n let refreshing: Promise<void> | undefined;\n let refreshAgain = false;\n\n let streamAbort: AbortController | undefined;\n let streamConnected = false;\n let streamDisabled = !wantsStream;\n let shortStreams = 0;\n let streamFailures = 0;\n\n // Bumped per mutation so an in-flight poll cannot revert an optimistic update.\n let generation = 0;\n\n // A follower's own optimistic changes, kept until the leader's state reflects them.\n const localReads = new Set<string>();\n let localSeen = false;\n\n const stamp = () => new Date().toISOString();\n\n function emit() {\n for (const listener of listeners) listener(state);\n }\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n emit();\n if (role === \"leader\") post({ type: \"state\", state });\n }\n\n function post(message: TabMessage) {\n try {\n channel?.postMessage(message);\n } catch {\n // channel closed\n }\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function fetchPage(): Promise<void> {\n const startedAt = generation;\n\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight; the response is stale.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n /** Coalesces overlapping calls: a second request while one is in flight runs once after it. */\n function refresh(): Promise<void> {\n if (refreshing) {\n refreshAgain = true;\n return refreshing;\n }\n refreshing = (async () => {\n try {\n do {\n refreshAgain = false;\n await fetchPage();\n lastRefreshAt = now();\n } while (refreshAgain);\n } finally {\n refreshing = undefined;\n }\n })();\n return refreshing;\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Share the in-flight request so two clicks cannot append the same page twice.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n if (role === \"follower\") post({ type: \"mutated\" });\n } catch (error) {\n generation += 1;\n state = previous;\n emit();\n throw error;\n }\n }\n\n async function markAsRead(id: string): Promise<void> {\n if (role === \"follower\") localReads.add(id);\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n ).catch((error) => {\n localReads.delete(id);\n throw error;\n });\n }\n\n async function markAllRead(): Promise<void> {\n if (role === \"follower\") for (const row of state.notifications) localReads.add(row.id);\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n ).catch((error) => {\n localReads.clear();\n throw error;\n });\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could have seen: no cutoff would mark newer arrivals seen too.\n const newest = state.notifications[0]?.createdAt;\n if (role === \"follower\") localSeen = true;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n ).catch((error) => {\n localSeen = false;\n throw error;\n });\n }\n\n // ---- follower: mirror the leader, keep own optimistic changes until confirmed ----\n\n function applyRemoteState(remote: NotifyState) {\n const notifications = remote.notifications.map((row) => {\n if (!localReads.has(row.id)) return row;\n if (row.readAt) {\n localReads.delete(row.id);\n return row;\n }\n return { ...row, readAt: stamp() };\n });\n if (localSeen && remote.unseenCount === 0) localSeen = false;\n state = {\n ...remote,\n notifications,\n unseenCount: localSeen ? 0 : remote.unseenCount,\n unreadCount: countUnread(notifications),\n };\n emit();\n }\n\n function onTabMessage(event: { data: unknown }) {\n const message = event.data as TabMessage | null;\n if (!message || typeof message !== \"object\") return;\n if (role === \"leader\") {\n if (message.type === \"hello\") post({ type: \"state\", state });\n else if (message.type === \"mutated\") void refresh().catch(() => {});\n return;\n }\n if (message.type === \"state\") applyRemoteState(message.state);\n }\n\n // ---- leader / solo: polling with activity-shaped backoff ----\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n function nextDelay(): number {\n if (consecutiveFailures > 0) {\n return Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs);\n }\n if (streamConnected) return safetyNetMs;\n if (now() < activeUntil) return pollIntervalMs;\n idleTicks += 1;\n return Math.min(pollIntervalMs * 2 ** idleTicks, maxPollIntervalMs);\n }\n\n async function tick() {\n if (!running) return;\n // A hidden tab is not looking at the bell.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n if (running) schedule(nextDelay());\n }\n\n /** User input or a returning tab: poll fast again, and catch up now if the last refresh is stale. */\n function onActivity() {\n if (!running || role === \"follower\") return;\n activeUntil = now() + activeWindowMs;\n idleTicks = 0;\n if (!isHidden() && now() - lastRefreshAt > pollIntervalMs) {\n void refresh().catch(() => {});\n schedule(nextDelay());\n }\n }\n\n const onVisibility = () => {\n if (!isHidden()) onActivity();\n };\n\n const ACTIVITY_EVENTS = [\"focus\", \"pointerdown\", \"keydown\", \"online\"];\n\n // ---- leader / solo: the event stream ----\n\n function changed() {\n if (!running) return;\n void refresh().catch(() => {});\n }\n\n async function readStream(signal: AbortSignal): Promise<void> {\n const openedAt = now();\n const response = await doFetch(`${baseUrl}/events`, {\n headers: { accept: \"text/event-stream\" },\n signal,\n });\n if (!response.ok || !response.body) throw new Error(`GET /events failed: ${response.status}`);\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let eventName = \"\";\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let newline = buffer.indexOf(\"\\n\");\n while (newline >= 0) {\n const line = buffer.slice(0, newline).replace(/\\r$/, \"\");\n buffer = buffer.slice(newline + 1);\n if (line === \"\") {\n if (eventName === \"ready\") {\n streamConnected = true;\n streamFailures = 0;\n schedule(nextDelay());\n } else if (eventName === \"changed\") changed();\n eventName = \"\";\n } else if (line.startsWith(\"event:\")) eventName = line.slice(6).trim();\n newline = buffer.indexOf(\"\\n\");\n }\n }\n } finally {\n streamConnected = false;\n if (now() - openedAt < SHORT_STREAM_MS) shortStreams += 1;\n else shortStreams = 0;\n }\n }\n\n const isFollower = () => role === \"follower\";\n\n async function streamLoop() {\n while (running && !streamDisabled && !isFollower()) {\n streamAbort = new AbortController();\n try {\n await readStream(streamAbort.signal);\n } catch {\n if (!running) return;\n streamFailures += 1;\n }\n if (!running || isFollower()) return;\n if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {\n // The platform cuts streams too fast for them to be worth it here.\n streamDisabled = true;\n schedule(nextDelay());\n return;\n }\n await new Promise((resolve) =>\n setTimeout(resolve, Math.min(3_000 * 2 ** streamFailures, 60_000)),\n );\n }\n }\n\n // ---- roles ----\n\n function becomeLeader() {\n role = \"leader\";\n post({ type: \"leader\" });\n post({ type: \"state\", state });\n void tick();\n if (!streamDisabled) void streamLoop();\n }\n\n function becomeFollower() {\n role = \"follower\";\n post({ type: \"hello\" });\n }\n\n function acquireLeadership() {\n if (!locks || !channel) {\n role = \"solo\";\n void tick();\n if (!streamDisabled) void streamLoop();\n return;\n }\n becomeFollower();\n lockAbort = new AbortController();\n locks\n .request(\n `easy-ping:leader:${groupKey}`,\n { mode: \"exclusive\", signal: lockAbort.signal },\n () =>\n new Promise<void>((resolve) => {\n releaseLock = resolve;\n if (running) becomeLeader();\n else resolve();\n }),\n )\n .catch(() => {\n // aborted on stop, or Web Locks refused; either way no leadership here\n });\n }\n\n function start() {\n running = true;\n channel = makeChannel ? makeChannel() : undefined;\n channel?.addEventListener(\"message\", onTabMessage);\n serviceWorker?.addEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.addEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n activeUntil = now() + activeWindowMs;\n acquireLeadership();\n }\n\n function stop() {\n running = false;\n clearTimeout(timer);\n timer = undefined;\n streamAbort?.abort();\n streamAbort = undefined;\n lockAbort?.abort();\n lockAbort = undefined;\n releaseLock?.();\n releaseLock = undefined;\n channel?.removeEventListener(\"message\", onTabMessage);\n channel?.close();\n channel = undefined;\n serviceWorker?.removeEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.removeEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n role = \"solo\";\n }\n\n // ---- signals from outside: the service worker relay and the piggyback header ----\n\n function onRelay(event: { data?: unknown }) {\n const data = event.data as { source?: unknown; type?: unknown } | null;\n if (data?.source === \"easy-ping\" && data.type === \"changed\") changed();\n }\n\n let lastInboxVersion: string | null = null;\n\n // Only the app's own origin may steer the bell; a third-party response cannot.\n const sameOrigin = (response: Response) => {\n if (!response.url || typeof location === \"undefined\") return true;\n try {\n return new URL(response.url).origin === location.origin;\n } catch {\n return false;\n }\n };\n\n /**\n * Wraps your app's fetch so responses carrying the inbox-version header\n * refresh the bell when the version moves. Active users then never need a\n * dedicated poll. RFC 0006 idea 1.\n */\n function instrument(inner: typeof globalThis.fetch): typeof globalThis.fetch {\n return (async (...args: Parameters<typeof globalThis.fetch>) => {\n const response = await inner(...args);\n const version = sameOrigin(response) ? response.headers.get(\"x-easy-ping-inbox\") : null;\n if (version !== null && version !== lastInboxVersion) {\n const first = lastInboxVersion === null;\n lastInboxVersion = version;\n if (!first && role !== \"follower\") changed();\n }\n return response;\n }) as typeof globalThis.fetch;\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One transport regardless of how many components subscribe.\n if (listeners.size === 1) start();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) stop();\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n instrument,\n getState: () => state,\n getTransport: (): TransportStatus => ({\n role,\n transport: streamDisabled ? \"poll\" : \"sse\",\n connected: streamConnected,\n }),\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/react/index.ts","../src/client/index.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/** State lives in the framework-agnostic client; this only wires React's lifecycle. */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const {\n client: provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n } = options;\n\n // Held in a ref so a new callback identity per render does not rebuild the client.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [\n provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n ],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type Transport = \"auto\" | \"sse\" | \"poll\";\n\nexport type TransportStatus = {\n /** \"leader\" holds this browser's single connection; \"follower\" mirrors it; \"solo\" has no Web Locks. */\n role: \"leader\" | \"follower\" | \"solo\";\n /** What the leader is using right now. */\n transport: \"sse\" | \"poll\";\n /** True while the event stream has said `ready` and not since closed. */\n connected: boolean;\n};\n\ntype LockManagerLike = {\n request(\n name: string,\n options: { mode: \"exclusive\"; signal?: AbortSignal },\n callback: () => Promise<void>,\n ): Promise<void>;\n};\n\ntype ChannelLike = {\n postMessage(message: unknown): void;\n addEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n removeEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n close(): void;\n};\n\ntype MessageTargetLike = {\n addEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n removeEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n /** Poll interval while the user is active and no stream is connected. Default 15 s. */\n pollIntervalMs?: number | undefined;\n /** Ceiling the idle backoff grows to. Default 10 min. */\n maxPollIntervalMs?: number | undefined;\n /** Poll interval while the event stream is connected: a safety net. Default 5 min. */\n safetyNetMs?: number | undefined;\n /** How long after user input polling stays at the base interval. Default 60 s. */\n activeWindowMs?: number | undefined;\n /** \"auto\" streams when a DOM is present, \"poll\" never streams, \"sse\" always tries. */\n transport?: Transport | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n /** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */\n locks?: LockManagerLike | null | undefined;\n /** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */\n channel?: (() => ChannelLike) | null | undefined;\n /** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */\n serviceWorker?: MessageTargetLike | null | undefined;\n /** Where user activity is observed. Defaults to window; null disables. */\n activityTarget?: MessageTargetLike | null | undefined;\n now?: (() => number) | undefined;\n /**\n * Separates browsers' tab groups when identity is not a cookie: pass the\n * current user's id (or session id) so tabs signed in as different users\n * never share a leader or mirror each other's inbox.\n */\n scope?: string | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\n/** A stream that dies this fast, repeatedly, is being cut by the platform: stop trying. */\nconst SHORT_STREAM_MS = 15_000;\nconst SHORT_STREAMS_BEFORE_GIVING_UP = 3;\n\ntype TabMessage =\n | { type: \"hello\" }\n | { type: \"state\"; state: NotifyState }\n | { type: \"mutated\" }\n | { type: \"leader\" };\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 10 * 60_000;\n const safetyNetMs = options.safetyNetMs ?? 5 * 60_000;\n const activeWindowMs = options.activeWindowMs ?? 60_000;\n const now = options.now ?? (() => Date.now());\n const groupKey = options.scope ? `${baseUrl}#${options.scope}` : baseUrl;\n const hasDom = typeof window !== \"undefined\" && typeof document !== \"undefined\";\n const transport: Transport = options.transport ?? \"auto\";\n const wantsStream = transport === \"sse\" || (transport === \"auto\" && hasDom);\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n const locks =\n options.locks === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { locks?: LockManagerLike }).locks ?? null)\n : null\n : options.locks;\n const makeChannel =\n options.channel === undefined\n ? typeof BroadcastChannel !== \"undefined\"\n ? () => new BroadcastChannel(`easy-ping:${groupKey}`) as unknown as ChannelLike\n : null\n : options.channel;\n const serviceWorker =\n options.serviceWorker === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { serviceWorker?: MessageTargetLike }).serviceWorker ?? null)\n : null\n : options.serviceWorker;\n const activityTarget =\n options.activityTarget === undefined\n ? hasDom\n ? (window as unknown as MessageTargetLike)\n : null\n : options.activityTarget;\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let role: TransportStatus[\"role\"] = \"solo\";\n let running = false;\n let channel: ChannelLike | undefined;\n let releaseLock: (() => void) | undefined;\n let lockAbort: AbortController | undefined;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n let idleTicks = 0;\n let activeUntil = 0;\n let lastRefreshAt = 0;\n let refreshing: Promise<void> | undefined;\n let refreshAgain = false;\n\n let streamAbort: AbortController | undefined;\n let streamConnected = false;\n let streamDisabled = !wantsStream;\n let shortStreams = 0;\n let streamFailures = 0;\n\n // Bumped per mutation so an in-flight poll cannot revert an optimistic update.\n let generation = 0;\n\n // A follower's own optimistic changes, kept until the leader's state reflects them.\n const localReads = new Set<string>();\n let localSeen = false;\n\n const stamp = () => new Date().toISOString();\n\n function emit() {\n for (const listener of listeners) listener(state);\n }\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n emit();\n if (role === \"leader\") post({ type: \"state\", state });\n }\n\n function post(message: TabMessage) {\n try {\n channel?.postMessage(message);\n } catch {\n // channel closed\n }\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function fetchPage(): Promise<void> {\n const startedAt = generation;\n\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight; the response is stale.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n /** Coalesces overlapping calls: a second request while one is in flight runs once after it. */\n function refresh(): Promise<void> {\n if (refreshing) {\n refreshAgain = true;\n return refreshing;\n }\n refreshing = (async () => {\n try {\n do {\n refreshAgain = false;\n await fetchPage();\n lastRefreshAt = now();\n } while (refreshAgain);\n } finally {\n refreshing = undefined;\n }\n })();\n return refreshing;\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Share the in-flight request so two clicks cannot append the same page twice.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n if (role === \"follower\") post({ type: \"mutated\" });\n } catch (error) {\n generation += 1;\n state = previous;\n emit();\n throw error;\n }\n }\n\n async function markAsRead(id: string): Promise<void> {\n if (role === \"follower\") localReads.add(id);\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n ).catch((error) => {\n localReads.delete(id);\n throw error;\n });\n }\n\n async function markAllRead(): Promise<void> {\n if (role === \"follower\") for (const row of state.notifications) localReads.add(row.id);\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n ).catch((error) => {\n localReads.clear();\n throw error;\n });\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could have seen: no cutoff would mark newer arrivals seen too.\n const newest = state.notifications[0]?.createdAt;\n if (role === \"follower\") localSeen = true;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n ).catch((error) => {\n localSeen = false;\n throw error;\n });\n }\n\n // ---- follower: mirror the leader, keep own optimistic changes until confirmed ----\n\n function applyRemoteState(remote: NotifyState) {\n const notifications = remote.notifications.map((row) => {\n if (!localReads.has(row.id)) return row;\n if (row.readAt) {\n localReads.delete(row.id);\n return row;\n }\n return { ...row, readAt: stamp() };\n });\n if (localSeen && remote.unseenCount === 0) localSeen = false;\n state = {\n ...remote,\n notifications,\n unseenCount: localSeen ? 0 : remote.unseenCount,\n unreadCount: countUnread(notifications),\n };\n emit();\n }\n\n function onTabMessage(event: { data: unknown }) {\n const message = event.data as TabMessage | null;\n if (!message || typeof message !== \"object\") return;\n if (role === \"leader\") {\n if (message.type === \"hello\") post({ type: \"state\", state });\n else if (message.type === \"mutated\") void refresh().catch(() => {});\n return;\n }\n if (message.type === \"state\") applyRemoteState(message.state);\n }\n\n // ---- leader / solo: polling with activity-shaped backoff ----\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n function nextDelay(): number {\n if (consecutiveFailures > 0) {\n return Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs);\n }\n if (streamConnected) return safetyNetMs;\n if (now() < activeUntil) return pollIntervalMs;\n idleTicks += 1;\n return Math.min(pollIntervalMs * 2 ** idleTicks, maxPollIntervalMs);\n }\n\n async function tick() {\n if (!running) return;\n // A hidden tab is not looking at the bell.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n if (running) schedule(nextDelay());\n }\n\n /** User input or a returning tab: poll fast again, and catch up now if the last refresh is stale. */\n function onActivity() {\n if (!running || role === \"follower\") return;\n activeUntil = now() + activeWindowMs;\n idleTicks = 0;\n if (!isHidden() && now() - lastRefreshAt > pollIntervalMs) {\n void refresh().catch(() => {});\n schedule(nextDelay());\n }\n }\n\n const onVisibility = () => {\n if (!isHidden()) onActivity();\n };\n\n const ACTIVITY_EVENTS = [\"focus\", \"pointerdown\", \"keydown\", \"online\"];\n\n // ---- leader / solo: the event stream ----\n\n function changed() {\n if (!running) return;\n void refresh().catch(() => {});\n }\n\n async function readStream(signal: AbortSignal): Promise<void> {\n const openedAt = now();\n const response = await doFetch(`${baseUrl}/events`, {\n headers: { accept: \"text/event-stream\" },\n signal,\n });\n if (!response.ok) throw new Error(`GET /events failed: ${response.status}`);\n if (!response.body) {\n // This fetch cannot stream a body (React Native's built-in one, some polyfills): poll instead.\n streamDisabled = true;\n schedule(nextDelay());\n return;\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let eventName = \"\";\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let newline = buffer.indexOf(\"\\n\");\n while (newline >= 0) {\n const line = buffer.slice(0, newline).replace(/\\r$/, \"\");\n buffer = buffer.slice(newline + 1);\n if (line === \"\") {\n if (eventName === \"ready\") {\n streamConnected = true;\n streamFailures = 0;\n schedule(nextDelay());\n } else if (eventName === \"changed\") changed();\n eventName = \"\";\n } else if (line.startsWith(\"event:\")) eventName = line.slice(6).trim();\n newline = buffer.indexOf(\"\\n\");\n }\n }\n } finally {\n streamConnected = false;\n if (now() - openedAt < SHORT_STREAM_MS) shortStreams += 1;\n else shortStreams = 0;\n }\n }\n\n const isFollower = () => role === \"follower\";\n\n async function streamLoop() {\n while (running && !streamDisabled && !isFollower()) {\n streamAbort = new AbortController();\n try {\n await readStream(streamAbort.signal);\n } catch {\n if (!running) return;\n streamFailures += 1;\n }\n if (!running || isFollower() || streamDisabled) return;\n if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {\n // The platform cuts streams too fast for them to be worth it here.\n streamDisabled = true;\n schedule(nextDelay());\n return;\n }\n await new Promise((resolve) =>\n setTimeout(resolve, Math.min(3_000 * 2 ** streamFailures, 60_000)),\n );\n }\n }\n\n // ---- roles ----\n\n function becomeLeader() {\n role = \"leader\";\n post({ type: \"leader\" });\n post({ type: \"state\", state });\n void tick();\n if (!streamDisabled) void streamLoop();\n }\n\n function becomeFollower() {\n role = \"follower\";\n post({ type: \"hello\" });\n }\n\n function acquireLeadership() {\n if (!locks || !channel) {\n role = \"solo\";\n void tick();\n if (!streamDisabled) void streamLoop();\n return;\n }\n becomeFollower();\n lockAbort = new AbortController();\n locks\n .request(\n `easy-ping:leader:${groupKey}`,\n { mode: \"exclusive\", signal: lockAbort.signal },\n () =>\n new Promise<void>((resolve) => {\n releaseLock = resolve;\n if (running) becomeLeader();\n else resolve();\n }),\n )\n .catch(() => {\n // aborted on stop, or Web Locks refused; either way no leadership here\n });\n }\n\n function start() {\n running = true;\n channel = makeChannel ? makeChannel() : undefined;\n channel?.addEventListener(\"message\", onTabMessage);\n serviceWorker?.addEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.addEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n activeUntil = now() + activeWindowMs;\n acquireLeadership();\n }\n\n function stop() {\n running = false;\n clearTimeout(timer);\n timer = undefined;\n streamAbort?.abort();\n streamAbort = undefined;\n lockAbort?.abort();\n lockAbort = undefined;\n releaseLock?.();\n releaseLock = undefined;\n channel?.removeEventListener(\"message\", onTabMessage);\n channel?.close();\n channel = undefined;\n serviceWorker?.removeEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.removeEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n role = \"solo\";\n }\n\n // ---- signals from outside: the service worker relay and the piggyback header ----\n\n function onRelay(event: { data?: unknown }) {\n const data = event.data as { source?: unknown; type?: unknown } | null;\n if (data?.source === \"easy-ping\" && data.type === \"changed\") changed();\n }\n\n let lastInboxVersion: string | null = null;\n\n // Only the app's own origin may steer the bell; a third-party response cannot.\n const sameOrigin = (response: Response) => {\n if (!response.url || typeof location === \"undefined\") return true;\n try {\n return new URL(response.url).origin === location.origin;\n } catch {\n return false;\n }\n };\n\n /**\n * Wraps your app's fetch so responses carrying the inbox-version header\n * refresh the bell when the version moves. Active users then never need a\n * dedicated poll. RFC 0006 idea 1.\n */\n function instrument(inner: typeof globalThis.fetch): typeof globalThis.fetch {\n return (async (...args: Parameters<typeof globalThis.fetch>) => {\n const response = await inner(...args);\n const version = sameOrigin(response) ? response.headers.get(\"x-easy-ping-inbox\") : null;\n if (version !== null && version !== lastInboxVersion) {\n const first = lastInboxVersion === null;\n lastInboxVersion = version;\n if (!first && role !== \"follower\") changed();\n }\n return response;\n }) as typeof globalThis.fetch;\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One transport regardless of how many components subscribe.\n if (listeners.size === 1) start();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) stop();\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n instrument,\n getState: () => state,\n getTransport: (): TransportStatus => ({\n role,\n transport: streamDisabled ? \"poll\" : \"sse\",\n connected: streamConnected,\n }),\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
package/dist/react.js CHANGED
@@ -267,7 +267,12 @@ function createNotifyClient(options = {}) {
267
267
  headers: { accept: "text/event-stream" },
268
268
  signal
269
269
  });
270
- if (!response.ok || !response.body) throw new Error(`GET /events failed: ${response.status}`);
270
+ if (!response.ok) throw new Error(`GET /events failed: ${response.status}`);
271
+ if (!response.body) {
272
+ streamDisabled = true;
273
+ schedule(nextDelay());
274
+ return;
275
+ }
271
276
  const reader = response.body.getReader();
272
277
  const decoder = new TextDecoder();
273
278
  let buffer = "";
@@ -308,7 +313,7 @@ function createNotifyClient(options = {}) {
308
313
  if (!running) return;
309
314
  streamFailures += 1;
310
315
  }
311
- if (!running || isFollower()) return;
316
+ if (!running || isFollower() || streamDisabled) return;
312
317
  if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {
313
318
  streamDisabled = true;
314
319
  schedule(nextDelay());
package/dist/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/react/index.ts","../src/client/index.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/** State lives in the framework-agnostic client; this only wires React's lifecycle. */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const {\n client: provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n } = options;\n\n // Held in a ref so a new callback identity per render does not rebuild the client.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [\n provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n ],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type Transport = \"auto\" | \"sse\" | \"poll\";\n\nexport type TransportStatus = {\n /** \"leader\" holds this browser's single connection; \"follower\" mirrors it; \"solo\" has no Web Locks. */\n role: \"leader\" | \"follower\" | \"solo\";\n /** What the leader is using right now. */\n transport: \"sse\" | \"poll\";\n /** True while the event stream has said `ready` and not since closed. */\n connected: boolean;\n};\n\ntype LockManagerLike = {\n request(\n name: string,\n options: { mode: \"exclusive\"; signal?: AbortSignal },\n callback: () => Promise<void>,\n ): Promise<void>;\n};\n\ntype ChannelLike = {\n postMessage(message: unknown): void;\n addEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n removeEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n close(): void;\n};\n\ntype MessageTargetLike = {\n addEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n removeEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n /** Poll interval while the user is active and no stream is connected. Default 15 s. */\n pollIntervalMs?: number | undefined;\n /** Ceiling the idle backoff grows to. Default 10 min. */\n maxPollIntervalMs?: number | undefined;\n /** Poll interval while the event stream is connected: a safety net. Default 5 min. */\n safetyNetMs?: number | undefined;\n /** How long after user input polling stays at the base interval. Default 60 s. */\n activeWindowMs?: number | undefined;\n /** \"auto\" streams when a DOM is present, \"poll\" never streams, \"sse\" always tries. */\n transport?: Transport | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n /** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */\n locks?: LockManagerLike | null | undefined;\n /** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */\n channel?: (() => ChannelLike) | null | undefined;\n /** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */\n serviceWorker?: MessageTargetLike | null | undefined;\n /** Where user activity is observed. Defaults to window; null disables. */\n activityTarget?: MessageTargetLike | null | undefined;\n now?: (() => number) | undefined;\n /**\n * Separates browsers' tab groups when identity is not a cookie: pass the\n * current user's id (or session id) so tabs signed in as different users\n * never share a leader or mirror each other's inbox.\n */\n scope?: string | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\n/** A stream that dies this fast, repeatedly, is being cut by the platform: stop trying. */\nconst SHORT_STREAM_MS = 15_000;\nconst SHORT_STREAMS_BEFORE_GIVING_UP = 3;\n\ntype TabMessage =\n | { type: \"hello\" }\n | { type: \"state\"; state: NotifyState }\n | { type: \"mutated\" }\n | { type: \"leader\" };\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 10 * 60_000;\n const safetyNetMs = options.safetyNetMs ?? 5 * 60_000;\n const activeWindowMs = options.activeWindowMs ?? 60_000;\n const now = options.now ?? (() => Date.now());\n const groupKey = options.scope ? `${baseUrl}#${options.scope}` : baseUrl;\n const hasDom = typeof window !== \"undefined\" && typeof document !== \"undefined\";\n const transport: Transport = options.transport ?? \"auto\";\n const wantsStream = transport === \"sse\" || (transport === \"auto\" && hasDom);\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n const locks =\n options.locks === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { locks?: LockManagerLike }).locks ?? null)\n : null\n : options.locks;\n const makeChannel =\n options.channel === undefined\n ? typeof BroadcastChannel !== \"undefined\"\n ? () => new BroadcastChannel(`easy-ping:${groupKey}`) as unknown as ChannelLike\n : null\n : options.channel;\n const serviceWorker =\n options.serviceWorker === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { serviceWorker?: MessageTargetLike }).serviceWorker ?? null)\n : null\n : options.serviceWorker;\n const activityTarget =\n options.activityTarget === undefined\n ? hasDom\n ? (window as unknown as MessageTargetLike)\n : null\n : options.activityTarget;\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let role: TransportStatus[\"role\"] = \"solo\";\n let running = false;\n let channel: ChannelLike | undefined;\n let releaseLock: (() => void) | undefined;\n let lockAbort: AbortController | undefined;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n let idleTicks = 0;\n let activeUntil = 0;\n let lastRefreshAt = 0;\n let refreshing: Promise<void> | undefined;\n let refreshAgain = false;\n\n let streamAbort: AbortController | undefined;\n let streamConnected = false;\n let streamDisabled = !wantsStream;\n let shortStreams = 0;\n let streamFailures = 0;\n\n // Bumped per mutation so an in-flight poll cannot revert an optimistic update.\n let generation = 0;\n\n // A follower's own optimistic changes, kept until the leader's state reflects them.\n const localReads = new Set<string>();\n let localSeen = false;\n\n const stamp = () => new Date().toISOString();\n\n function emit() {\n for (const listener of listeners) listener(state);\n }\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n emit();\n if (role === \"leader\") post({ type: \"state\", state });\n }\n\n function post(message: TabMessage) {\n try {\n channel?.postMessage(message);\n } catch {\n // channel closed\n }\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function fetchPage(): Promise<void> {\n const startedAt = generation;\n\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight; the response is stale.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n /** Coalesces overlapping calls: a second request while one is in flight runs once after it. */\n function refresh(): Promise<void> {\n if (refreshing) {\n refreshAgain = true;\n return refreshing;\n }\n refreshing = (async () => {\n try {\n do {\n refreshAgain = false;\n await fetchPage();\n lastRefreshAt = now();\n } while (refreshAgain);\n } finally {\n refreshing = undefined;\n }\n })();\n return refreshing;\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Share the in-flight request so two clicks cannot append the same page twice.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n if (role === \"follower\") post({ type: \"mutated\" });\n } catch (error) {\n generation += 1;\n state = previous;\n emit();\n throw error;\n }\n }\n\n async function markAsRead(id: string): Promise<void> {\n if (role === \"follower\") localReads.add(id);\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n ).catch((error) => {\n localReads.delete(id);\n throw error;\n });\n }\n\n async function markAllRead(): Promise<void> {\n if (role === \"follower\") for (const row of state.notifications) localReads.add(row.id);\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n ).catch((error) => {\n localReads.clear();\n throw error;\n });\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could have seen: no cutoff would mark newer arrivals seen too.\n const newest = state.notifications[0]?.createdAt;\n if (role === \"follower\") localSeen = true;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n ).catch((error) => {\n localSeen = false;\n throw error;\n });\n }\n\n // ---- follower: mirror the leader, keep own optimistic changes until confirmed ----\n\n function applyRemoteState(remote: NotifyState) {\n const notifications = remote.notifications.map((row) => {\n if (!localReads.has(row.id)) return row;\n if (row.readAt) {\n localReads.delete(row.id);\n return row;\n }\n return { ...row, readAt: stamp() };\n });\n if (localSeen && remote.unseenCount === 0) localSeen = false;\n state = {\n ...remote,\n notifications,\n unseenCount: localSeen ? 0 : remote.unseenCount,\n unreadCount: countUnread(notifications),\n };\n emit();\n }\n\n function onTabMessage(event: { data: unknown }) {\n const message = event.data as TabMessage | null;\n if (!message || typeof message !== \"object\") return;\n if (role === \"leader\") {\n if (message.type === \"hello\") post({ type: \"state\", state });\n else if (message.type === \"mutated\") void refresh().catch(() => {});\n return;\n }\n if (message.type === \"state\") applyRemoteState(message.state);\n }\n\n // ---- leader / solo: polling with activity-shaped backoff ----\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n function nextDelay(): number {\n if (consecutiveFailures > 0) {\n return Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs);\n }\n if (streamConnected) return safetyNetMs;\n if (now() < activeUntil) return pollIntervalMs;\n idleTicks += 1;\n return Math.min(pollIntervalMs * 2 ** idleTicks, maxPollIntervalMs);\n }\n\n async function tick() {\n if (!running) return;\n // A hidden tab is not looking at the bell.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n if (running) schedule(nextDelay());\n }\n\n /** User input or a returning tab: poll fast again, and catch up now if the last refresh is stale. */\n function onActivity() {\n if (!running || role === \"follower\") return;\n activeUntil = now() + activeWindowMs;\n idleTicks = 0;\n if (!isHidden() && now() - lastRefreshAt > pollIntervalMs) {\n void refresh().catch(() => {});\n schedule(nextDelay());\n }\n }\n\n const onVisibility = () => {\n if (!isHidden()) onActivity();\n };\n\n const ACTIVITY_EVENTS = [\"focus\", \"pointerdown\", \"keydown\", \"online\"];\n\n // ---- leader / solo: the event stream ----\n\n function changed() {\n if (!running) return;\n void refresh().catch(() => {});\n }\n\n async function readStream(signal: AbortSignal): Promise<void> {\n const openedAt = now();\n const response = await doFetch(`${baseUrl}/events`, {\n headers: { accept: \"text/event-stream\" },\n signal,\n });\n if (!response.ok || !response.body) throw new Error(`GET /events failed: ${response.status}`);\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let eventName = \"\";\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let newline = buffer.indexOf(\"\\n\");\n while (newline >= 0) {\n const line = buffer.slice(0, newline).replace(/\\r$/, \"\");\n buffer = buffer.slice(newline + 1);\n if (line === \"\") {\n if (eventName === \"ready\") {\n streamConnected = true;\n streamFailures = 0;\n schedule(nextDelay());\n } else if (eventName === \"changed\") changed();\n eventName = \"\";\n } else if (line.startsWith(\"event:\")) eventName = line.slice(6).trim();\n newline = buffer.indexOf(\"\\n\");\n }\n }\n } finally {\n streamConnected = false;\n if (now() - openedAt < SHORT_STREAM_MS) shortStreams += 1;\n else shortStreams = 0;\n }\n }\n\n const isFollower = () => role === \"follower\";\n\n async function streamLoop() {\n while (running && !streamDisabled && !isFollower()) {\n streamAbort = new AbortController();\n try {\n await readStream(streamAbort.signal);\n } catch {\n if (!running) return;\n streamFailures += 1;\n }\n if (!running || isFollower()) return;\n if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {\n // The platform cuts streams too fast for them to be worth it here.\n streamDisabled = true;\n schedule(nextDelay());\n return;\n }\n await new Promise((resolve) =>\n setTimeout(resolve, Math.min(3_000 * 2 ** streamFailures, 60_000)),\n );\n }\n }\n\n // ---- roles ----\n\n function becomeLeader() {\n role = \"leader\";\n post({ type: \"leader\" });\n post({ type: \"state\", state });\n void tick();\n if (!streamDisabled) void streamLoop();\n }\n\n function becomeFollower() {\n role = \"follower\";\n post({ type: \"hello\" });\n }\n\n function acquireLeadership() {\n if (!locks || !channel) {\n role = \"solo\";\n void tick();\n if (!streamDisabled) void streamLoop();\n return;\n }\n becomeFollower();\n lockAbort = new AbortController();\n locks\n .request(\n `easy-ping:leader:${groupKey}`,\n { mode: \"exclusive\", signal: lockAbort.signal },\n () =>\n new Promise<void>((resolve) => {\n releaseLock = resolve;\n if (running) becomeLeader();\n else resolve();\n }),\n )\n .catch(() => {\n // aborted on stop, or Web Locks refused; either way no leadership here\n });\n }\n\n function start() {\n running = true;\n channel = makeChannel ? makeChannel() : undefined;\n channel?.addEventListener(\"message\", onTabMessage);\n serviceWorker?.addEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.addEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n activeUntil = now() + activeWindowMs;\n acquireLeadership();\n }\n\n function stop() {\n running = false;\n clearTimeout(timer);\n timer = undefined;\n streamAbort?.abort();\n streamAbort = undefined;\n lockAbort?.abort();\n lockAbort = undefined;\n releaseLock?.();\n releaseLock = undefined;\n channel?.removeEventListener(\"message\", onTabMessage);\n channel?.close();\n channel = undefined;\n serviceWorker?.removeEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.removeEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n role = \"solo\";\n }\n\n // ---- signals from outside: the service worker relay and the piggyback header ----\n\n function onRelay(event: { data?: unknown }) {\n const data = event.data as { source?: unknown; type?: unknown } | null;\n if (data?.source === \"easy-ping\" && data.type === \"changed\") changed();\n }\n\n let lastInboxVersion: string | null = null;\n\n // Only the app's own origin may steer the bell; a third-party response cannot.\n const sameOrigin = (response: Response) => {\n if (!response.url || typeof location === \"undefined\") return true;\n try {\n return new URL(response.url).origin === location.origin;\n } catch {\n return false;\n }\n };\n\n /**\n * Wraps your app's fetch so responses carrying the inbox-version header\n * refresh the bell when the version moves. Active users then never need a\n * dedicated poll. RFC 0006 idea 1.\n */\n function instrument(inner: typeof globalThis.fetch): typeof globalThis.fetch {\n return (async (...args: Parameters<typeof globalThis.fetch>) => {\n const response = await inner(...args);\n const version = sameOrigin(response) ? response.headers.get(\"x-easy-ping-inbox\") : null;\n if (version !== null && version !== lastInboxVersion) {\n const first = lastInboxVersion === null;\n lastInboxVersion = version;\n if (!first && role !== \"follower\") changed();\n }\n return response;\n }) as typeof globalThis.fetch;\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One transport regardless of how many components subscribe.\n if (listeners.size === 1) start();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) stop();\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n instrument,\n getState: () => state,\n getTransport: (): TransportStatus => ({\n role,\n transport: streamDisabled ? \"poll\" : \"sse\",\n connected: streamConnected,\n }),\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/react/index.ts","../src/client/index.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n createNotifyClient,\n type NotifyClient,\n type NotifyClientOptions,\n type NotifyState,\n} from \"../client\";\n\nexport type UseNotificationsResult = NotifyState & {\n markAsRead: (id: string) => Promise<void>;\n markAllRead: () => Promise<void>;\n markSeen: () => Promise<void>;\n loadMore: () => Promise<void>;\n refresh: () => Promise<void>;\n};\n\n/** State lives in the framework-agnostic client; this only wires React's lifecycle. */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const {\n client: provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n } = options;\n\n // Held in a ref so a new callback identity per render does not rebuild the client.\n const callbacksRef = useRef(options);\n callbacksRef.current = options;\n\n const client = useMemo(\n () =>\n provided ??\n createNotifyClient({\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [\n provided,\n baseUrl,\n limit,\n pollIntervalMs,\n maxPollIntervalMs,\n safetyNetMs,\n activeWindowMs,\n transport,\n ],\n );\n\n const [state, setState] = useState<NotifyState>(() => client.getState());\n\n useEffect(() => client.subscribe(setState), [client]);\n\n return {\n ...state,\n markAsRead: useCallback((id: string) => client.markAsRead(id), [client]),\n markAllRead: useCallback(() => client.markAllRead(), [client]),\n markSeen: useCallback(() => client.markSeen(), [client]),\n loadMore: useCallback(() => client.loadMore(), [client]),\n refresh: useCallback(() => client.refresh(), [client]),\n };\n}\n\nexport type {\n NotificationView,\n NotifyClient,\n NotifyClientOptions,\n NotifyState,\n} from \"../client\";\nexport { createNotifyClient } from \"../client\";\n","export type NotificationView = {\n id: string;\n userId: string;\n type: string;\n payload: unknown;\n actorId: string | null;\n groupKey: string | null;\n seenAt: string | null;\n readAt: string | null;\n createdAt: string;\n};\n\nexport type NotifyState = {\n notifications: readonly NotificationView[];\n /** Badge count — cleared when the dropdown opens. Server-authoritative. */\n unseenCount: number;\n /** Items still bold. Derived from what is loaded, not a server total. */\n unreadCount: number;\n nextCursor: string | null;\n isLoading: boolean;\n error: Error | null;\n};\n\nexport type Transport = \"auto\" | \"sse\" | \"poll\";\n\nexport type TransportStatus = {\n /** \"leader\" holds this browser's single connection; \"follower\" mirrors it; \"solo\" has no Web Locks. */\n role: \"leader\" | \"follower\" | \"solo\";\n /** What the leader is using right now. */\n transport: \"sse\" | \"poll\";\n /** True while the event stream has said `ready` and not since closed. */\n connected: boolean;\n};\n\ntype LockManagerLike = {\n request(\n name: string,\n options: { mode: \"exclusive\"; signal?: AbortSignal },\n callback: () => Promise<void>,\n ): Promise<void>;\n};\n\ntype ChannelLike = {\n postMessage(message: unknown): void;\n addEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n removeEventListener(type: \"message\", handler: (event: { data: unknown }) => void): void;\n close(): void;\n};\n\ntype MessageTargetLike = {\n addEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n removeEventListener(type: string, handler: (event: { data?: unknown }) => void): void;\n};\n\nexport type NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n /** Poll interval while the user is active and no stream is connected. Default 15 s. */\n pollIntervalMs?: number | undefined;\n /** Ceiling the idle backoff grows to. Default 10 min. */\n maxPollIntervalMs?: number | undefined;\n /** Poll interval while the event stream is connected: a safety net. Default 5 min. */\n safetyNetMs?: number | undefined;\n /** How long after user input polling stays at the base interval. Default 60 s. */\n activeWindowMs?: number | undefined;\n /** \"auto\" streams when a DOM is present, \"poll\" never streams, \"sse\" always tries. */\n transport?: Transport | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | undefined;\n /** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */\n locks?: LockManagerLike | null | undefined;\n /** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */\n channel?: (() => ChannelLike) | null | undefined;\n /** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */\n serviceWorker?: MessageTargetLike | null | undefined;\n /** Where user activity is observed. Defaults to window; null disables. */\n activityTarget?: MessageTargetLike | null | undefined;\n now?: (() => number) | undefined;\n /**\n * Separates browsers' tab groups when identity is not a cookie: pass the\n * current user's id (or session id) so tabs signed in as different users\n * never share a leader or mirror each other's inbox.\n */\n scope?: string | undefined;\n};\n\nconst INITIAL: NotifyState = {\n notifications: [],\n unseenCount: 0,\n unreadCount: 0,\n nextCursor: null,\n isLoading: true,\n error: null,\n};\n\nconst countUnread = (rows: readonly NotificationView[]) =>\n rows.reduce((total, row) => (row.readAt === null ? total + 1 : total), 0);\n\n/** A stream that dies this fast, repeatedly, is being cut by the platform: stop trying. */\nconst SHORT_STREAM_MS = 15_000;\nconst SHORT_STREAMS_BEFORE_GIVING_UP = 3;\n\ntype TabMessage =\n | { type: \"hello\" }\n | { type: \"state\"; state: NotifyState }\n | { type: \"mutated\" }\n | { type: \"leader\" };\n\nexport function createNotifyClient(options: NotifyClientOptions = {}) {\n const baseUrl = (options.baseUrl ?? \"/api/notifications\").replace(/\\/$/, \"\");\n const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));\n const limit = options.limit ?? 20;\n const pollIntervalMs = options.pollIntervalMs ?? 15_000;\n const maxPollIntervalMs = options.maxPollIntervalMs ?? 10 * 60_000;\n const safetyNetMs = options.safetyNetMs ?? 5 * 60_000;\n const activeWindowMs = options.activeWindowMs ?? 60_000;\n const now = options.now ?? (() => Date.now());\n const groupKey = options.scope ? `${baseUrl}#${options.scope}` : baseUrl;\n const hasDom = typeof window !== \"undefined\" && typeof document !== \"undefined\";\n const transport: Transport = options.transport ?? \"auto\";\n const wantsStream = transport === \"sse\" || (transport === \"auto\" && hasDom);\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n const locks =\n options.locks === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { locks?: LockManagerLike }).locks ?? null)\n : null\n : options.locks;\n const makeChannel =\n options.channel === undefined\n ? typeof BroadcastChannel !== \"undefined\"\n ? () => new BroadcastChannel(`easy-ping:${groupKey}`) as unknown as ChannelLike\n : null\n : options.channel;\n const serviceWorker =\n options.serviceWorker === undefined\n ? typeof navigator !== \"undefined\"\n ? ((navigator as { serviceWorker?: MessageTargetLike }).serviceWorker ?? null)\n : null\n : options.serviceWorker;\n const activityTarget =\n options.activityTarget === undefined\n ? hasDom\n ? (window as unknown as MessageTargetLike)\n : null\n : options.activityTarget;\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let role: TransportStatus[\"role\"] = \"solo\";\n let running = false;\n let channel: ChannelLike | undefined;\n let releaseLock: (() => void) | undefined;\n let lockAbort: AbortController | undefined;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n let idleTicks = 0;\n let activeUntil = 0;\n let lastRefreshAt = 0;\n let refreshing: Promise<void> | undefined;\n let refreshAgain = false;\n\n let streamAbort: AbortController | undefined;\n let streamConnected = false;\n let streamDisabled = !wantsStream;\n let shortStreams = 0;\n let streamFailures = 0;\n\n // Bumped per mutation so an in-flight poll cannot revert an optimistic update.\n let generation = 0;\n\n // A follower's own optimistic changes, kept until the leader's state reflects them.\n const localReads = new Set<string>();\n let localSeen = false;\n\n const stamp = () => new Date().toISOString();\n\n function emit() {\n for (const listener of listeners) listener(state);\n }\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n emit();\n if (role === \"leader\") post({ type: \"state\", state });\n }\n\n function post(message: TabMessage) {\n try {\n channel?.postMessage(message);\n } catch {\n // channel closed\n }\n }\n\n async function request<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await doFetch(`${baseUrl}${path}`, {\n ...init,\n headers: { \"content-type\": \"application/json\", ...init?.headers },\n });\n if (!response.ok)\n throw new Error(`${init?.method ?? \"GET\"} ${path} failed: ${response.status}`);\n return (await response.json()) as T;\n }\n\n async function fetchPage(): Promise<void> {\n const startedAt = generation;\n\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n unseenCount?: number;\n }>(`/?limit=${limit}`);\n\n const unseenCount = page.unseenCount ?? (await request<{ unseen: number }>(\"/count\")).unseen;\n\n // A mutation landed while this was in flight; the response is stale.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount,\n isLoading: false,\n error: null,\n });\n }\n\n /** Coalesces overlapping calls: a second request while one is in flight runs once after it. */\n function refresh(): Promise<void> {\n if (refreshing) {\n refreshAgain = true;\n return refreshing;\n }\n refreshing = (async () => {\n try {\n do {\n refreshAgain = false;\n await fetchPage();\n lastRefreshAt = now();\n } while (refreshAgain);\n } finally {\n refreshing = undefined;\n }\n })();\n return refreshing;\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Share the in-flight request so two clicks cannot append the same page twice.\n if (loadingMore) return loadingMore;\n\n const cursor = state.nextCursor;\n\n loadingMore = (async () => {\n try {\n const page = await request<{\n notifications: NotificationView[];\n nextCursor: string | null;\n }>(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);\n\n const known = new Set(state.notifications.map((row) => row.id));\n\n set({\n notifications: [\n ...state.notifications,\n ...page.notifications.filter((row) => !known.has(row.id)),\n ],\n nextCursor: page.nextCursor,\n });\n } finally {\n loadingMore = undefined;\n }\n })();\n\n return loadingMore;\n }\n\n /** Applies a patch immediately and reverts it if the request fails. */\n async function optimistic(\n apply: (rows: readonly NotificationView[]) => readonly NotificationView[],\n patch: Partial<NotifyState>,\n send: () => Promise<unknown>,\n ): Promise<void> {\n const previous = state;\n generation += 1;\n set({ notifications: apply(state.notifications), ...patch });\n\n try {\n await send();\n if (role === \"follower\") post({ type: \"mutated\" });\n } catch (error) {\n generation += 1;\n state = previous;\n emit();\n throw error;\n }\n }\n\n async function markAsRead(id: string): Promise<void> {\n if (role === \"follower\") localReads.add(id);\n await optimistic(\n (rows) =>\n rows.map((row) => (row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row)),\n {},\n () => request(\"/read\", { method: \"POST\", body: JSON.stringify({ ids: [id] }) }),\n ).catch((error) => {\n localReads.delete(id);\n throw error;\n });\n }\n\n async function markAllRead(): Promise<void> {\n if (role === \"follower\") for (const row of state.notifications) localReads.add(row.id);\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n ).catch((error) => {\n localReads.clear();\n throw error;\n });\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could have seen: no cutoff would mark newer arrivals seen too.\n const newest = state.notifications[0]?.createdAt;\n if (role === \"follower\") localSeen = true;\n\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),\n { unseenCount: 0 },\n () =>\n request(\"/seen\", {\n method: \"POST\",\n body: JSON.stringify(newest ? { before: newest } : {}),\n }),\n ).catch((error) => {\n localSeen = false;\n throw error;\n });\n }\n\n // ---- follower: mirror the leader, keep own optimistic changes until confirmed ----\n\n function applyRemoteState(remote: NotifyState) {\n const notifications = remote.notifications.map((row) => {\n if (!localReads.has(row.id)) return row;\n if (row.readAt) {\n localReads.delete(row.id);\n return row;\n }\n return { ...row, readAt: stamp() };\n });\n if (localSeen && remote.unseenCount === 0) localSeen = false;\n state = {\n ...remote,\n notifications,\n unseenCount: localSeen ? 0 : remote.unseenCount,\n unreadCount: countUnread(notifications),\n };\n emit();\n }\n\n function onTabMessage(event: { data: unknown }) {\n const message = event.data as TabMessage | null;\n if (!message || typeof message !== \"object\") return;\n if (role === \"leader\") {\n if (message.type === \"hello\") post({ type: \"state\", state });\n else if (message.type === \"mutated\") void refresh().catch(() => {});\n return;\n }\n if (message.type === \"state\") applyRemoteState(message.state);\n }\n\n // ---- leader / solo: polling with activity-shaped backoff ----\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n function nextDelay(): number {\n if (consecutiveFailures > 0) {\n return Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs);\n }\n if (streamConnected) return safetyNetMs;\n if (now() < activeUntil) return pollIntervalMs;\n idleTicks += 1;\n return Math.min(pollIntervalMs * 2 ** idleTicks, maxPollIntervalMs);\n }\n\n async function tick() {\n if (!running) return;\n // A hidden tab is not looking at the bell.\n if (isHidden()) {\n schedule(pollIntervalMs);\n return;\n }\n\n try {\n await refresh();\n consecutiveFailures = 0;\n } catch (error) {\n consecutiveFailures += 1;\n set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });\n }\n\n if (running) schedule(nextDelay());\n }\n\n /** User input or a returning tab: poll fast again, and catch up now if the last refresh is stale. */\n function onActivity() {\n if (!running || role === \"follower\") return;\n activeUntil = now() + activeWindowMs;\n idleTicks = 0;\n if (!isHidden() && now() - lastRefreshAt > pollIntervalMs) {\n void refresh().catch(() => {});\n schedule(nextDelay());\n }\n }\n\n const onVisibility = () => {\n if (!isHidden()) onActivity();\n };\n\n const ACTIVITY_EVENTS = [\"focus\", \"pointerdown\", \"keydown\", \"online\"];\n\n // ---- leader / solo: the event stream ----\n\n function changed() {\n if (!running) return;\n void refresh().catch(() => {});\n }\n\n async function readStream(signal: AbortSignal): Promise<void> {\n const openedAt = now();\n const response = await doFetch(`${baseUrl}/events`, {\n headers: { accept: \"text/event-stream\" },\n signal,\n });\n if (!response.ok) throw new Error(`GET /events failed: ${response.status}`);\n if (!response.body) {\n // This fetch cannot stream a body (React Native's built-in one, some polyfills): poll instead.\n streamDisabled = true;\n schedule(nextDelay());\n return;\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let eventName = \"\";\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n let newline = buffer.indexOf(\"\\n\");\n while (newline >= 0) {\n const line = buffer.slice(0, newline).replace(/\\r$/, \"\");\n buffer = buffer.slice(newline + 1);\n if (line === \"\") {\n if (eventName === \"ready\") {\n streamConnected = true;\n streamFailures = 0;\n schedule(nextDelay());\n } else if (eventName === \"changed\") changed();\n eventName = \"\";\n } else if (line.startsWith(\"event:\")) eventName = line.slice(6).trim();\n newline = buffer.indexOf(\"\\n\");\n }\n }\n } finally {\n streamConnected = false;\n if (now() - openedAt < SHORT_STREAM_MS) shortStreams += 1;\n else shortStreams = 0;\n }\n }\n\n const isFollower = () => role === \"follower\";\n\n async function streamLoop() {\n while (running && !streamDisabled && !isFollower()) {\n streamAbort = new AbortController();\n try {\n await readStream(streamAbort.signal);\n } catch {\n if (!running) return;\n streamFailures += 1;\n }\n if (!running || isFollower() || streamDisabled) return;\n if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {\n // The platform cuts streams too fast for them to be worth it here.\n streamDisabled = true;\n schedule(nextDelay());\n return;\n }\n await new Promise((resolve) =>\n setTimeout(resolve, Math.min(3_000 * 2 ** streamFailures, 60_000)),\n );\n }\n }\n\n // ---- roles ----\n\n function becomeLeader() {\n role = \"leader\";\n post({ type: \"leader\" });\n post({ type: \"state\", state });\n void tick();\n if (!streamDisabled) void streamLoop();\n }\n\n function becomeFollower() {\n role = \"follower\";\n post({ type: \"hello\" });\n }\n\n function acquireLeadership() {\n if (!locks || !channel) {\n role = \"solo\";\n void tick();\n if (!streamDisabled) void streamLoop();\n return;\n }\n becomeFollower();\n lockAbort = new AbortController();\n locks\n .request(\n `easy-ping:leader:${groupKey}`,\n { mode: \"exclusive\", signal: lockAbort.signal },\n () =>\n new Promise<void>((resolve) => {\n releaseLock = resolve;\n if (running) becomeLeader();\n else resolve();\n }),\n )\n .catch(() => {\n // aborted on stop, or Web Locks refused; either way no leadership here\n });\n }\n\n function start() {\n running = true;\n channel = makeChannel ? makeChannel() : undefined;\n channel?.addEventListener(\"message\", onTabMessage);\n serviceWorker?.addEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.addEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.addEventListener(\"visibilitychange\", onVisibility);\n }\n activeUntil = now() + activeWindowMs;\n acquireLeadership();\n }\n\n function stop() {\n running = false;\n clearTimeout(timer);\n timer = undefined;\n streamAbort?.abort();\n streamAbort = undefined;\n lockAbort?.abort();\n lockAbort = undefined;\n releaseLock?.();\n releaseLock = undefined;\n channel?.removeEventListener(\"message\", onTabMessage);\n channel?.close();\n channel = undefined;\n serviceWorker?.removeEventListener(\"message\", onRelay);\n if (activityTarget) {\n for (const name of ACTIVITY_EVENTS) activityTarget.removeEventListener(name, onActivity);\n }\n if (typeof document !== \"undefined\") {\n document.removeEventListener(\"visibilitychange\", onVisibility);\n }\n role = \"solo\";\n }\n\n // ---- signals from outside: the service worker relay and the piggyback header ----\n\n function onRelay(event: { data?: unknown }) {\n const data = event.data as { source?: unknown; type?: unknown } | null;\n if (data?.source === \"easy-ping\" && data.type === \"changed\") changed();\n }\n\n let lastInboxVersion: string | null = null;\n\n // Only the app's own origin may steer the bell; a third-party response cannot.\n const sameOrigin = (response: Response) => {\n if (!response.url || typeof location === \"undefined\") return true;\n try {\n return new URL(response.url).origin === location.origin;\n } catch {\n return false;\n }\n };\n\n /**\n * Wraps your app's fetch so responses carrying the inbox-version header\n * refresh the bell when the version moves. Active users then never need a\n * dedicated poll. RFC 0006 idea 1.\n */\n function instrument(inner: typeof globalThis.fetch): typeof globalThis.fetch {\n return (async (...args: Parameters<typeof globalThis.fetch>) => {\n const response = await inner(...args);\n const version = sameOrigin(response) ? response.headers.get(\"x-easy-ping-inbox\") : null;\n if (version !== null && version !== lastInboxVersion) {\n const first = lastInboxVersion === null;\n lastInboxVersion = version;\n if (!first && role !== \"follower\") changed();\n }\n return response;\n }) as typeof globalThis.fetch;\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One transport regardless of how many components subscribe.\n if (listeners.size === 1) start();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) stop();\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n instrument,\n getState: () => state,\n getTransport: (): TransportStatus => ({\n role,\n transport: streamDisabled ? \"poll\" : \"sse\",\n connected: streamConnected,\n }),\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
package/dist/schema.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as SchemaDeclaration } from './plugin-Wwp1nvo0.cjs';
2
- import './adapter-BJ8Iejvs.cjs';
1
+ import { S as SchemaDeclaration } from './plugin-6S8tpH0n.cjs';
2
+ import './adapter-CV5Yt2c6.cjs';
3
3
 
4
4
  declare const toSnakeCase: (value: string) => string;
5
5
  /** Core tables in the plugin format. A drift test asserts it agrees with the Drizzle schema. */
package/dist/schema.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as SchemaDeclaration } from './plugin-CSLKKgXT.js';
2
- import './adapter-BJ8Iejvs.js';
1
+ import { S as SchemaDeclaration } from './plugin-C4fikLl7.js';
2
+ import './adapter-CV5Yt2c6.js';
3
3
 
4
4
  declare const toSnakeCase: (value: string) => string;
5
5
  /** Core tables in the plugin format. A drift test asserts it agrees with the Drizzle schema. */
@@ -1,4 +1,4 @@
1
- import { D as DatabaseAdapter } from './adapter-BJ8Iejvs.cjs';
1
+ import { D as DatabaseAdapter } from './adapter-CV5Yt2c6.cjs';
2
2
 
3
3
  type ConformanceContext = {
4
4
  adapter: DatabaseAdapter;
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { D as DatabaseAdapter } from './adapter-BJ8Iejvs.js';
1
+ import { D as DatabaseAdapter } from './adapter-CV5Yt2c6.js';
2
2
 
3
3
  type ConformanceContext = {
4
4
  adapter: DatabaseAdapter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easy-ping",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Self-hosted, type-safe notifications for TypeScript apps. Your database, your users.",
5
5
  "license": "MIT",
6
6
  "author": "Teklu Moges",
@@ -35,7 +35,11 @@
35
35
  "postgres",
36
36
  "mongodb",
37
37
  "react",
38
- "nextjs"
38
+ "nextjs",
39
+ "react-native",
40
+ "expo",
41
+ "expo-notifications",
42
+ "mobile-push"
39
43
  ],
40
44
  "type": "module",
41
45
  "sideEffects": false,
@@ -101,6 +105,11 @@
101
105
  "import": "./dist/react.js",
102
106
  "require": "./dist/react.cjs"
103
107
  },
108
+ "./react-native": {
109
+ "types": "./dist/react-native.d.ts",
110
+ "import": "./dist/react-native.js",
111
+ "require": "./dist/react-native.cjs"
112
+ },
104
113
  "./plugins/preferences": {
105
114
  "types": "./dist/plugins/preferences.d.ts",
106
115
  "import": "./dist/plugins/preferences.js",
@@ -136,6 +145,11 @@
136
145
  "import": "./dist/plugins/telegram.js",
137
146
  "require": "./dist/plugins/telegram.cjs"
138
147
  },
148
+ "./plugins/mobile-push": {
149
+ "types": "./dist/plugins/mobile-push.d.ts",
150
+ "import": "./dist/plugins/mobile-push.js",
151
+ "require": "./dist/plugins/mobile-push.cjs"
152
+ },
139
153
  "./providers/web-push": {
140
154
  "types": "./dist/providers/web-push.d.ts",
141
155
  "import": "./dist/providers/web-push.js",
@@ -146,6 +160,11 @@
146
160
  "import": "./dist/providers/telegram.js",
147
161
  "require": "./dist/providers/telegram.cjs"
148
162
  },
163
+ "./providers/expo-push": {
164
+ "types": "./dist/providers/expo-push.d.ts",
165
+ "import": "./dist/providers/expo-push.js",
166
+ "require": "./dist/providers/expo-push.cjs"
167
+ },
149
168
  "./browser": {
150
169
  "types": "./dist/browser.d.ts",
151
170
  "import": "./dist/browser.js",
@@ -159,7 +178,8 @@
159
178
  },
160
179
  "peerDependencies": {
161
180
  "drizzle-orm": ">=0.36.0",
162
- "react": ">=18"
181
+ "react": ">=18",
182
+ "react-native": ">=0.72"
163
183
  },
164
184
  "peerDependenciesMeta": {
165
185
  "drizzle-orm": {
@@ -167,6 +187,9 @@
167
187
  },
168
188
  "react": {
169
189
  "optional": true
190
+ },
191
+ "react-native": {
192
+ "optional": true
170
193
  }
171
194
  },
172
195
  "devDependencies": {