@sinlungtech/push-client 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.
@@ -0,0 +1,125 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ interface PushSubscription {
4
+ endpoint: string;
5
+ keys: {
6
+ p256dh: string;
7
+ auth: string;
8
+ };
9
+ }
10
+ interface PushPayload {
11
+ title: string;
12
+ body: string;
13
+ data?: Record<string, unknown>;
14
+ icon?: string;
15
+ badge?: string;
16
+ tag?: string;
17
+ }
18
+ interface LiveNotificationsConfig {
19
+ /**
20
+ * Set false to disable live Ably notifications.
21
+ * Default: true
22
+ */
23
+ enabled?: boolean;
24
+ /**
25
+ * Optional explicit channel name. If omitted, the channel is derived from the
26
+ * chat JWT claims:
27
+ * admin -> notifications:admin:{appId}
28
+ * user -> notifications:user:{sub}
29
+ */
30
+ channel?: string;
31
+ /**
32
+ * Event name to subscribe to. Default: 'notification'
33
+ */
34
+ eventName?: string;
35
+ /**
36
+ * Show browser Notification for live events when permission is granted.
37
+ * Default: true
38
+ */
39
+ showSystemNotification?: boolean;
40
+ }
41
+ interface PushProviderProps {
42
+ /** NEXT_PUBLIC_VAPID_PUBLIC_KEY — the public VAPID key (URL-safe base64) */
43
+ vapidKey: string;
44
+ /** Path to the service worker file, relative to public/. Default: '/sw.js' */
45
+ serviceWorkerPath?: string;
46
+ /**
47
+ * Called after a successful subscription.
48
+ * Use this to send the subscription to your server's /api/push/subscribe endpoint.
49
+ */
50
+ onSubscribe?: (subscription: PushSubscription) => Promise<void> | void;
51
+ /**
52
+ * Called after the subscription is removed.
53
+ * Use this to call DELETE /api/push/subscribe on your server.
54
+ */
55
+ onUnsubscribe?: (endpoint: string) => Promise<void> | void;
56
+ /**
57
+ * If onSubscribe/onUnsubscribe are not provided, PushProvider can wire itself
58
+ * directly to a chat server by using these options.
59
+ */
60
+ chatServerUrl?: string;
61
+ getToken?: () => Promise<string>;
62
+ subscriptionsEndpoint?: string;
63
+ /**
64
+ * Live notifications path (Ably subscribe).
65
+ */
66
+ live?: LiveNotificationsConfig;
67
+ /**
68
+ * Called whenever a live notification event is received.
69
+ */
70
+ onLiveNotification?: (payload: PushPayload) => void;
71
+ /**
72
+ * If true, requests notification permission and subscribes immediately on mount.
73
+ * If false (default), call subscribe() manually via usePush().
74
+ */
75
+ autoSubscribe?: boolean;
76
+ children: React.ReactNode;
77
+ }
78
+
79
+ interface PushContextValue {
80
+ /** Whether the browser supports Web Push */
81
+ isSupported: boolean;
82
+ /** Current Notification.permission value */
83
+ permission: NotificationPermission | 'default';
84
+ /** Whether the user is currently subscribed */
85
+ isSubscribed: boolean;
86
+ /** Request permission and subscribe */
87
+ subscribe: () => Promise<void>;
88
+ /** Unsubscribe and clean up */
89
+ unsubscribe: () => Promise<void>;
90
+ /** Any error that occurred during subscribe/unsubscribe */
91
+ error: Error | null;
92
+ }
93
+ /**
94
+ * PushProvider — wraps your app and manages Web Push subscription lifecycle.
95
+ *
96
+ * Usage in Next.js layout.tsx:
97
+ *
98
+ * <PushProvider
99
+ * vapidKey={process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!}
100
+ * serviceWorkerPath="/sw.js"
101
+ * onSubscribe={async (sub) => {
102
+ * const token = await getIdToken();
103
+ * await fetch('/api/push/subscribe', {
104
+ * method: 'POST',
105
+ * headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
106
+ * body: JSON.stringify(sub),
107
+ * });
108
+ * }}
109
+ * onUnsubscribe={async (endpoint) => {
110
+ * await fetch('/api/push/subscribe', { method: 'DELETE', body: JSON.stringify({ endpoint }) });
111
+ * }}
112
+ * autoSubscribe
113
+ * >
114
+ * {children}
115
+ * </PushProvider>
116
+ *
117
+ * Then in any component:
118
+ * const { isSubscribed, permission, subscribe, unsubscribe } = usePush();
119
+ */
120
+ declare function PushProvider({ vapidKey, serviceWorkerPath, onSubscribe, onUnsubscribe, chatServerUrl, getToken, subscriptionsEndpoint, live, onLiveNotification, autoSubscribe, children, }: PushProviderProps): react_jsx_runtime.JSX.Element;
121
+ declare function usePush(): PushContextValue;
122
+
123
+ declare function urlBase64ToUint8Array(base64String: string): ArrayBuffer;
124
+
125
+ export { type PushPayload, PushProvider, type PushProviderProps, type PushSubscription, urlBase64ToUint8Array, usePush };
@@ -0,0 +1,125 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ interface PushSubscription {
4
+ endpoint: string;
5
+ keys: {
6
+ p256dh: string;
7
+ auth: string;
8
+ };
9
+ }
10
+ interface PushPayload {
11
+ title: string;
12
+ body: string;
13
+ data?: Record<string, unknown>;
14
+ icon?: string;
15
+ badge?: string;
16
+ tag?: string;
17
+ }
18
+ interface LiveNotificationsConfig {
19
+ /**
20
+ * Set false to disable live Ably notifications.
21
+ * Default: true
22
+ */
23
+ enabled?: boolean;
24
+ /**
25
+ * Optional explicit channel name. If omitted, the channel is derived from the
26
+ * chat JWT claims:
27
+ * admin -> notifications:admin:{appId}
28
+ * user -> notifications:user:{sub}
29
+ */
30
+ channel?: string;
31
+ /**
32
+ * Event name to subscribe to. Default: 'notification'
33
+ */
34
+ eventName?: string;
35
+ /**
36
+ * Show browser Notification for live events when permission is granted.
37
+ * Default: true
38
+ */
39
+ showSystemNotification?: boolean;
40
+ }
41
+ interface PushProviderProps {
42
+ /** NEXT_PUBLIC_VAPID_PUBLIC_KEY — the public VAPID key (URL-safe base64) */
43
+ vapidKey: string;
44
+ /** Path to the service worker file, relative to public/. Default: '/sw.js' */
45
+ serviceWorkerPath?: string;
46
+ /**
47
+ * Called after a successful subscription.
48
+ * Use this to send the subscription to your server's /api/push/subscribe endpoint.
49
+ */
50
+ onSubscribe?: (subscription: PushSubscription) => Promise<void> | void;
51
+ /**
52
+ * Called after the subscription is removed.
53
+ * Use this to call DELETE /api/push/subscribe on your server.
54
+ */
55
+ onUnsubscribe?: (endpoint: string) => Promise<void> | void;
56
+ /**
57
+ * If onSubscribe/onUnsubscribe are not provided, PushProvider can wire itself
58
+ * directly to a chat server by using these options.
59
+ */
60
+ chatServerUrl?: string;
61
+ getToken?: () => Promise<string>;
62
+ subscriptionsEndpoint?: string;
63
+ /**
64
+ * Live notifications path (Ably subscribe).
65
+ */
66
+ live?: LiveNotificationsConfig;
67
+ /**
68
+ * Called whenever a live notification event is received.
69
+ */
70
+ onLiveNotification?: (payload: PushPayload) => void;
71
+ /**
72
+ * If true, requests notification permission and subscribes immediately on mount.
73
+ * If false (default), call subscribe() manually via usePush().
74
+ */
75
+ autoSubscribe?: boolean;
76
+ children: React.ReactNode;
77
+ }
78
+
79
+ interface PushContextValue {
80
+ /** Whether the browser supports Web Push */
81
+ isSupported: boolean;
82
+ /** Current Notification.permission value */
83
+ permission: NotificationPermission | 'default';
84
+ /** Whether the user is currently subscribed */
85
+ isSubscribed: boolean;
86
+ /** Request permission and subscribe */
87
+ subscribe: () => Promise<void>;
88
+ /** Unsubscribe and clean up */
89
+ unsubscribe: () => Promise<void>;
90
+ /** Any error that occurred during subscribe/unsubscribe */
91
+ error: Error | null;
92
+ }
93
+ /**
94
+ * PushProvider — wraps your app and manages Web Push subscription lifecycle.
95
+ *
96
+ * Usage in Next.js layout.tsx:
97
+ *
98
+ * <PushProvider
99
+ * vapidKey={process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!}
100
+ * serviceWorkerPath="/sw.js"
101
+ * onSubscribe={async (sub) => {
102
+ * const token = await getIdToken();
103
+ * await fetch('/api/push/subscribe', {
104
+ * method: 'POST',
105
+ * headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
106
+ * body: JSON.stringify(sub),
107
+ * });
108
+ * }}
109
+ * onUnsubscribe={async (endpoint) => {
110
+ * await fetch('/api/push/subscribe', { method: 'DELETE', body: JSON.stringify({ endpoint }) });
111
+ * }}
112
+ * autoSubscribe
113
+ * >
114
+ * {children}
115
+ * </PushProvider>
116
+ *
117
+ * Then in any component:
118
+ * const { isSubscribed, permission, subscribe, unsubscribe } = usePush();
119
+ */
120
+ declare function PushProvider({ vapidKey, serviceWorkerPath, onSubscribe, onUnsubscribe, chatServerUrl, getToken, subscriptionsEndpoint, live, onLiveNotification, autoSubscribe, children, }: PushProviderProps): react_jsx_runtime.JSX.Element;
121
+ declare function usePush(): PushContextValue;
122
+
123
+ declare function urlBase64ToUint8Array(base64String: string): ArrayBuffer;
124
+
125
+ export { type PushPayload, PushProvider, type PushProviderProps, type PushSubscription, urlBase64ToUint8Array, usePush };
package/dist/index.js ADDED
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
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 __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ PushProvider: () => PushProvider,
34
+ urlBase64ToUint8Array: () => urlBase64ToUint8Array,
35
+ usePush: () => usePush
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+
39
+ // src/PushProvider.tsx
40
+ var import_react = require("react");
41
+
42
+ // src/utils.ts
43
+ function urlBase64ToUint8Array(base64String) {
44
+ const padding = "=".repeat((4 - base64String.length % 4) % 4);
45
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
46
+ const raw = atob(base64);
47
+ const bytes = new Uint8Array(raw.length);
48
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
49
+ return bytes.buffer;
50
+ }
51
+ function isSupported() {
52
+ return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
53
+ }
54
+
55
+ // src/PushProvider.tsx
56
+ var Ably = __toESM(require("ably"));
57
+ var import_jsx_runtime = require("react/jsx-runtime");
58
+ var PushContext = (0, import_react.createContext)({
59
+ isSupported: false,
60
+ permission: "default",
61
+ isSubscribed: false,
62
+ subscribe: async () => {
63
+ },
64
+ unsubscribe: async () => {
65
+ },
66
+ error: null
67
+ });
68
+ function PushProvider({
69
+ vapidKey,
70
+ serviceWorkerPath = "/sw.js",
71
+ onSubscribe,
72
+ onUnsubscribe,
73
+ chatServerUrl,
74
+ getToken,
75
+ subscriptionsEndpoint = "/api/push/subscriptions",
76
+ live,
77
+ onLiveNotification,
78
+ autoSubscribe = false,
79
+ children
80
+ }) {
81
+ const supported = isSupported();
82
+ const [permission, setPermission] = (0, import_react.useState)(
83
+ supported ? Notification.permission : "default"
84
+ );
85
+ const [isSubscribed, setIsSubscribed] = (0, import_react.useState)(false);
86
+ const [error, setError] = (0, import_react.useState)(null);
87
+ const [swReg, setSwReg] = (0, import_react.useState)(null);
88
+ const resolveServerUrl = (0, import_react.useCallback)(
89
+ (path) => {
90
+ if (/^https?:\/\//i.test(path)) return path;
91
+ if (!chatServerUrl) return path;
92
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
93
+ return `${chatServerUrl}${normalizedPath}`;
94
+ },
95
+ [chatServerUrl]
96
+ );
97
+ const defaultSubscribe = (0, import_react.useCallback)(
98
+ async (subscription) => {
99
+ if (!getToken || !chatServerUrl) {
100
+ throw new Error("Provide onSubscribe or configure chatServerUrl + getToken");
101
+ }
102
+ const token = await getToken();
103
+ const res = await fetch(resolveServerUrl(subscriptionsEndpoint), {
104
+ method: "POST",
105
+ headers: {
106
+ "Content-Type": "application/json",
107
+ Authorization: `Bearer ${token}`
108
+ },
109
+ body: JSON.stringify(subscription)
110
+ });
111
+ if (!res.ok) throw new Error(`Subscribe failed (${res.status})`);
112
+ },
113
+ [getToken, chatServerUrl, resolveServerUrl, subscriptionsEndpoint]
114
+ );
115
+ const defaultUnsubscribe = (0, import_react.useCallback)(
116
+ async (endpoint) => {
117
+ if (!getToken || !chatServerUrl) return;
118
+ const token = await getToken();
119
+ await fetch(resolveServerUrl(subscriptionsEndpoint), {
120
+ method: "DELETE",
121
+ headers: {
122
+ "Content-Type": "application/json",
123
+ Authorization: `Bearer ${token}`
124
+ },
125
+ body: JSON.stringify({ endpoint })
126
+ });
127
+ },
128
+ [getToken, chatServerUrl, resolveServerUrl, subscriptionsEndpoint]
129
+ );
130
+ (0, import_react.useEffect)(() => {
131
+ if (!supported) return;
132
+ navigator.serviceWorker.register(serviceWorkerPath).then((reg) => {
133
+ setSwReg(reg);
134
+ return reg.pushManager.getSubscription();
135
+ }).then((existing) => {
136
+ setIsSubscribed(!!existing);
137
+ }).catch((err) => {
138
+ console.warn("[PushClient] SW registration failed:", err);
139
+ });
140
+ }, [supported, serviceWorkerPath]);
141
+ const subscribe = (0, import_react.useCallback)(async () => {
142
+ if (!supported || !swReg) return;
143
+ setError(null);
144
+ try {
145
+ const perm = await Notification.requestPermission();
146
+ setPermission(perm);
147
+ if (perm !== "granted") return;
148
+ const sub = await swReg.pushManager.subscribe({
149
+ userVisibleOnly: true,
150
+ applicationServerKey: urlBase64ToUint8Array(vapidKey)
151
+ });
152
+ const json = sub.toJSON();
153
+ await (onSubscribe ?? defaultSubscribe)({ endpoint: json.endpoint, keys: json.keys });
154
+ setIsSubscribed(true);
155
+ } catch (err) {
156
+ const e = err instanceof Error ? err : new Error(String(err));
157
+ setError(e);
158
+ console.warn("[PushClient] subscribe failed:", e);
159
+ }
160
+ }, [supported, swReg, vapidKey, onSubscribe, defaultSubscribe]);
161
+ const unsubscribe = (0, import_react.useCallback)(async () => {
162
+ if (!supported || !swReg) return;
163
+ setError(null);
164
+ try {
165
+ const sub = await swReg.pushManager.getSubscription();
166
+ if (!sub) return;
167
+ const endpoint = sub.endpoint;
168
+ await sub.unsubscribe();
169
+ if (onUnsubscribe) await onUnsubscribe(endpoint);
170
+ else await defaultUnsubscribe(endpoint);
171
+ setIsSubscribed(false);
172
+ } catch (err) {
173
+ const e = err instanceof Error ? err : new Error(String(err));
174
+ setError(e);
175
+ }
176
+ }, [supported, swReg, onUnsubscribe, defaultUnsubscribe]);
177
+ (0, import_react.useEffect)(() => {
178
+ if (autoSubscribe && swReg && !isSubscribed && permission !== "denied") {
179
+ subscribe();
180
+ }
181
+ }, [autoSubscribe, swReg, isSubscribed, permission, subscribe]);
182
+ (0, import_react.useEffect)(() => {
183
+ if (live?.enabled === false) return;
184
+ if (!chatServerUrl || !getToken) return;
185
+ let disposed = false;
186
+ let channelName = live?.channel ?? "";
187
+ const eventName = live?.eventName ?? "notification";
188
+ const showSystem = live?.showSystemNotification ?? true;
189
+ function deriveChannelFromJwt(token) {
190
+ if (channelName) return channelName;
191
+ try {
192
+ const payload = token.split(".")[1];
193
+ if (!payload) return "";
194
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
195
+ const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
196
+ const decoded = JSON.parse(atob(padded));
197
+ if (decoded.role === "admin" && decoded.appId) {
198
+ return `notifications:admin:${decoded.appId}`;
199
+ }
200
+ if (decoded.sub) {
201
+ return `notifications:user:${decoded.sub}`;
202
+ }
203
+ } catch {
204
+ }
205
+ return "";
206
+ }
207
+ const client = new Ably.Realtime({
208
+ authCallback: async (_params, callback) => {
209
+ try {
210
+ const token = await getToken();
211
+ if (!channelName) channelName = deriveChannelFromJwt(token);
212
+ const res = await fetch(`${chatServerUrl}/api/ably/token`, {
213
+ headers: { Authorization: `Bearer ${token}` },
214
+ cache: "no-store"
215
+ });
216
+ if (!res.ok) throw new Error("Ably token fetch failed");
217
+ callback(null, await res.json());
218
+ } catch (err) {
219
+ callback(err, null);
220
+ }
221
+ }
222
+ });
223
+ const subReady = async () => {
224
+ if (!channelName) {
225
+ try {
226
+ const token = await getToken();
227
+ channelName = deriveChannelFromJwt(token);
228
+ } catch {
229
+ }
230
+ }
231
+ if (!channelName || disposed) return;
232
+ const channel = client.channels.get(channelName);
233
+ channel.subscribe(eventName, (msg) => {
234
+ const payload = msg.data;
235
+ const normalized = {
236
+ title: payload.title ?? "Notification",
237
+ body: payload.body ?? "",
238
+ data: payload.data ?? {},
239
+ icon: payload.icon,
240
+ badge: payload.badge,
241
+ tag: payload.tag
242
+ };
243
+ onLiveNotification?.(normalized);
244
+ if (showSystem && typeof window !== "undefined" && Notification.permission === "granted") {
245
+ new Notification(normalized.title, {
246
+ body: normalized.body,
247
+ data: normalized.data,
248
+ icon: normalized.icon,
249
+ badge: normalized.badge,
250
+ tag: normalized.tag
251
+ });
252
+ }
253
+ });
254
+ };
255
+ subReady();
256
+ return () => {
257
+ disposed = true;
258
+ try {
259
+ client.close();
260
+ } catch {
261
+ }
262
+ };
263
+ }, [chatServerUrl, getToken, live, onLiveNotification]);
264
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PushContext.Provider, { value: { isSupported: supported, permission, isSubscribed, subscribe, unsubscribe, error }, children });
265
+ }
266
+ function usePush() {
267
+ return (0, import_react.useContext)(PushContext);
268
+ }
269
+ // Annotate the CommonJS export names for ESM import in node:
270
+ 0 && (module.exports = {
271
+ PushProvider,
272
+ urlBase64ToUint8Array,
273
+ usePush
274
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,235 @@
1
+ // src/PushProvider.tsx
2
+ import { createContext, useCallback, useContext, useEffect, useState } from "react";
3
+
4
+ // src/utils.ts
5
+ function urlBase64ToUint8Array(base64String) {
6
+ const padding = "=".repeat((4 - base64String.length % 4) % 4);
7
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
8
+ const raw = atob(base64);
9
+ const bytes = new Uint8Array(raw.length);
10
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
11
+ return bytes.buffer;
12
+ }
13
+ function isSupported() {
14
+ return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
15
+ }
16
+
17
+ // src/PushProvider.tsx
18
+ import * as Ably from "ably";
19
+ import { jsx } from "react/jsx-runtime";
20
+ var PushContext = createContext({
21
+ isSupported: false,
22
+ permission: "default",
23
+ isSubscribed: false,
24
+ subscribe: async () => {
25
+ },
26
+ unsubscribe: async () => {
27
+ },
28
+ error: null
29
+ });
30
+ function PushProvider({
31
+ vapidKey,
32
+ serviceWorkerPath = "/sw.js",
33
+ onSubscribe,
34
+ onUnsubscribe,
35
+ chatServerUrl,
36
+ getToken,
37
+ subscriptionsEndpoint = "/api/push/subscriptions",
38
+ live,
39
+ onLiveNotification,
40
+ autoSubscribe = false,
41
+ children
42
+ }) {
43
+ const supported = isSupported();
44
+ const [permission, setPermission] = useState(
45
+ supported ? Notification.permission : "default"
46
+ );
47
+ const [isSubscribed, setIsSubscribed] = useState(false);
48
+ const [error, setError] = useState(null);
49
+ const [swReg, setSwReg] = useState(null);
50
+ const resolveServerUrl = useCallback(
51
+ (path) => {
52
+ if (/^https?:\/\//i.test(path)) return path;
53
+ if (!chatServerUrl) return path;
54
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
55
+ return `${chatServerUrl}${normalizedPath}`;
56
+ },
57
+ [chatServerUrl]
58
+ );
59
+ const defaultSubscribe = useCallback(
60
+ async (subscription) => {
61
+ if (!getToken || !chatServerUrl) {
62
+ throw new Error("Provide onSubscribe or configure chatServerUrl + getToken");
63
+ }
64
+ const token = await getToken();
65
+ const res = await fetch(resolveServerUrl(subscriptionsEndpoint), {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ Authorization: `Bearer ${token}`
70
+ },
71
+ body: JSON.stringify(subscription)
72
+ });
73
+ if (!res.ok) throw new Error(`Subscribe failed (${res.status})`);
74
+ },
75
+ [getToken, chatServerUrl, resolveServerUrl, subscriptionsEndpoint]
76
+ );
77
+ const defaultUnsubscribe = useCallback(
78
+ async (endpoint) => {
79
+ if (!getToken || !chatServerUrl) return;
80
+ const token = await getToken();
81
+ await fetch(resolveServerUrl(subscriptionsEndpoint), {
82
+ method: "DELETE",
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ Authorization: `Bearer ${token}`
86
+ },
87
+ body: JSON.stringify({ endpoint })
88
+ });
89
+ },
90
+ [getToken, chatServerUrl, resolveServerUrl, subscriptionsEndpoint]
91
+ );
92
+ useEffect(() => {
93
+ if (!supported) return;
94
+ navigator.serviceWorker.register(serviceWorkerPath).then((reg) => {
95
+ setSwReg(reg);
96
+ return reg.pushManager.getSubscription();
97
+ }).then((existing) => {
98
+ setIsSubscribed(!!existing);
99
+ }).catch((err) => {
100
+ console.warn("[PushClient] SW registration failed:", err);
101
+ });
102
+ }, [supported, serviceWorkerPath]);
103
+ const subscribe = useCallback(async () => {
104
+ if (!supported || !swReg) return;
105
+ setError(null);
106
+ try {
107
+ const perm = await Notification.requestPermission();
108
+ setPermission(perm);
109
+ if (perm !== "granted") return;
110
+ const sub = await swReg.pushManager.subscribe({
111
+ userVisibleOnly: true,
112
+ applicationServerKey: urlBase64ToUint8Array(vapidKey)
113
+ });
114
+ const json = sub.toJSON();
115
+ await (onSubscribe ?? defaultSubscribe)({ endpoint: json.endpoint, keys: json.keys });
116
+ setIsSubscribed(true);
117
+ } catch (err) {
118
+ const e = err instanceof Error ? err : new Error(String(err));
119
+ setError(e);
120
+ console.warn("[PushClient] subscribe failed:", e);
121
+ }
122
+ }, [supported, swReg, vapidKey, onSubscribe, defaultSubscribe]);
123
+ const unsubscribe = useCallback(async () => {
124
+ if (!supported || !swReg) return;
125
+ setError(null);
126
+ try {
127
+ const sub = await swReg.pushManager.getSubscription();
128
+ if (!sub) return;
129
+ const endpoint = sub.endpoint;
130
+ await sub.unsubscribe();
131
+ if (onUnsubscribe) await onUnsubscribe(endpoint);
132
+ else await defaultUnsubscribe(endpoint);
133
+ setIsSubscribed(false);
134
+ } catch (err) {
135
+ const e = err instanceof Error ? err : new Error(String(err));
136
+ setError(e);
137
+ }
138
+ }, [supported, swReg, onUnsubscribe, defaultUnsubscribe]);
139
+ useEffect(() => {
140
+ if (autoSubscribe && swReg && !isSubscribed && permission !== "denied") {
141
+ subscribe();
142
+ }
143
+ }, [autoSubscribe, swReg, isSubscribed, permission, subscribe]);
144
+ useEffect(() => {
145
+ if (live?.enabled === false) return;
146
+ if (!chatServerUrl || !getToken) return;
147
+ let disposed = false;
148
+ let channelName = live?.channel ?? "";
149
+ const eventName = live?.eventName ?? "notification";
150
+ const showSystem = live?.showSystemNotification ?? true;
151
+ function deriveChannelFromJwt(token) {
152
+ if (channelName) return channelName;
153
+ try {
154
+ const payload = token.split(".")[1];
155
+ if (!payload) return "";
156
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
157
+ const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
158
+ const decoded = JSON.parse(atob(padded));
159
+ if (decoded.role === "admin" && decoded.appId) {
160
+ return `notifications:admin:${decoded.appId}`;
161
+ }
162
+ if (decoded.sub) {
163
+ return `notifications:user:${decoded.sub}`;
164
+ }
165
+ } catch {
166
+ }
167
+ return "";
168
+ }
169
+ const client = new Ably.Realtime({
170
+ authCallback: async (_params, callback) => {
171
+ try {
172
+ const token = await getToken();
173
+ if (!channelName) channelName = deriveChannelFromJwt(token);
174
+ const res = await fetch(`${chatServerUrl}/api/ably/token`, {
175
+ headers: { Authorization: `Bearer ${token}` },
176
+ cache: "no-store"
177
+ });
178
+ if (!res.ok) throw new Error("Ably token fetch failed");
179
+ callback(null, await res.json());
180
+ } catch (err) {
181
+ callback(err, null);
182
+ }
183
+ }
184
+ });
185
+ const subReady = async () => {
186
+ if (!channelName) {
187
+ try {
188
+ const token = await getToken();
189
+ channelName = deriveChannelFromJwt(token);
190
+ } catch {
191
+ }
192
+ }
193
+ if (!channelName || disposed) return;
194
+ const channel = client.channels.get(channelName);
195
+ channel.subscribe(eventName, (msg) => {
196
+ const payload = msg.data;
197
+ const normalized = {
198
+ title: payload.title ?? "Notification",
199
+ body: payload.body ?? "",
200
+ data: payload.data ?? {},
201
+ icon: payload.icon,
202
+ badge: payload.badge,
203
+ tag: payload.tag
204
+ };
205
+ onLiveNotification?.(normalized);
206
+ if (showSystem && typeof window !== "undefined" && Notification.permission === "granted") {
207
+ new Notification(normalized.title, {
208
+ body: normalized.body,
209
+ data: normalized.data,
210
+ icon: normalized.icon,
211
+ badge: normalized.badge,
212
+ tag: normalized.tag
213
+ });
214
+ }
215
+ });
216
+ };
217
+ subReady();
218
+ return () => {
219
+ disposed = true;
220
+ try {
221
+ client.close();
222
+ } catch {
223
+ }
224
+ };
225
+ }, [chatServerUrl, getToken, live, onLiveNotification]);
226
+ return /* @__PURE__ */ jsx(PushContext.Provider, { value: { isSupported: supported, permission, isSubscribed, subscribe, unsubscribe, error }, children });
227
+ }
228
+ function usePush() {
229
+ return useContext(PushContext);
230
+ }
231
+ export {
232
+ PushProvider,
233
+ urlBase64ToUint8Array,
234
+ usePush
235
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@sinlungtech/push-client",
3
+ "version": "0.1.0",
4
+ "description": "Web Push notification client for Next.js — service worker registration, permission handling, and subscription management",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "files": ["dist", "sw-template.js"],
9
+ "scripts": {
10
+ "build": "tsup src/index.ts --format cjs,esm --dts",
11
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
12
+ },
13
+ "dependencies": {
14
+ "ably": "^2.3.1"
15
+ },
16
+ "peerDependencies": {
17
+ "react": ">=18",
18
+ "react-dom": ">=18"
19
+ },
20
+ "devDependencies": {
21
+ "@types/react": "^18",
22
+ "tsup": "^8.0.0",
23
+ "typescript": "^5"
24
+ },
25
+ "keywords": ["web-push", "pwa", "service-worker", "notifications", "react", "nextjs"],
26
+ "license": "MIT"
27
+ }
package/sw-template.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Service Worker template for @sinlungtech/push-client
3
+ *
4
+ * Copy this file to your Next.js public/sw.js and customise the notification
5
+ * click handler to match your app's URL structure.
6
+ *
7
+ * The `data` field in push payloads can carry any key-value pairs your server
8
+ * sends — use them to route the user to the right page on notification click.
9
+ */
10
+
11
+ self.addEventListener('push', (event) => {
12
+ if (!event.data) return;
13
+
14
+ let payload;
15
+ try {
16
+ payload = event.data.json();
17
+ } catch {
18
+ payload = { title: 'Notification', body: event.data.text() };
19
+ }
20
+
21
+ const { title, body, icon, badge, tag, data = {} } = payload;
22
+
23
+ event.waitUntil(
24
+ self.registration.showNotification(title ?? 'Notification', {
25
+ body,
26
+ icon: icon ?? '/icon-192.png',
27
+ badge: badge ?? '/badge-72.png',
28
+ tag: tag ?? 'general',
29
+ renotify: true,
30
+ data,
31
+ }),
32
+ );
33
+ });
34
+
35
+ self.addEventListener('notificationclick', (event) => {
36
+ event.notification.close();
37
+
38
+ // Customise this URL based on your notification data
39
+ const url = event.notification.data?.url ?? '/';
40
+
41
+ event.waitUntil(
42
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
43
+ for (const client of clientList) {
44
+ if ('focus' in client) {
45
+ client.navigate(url);
46
+ return client.focus();
47
+ }
48
+ }
49
+ if (clients.openWindow) return clients.openWindow(url);
50
+ }),
51
+ );
52
+ });