easy-ping 0.1.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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +374 -0
  3. package/dist/adapter-f0OR2DhY.d.cts +200 -0
  4. package/dist/adapter-f0OR2DhY.d.ts +200 -0
  5. package/dist/adapters/drizzle.cjs +440 -0
  6. package/dist/adapters/drizzle.cjs.map +1 -0
  7. package/dist/adapters/drizzle.d.cts +991 -0
  8. package/dist/adapters/drizzle.d.ts +991 -0
  9. package/dist/adapters/drizzle.js +409 -0
  10. package/dist/adapters/drizzle.js.map +1 -0
  11. package/dist/adapters/mongodb.cjs +458 -0
  12. package/dist/adapters/mongodb.cjs.map +1 -0
  13. package/dist/adapters/mongodb.d.cts +32 -0
  14. package/dist/adapters/mongodb.d.ts +32 -0
  15. package/dist/adapters/mongodb.js +376 -0
  16. package/dist/adapters/mongodb.js.map +1 -0
  17. package/dist/browser.cjs +96 -0
  18. package/dist/browser.cjs.map +1 -0
  19. package/dist/browser.d.cts +45 -0
  20. package/dist/browser.d.ts +45 -0
  21. package/dist/browser.js +89 -0
  22. package/dist/browser.js.map +1 -0
  23. package/dist/chunk-BV3U542P.js +11 -0
  24. package/dist/chunk-BV3U542P.js.map +1 -0
  25. package/dist/chunk-NYDVNMKG.js +62 -0
  26. package/dist/chunk-NYDVNMKG.js.map +1 -0
  27. package/dist/chunk-OZSOIX23.js +120 -0
  28. package/dist/chunk-OZSOIX23.js.map +1 -0
  29. package/dist/chunk-PTGHMCCG.js +23 -0
  30. package/dist/chunk-PTGHMCCG.js.map +1 -0
  31. package/dist/chunk-W3BMEPPQ.js +50 -0
  32. package/dist/chunk-W3BMEPPQ.js.map +1 -0
  33. package/dist/client.cjs +160 -0
  34. package/dist/client.cjs.map +1 -0
  35. package/dist/client.d.cts +42 -0
  36. package/dist/client.d.ts +42 -0
  37. package/dist/client.js +158 -0
  38. package/dist/client.js.map +1 -0
  39. package/dist/index.cjs +920 -0
  40. package/dist/index.cjs.map +1 -0
  41. package/dist/index.d.cts +203 -0
  42. package/dist/index.d.ts +203 -0
  43. package/dist/index.js +723 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/node.cjs +49 -0
  46. package/dist/node.cjs.map +1 -0
  47. package/dist/node.d.cts +8 -0
  48. package/dist/node.d.ts +8 -0
  49. package/dist/node.js +46 -0
  50. package/dist/node.js.map +1 -0
  51. package/dist/plugin-Bqj9xAOS.d.cts +217 -0
  52. package/dist/plugin-bTJKmnlq.d.ts +217 -0
  53. package/dist/plugins/digests.cjs +206 -0
  54. package/dist/plugins/digests.cjs.map +1 -0
  55. package/dist/plugins/digests.d.cts +68 -0
  56. package/dist/plugins/digests.d.ts +68 -0
  57. package/dist/plugins/digests.js +201 -0
  58. package/dist/plugins/digests.js.map +1 -0
  59. package/dist/plugins/preferences-client.cjs +23 -0
  60. package/dist/plugins/preferences-client.cjs.map +1 -0
  61. package/dist/plugins/preferences-client.d.cts +29 -0
  62. package/dist/plugins/preferences-client.d.ts +29 -0
  63. package/dist/plugins/preferences-client.js +18 -0
  64. package/dist/plugins/preferences-client.js.map +1 -0
  65. package/dist/plugins/preferences-react.cjs +125 -0
  66. package/dist/plugins/preferences-react.cjs.map +1 -0
  67. package/dist/plugins/preferences-react.d.cts +40 -0
  68. package/dist/plugins/preferences-react.d.ts +40 -0
  69. package/dist/plugins/preferences-react.js +101 -0
  70. package/dist/plugins/preferences-react.js.map +1 -0
  71. package/dist/plugins/preferences.cjs +147 -0
  72. package/dist/plugins/preferences.cjs.map +1 -0
  73. package/dist/plugins/preferences.d.cts +22 -0
  74. package/dist/plugins/preferences.d.ts +22 -0
  75. package/dist/plugins/preferences.js +119 -0
  76. package/dist/plugins/preferences.js.map +1 -0
  77. package/dist/plugins/push.cjs +146 -0
  78. package/dist/plugins/push.cjs.map +1 -0
  79. package/dist/plugins/push.d.cts +60 -0
  80. package/dist/plugins/push.d.ts +60 -0
  81. package/dist/plugins/push.js +144 -0
  82. package/dist/plugins/push.js.map +1 -0
  83. package/dist/provider-CA45WVqt.d.cts +23 -0
  84. package/dist/provider-CA45WVqt.d.ts +23 -0
  85. package/dist/providers/resend.cjs +69 -0
  86. package/dist/providers/resend.cjs.map +1 -0
  87. package/dist/providers/resend.d.cts +20 -0
  88. package/dist/providers/resend.d.ts +20 -0
  89. package/dist/providers/resend.js +66 -0
  90. package/dist/providers/resend.js.map +1 -0
  91. package/dist/providers/web-push.cjs +211 -0
  92. package/dist/providers/web-push.cjs.map +1 -0
  93. package/dist/providers/web-push.d.cts +33 -0
  94. package/dist/providers/web-push.d.ts +33 -0
  95. package/dist/providers/web-push.js +193 -0
  96. package/dist/providers/web-push.js.map +1 -0
  97. package/dist/react.cjs +218 -0
  98. package/dist/react.cjs.map +1 -0
  99. package/dist/react.d.cts +57 -0
  100. package/dist/react.d.ts +57 -0
  101. package/dist/react.js +193 -0
  102. package/dist/react.js.map +1 -0
  103. package/dist/schema.cjs +263 -0
  104. package/dist/schema.cjs.map +1 -0
  105. package/dist/schema.d.cts +180 -0
  106. package/dist/schema.d.ts +180 -0
  107. package/dist/schema.js +200 -0
  108. package/dist/schema.js.map +1 -0
  109. package/dist/testing.cjs +342 -0
  110. package/dist/testing.cjs.map +1 -0
  111. package/dist/testing.d.cts +38 -0
  112. package/dist/testing.d.ts +38 -0
  113. package/dist/testing.js +340 -0
  114. package/dist/testing.js.map +1 -0
  115. package/package.json +168 -0
package/dist/react.cjs ADDED
@@ -0,0 +1,218 @@
1
+ "use client";
2
+ "use strict";
3
+ "use client";
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
21
+
22
+ // src/react/index.ts
23
+ var react_exports = {};
24
+ __export(react_exports, {
25
+ createNotifyClient: () => createNotifyClient,
26
+ useNotifications: () => useNotifications
27
+ });
28
+ module.exports = __toCommonJS(react_exports);
29
+ var import_react = require("react");
30
+
31
+ // src/client/index.ts
32
+ var INITIAL = {
33
+ notifications: [],
34
+ unseenCount: 0,
35
+ unreadCount: 0,
36
+ nextCursor: null,
37
+ isLoading: true,
38
+ error: null
39
+ };
40
+ var countUnread = (rows) => rows.reduce((total, row) => row.readAt === null ? total + 1 : total, 0);
41
+ function createNotifyClient(options = {}) {
42
+ const baseUrl = (options.baseUrl ?? "/api/notifications").replace(/\/$/, "");
43
+ const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
44
+ const limit = options.limit ?? 20;
45
+ const pollIntervalMs = options.pollIntervalMs ?? 15e3;
46
+ const maxPollIntervalMs = options.maxPollIntervalMs ?? 5 * 6e4;
47
+ const isHidden = options.isDocumentHidden ?? (() => typeof document !== "undefined" && document.visibilityState === "hidden");
48
+ let state = INITIAL;
49
+ const listeners = /* @__PURE__ */ new Set();
50
+ let timer;
51
+ let consecutiveFailures = 0;
52
+ let generation = 0;
53
+ function set(patch) {
54
+ state = { ...state, ...patch };
55
+ state = { ...state, unreadCount: countUnread(state.notifications) };
56
+ for (const listener of listeners) listener(state);
57
+ }
58
+ async function request(path, init) {
59
+ const response = await doFetch(`${baseUrl}${path}`, {
60
+ ...init,
61
+ headers: { "content-type": "application/json", ...init?.headers }
62
+ });
63
+ if (!response.ok)
64
+ throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`);
65
+ return await response.json();
66
+ }
67
+ async function refresh() {
68
+ const startedAt = generation;
69
+ const [page, count] = await Promise.all([
70
+ request(`/?limit=${limit}`),
71
+ request("/count")
72
+ ]);
73
+ if (generation !== startedAt) return;
74
+ set({
75
+ notifications: page.notifications,
76
+ nextCursor: page.nextCursor,
77
+ unseenCount: count.unseen,
78
+ isLoading: false,
79
+ error: null
80
+ });
81
+ }
82
+ let loadingMore;
83
+ async function loadMore() {
84
+ if (!state.nextCursor) return;
85
+ if (loadingMore) return loadingMore;
86
+ const cursor = state.nextCursor;
87
+ loadingMore = (async () => {
88
+ try {
89
+ const page = await request(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);
90
+ const known = new Set(state.notifications.map((row) => row.id));
91
+ set({
92
+ notifications: [
93
+ ...state.notifications,
94
+ ...page.notifications.filter((row) => !known.has(row.id))
95
+ ],
96
+ nextCursor: page.nextCursor
97
+ });
98
+ } finally {
99
+ loadingMore = void 0;
100
+ }
101
+ })();
102
+ return loadingMore;
103
+ }
104
+ async function optimistic(apply, patch, send) {
105
+ const previous = state;
106
+ generation += 1;
107
+ set({ notifications: apply(state.notifications), ...patch });
108
+ try {
109
+ await send();
110
+ } catch (error) {
111
+ generation += 1;
112
+ state = previous;
113
+ for (const listener of listeners) listener(state);
114
+ throw error;
115
+ }
116
+ }
117
+ const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
118
+ async function markAsRead(id) {
119
+ await optimistic(
120
+ (rows) => rows.map((row) => row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row),
121
+ {},
122
+ () => request("/read", { method: "POST", body: JSON.stringify({ ids: [id] }) })
123
+ );
124
+ }
125
+ async function markAllRead() {
126
+ await optimistic(
127
+ (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),
128
+ {},
129
+ () => request("/read-all", { method: "POST" })
130
+ );
131
+ }
132
+ async function markSeen() {
133
+ const newest = state.notifications[0]?.createdAt;
134
+ await optimistic(
135
+ (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),
136
+ { unseenCount: 0 },
137
+ () => request("/seen", {
138
+ method: "POST",
139
+ body: JSON.stringify(newest ? { before: newest } : {})
140
+ })
141
+ );
142
+ }
143
+ function schedule(delay) {
144
+ clearTimeout(timer);
145
+ timer = setTimeout(tick, delay);
146
+ }
147
+ async function tick() {
148
+ if (isHidden()) {
149
+ schedule(pollIntervalMs);
150
+ return;
151
+ }
152
+ try {
153
+ await refresh();
154
+ consecutiveFailures = 0;
155
+ } catch (error) {
156
+ consecutiveFailures += 1;
157
+ set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });
158
+ }
159
+ schedule(
160
+ consecutiveFailures === 0 ? pollIntervalMs : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs)
161
+ );
162
+ }
163
+ function subscribe(listener) {
164
+ listeners.add(listener);
165
+ listener(state);
166
+ if (listeners.size === 1) void tick();
167
+ return () => {
168
+ listeners.delete(listener);
169
+ if (listeners.size === 0) {
170
+ clearTimeout(timer);
171
+ timer = void 0;
172
+ }
173
+ };
174
+ }
175
+ return {
176
+ subscribe,
177
+ refresh,
178
+ loadMore,
179
+ markAsRead,
180
+ markAllRead,
181
+ markSeen,
182
+ getState: () => state
183
+ };
184
+ }
185
+
186
+ // src/react/index.ts
187
+ function useNotifications(options = {}) {
188
+ const { client: provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs } = options;
189
+ const callbacksRef = (0, import_react.useRef)(options);
190
+ callbacksRef.current = options;
191
+ const client = (0, import_react.useMemo)(
192
+ () => provided ?? createNotifyClient({
193
+ baseUrl,
194
+ limit,
195
+ pollIntervalMs,
196
+ maxPollIntervalMs,
197
+ fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),
198
+ isDocumentHidden: () => callbacksRef.current.isDocumentHidden?.() ?? (typeof document !== "undefined" && document.visibilityState === "hidden")
199
+ }),
200
+ [provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs]
201
+ );
202
+ const [state, setState] = (0, import_react.useState)(() => client.getState());
203
+ (0, import_react.useEffect)(() => client.subscribe(setState), [client]);
204
+ return {
205
+ ...state,
206
+ markAsRead: (0, import_react.useCallback)((id) => client.markAsRead(id), [client]),
207
+ markAllRead: (0, import_react.useCallback)(() => client.markAllRead(), [client]),
208
+ markSeen: (0, import_react.useCallback)(() => client.markSeen(), [client]),
209
+ loadMore: (0, import_react.useCallback)(() => client.loadMore(), [client]),
210
+ refresh: (0, import_react.useCallback)(() => client.refresh(), [client])
211
+ };
212
+ }
213
+ // Annotate the CommonJS export names for ESM import in node:
214
+ 0 && (module.exports = {
215
+ createNotifyClient,
216
+ useNotifications
217
+ });
218
+ //# sourceMappingURL=react.cjs.map
@@ -0,0 +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/**\n * All state and optimistic-rollback logic lives in the framework-agnostic\n * client; this binding only wires it to React's lifecycle.\n */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const { client: provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs } = options;\n\n // Callbacks are usually inline literals. Held in a ref and called through a\n // stable wrapper so a new function identity each render does not rebuild the\n // client and restart polling.\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 fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs],\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 NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n pollIntervalMs?: number | undefined;\n maxPollIntervalMs?: number | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | 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\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 ?? 5 * 60_000;\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n\n // Bumped per mutation: an in-flight poll returns pre-mutation data, which\n // would silently revert the optimistic update.\n let generation = 0;\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n for (const listener of listeners) listener(state);\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 refresh(): Promise<void> {\n const startedAt = generation;\n\n const [page, count] = await Promise.all([\n request<{ notifications: NotificationView[]; nextCursor: string | null }>(`/?limit=${limit}`),\n request<{ unseen: number }>(\"/count\"),\n ]);\n\n // A mutation landed while this was in flight, so the response is already\n // stale. Dropping it is correct: the mutation's own optimistic state is\n // newer, and the next poll will reconcile.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount: count.unseen,\n isLoading: false,\n error: null,\n });\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Two clicks before the first response lands would both read the same\n // cursor and append the same page twice. Share the in-flight request.\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 // Guard against a duplicate arriving anyway — a concurrent refresh may\n // already have pulled some of these into the list.\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 } catch (error) {\n generation += 1;\n state = previous;\n for (const listener of listeners) listener(state);\n throw error;\n }\n }\n\n const stamp = () => new Date().toISOString();\n\n async function markAsRead(id: string): Promise<void> {\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 );\n }\n\n async function markAllRead(): Promise<void> {\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n );\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could actually have seen. Sending no cutoff would\n // mark notifications that landed after the last poll as seen too.\n const newest = state.notifications[0]?.createdAt;\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 );\n }\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n async function tick() {\n // A hidden tab still holds an open bell. Polling it burns requests for a\n // UI nobody is looking at.\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 // Back off on failure so an outage does not turn every open tab into a\n // retry loop against a struggling server.\n schedule(\n consecutiveFailures === 0\n ? pollIntervalMs\n : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs),\n );\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One poller regardless of how many components subscribe — otherwise two\n // bells, or a StrictMode double-mount, double the request rate.\n if (listeners.size === 1) void tick();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n clearTimeout(timer);\n timer = undefined;\n }\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n getState: () => state,\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}
@@ -0,0 +1,57 @@
1
+ type NotificationView = {
2
+ id: string;
3
+ userId: string;
4
+ type: string;
5
+ payload: unknown;
6
+ actorId: string | null;
7
+ groupKey: string | null;
8
+ seenAt: string | null;
9
+ readAt: string | null;
10
+ createdAt: string;
11
+ };
12
+ type NotifyState = {
13
+ notifications: readonly NotificationView[];
14
+ /** Badge count — cleared when the dropdown opens. Server-authoritative. */
15
+ unseenCount: number;
16
+ /** Items still bold. Derived from what is loaded, not a server total. */
17
+ unreadCount: number;
18
+ nextCursor: string | null;
19
+ isLoading: boolean;
20
+ error: Error | null;
21
+ };
22
+ type NotifyClientOptions = {
23
+ baseUrl?: string | undefined;
24
+ fetch?: typeof globalThis.fetch | undefined;
25
+ limit?: number | undefined;
26
+ pollIntervalMs?: number | undefined;
27
+ maxPollIntervalMs?: number | undefined;
28
+ /** Injectable so the poller can be tested without a DOM. */
29
+ isDocumentHidden?: (() => boolean) | undefined;
30
+ };
31
+ declare function createNotifyClient(options?: NotifyClientOptions): {
32
+ subscribe: (listener: (state: NotifyState) => void) => () => void;
33
+ refresh: () => Promise<void>;
34
+ loadMore: () => Promise<void>;
35
+ markAsRead: (id: string) => Promise<void>;
36
+ markAllRead: () => Promise<void>;
37
+ markSeen: () => Promise<void>;
38
+ getState: () => NotifyState;
39
+ };
40
+ type NotifyClient = ReturnType<typeof createNotifyClient>;
41
+
42
+ type UseNotificationsResult = NotifyState & {
43
+ markAsRead: (id: string) => Promise<void>;
44
+ markAllRead: () => Promise<void>;
45
+ markSeen: () => Promise<void>;
46
+ loadMore: () => Promise<void>;
47
+ refresh: () => Promise<void>;
48
+ };
49
+ /**
50
+ * All state and optimistic-rollback logic lives in the framework-agnostic
51
+ * client; this binding only wires it to React's lifecycle.
52
+ */
53
+ declare function useNotifications(options?: NotifyClientOptions & {
54
+ client?: NotifyClient;
55
+ }): UseNotificationsResult;
56
+
57
+ export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, type UseNotificationsResult, createNotifyClient, useNotifications };
@@ -0,0 +1,57 @@
1
+ type NotificationView = {
2
+ id: string;
3
+ userId: string;
4
+ type: string;
5
+ payload: unknown;
6
+ actorId: string | null;
7
+ groupKey: string | null;
8
+ seenAt: string | null;
9
+ readAt: string | null;
10
+ createdAt: string;
11
+ };
12
+ type NotifyState = {
13
+ notifications: readonly NotificationView[];
14
+ /** Badge count — cleared when the dropdown opens. Server-authoritative. */
15
+ unseenCount: number;
16
+ /** Items still bold. Derived from what is loaded, not a server total. */
17
+ unreadCount: number;
18
+ nextCursor: string | null;
19
+ isLoading: boolean;
20
+ error: Error | null;
21
+ };
22
+ type NotifyClientOptions = {
23
+ baseUrl?: string | undefined;
24
+ fetch?: typeof globalThis.fetch | undefined;
25
+ limit?: number | undefined;
26
+ pollIntervalMs?: number | undefined;
27
+ maxPollIntervalMs?: number | undefined;
28
+ /** Injectable so the poller can be tested without a DOM. */
29
+ isDocumentHidden?: (() => boolean) | undefined;
30
+ };
31
+ declare function createNotifyClient(options?: NotifyClientOptions): {
32
+ subscribe: (listener: (state: NotifyState) => void) => () => void;
33
+ refresh: () => Promise<void>;
34
+ loadMore: () => Promise<void>;
35
+ markAsRead: (id: string) => Promise<void>;
36
+ markAllRead: () => Promise<void>;
37
+ markSeen: () => Promise<void>;
38
+ getState: () => NotifyState;
39
+ };
40
+ type NotifyClient = ReturnType<typeof createNotifyClient>;
41
+
42
+ type UseNotificationsResult = NotifyState & {
43
+ markAsRead: (id: string) => Promise<void>;
44
+ markAllRead: () => Promise<void>;
45
+ markSeen: () => Promise<void>;
46
+ loadMore: () => Promise<void>;
47
+ refresh: () => Promise<void>;
48
+ };
49
+ /**
50
+ * All state and optimistic-rollback logic lives in the framework-agnostic
51
+ * client; this binding only wires it to React's lifecycle.
52
+ */
53
+ declare function useNotifications(options?: NotifyClientOptions & {
54
+ client?: NotifyClient;
55
+ }): UseNotificationsResult;
56
+
57
+ export { type NotificationView, type NotifyClient, type NotifyClientOptions, type NotifyState, type UseNotificationsResult, createNotifyClient, useNotifications };
package/dist/react.js ADDED
@@ -0,0 +1,193 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/react/index.ts
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+
7
+ // src/client/index.ts
8
+ var INITIAL = {
9
+ notifications: [],
10
+ unseenCount: 0,
11
+ unreadCount: 0,
12
+ nextCursor: null,
13
+ isLoading: true,
14
+ error: null
15
+ };
16
+ var countUnread = (rows) => rows.reduce((total, row) => row.readAt === null ? total + 1 : total, 0);
17
+ function createNotifyClient(options = {}) {
18
+ const baseUrl = (options.baseUrl ?? "/api/notifications").replace(/\/$/, "");
19
+ const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
20
+ const limit = options.limit ?? 20;
21
+ const pollIntervalMs = options.pollIntervalMs ?? 15e3;
22
+ const maxPollIntervalMs = options.maxPollIntervalMs ?? 5 * 6e4;
23
+ const isHidden = options.isDocumentHidden ?? (() => typeof document !== "undefined" && document.visibilityState === "hidden");
24
+ let state = INITIAL;
25
+ const listeners = /* @__PURE__ */ new Set();
26
+ let timer;
27
+ let consecutiveFailures = 0;
28
+ let generation = 0;
29
+ function set(patch) {
30
+ state = { ...state, ...patch };
31
+ state = { ...state, unreadCount: countUnread(state.notifications) };
32
+ for (const listener of listeners) listener(state);
33
+ }
34
+ async function request(path, init) {
35
+ const response = await doFetch(`${baseUrl}${path}`, {
36
+ ...init,
37
+ headers: { "content-type": "application/json", ...init?.headers }
38
+ });
39
+ if (!response.ok)
40
+ throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`);
41
+ return await response.json();
42
+ }
43
+ async function refresh() {
44
+ const startedAt = generation;
45
+ const [page, count] = await Promise.all([
46
+ request(`/?limit=${limit}`),
47
+ request("/count")
48
+ ]);
49
+ if (generation !== startedAt) return;
50
+ set({
51
+ notifications: page.notifications,
52
+ nextCursor: page.nextCursor,
53
+ unseenCount: count.unseen,
54
+ isLoading: false,
55
+ error: null
56
+ });
57
+ }
58
+ let loadingMore;
59
+ async function loadMore() {
60
+ if (!state.nextCursor) return;
61
+ if (loadingMore) return loadingMore;
62
+ const cursor = state.nextCursor;
63
+ loadingMore = (async () => {
64
+ try {
65
+ const page = await request(`/?limit=${limit}&cursor=${encodeURIComponent(cursor)}`);
66
+ const known = new Set(state.notifications.map((row) => row.id));
67
+ set({
68
+ notifications: [
69
+ ...state.notifications,
70
+ ...page.notifications.filter((row) => !known.has(row.id))
71
+ ],
72
+ nextCursor: page.nextCursor
73
+ });
74
+ } finally {
75
+ loadingMore = void 0;
76
+ }
77
+ })();
78
+ return loadingMore;
79
+ }
80
+ async function optimistic(apply, patch, send) {
81
+ const previous = state;
82
+ generation += 1;
83
+ set({ notifications: apply(state.notifications), ...patch });
84
+ try {
85
+ await send();
86
+ } catch (error) {
87
+ generation += 1;
88
+ state = previous;
89
+ for (const listener of listeners) listener(state);
90
+ throw error;
91
+ }
92
+ }
93
+ const stamp = () => (/* @__PURE__ */ new Date()).toISOString();
94
+ async function markAsRead(id) {
95
+ await optimistic(
96
+ (rows) => rows.map((row) => row.id === id ? { ...row, readAt: row.readAt ?? stamp() } : row),
97
+ {},
98
+ () => request("/read", { method: "POST", body: JSON.stringify({ ids: [id] }) })
99
+ );
100
+ }
101
+ async function markAllRead() {
102
+ await optimistic(
103
+ (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),
104
+ {},
105
+ () => request("/read-all", { method: "POST" })
106
+ );
107
+ }
108
+ async function markSeen() {
109
+ const newest = state.notifications[0]?.createdAt;
110
+ await optimistic(
111
+ (rows) => rows.map((row) => ({ ...row, seenAt: row.seenAt ?? stamp() })),
112
+ { unseenCount: 0 },
113
+ () => request("/seen", {
114
+ method: "POST",
115
+ body: JSON.stringify(newest ? { before: newest } : {})
116
+ })
117
+ );
118
+ }
119
+ function schedule(delay) {
120
+ clearTimeout(timer);
121
+ timer = setTimeout(tick, delay);
122
+ }
123
+ async function tick() {
124
+ if (isHidden()) {
125
+ schedule(pollIntervalMs);
126
+ return;
127
+ }
128
+ try {
129
+ await refresh();
130
+ consecutiveFailures = 0;
131
+ } catch (error) {
132
+ consecutiveFailures += 1;
133
+ set({ isLoading: false, error: error instanceof Error ? error : new Error(String(error)) });
134
+ }
135
+ schedule(
136
+ consecutiveFailures === 0 ? pollIntervalMs : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs)
137
+ );
138
+ }
139
+ function subscribe(listener) {
140
+ listeners.add(listener);
141
+ listener(state);
142
+ if (listeners.size === 1) void tick();
143
+ return () => {
144
+ listeners.delete(listener);
145
+ if (listeners.size === 0) {
146
+ clearTimeout(timer);
147
+ timer = void 0;
148
+ }
149
+ };
150
+ }
151
+ return {
152
+ subscribe,
153
+ refresh,
154
+ loadMore,
155
+ markAsRead,
156
+ markAllRead,
157
+ markSeen,
158
+ getState: () => state
159
+ };
160
+ }
161
+
162
+ // src/react/index.ts
163
+ function useNotifications(options = {}) {
164
+ const { client: provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs } = options;
165
+ const callbacksRef = useRef(options);
166
+ callbacksRef.current = options;
167
+ const client = useMemo(
168
+ () => provided ?? createNotifyClient({
169
+ baseUrl,
170
+ limit,
171
+ pollIntervalMs,
172
+ maxPollIntervalMs,
173
+ fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),
174
+ isDocumentHidden: () => callbacksRef.current.isDocumentHidden?.() ?? (typeof document !== "undefined" && document.visibilityState === "hidden")
175
+ }),
176
+ [provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs]
177
+ );
178
+ const [state, setState] = useState(() => client.getState());
179
+ useEffect(() => client.subscribe(setState), [client]);
180
+ return {
181
+ ...state,
182
+ markAsRead: useCallback((id) => client.markAsRead(id), [client]),
183
+ markAllRead: useCallback(() => client.markAllRead(), [client]),
184
+ markSeen: useCallback(() => client.markSeen(), [client]),
185
+ loadMore: useCallback(() => client.loadMore(), [client]),
186
+ refresh: useCallback(() => client.refresh(), [client])
187
+ };
188
+ }
189
+ export {
190
+ createNotifyClient,
191
+ useNotifications
192
+ };
193
+ //# sourceMappingURL=react.js.map
@@ -0,0 +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/**\n * All state and optimistic-rollback logic lives in the framework-agnostic\n * client; this binding only wires it to React's lifecycle.\n */\nexport function useNotifications(\n options: NotifyClientOptions & { client?: NotifyClient } = {},\n): UseNotificationsResult {\n const { client: provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs } = options;\n\n // Callbacks are usually inline literals. Held in a ref and called through a\n // stable wrapper so a new function identity each render does not rebuild the\n // client and restart polling.\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 fetch: (...args) => (callbacksRef.current.fetch ?? globalThis.fetch)(...args),\n isDocumentHidden: () =>\n callbacksRef.current.isDocumentHidden?.() ??\n (typeof document !== \"undefined\" && document.visibilityState === \"hidden\"),\n }),\n [provided, baseUrl, limit, pollIntervalMs, maxPollIntervalMs],\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 NotifyClientOptions = {\n baseUrl?: string | undefined;\n fetch?: typeof globalThis.fetch | undefined;\n limit?: number | undefined;\n pollIntervalMs?: number | undefined;\n maxPollIntervalMs?: number | undefined;\n /** Injectable so the poller can be tested without a DOM. */\n isDocumentHidden?: (() => boolean) | 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\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 ?? 5 * 60_000;\n const isHidden =\n options.isDocumentHidden ??\n (() => typeof document !== \"undefined\" && document.visibilityState === \"hidden\");\n\n let state: NotifyState = INITIAL;\n const listeners = new Set<(state: NotifyState) => void>();\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n let consecutiveFailures = 0;\n\n // Bumped per mutation: an in-flight poll returns pre-mutation data, which\n // would silently revert the optimistic update.\n let generation = 0;\n\n function set(patch: Partial<NotifyState>) {\n state = { ...state, ...patch };\n state = { ...state, unreadCount: countUnread(state.notifications) };\n for (const listener of listeners) listener(state);\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 refresh(): Promise<void> {\n const startedAt = generation;\n\n const [page, count] = await Promise.all([\n request<{ notifications: NotificationView[]; nextCursor: string | null }>(`/?limit=${limit}`),\n request<{ unseen: number }>(\"/count\"),\n ]);\n\n // A mutation landed while this was in flight, so the response is already\n // stale. Dropping it is correct: the mutation's own optimistic state is\n // newer, and the next poll will reconcile.\n if (generation !== startedAt) return;\n\n set({\n notifications: page.notifications,\n nextCursor: page.nextCursor,\n unseenCount: count.unseen,\n isLoading: false,\n error: null,\n });\n }\n\n let loadingMore: Promise<void> | undefined;\n\n async function loadMore(): Promise<void> {\n if (!state.nextCursor) return;\n\n // Two clicks before the first response lands would both read the same\n // cursor and append the same page twice. Share the in-flight request.\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 // Guard against a duplicate arriving anyway — a concurrent refresh may\n // already have pulled some of these into the list.\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 } catch (error) {\n generation += 1;\n state = previous;\n for (const listener of listeners) listener(state);\n throw error;\n }\n }\n\n const stamp = () => new Date().toISOString();\n\n async function markAsRead(id: string): Promise<void> {\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 );\n }\n\n async function markAllRead(): Promise<void> {\n await optimistic(\n (rows) => rows.map((row) => ({ ...row, readAt: row.readAt ?? stamp() })),\n {},\n () => request(\"/read-all\", { method: \"POST\" }),\n );\n }\n\n async function markSeen(): Promise<void> {\n // Only what the user could actually have seen. Sending no cutoff would\n // mark notifications that landed after the last poll as seen too.\n const newest = state.notifications[0]?.createdAt;\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 );\n }\n\n function schedule(delay: number) {\n clearTimeout(timer);\n timer = setTimeout(tick, delay);\n }\n\n async function tick() {\n // A hidden tab still holds an open bell. Polling it burns requests for a\n // UI nobody is looking at.\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 // Back off on failure so an outage does not turn every open tab into a\n // retry loop against a struggling server.\n schedule(\n consecutiveFailures === 0\n ? pollIntervalMs\n : Math.min(pollIntervalMs * 2 ** consecutiveFailures, maxPollIntervalMs),\n );\n }\n\n function subscribe(listener: (state: NotifyState) => void): () => void {\n listeners.add(listener);\n listener(state);\n\n // One poller regardless of how many components subscribe — otherwise two\n // bells, or a StrictMode double-mount, double the request rate.\n if (listeners.size === 1) void tick();\n\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n clearTimeout(timer);\n timer = undefined;\n }\n };\n }\n\n return {\n subscribe,\n refresh,\n loadMore,\n markAsRead,\n markAllRead,\n markSeen,\n getState: () => state,\n };\n}\n\nexport type NotifyClient = ReturnType<typeof createNotifyClient>;\n"],"mappings":"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","names":[]}