@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.
- package/dist/index.d.mts +125 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +274 -0
- package/dist/index.mjs +235 -0
- package/package.json +27 -0
- package/sw-template.js +52 -0
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
});
|