easy-ping 0.5.0 → 0.6.1

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 (64) hide show
  1. package/README.md +548 -508
  2. package/dist/adapters/mongodb.cjs +142 -0
  3. package/dist/adapters/mongodb.cjs.map +1 -1
  4. package/dist/adapters/mongodb.d.cts +39 -1
  5. package/dist/adapters/mongodb.d.ts +39 -1
  6. package/dist/adapters/mongodb.js +73 -1
  7. package/dist/adapters/mongodb.js.map +1 -1
  8. package/dist/adapters/postgres.cjs +146 -0
  9. package/dist/adapters/postgres.cjs.map +1 -1
  10. package/dist/adapters/postgres.d.cts +36 -1
  11. package/dist/adapters/postgres.d.ts +36 -1
  12. package/dist/adapters/postgres.js +78 -3
  13. package/dist/adapters/postgres.js.map +1 -1
  14. package/dist/adapters/sqlite.js +1 -1
  15. package/dist/{chunk-GJNLOQJS.js → chunk-JKTMUMQK.js} +31 -6
  16. package/dist/chunk-JKTMUMQK.js.map +1 -0
  17. package/dist/{chunk-BECMTNK7.js → chunk-JOVIZXO6.js} +142 -6
  18. package/dist/chunk-JOVIZXO6.js.map +1 -0
  19. package/dist/chunk-ORZKH224.js +75 -0
  20. package/dist/chunk-ORZKH224.js.map +1 -0
  21. package/dist/client.cjs +297 -17
  22. package/dist/client.cjs.map +1 -1
  23. package/dist/client.d.cts +59 -1
  24. package/dist/client.d.ts +59 -1
  25. package/dist/client.js +297 -17
  26. package/dist/client.js.map +1 -1
  27. package/dist/index.cjs +293 -10
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +46 -1
  30. package/dist/index.d.ts +46 -1
  31. package/dist/index.js +57 -7
  32. package/dist/index.js.map +1 -1
  33. package/dist/node.cjs +26 -5
  34. package/dist/node.cjs.map +1 -1
  35. package/dist/node.d.cts +1 -1
  36. package/dist/node.d.ts +1 -1
  37. package/dist/node.js +28 -6
  38. package/dist/node.js.map +1 -1
  39. package/dist/plugins/preferences.cjs +2 -2
  40. package/dist/plugins/preferences.cjs.map +1 -1
  41. package/dist/plugins/preferences.js +2 -1
  42. package/dist/plugins/preferences.js.map +1 -1
  43. package/dist/plugins/push.cjs +2 -2
  44. package/dist/plugins/push.cjs.map +1 -1
  45. package/dist/plugins/push.js +3 -2
  46. package/dist/plugins/push.js.map +1 -1
  47. package/dist/react.cjs +320 -19
  48. package/dist/react.cjs.map +1 -1
  49. package/dist/react.d.cts +58 -0
  50. package/dist/react.d.ts +58 -0
  51. package/dist/react.js +320 -19
  52. package/dist/react.js.map +1 -1
  53. package/dist/schema.js +1 -1
  54. package/dist/signals-kPmOlKDX.d.cts +27 -0
  55. package/dist/signals-kPmOlKDX.d.ts +27 -0
  56. package/dist/sw.cjs +39 -0
  57. package/dist/sw.cjs.map +1 -0
  58. package/dist/sw.d.cts +52 -0
  59. package/dist/sw.d.ts +52 -0
  60. package/dist/sw.js +36 -0
  61. package/dist/sw.js.map +1 -0
  62. package/package.json +9 -3
  63. package/dist/chunk-BECMTNK7.js.map +0 -1
  64. package/dist/chunk-GJNLOQJS.js.map +0 -1
package/dist/client.d.cts CHANGED
@@ -19,14 +19,70 @@ type NotifyState = {
19
19
  isLoading: boolean;
20
20
  error: Error | null;
21
21
  };
22
+ type Transport = "auto" | "sse" | "poll";
23
+ type TransportStatus = {
24
+ /** "leader" holds this browser's single connection; "follower" mirrors it; "solo" has no Web Locks. */
25
+ role: "leader" | "follower" | "solo";
26
+ /** What the leader is using right now. */
27
+ transport: "sse" | "poll";
28
+ /** True while the event stream has said `ready` and not since closed. */
29
+ connected: boolean;
30
+ };
31
+ type LockManagerLike = {
32
+ request(name: string, options: {
33
+ mode: "exclusive";
34
+ signal?: AbortSignal;
35
+ }, callback: () => Promise<void>): Promise<void>;
36
+ };
37
+ type ChannelLike = {
38
+ postMessage(message: unknown): void;
39
+ addEventListener(type: "message", handler: (event: {
40
+ data: unknown;
41
+ }) => void): void;
42
+ removeEventListener(type: "message", handler: (event: {
43
+ data: unknown;
44
+ }) => void): void;
45
+ close(): void;
46
+ };
47
+ type MessageTargetLike = {
48
+ addEventListener(type: string, handler: (event: {
49
+ data?: unknown;
50
+ }) => void): void;
51
+ removeEventListener(type: string, handler: (event: {
52
+ data?: unknown;
53
+ }) => void): void;
54
+ };
22
55
  type NotifyClientOptions = {
23
56
  baseUrl?: string | undefined;
24
57
  fetch?: typeof globalThis.fetch | undefined;
25
58
  limit?: number | undefined;
59
+ /** Poll interval while the user is active and no stream is connected. Default 15 s. */
26
60
  pollIntervalMs?: number | undefined;
61
+ /** Ceiling the idle backoff grows to. Default 10 min. */
27
62
  maxPollIntervalMs?: number | undefined;
63
+ /** Poll interval while the event stream is connected: a safety net. Default 5 min. */
64
+ safetyNetMs?: number | undefined;
65
+ /** How long after user input polling stays at the base interval. Default 60 s. */
66
+ activeWindowMs?: number | undefined;
67
+ /** "auto" streams when a DOM is present, "poll" never streams, "sse" always tries. */
68
+ transport?: Transport | undefined;
28
69
  /** Injectable so the poller can be tested without a DOM. */
29
70
  isDocumentHidden?: (() => boolean) | undefined;
71
+ /** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */
72
+ locks?: LockManagerLike | null | undefined;
73
+ /** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */
74
+ channel?: (() => ChannelLike) | null | undefined;
75
+ /** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */
76
+ serviceWorker?: MessageTargetLike | null | undefined;
77
+ /** Where user activity is observed. Defaults to window; null disables. */
78
+ activityTarget?: MessageTargetLike | null | undefined;
79
+ now?: (() => number) | undefined;
80
+ /**
81
+ * Separates browsers' tab groups when identity is not a cookie: pass the
82
+ * current user's id (or session id) so tabs signed in as different users
83
+ * never share a leader or mirror each other's inbox.
84
+ */
85
+ scope?: string | undefined;
30
86
  };
31
87
  declare function createNotifyClient(options?: NotifyClientOptions): {
32
88
  subscribe: (listener: (state: NotifyState) => void) => () => void;
@@ -35,8 +91,10 @@ declare function createNotifyClient(options?: NotifyClientOptions): {
35
91
  markAsRead: (id: string) => Promise<void>;
36
92
  markAllRead: () => Promise<void>;
37
93
  markSeen: () => Promise<void>;
94
+ instrument: (inner: typeof globalThis.fetch) => typeof globalThis.fetch;
38
95
  getState: () => NotifyState;
96
+ getTransport: () => TransportStatus;
39
97
  };
40
98
  type NotifyClient = ReturnType<typeof createNotifyClient>;
41
99
 
42
- export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, createNotifyClient };
100
+ export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, type Transport, type TransportStatus, createNotifyClient };
package/dist/client.d.ts CHANGED
@@ -19,14 +19,70 @@ type NotifyState = {
19
19
  isLoading: boolean;
20
20
  error: Error | null;
21
21
  };
22
+ type Transport = "auto" | "sse" | "poll";
23
+ type TransportStatus = {
24
+ /** "leader" holds this browser's single connection; "follower" mirrors it; "solo" has no Web Locks. */
25
+ role: "leader" | "follower" | "solo";
26
+ /** What the leader is using right now. */
27
+ transport: "sse" | "poll";
28
+ /** True while the event stream has said `ready` and not since closed. */
29
+ connected: boolean;
30
+ };
31
+ type LockManagerLike = {
32
+ request(name: string, options: {
33
+ mode: "exclusive";
34
+ signal?: AbortSignal;
35
+ }, callback: () => Promise<void>): Promise<void>;
36
+ };
37
+ type ChannelLike = {
38
+ postMessage(message: unknown): void;
39
+ addEventListener(type: "message", handler: (event: {
40
+ data: unknown;
41
+ }) => void): void;
42
+ removeEventListener(type: "message", handler: (event: {
43
+ data: unknown;
44
+ }) => void): void;
45
+ close(): void;
46
+ };
47
+ type MessageTargetLike = {
48
+ addEventListener(type: string, handler: (event: {
49
+ data?: unknown;
50
+ }) => void): void;
51
+ removeEventListener(type: string, handler: (event: {
52
+ data?: unknown;
53
+ }) => void): void;
54
+ };
22
55
  type NotifyClientOptions = {
23
56
  baseUrl?: string | undefined;
24
57
  fetch?: typeof globalThis.fetch | undefined;
25
58
  limit?: number | undefined;
59
+ /** Poll interval while the user is active and no stream is connected. Default 15 s. */
26
60
  pollIntervalMs?: number | undefined;
61
+ /** Ceiling the idle backoff grows to. Default 10 min. */
27
62
  maxPollIntervalMs?: number | undefined;
63
+ /** Poll interval while the event stream is connected: a safety net. Default 5 min. */
64
+ safetyNetMs?: number | undefined;
65
+ /** How long after user input polling stays at the base interval. Default 60 s. */
66
+ activeWindowMs?: number | undefined;
67
+ /** "auto" streams when a DOM is present, "poll" never streams, "sse" always tries. */
68
+ transport?: Transport | undefined;
28
69
  /** Injectable so the poller can be tested without a DOM. */
29
70
  isDocumentHidden?: (() => boolean) | undefined;
71
+ /** Web Locks, for one connection per browser. Defaults to navigator.locks; null disables. */
72
+ locks?: LockManagerLike | null | undefined;
73
+ /** Cross-tab channel factory. Defaults to BroadcastChannel; null disables. */
74
+ channel?: (() => ChannelLike) | null | undefined;
75
+ /** Where push relays arrive. Defaults to navigator.serviceWorker; null disables. */
76
+ serviceWorker?: MessageTargetLike | null | undefined;
77
+ /** Where user activity is observed. Defaults to window; null disables. */
78
+ activityTarget?: MessageTargetLike | null | undefined;
79
+ now?: (() => number) | undefined;
80
+ /**
81
+ * Separates browsers' tab groups when identity is not a cookie: pass the
82
+ * current user's id (or session id) so tabs signed in as different users
83
+ * never share a leader or mirror each other's inbox.
84
+ */
85
+ scope?: string | undefined;
30
86
  };
31
87
  declare function createNotifyClient(options?: NotifyClientOptions): {
32
88
  subscribe: (listener: (state: NotifyState) => void) => () => void;
@@ -35,8 +91,10 @@ declare function createNotifyClient(options?: NotifyClientOptions): {
35
91
  markAsRead: (id: string) => Promise<void>;
36
92
  markAllRead: () => Promise<void>;
37
93
  markSeen: () => Promise<void>;
94
+ instrument: (inner: typeof globalThis.fetch) => typeof globalThis.fetch;
38
95
  getState: () => NotifyState;
96
+ getTransport: () => TransportStatus;
39
97
  };
40
98
  type NotifyClient = ReturnType<typeof createNotifyClient>;
41
99
 
42
- export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, createNotifyClient };
100
+ export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, type Transport, type TransportStatus, createNotifyClient };
package/dist/client.js CHANGED
@@ -8,22 +8,63 @@ var INITIAL = {
8
8
  error: null
9
9
  };
10
10
  var countUnread = (rows) => rows.reduce((total, row) => row.readAt === null ? total + 1 : total, 0);
11
+ var SHORT_STREAM_MS = 15e3;
12
+ var SHORT_STREAMS_BEFORE_GIVING_UP = 3;
11
13
  function createNotifyClient(options = {}) {
12
14
  const baseUrl = (options.baseUrl ?? "/api/notifications").replace(/\/$/, "");
13
15
  const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
14
16
  const limit = options.limit ?? 20;
15
17
  const pollIntervalMs = options.pollIntervalMs ?? 15e3;
16
- const maxPollIntervalMs = options.maxPollIntervalMs ?? 5 * 6e4;
18
+ const maxPollIntervalMs = options.maxPollIntervalMs ?? 10 * 6e4;
19
+ const safetyNetMs = options.safetyNetMs ?? 5 * 6e4;
20
+ const activeWindowMs = options.activeWindowMs ?? 6e4;
21
+ const now = options.now ?? (() => Date.now());
22
+ const groupKey = options.scope ? `${baseUrl}#${options.scope}` : baseUrl;
23
+ const hasDom = typeof window !== "undefined" && typeof document !== "undefined";
24
+ const transport = options.transport ?? "auto";
25
+ const wantsStream = transport === "sse" || transport === "auto" && hasDom;
17
26
  const isHidden = options.isDocumentHidden ?? (() => typeof document !== "undefined" && document.visibilityState === "hidden");
27
+ const locks = options.locks === void 0 ? typeof navigator !== "undefined" ? navigator.locks ?? null : null : options.locks;
28
+ const makeChannel = options.channel === void 0 ? typeof BroadcastChannel !== "undefined" ? () => new BroadcastChannel(`easy-ping:${groupKey}`) : null : options.channel;
29
+ const serviceWorker = options.serviceWorker === void 0 ? typeof navigator !== "undefined" ? navigator.serviceWorker ?? null : null : options.serviceWorker;
30
+ const activityTarget = options.activityTarget === void 0 ? hasDom ? window : null : options.activityTarget;
18
31
  let state = INITIAL;
19
32
  const listeners = /* @__PURE__ */ new Set();
33
+ let role = "solo";
34
+ let running = false;
35
+ let channel;
36
+ let releaseLock;
37
+ let lockAbort;
20
38
  let timer;
21
39
  let consecutiveFailures = 0;
40
+ let idleTicks = 0;
41
+ let activeUntil = 0;
42
+ let lastRefreshAt = 0;
43
+ let refreshing;
44
+ let refreshAgain = false;
45
+ let streamAbort;
46
+ let streamConnected = false;
47
+ let streamDisabled = !wantsStream;
48
+ let shortStreams = 0;
49
+ let streamFailures = 0;
22
50
  let generation = 0;
51
+ const localReads = /* @__PURE__ */ new Set();
52
+ let localSeen = false;
53
+ const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
54
+ function emit() {
55
+ for (const listener of listeners) listener(state);
56
+ }
23
57
  function set(patch) {
24
58
  state = { ...state, ...patch };
25
59
  state = { ...state, unreadCount: countUnread(state.notifications) };
26
- for (const listener of listeners) listener(state);
60
+ emit();
61
+ if (role === "leader") post({ type: "state", state });
62
+ }
63
+ function post(message) {
64
+ try {
65
+ channel?.postMessage(message);
66
+ } catch {
67
+ }
27
68
  }
28
69
  async function request(path, init) {
29
70
  const response = await doFetch(`${baseUrl}${path}`, {
@@ -34,7 +75,7 @@ function createNotifyClient(options = {}) {
34
75
  throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`);
35
76
  return await response.json();
36
77
  }
37
- async function refresh() {
78
+ async function fetchPage() {
38
79
  const startedAt = generation;
39
80
  const page = await request(`/?limit=${limit}`);
40
81
  const unseenCount = page.unseenCount ?? (await request("/count")).unseen;
@@ -47,6 +88,24 @@ function createNotifyClient(options = {}) {
47
88
  error: null
48
89
  });
49
90
  }
91
+ function refresh() {
92
+ if (refreshing) {
93
+ refreshAgain = true;
94
+ return refreshing;
95
+ }
96
+ refreshing = (async () => {
97
+ try {
98
+ do {
99
+ refreshAgain = false;
100
+ await fetchPage();
101
+ lastRefreshAt = now();
102
+ } while (refreshAgain);
103
+ } finally {
104
+ refreshing = void 0;
105
+ }
106
+ })();
107
+ return refreshing;
108
+ }
50
109
  let loadingMore;
51
110
  async function loadMore() {
52
111
  if (!state.nextCursor) return;
@@ -75,30 +134,39 @@ function createNotifyClient(options = {}) {
75
134
  set({ notifications: apply(state.notifications), ...patch });
76
135
  try {
77
136
  await send();
137
+ if (role === "follower") post({ type: "mutated" });
78
138
  } catch (error) {
79
139
  generation += 1;
80
140
  state = previous;
81
- for (const listener of listeners) listener(state);
141
+ emit();
82
142
  throw error;
83
143
  }
84
144
  }
85
- const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
86
145
  async function markAsRead(id) {
146
+ if (role === "follower") localReads.add(id);
87
147
  await optimistic(
88
148
  (rows) => rows.map((row) => row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row),
89
149
  {},
90
150
  () => request("/read", { method: "POST", body: JSON.stringify({ ids: [id] }) })
91
- );
151
+ ).catch((error) => {
152
+ localReads.delete(id);
153
+ throw error;
154
+ });
92
155
  }
93
156
  async function markAllRead() {
157
+ if (role === "follower") for (const row of state.notifications) localReads.add(row.id);
94
158
  await optimistic(
95
159
  (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),
96
160
  {},
97
161
  () => request("/read-all", { method: "POST" })
98
- );
162
+ ).catch((error) => {
163
+ localReads.clear();
164
+ throw error;
165
+ });
99
166
  }
100
167
  async function markSeen() {
101
168
  const newest = state.notifications[0]?.createdAt;
169
+ if (role === "follower") localSeen = true;
102
170
  await optimistic(
103
171
  (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),
104
172
  { unseenCount: 0 },
@@ -106,13 +174,55 @@ function createNotifyClient(options = {}) {
106
174
  method: "POST",
107
175
  body: JSON.stringify(newest ? { before: newest } : {})
108
176
  })
109
- );
177
+ ).catch((error) => {
178
+ localSeen = false;
179
+ throw error;
180
+ });
181
+ }
182
+ function applyRemoteState(remote) {
183
+ const notifications = remote.notifications.map((row) => {
184
+ if (!localReads.has(row.id)) return row;
185
+ if (row.readAt) {
186
+ localReads.delete(row.id);
187
+ return row;
188
+ }
189
+ return { ...row, readAt: stamp() };
190
+ });
191
+ if (localSeen && remote.unseenCount === 0) localSeen = false;
192
+ state = {
193
+ ...remote,
194
+ notifications,
195
+ unseenCount: localSeen ? 0 : remote.unseenCount,
196
+ unreadCount: countUnread(notifications)
197
+ };
198
+ emit();
199
+ }
200
+ function onTabMessage(event) {
201
+ const message = event.data;
202
+ if (!message || typeof message !== "object") return;
203
+ if (role === "leader") {
204
+ if (message.type === "hello") post({ type: "state", state });
205
+ else if (message.type === "mutated") void refresh().catch(() => {
206
+ });
207
+ return;
208
+ }
209
+ if (message.type === "state") applyRemoteState(message.state);
110
210
  }
111
211
  function schedule(delay) {
112
212
  clearTimeout(timer);
113
213
  timer = setTimeout(tick, delay);
114
214
  }
215
+ function nextDelay() {
216
+ if (consecutiveFailures > 0) {
217
+ return Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs);
218
+ }
219
+ if (streamConnected) return safetyNetMs;
220
+ if (now() < activeUntil) return pollIntervalMs;
221
+ idleTicks += 1;
222
+ return Math.min(pollIntervalMs * 2 ** idleTicks, maxPollIntervalMs);
223
+ }
115
224
  async function tick() {
225
+ if (!running) return;
116
226
  if (isHidden()) {
117
227
  schedule(pollIntervalMs);
118
228
  return;
@@ -124,20 +234,184 @@ function createNotifyClient(options = {}) {
124
234
  consecutiveFailures += 1;
125
235
  set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });
126
236
  }
127
- schedule(
128
- consecutiveFailures === 0 ? pollIntervalMs : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs)
129
- );
237
+ if (running) schedule(nextDelay());
238
+ }
239
+ function onActivity() {
240
+ if (!running || role === "follower") return;
241
+ activeUntil = now() + activeWindowMs;
242
+ idleTicks = 0;
243
+ if (!isHidden() && now() - lastRefreshAt > pollIntervalMs) {
244
+ void refresh().catch(() => {
245
+ });
246
+ schedule(nextDelay());
247
+ }
248
+ }
249
+ const onVisibility = () => {
250
+ if (!isHidden()) onActivity();
251
+ };
252
+ const ACTIVITY_EVENTS = ["focus", "pointerdown", "keydown", "online"];
253
+ function changed() {
254
+ if (!running) return;
255
+ void refresh().catch(() => {
256
+ });
257
+ }
258
+ async function readStream(signal) {
259
+ const openedAt = now();
260
+ const response = await doFetch(`${baseUrl}/events`, {
261
+ headers: { accept: "text/event-stream" },
262
+ signal
263
+ });
264
+ if (!response.ok || !response.body) throw new Error(`GET /events failed: ${response.status}`);
265
+ const reader = response.body.getReader();
266
+ const decoder = new TextDecoder();
267
+ let buffer = "";
268
+ let eventName = "";
269
+ try {
270
+ for (; ; ) {
271
+ const { done, value } = await reader.read();
272
+ if (done) break;
273
+ buffer += decoder.decode(value, { stream: true });
274
+ let newline = buffer.indexOf("\n");
275
+ while (newline >= 0) {
276
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
277
+ buffer = buffer.slice(newline + 1);
278
+ if (line === "") {
279
+ if (eventName === "ready") {
280
+ streamConnected = true;
281
+ streamFailures = 0;
282
+ schedule(nextDelay());
283
+ } else if (eventName === "changed") changed();
284
+ eventName = "";
285
+ } else if (line.startsWith("event:")) eventName = line.slice(6).trim();
286
+ newline = buffer.indexOf("\n");
287
+ }
288
+ }
289
+ } finally {
290
+ streamConnected = false;
291
+ if (now() - openedAt < SHORT_STREAM_MS) shortStreams += 1;
292
+ else shortStreams = 0;
293
+ }
294
+ }
295
+ const isFollower = () => role === "follower";
296
+ async function streamLoop() {
297
+ while (running && !streamDisabled && !isFollower()) {
298
+ streamAbort = new AbortController();
299
+ try {
300
+ await readStream(streamAbort.signal);
301
+ } catch {
302
+ if (!running) return;
303
+ streamFailures += 1;
304
+ }
305
+ if (!running || isFollower()) return;
306
+ if (shortStreams >= SHORT_STREAMS_BEFORE_GIVING_UP) {
307
+ streamDisabled = true;
308
+ schedule(nextDelay());
309
+ return;
310
+ }
311
+ await new Promise(
312
+ (resolve) => setTimeout(resolve, Math.min(3e3 * 2 ** streamFailures, 6e4))
313
+ );
314
+ }
315
+ }
316
+ function becomeLeader() {
317
+ role = "leader";
318
+ post({ type: "leader" });
319
+ post({ type: "state", state });
320
+ void tick();
321
+ if (!streamDisabled) void streamLoop();
322
+ }
323
+ function becomeFollower() {
324
+ role = "follower";
325
+ post({ type: "hello" });
326
+ }
327
+ function acquireLeadership() {
328
+ if (!locks || !channel) {
329
+ role = "solo";
330
+ void tick();
331
+ if (!streamDisabled) void streamLoop();
332
+ return;
333
+ }
334
+ becomeFollower();
335
+ lockAbort = new AbortController();
336
+ locks.request(
337
+ `easy-ping:leader:${groupKey}`,
338
+ { mode: "exclusive", signal: lockAbort.signal },
339
+ () => new Promise((resolve) => {
340
+ releaseLock = resolve;
341
+ if (running) becomeLeader();
342
+ else resolve();
343
+ })
344
+ ).catch(() => {
345
+ });
346
+ }
347
+ function start() {
348
+ running = true;
349
+ channel = makeChannel ? makeChannel() : void 0;
350
+ channel?.addEventListener("message", onTabMessage);
351
+ serviceWorker?.addEventListener("message", onRelay);
352
+ if (activityTarget) {
353
+ for (const name of ACTIVITY_EVENTS) activityTarget.addEventListener(name, onActivity);
354
+ }
355
+ if (typeof document !== "undefined") {
356
+ document.addEventListener("visibilitychange", onVisibility);
357
+ }
358
+ activeUntil = now() + activeWindowMs;
359
+ acquireLeadership();
360
+ }
361
+ function stop() {
362
+ running = false;
363
+ clearTimeout(timer);
364
+ timer = void 0;
365
+ streamAbort?.abort();
366
+ streamAbort = void 0;
367
+ lockAbort?.abort();
368
+ lockAbort = void 0;
369
+ releaseLock?.();
370
+ releaseLock = void 0;
371
+ channel?.removeEventListener("message", onTabMessage);
372
+ channel?.close();
373
+ channel = void 0;
374
+ serviceWorker?.removeEventListener("message", onRelay);
375
+ if (activityTarget) {
376
+ for (const name of ACTIVITY_EVENTS) activityTarget.removeEventListener(name, onActivity);
377
+ }
378
+ if (typeof document !== "undefined") {
379
+ document.removeEventListener("visibilitychange", onVisibility);
380
+ }
381
+ role = "solo";
382
+ }
383
+ function onRelay(event) {
384
+ const data = event.data;
385
+ if (data?.source === "easy-ping" && data.type === "changed") changed();
386
+ }
387
+ let lastInboxVersion = null;
388
+ const sameOrigin = (response) => {
389
+ if (!response.url || typeof location === "undefined") return true;
390
+ try {
391
+ return new URL(response.url).origin === location.origin;
392
+ } catch {
393
+ return false;
394
+ }
395
+ };
396
+ function instrument(inner) {
397
+ return (async (...args) => {
398
+ const response = await inner(...args);
399
+ const version = sameOrigin(response) ? response.headers.get("x-easy-ping-inbox") : null;
400
+ if (version !== null && version !== lastInboxVersion) {
401
+ const first = lastInboxVersion === null;
402
+ lastInboxVersion = version;
403
+ if (!first && role !== "follower") changed();
404
+ }
405
+ return response;
406
+ });
130
407
  }
131
408
  function subscribe(listener) {
132
409
  listeners.add(listener);
133
410
  listener(state);
134
- if (listeners.size === 1) void tick();
411
+ if (listeners.size === 1) start();
135
412
  return () => {
136
413
  listeners.delete(listener);
137
- if (listeners.size === 0) {
138
- clearTimeout(timer);
139
- timer = void 0;
140
- }
414
+ if (listeners.size === 0) stop();
141
415
  };
142
416
  }
143
417
  return {
@@ -147,7 +421,13 @@ function createNotifyClient(options = {}) {
147
421
  markAsRead,
148
422
  markAllRead,
149
423
  markSeen,
150
- getState: () => state
424
+ instrument,
425
+ getState: () => state,
426
+ getTransport: () => ({
427
+ role,
428
+ transport: streamDisabled ? "poll" : "sse",
429
+ connected: streamConnected
430
+ })
151
431
  };
152
432
  }
153
433