@sinlungtech/push-client 0.1.0 → 0.1.2

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.
package/dist/index.d.mts CHANGED
@@ -68,6 +68,17 @@ interface PushProviderProps {
68
68
  * Called whenever a live notification event is received.
69
69
  */
70
70
  onLiveNotification?: (payload: PushPayload) => void;
71
+ /**
72
+ * Built-in in-page alert UI for live notifications.
73
+ * Useful as a zero-config default when hosts don't provide custom handlers.
74
+ * Default: true
75
+ */
76
+ showInPageAlerts?: boolean;
77
+ /**
78
+ * Auto-dismiss timeout (ms) for built-in in-page alerts.
79
+ * Default: 5000
80
+ */
81
+ inPageAlertDurationMs?: number;
71
82
  /**
72
83
  * If true, requests notification permission and subscribes immediately on mount.
73
84
  * If false (default), call subscribe() manually via usePush().
@@ -117,7 +128,7 @@ interface PushContextValue {
117
128
  * Then in any component:
118
129
  * const { isSubscribed, permission, subscribe, unsubscribe } = usePush();
119
130
  */
120
- declare function PushProvider({ vapidKey, serviceWorkerPath, onSubscribe, onUnsubscribe, chatServerUrl, getToken, subscriptionsEndpoint, live, onLiveNotification, autoSubscribe, children, }: PushProviderProps): react_jsx_runtime.JSX.Element;
131
+ declare function PushProvider({ vapidKey, serviceWorkerPath, onSubscribe, onUnsubscribe, chatServerUrl, getToken, subscriptionsEndpoint, live, onLiveNotification, showInPageAlerts, inPageAlertDurationMs, autoSubscribe, children, }: PushProviderProps): react_jsx_runtime.JSX.Element;
121
132
  declare function usePush(): PushContextValue;
122
133
 
123
134
  declare function urlBase64ToUint8Array(base64String: string): ArrayBuffer;
package/dist/index.d.ts CHANGED
@@ -68,6 +68,17 @@ interface PushProviderProps {
68
68
  * Called whenever a live notification event is received.
69
69
  */
70
70
  onLiveNotification?: (payload: PushPayload) => void;
71
+ /**
72
+ * Built-in in-page alert UI for live notifications.
73
+ * Useful as a zero-config default when hosts don't provide custom handlers.
74
+ * Default: true
75
+ */
76
+ showInPageAlerts?: boolean;
77
+ /**
78
+ * Auto-dismiss timeout (ms) for built-in in-page alerts.
79
+ * Default: 5000
80
+ */
81
+ inPageAlertDurationMs?: number;
71
82
  /**
72
83
  * If true, requests notification permission and subscribes immediately on mount.
73
84
  * If false (default), call subscribe() manually via usePush().
@@ -117,7 +128,7 @@ interface PushContextValue {
117
128
  * Then in any component:
118
129
  * const { isSubscribed, permission, subscribe, unsubscribe } = usePush();
119
130
  */
120
- declare function PushProvider({ vapidKey, serviceWorkerPath, onSubscribe, onUnsubscribe, chatServerUrl, getToken, subscriptionsEndpoint, live, onLiveNotification, autoSubscribe, children, }: PushProviderProps): react_jsx_runtime.JSX.Element;
131
+ declare function PushProvider({ vapidKey, serviceWorkerPath, onSubscribe, onUnsubscribe, chatServerUrl, getToken, subscriptionsEndpoint, live, onLiveNotification, showInPageAlerts, inPageAlertDurationMs, autoSubscribe, children, }: PushProviderProps): react_jsx_runtime.JSX.Element;
121
132
  declare function usePush(): PushContextValue;
122
133
 
123
134
  declare function urlBase64ToUint8Array(base64String: string): ArrayBuffer;
package/dist/index.js CHANGED
@@ -75,6 +75,8 @@ function PushProvider({
75
75
  subscriptionsEndpoint = "/api/push/subscriptions",
76
76
  live,
77
77
  onLiveNotification,
78
+ showInPageAlerts = true,
79
+ inPageAlertDurationMs = 5e3,
78
80
  autoSubscribe = false,
79
81
  children
80
82
  }) {
@@ -85,6 +87,27 @@ function PushProvider({
85
87
  const [isSubscribed, setIsSubscribed] = (0, import_react.useState)(false);
86
88
  const [error, setError] = (0, import_react.useState)(null);
87
89
  const [swReg, setSwReg] = (0, import_react.useState)(null);
90
+ const [inPageAlerts, setInPageAlerts] = (0, import_react.useState)([]);
91
+ const pushInPageAlert = (0, import_react.useCallback)(
92
+ (title, body) => {
93
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
94
+ setInPageAlerts((prev) => [...prev, { id, title, body }].slice(-3));
95
+ window.setTimeout(() => {
96
+ setInPageAlerts((prev) => prev.filter((n) => n.id !== id));
97
+ }, Math.max(1e3, inPageAlertDurationMs));
98
+ },
99
+ [inPageAlertDurationMs]
100
+ );
101
+ const sameServerKey = (0, import_react.useCallback)((a, b) => {
102
+ if (!a) return false;
103
+ const ua = new Uint8Array(a);
104
+ const ub = new Uint8Array(b);
105
+ if (ua.length !== ub.length) return false;
106
+ for (let i = 0; i < ua.length; i += 1) {
107
+ if (ua[i] !== ub[i]) return false;
108
+ }
109
+ return true;
110
+ }, []);
88
111
  const resolveServerUrl = (0, import_react.useCallback)(
89
112
  (path) => {
90
113
  if (/^https?:\/\//i.test(path)) return path;
@@ -145,19 +168,72 @@ function PushProvider({
145
168
  const perm = await Notification.requestPermission();
146
169
  setPermission(perm);
147
170
  if (perm !== "granted") return;
171
+ const desiredServerKey = urlBase64ToUint8Array(vapidKey);
172
+ const existing = await swReg.pushManager.getSubscription();
173
+ if (existing) {
174
+ const existingKey = existing.options?.applicationServerKey ?? null;
175
+ const keyMatches = sameServerKey(existingKey, desiredServerKey);
176
+ if (keyMatches) {
177
+ const existingJson = existing.toJSON();
178
+ await (onSubscribe ?? defaultSubscribe)({
179
+ endpoint: existingJson.endpoint,
180
+ keys: existingJson.keys
181
+ });
182
+ setIsSubscribed(true);
183
+ return;
184
+ }
185
+ const oldEndpoint = existing.endpoint;
186
+ await existing.unsubscribe();
187
+ if (onUnsubscribe) await onUnsubscribe(oldEndpoint);
188
+ else await defaultUnsubscribe(oldEndpoint);
189
+ }
148
190
  const sub = await swReg.pushManager.subscribe({
149
191
  userVisibleOnly: true,
150
- applicationServerKey: urlBase64ToUint8Array(vapidKey)
192
+ applicationServerKey: desiredServerKey
151
193
  });
152
194
  const json = sub.toJSON();
153
195
  await (onSubscribe ?? defaultSubscribe)({ endpoint: json.endpoint, keys: json.keys });
154
196
  setIsSubscribed(true);
155
197
  } catch (err) {
198
+ const message = String(err?.message ?? err ?? "");
199
+ const recoverable = message.includes("different applicationServerKey") || message.includes("gcm_sender_id");
200
+ if (recoverable) {
201
+ try {
202
+ const stale = await swReg.pushManager.getSubscription();
203
+ if (stale) {
204
+ const oldEndpoint = stale.endpoint;
205
+ await stale.unsubscribe();
206
+ if (onUnsubscribe) await onUnsubscribe(oldEndpoint);
207
+ else await defaultUnsubscribe(oldEndpoint);
208
+ }
209
+ const retried = await swReg.pushManager.subscribe({
210
+ userVisibleOnly: true,
211
+ applicationServerKey: urlBase64ToUint8Array(vapidKey)
212
+ });
213
+ const retriedJson = retried.toJSON();
214
+ await (onSubscribe ?? defaultSubscribe)({
215
+ endpoint: retriedJson.endpoint,
216
+ keys: retriedJson.keys
217
+ });
218
+ setIsSubscribed(true);
219
+ return;
220
+ } catch {
221
+ }
222
+ }
156
223
  const e = err instanceof Error ? err : new Error(String(err));
157
224
  setError(e);
158
225
  console.warn("[PushClient] subscribe failed:", e);
159
226
  }
160
- }, [supported, swReg, vapidKey, onSubscribe, defaultSubscribe]);
227
+ }, [
228
+ supported,
229
+ swReg,
230
+ vapidKey,
231
+ onSubscribe,
232
+ onUnsubscribe,
233
+ defaultSubscribe,
234
+ defaultUnsubscribe,
235
+ sameServerKey
236
+ ]);
161
237
  const unsubscribe = (0, import_react.useCallback)(async () => {
162
238
  if (!supported || !swReg) return;
163
239
  setError(null);
@@ -175,7 +251,7 @@ function PushProvider({
175
251
  }
176
252
  }, [supported, swReg, onUnsubscribe, defaultUnsubscribe]);
177
253
  (0, import_react.useEffect)(() => {
178
- if (autoSubscribe && swReg && !isSubscribed && permission !== "denied") {
254
+ if (autoSubscribe && swReg && !isSubscribed && permission === "granted") {
179
255
  subscribe();
180
256
  }
181
257
  }, [autoSubscribe, swReg, isSubscribed, permission, subscribe]);
@@ -241,6 +317,9 @@ function PushProvider({
241
317
  tag: payload.tag
242
318
  };
243
319
  onLiveNotification?.(normalized);
320
+ if (!onLiveNotification && showInPageAlerts) {
321
+ pushInPageAlert(normalized.title, normalized.body || "You have a new notification.");
322
+ }
244
323
  if (showSystem && typeof window !== "undefined" && Notification.permission === "granted") {
245
324
  new Notification(normalized.title, {
246
325
  body: normalized.body,
@@ -260,8 +339,46 @@ function PushProvider({
260
339
  } catch {
261
340
  }
262
341
  };
263
- }, [chatServerUrl, getToken, live, onLiveNotification]);
264
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PushContext.Provider, { value: { isSupported: supported, permission, isSubscribed, subscribe, unsubscribe, error }, children });
342
+ }, [chatServerUrl, getToken, live, onLiveNotification, showInPageAlerts, pushInPageAlert]);
343
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(PushContext.Provider, { value: { isSupported: supported, permission, isSubscribed, subscribe, unsubscribe, error }, children: [
344
+ children,
345
+ showInPageAlerts && inPageAlerts.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
346
+ "div",
347
+ {
348
+ style: {
349
+ position: "fixed",
350
+ right: 16,
351
+ bottom: 16,
352
+ zIndex: 2147483640,
353
+ display: "flex",
354
+ flexDirection: "column",
355
+ gap: 8,
356
+ pointerEvents: "none"
357
+ },
358
+ children: inPageAlerts.map((n) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
359
+ "div",
360
+ {
361
+ style: {
362
+ pointerEvents: "auto",
363
+ width: 320,
364
+ maxWidth: "calc(100vw - 32px)",
365
+ borderRadius: 12,
366
+ border: "1px solid #e5e7eb",
367
+ background: "#fff",
368
+ boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
369
+ padding: "10px 12px",
370
+ fontFamily: "system-ui, sans-serif"
371
+ },
372
+ children: [
373
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { margin: 0, fontSize: 12, fontWeight: 700, color: "#111827" }, children: n.title }),
374
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { margin: "4px 0 0", fontSize: 12, color: "#4b5563", lineHeight: 1.35 }, children: n.body })
375
+ ]
376
+ },
377
+ n.id
378
+ ))
379
+ }
380
+ )
381
+ ] });
265
382
  }
266
383
  function usePush() {
267
384
  return (0, import_react.useContext)(PushContext);
package/dist/index.mjs CHANGED
@@ -16,7 +16,7 @@ function isSupported() {
16
16
 
17
17
  // src/PushProvider.tsx
18
18
  import * as Ably from "ably";
19
- import { jsx } from "react/jsx-runtime";
19
+ import { jsx, jsxs } from "react/jsx-runtime";
20
20
  var PushContext = createContext({
21
21
  isSupported: false,
22
22
  permission: "default",
@@ -37,6 +37,8 @@ function PushProvider({
37
37
  subscriptionsEndpoint = "/api/push/subscriptions",
38
38
  live,
39
39
  onLiveNotification,
40
+ showInPageAlerts = true,
41
+ inPageAlertDurationMs = 5e3,
40
42
  autoSubscribe = false,
41
43
  children
42
44
  }) {
@@ -47,6 +49,27 @@ function PushProvider({
47
49
  const [isSubscribed, setIsSubscribed] = useState(false);
48
50
  const [error, setError] = useState(null);
49
51
  const [swReg, setSwReg] = useState(null);
52
+ const [inPageAlerts, setInPageAlerts] = useState([]);
53
+ const pushInPageAlert = useCallback(
54
+ (title, body) => {
55
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
56
+ setInPageAlerts((prev) => [...prev, { id, title, body }].slice(-3));
57
+ window.setTimeout(() => {
58
+ setInPageAlerts((prev) => prev.filter((n) => n.id !== id));
59
+ }, Math.max(1e3, inPageAlertDurationMs));
60
+ },
61
+ [inPageAlertDurationMs]
62
+ );
63
+ const sameServerKey = useCallback((a, b) => {
64
+ if (!a) return false;
65
+ const ua = new Uint8Array(a);
66
+ const ub = new Uint8Array(b);
67
+ if (ua.length !== ub.length) return false;
68
+ for (let i = 0; i < ua.length; i += 1) {
69
+ if (ua[i] !== ub[i]) return false;
70
+ }
71
+ return true;
72
+ }, []);
50
73
  const resolveServerUrl = useCallback(
51
74
  (path) => {
52
75
  if (/^https?:\/\//i.test(path)) return path;
@@ -107,19 +130,72 @@ function PushProvider({
107
130
  const perm = await Notification.requestPermission();
108
131
  setPermission(perm);
109
132
  if (perm !== "granted") return;
133
+ const desiredServerKey = urlBase64ToUint8Array(vapidKey);
134
+ const existing = await swReg.pushManager.getSubscription();
135
+ if (existing) {
136
+ const existingKey = existing.options?.applicationServerKey ?? null;
137
+ const keyMatches = sameServerKey(existingKey, desiredServerKey);
138
+ if (keyMatches) {
139
+ const existingJson = existing.toJSON();
140
+ await (onSubscribe ?? defaultSubscribe)({
141
+ endpoint: existingJson.endpoint,
142
+ keys: existingJson.keys
143
+ });
144
+ setIsSubscribed(true);
145
+ return;
146
+ }
147
+ const oldEndpoint = existing.endpoint;
148
+ await existing.unsubscribe();
149
+ if (onUnsubscribe) await onUnsubscribe(oldEndpoint);
150
+ else await defaultUnsubscribe(oldEndpoint);
151
+ }
110
152
  const sub = await swReg.pushManager.subscribe({
111
153
  userVisibleOnly: true,
112
- applicationServerKey: urlBase64ToUint8Array(vapidKey)
154
+ applicationServerKey: desiredServerKey
113
155
  });
114
156
  const json = sub.toJSON();
115
157
  await (onSubscribe ?? defaultSubscribe)({ endpoint: json.endpoint, keys: json.keys });
116
158
  setIsSubscribed(true);
117
159
  } catch (err) {
160
+ const message = String(err?.message ?? err ?? "");
161
+ const recoverable = message.includes("different applicationServerKey") || message.includes("gcm_sender_id");
162
+ if (recoverable) {
163
+ try {
164
+ const stale = await swReg.pushManager.getSubscription();
165
+ if (stale) {
166
+ const oldEndpoint = stale.endpoint;
167
+ await stale.unsubscribe();
168
+ if (onUnsubscribe) await onUnsubscribe(oldEndpoint);
169
+ else await defaultUnsubscribe(oldEndpoint);
170
+ }
171
+ const retried = await swReg.pushManager.subscribe({
172
+ userVisibleOnly: true,
173
+ applicationServerKey: urlBase64ToUint8Array(vapidKey)
174
+ });
175
+ const retriedJson = retried.toJSON();
176
+ await (onSubscribe ?? defaultSubscribe)({
177
+ endpoint: retriedJson.endpoint,
178
+ keys: retriedJson.keys
179
+ });
180
+ setIsSubscribed(true);
181
+ return;
182
+ } catch {
183
+ }
184
+ }
118
185
  const e = err instanceof Error ? err : new Error(String(err));
119
186
  setError(e);
120
187
  console.warn("[PushClient] subscribe failed:", e);
121
188
  }
122
- }, [supported, swReg, vapidKey, onSubscribe, defaultSubscribe]);
189
+ }, [
190
+ supported,
191
+ swReg,
192
+ vapidKey,
193
+ onSubscribe,
194
+ onUnsubscribe,
195
+ defaultSubscribe,
196
+ defaultUnsubscribe,
197
+ sameServerKey
198
+ ]);
123
199
  const unsubscribe = useCallback(async () => {
124
200
  if (!supported || !swReg) return;
125
201
  setError(null);
@@ -137,7 +213,7 @@ function PushProvider({
137
213
  }
138
214
  }, [supported, swReg, onUnsubscribe, defaultUnsubscribe]);
139
215
  useEffect(() => {
140
- if (autoSubscribe && swReg && !isSubscribed && permission !== "denied") {
216
+ if (autoSubscribe && swReg && !isSubscribed && permission === "granted") {
141
217
  subscribe();
142
218
  }
143
219
  }, [autoSubscribe, swReg, isSubscribed, permission, subscribe]);
@@ -203,6 +279,9 @@ function PushProvider({
203
279
  tag: payload.tag
204
280
  };
205
281
  onLiveNotification?.(normalized);
282
+ if (!onLiveNotification && showInPageAlerts) {
283
+ pushInPageAlert(normalized.title, normalized.body || "You have a new notification.");
284
+ }
206
285
  if (showSystem && typeof window !== "undefined" && Notification.permission === "granted") {
207
286
  new Notification(normalized.title, {
208
287
  body: normalized.body,
@@ -222,8 +301,46 @@ function PushProvider({
222
301
  } catch {
223
302
  }
224
303
  };
225
- }, [chatServerUrl, getToken, live, onLiveNotification]);
226
- return /* @__PURE__ */ jsx(PushContext.Provider, { value: { isSupported: supported, permission, isSubscribed, subscribe, unsubscribe, error }, children });
304
+ }, [chatServerUrl, getToken, live, onLiveNotification, showInPageAlerts, pushInPageAlert]);
305
+ return /* @__PURE__ */ jsxs(PushContext.Provider, { value: { isSupported: supported, permission, isSubscribed, subscribe, unsubscribe, error }, children: [
306
+ children,
307
+ showInPageAlerts && inPageAlerts.length > 0 && /* @__PURE__ */ jsx(
308
+ "div",
309
+ {
310
+ style: {
311
+ position: "fixed",
312
+ right: 16,
313
+ bottom: 16,
314
+ zIndex: 2147483640,
315
+ display: "flex",
316
+ flexDirection: "column",
317
+ gap: 8,
318
+ pointerEvents: "none"
319
+ },
320
+ children: inPageAlerts.map((n) => /* @__PURE__ */ jsxs(
321
+ "div",
322
+ {
323
+ style: {
324
+ pointerEvents: "auto",
325
+ width: 320,
326
+ maxWidth: "calc(100vw - 32px)",
327
+ borderRadius: 12,
328
+ border: "1px solid #e5e7eb",
329
+ background: "#fff",
330
+ boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
331
+ padding: "10px 12px",
332
+ fontFamily: "system-ui, sans-serif"
333
+ },
334
+ children: [
335
+ /* @__PURE__ */ jsx("p", { style: { margin: 0, fontSize: 12, fontWeight: 700, color: "#111827" }, children: n.title }),
336
+ /* @__PURE__ */ jsx("p", { style: { margin: "4px 0 0", fontSize: 12, color: "#4b5563", lineHeight: 1.35 }, children: n.body })
337
+ ]
338
+ },
339
+ n.id
340
+ ))
341
+ }
342
+ )
343
+ ] });
227
344
  }
228
345
  function usePush() {
229
346
  return useContext(PushContext);
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@sinlungtech/push-client",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Web Push notification client for Next.js — service worker registration, permission handling, and subscription management",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
8
- "files": ["dist", "sw-template.js"],
8
+ "files": [
9
+ "dist",
10
+ "sw-template.js"
11
+ ],
9
12
  "scripts": {
10
13
  "build": "tsup src/index.ts --format cjs,esm --dts",
11
14
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
@@ -22,6 +25,13 @@
22
25
  "tsup": "^8.0.0",
23
26
  "typescript": "^5"
24
27
  },
25
- "keywords": ["web-push", "pwa", "service-worker", "notifications", "react", "nextjs"],
28
+ "keywords": [
29
+ "web-push",
30
+ "pwa",
31
+ "service-worker",
32
+ "notifications",
33
+ "react",
34
+ "nextjs"
35
+ ],
26
36
  "license": "MIT"
27
37
  }