@phoenix-iq/notifications 1.0.0 → 1.2.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/src/index.ts DELETED
@@ -1,331 +0,0 @@
1
- import { keys, type StoredConfig, type StoredDevice, type StoredLogin, store } from './store.js';
2
-
3
- /**
4
- * Phoenix Notifications web SDK: subscribes the browser to web push for an app on notifications.phnx-iq.com, binds it to
5
- * the signed-in user with an identity token from the app's own backend, and hands notification clicks to the app.
6
- *
7
- * Nothing here throws for an unsupported browser or a denied permission: check `isSupported()` / `permission()`.
8
- * The service worker file (phoenix-notifications-sw.js) must be served from the site, at `serviceWorkerPath`.
9
- */
10
-
11
- export const DEFAULT_SERVER_URL = 'https://notifications.phnx-iq.com';
12
- export const DEFAULT_SERVICE_WORKER_PATH = '/phoenix-notifications-sw.js';
13
-
14
- export interface InitOptions {
15
- /** The app id from the dashboard (App notifications → the app → Settings). */
16
- appId: string;
17
- /** Where the notifications API is. '' means this site's own origin (development through a proxy). */
18
- serverUrl?: string;
19
- serviceWorkerPath?: string;
20
- serviceWorkerScope?: string;
21
- /** Language the device reports (picks Arabic or English text); defaults to the browser's. */
22
- language?: string;
23
- appVersion?: string;
24
- }
25
-
26
- export type PushPermission = 'granted' | 'denied' | 'default' | 'unsupported';
27
-
28
- export interface NotificationClick {
29
- url?: string;
30
- data: Record<string, string>;
31
- messageId?: string;
32
- }
33
-
34
- interface Envelope<T> {
35
- result?: T;
36
- errors?: string[];
37
- }
38
-
39
- interface WebConfig {
40
- appId: string;
41
- name: string;
42
- vapidPublicKey: string;
43
- iconUrl?: string | null;
44
- defaultUrl?: string | null;
45
- }
46
-
47
- interface RegisteredDevice {
48
- deviceId: string;
49
- deviceSecret: string;
50
- vapidPublicKey: string;
51
- }
52
-
53
- export class NotificationsError extends Error {
54
- readonly status: number;
55
-
56
- constructor(status: number, message: string) {
57
- super(message);
58
- this.name = 'NotificationsError';
59
- this.status = status;
60
- }
61
- }
62
-
63
- const CLICK_MESSAGE = 'phoenix-notifications:click';
64
- const RESYNC_AFTER_MS = 24 * 60 * 60 * 1000;
65
-
66
- let options: Required<Omit<InitOptions, 'appVersion'>> & { appVersion?: string } = {
67
- appId: '',
68
- serverUrl: DEFAULT_SERVER_URL,
69
- serviceWorkerPath: DEFAULT_SERVICE_WORKER_PATH,
70
- serviceWorkerScope: '/',
71
- language: '',
72
- };
73
- let registration: ServiceWorkerRegistration | null = null;
74
- let ready: Promise<void> | null = null;
75
- let syncing: Promise<StoredDevice | null> | null = null;
76
- /** The last successful sync of this page, so init and login on the same load ask the server once. */
77
- let synced: { device: StoredDevice; at: number } | null = null;
78
- const SYNC_REUSE_MS = 5 * 60 * 1000;
79
- const clickHandlers = new Set<(click: NotificationClick) => void>();
80
- let listening = false;
81
-
82
- function supported(): boolean {
83
- return (
84
- typeof window !== 'undefined' &&
85
- window.isSecureContext &&
86
- 'serviceWorker' in navigator &&
87
- 'PushManager' in window &&
88
- 'Notification' in window &&
89
- 'indexedDB' in window
90
- );
91
- }
92
-
93
- function url(path: string): string {
94
- return `${options.serverUrl.replace(/\/+$/, '')}${path}`;
95
- }
96
-
97
- async function call<T>(method: string, path: string, body?: unknown, deviceSecret?: string): Promise<T | undefined> {
98
- const headers: Record<string, string> = {};
99
- if (body !== undefined) headers['Content-Type'] = 'application/json';
100
- if (deviceSecret) headers.Authorization = `Device ${deviceSecret}`;
101
- const response = await fetch(url(path), { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
102
- if (response.status === 204) return undefined;
103
- let payload: Envelope<T> | undefined;
104
- try {
105
- payload = (await response.json()) as Envelope<T>;
106
- } catch {
107
- payload = undefined;
108
- }
109
- if (!response.ok) throw new NotificationsError(response.status, payload?.errors?.join(' ') || `Request failed with ${response.status}`);
110
- return payload?.result;
111
- }
112
-
113
- function toBytes(base64Url: string): Uint8Array<ArrayBuffer> {
114
- const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (base64Url.length % 4)) % 4);
115
- const raw = atob(padded);
116
- const bytes = new Uint8Array(new ArrayBuffer(raw.length));
117
- for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
118
- return bytes;
119
- }
120
-
121
- function toBase64Url(buffer: ArrayBuffer | null): string {
122
- if (!buffer) return '';
123
- let raw = '';
124
- for (const byte of new Uint8Array(buffer)) raw += String.fromCharCode(byte);
125
- return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
126
- }
127
-
128
- function sameKey(subscription: PushSubscription, vapidKey: string): boolean {
129
- return toBase64Url(subscription.options.applicationServerKey) === vapidKey.replace(/=+$/, '');
130
- }
131
-
132
- function subscriptionBody(subscription: PushSubscription, vapidKey: string) {
133
- return {
134
- endpoint: subscription.endpoint,
135
- p256dh: toBase64Url(subscription.getKey('p256dh')),
136
- auth: toBase64Url(subscription.getKey('auth')),
137
- applicationServerKey: vapidKey,
138
- };
139
- }
140
-
141
- function deviceInfo() {
142
- return {
143
- language: options.language || navigator.language,
144
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
145
- appVersion: options.appVersion,
146
- os: navigator.platform || undefined,
147
- deviceModel: navigator.userAgent.slice(0, 200),
148
- };
149
- }
150
-
151
- function listenForClicks(): void {
152
- if (listening) return;
153
- listening = true;
154
- navigator.serviceWorker.addEventListener('message', (event: MessageEvent) => {
155
- const message = event.data as { type?: string; url?: string; data?: Record<string, string>; messageId?: string } | undefined;
156
- if (message?.type !== CLICK_MESSAGE) return;
157
- const click: NotificationClick = { url: message.url, data: message.data ?? {}, messageId: message.messageId };
158
- if (clickHandlers.size === 0) {
159
- if (click.url) window.location.assign(click.url);
160
- return;
161
- }
162
- for (const handler of clickHandlers) handler(click);
163
- });
164
- // messages from the worker wait in a queue until the page says it is listening
165
- navigator.serviceWorker.startMessages();
166
- }
167
-
168
- /** Subscribes (or renews the subscription) and registers or updates the device. Needs the permission granted. */
169
- async function sync(): Promise<StoredDevice | null> {
170
- if (!registration || Notification.permission !== 'granted') return null;
171
-
172
- const config = await call<WebConfig>('GET', `/v1/apps/${options.appId}/web`);
173
- if (!config) return null;
174
- const vapidKey = config.vapidPublicKey;
175
- await store.set<StoredConfig>(keys.config, { appId: options.appId, serverUrl: options.serverUrl, vapidKey });
176
-
177
- let subscription = await registration.pushManager.getSubscription();
178
- // a subscription made with another key (an earlier key of this app, or another push provider) cannot receive our pushes
179
- if (subscription && !sameKey(subscription, vapidKey)) {
180
- await subscription.unsubscribe();
181
- subscription = null;
182
- }
183
- subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(vapidKey) });
184
-
185
- const stored = await store.get<StoredDevice>(keys.device(options.appId));
186
- const body = { subscription: subscriptionBody(subscription, vapidKey), ...deviceInfo() };
187
- if (stored) {
188
- const changed = stored.endpoint !== subscription.endpoint || stored.vapidKey !== vapidKey;
189
- if (!changed && Date.now() - stored.syncedAt < RESYNC_AFTER_MS) return stored;
190
- try {
191
- await call('PUT', `/v1/devices/${stored.id}`, changed ? body : deviceInfo(), stored.secret);
192
- const updated = { ...stored, endpoint: subscription.endpoint, vapidKey, syncedAt: Date.now() };
193
- await store.set(keys.device(options.appId), updated);
194
- return updated;
195
- } catch (error) {
196
- // the server no longer knows this device (or another record owns the subscription): register it again
197
- if (!(error instanceof NotificationsError) || ![401, 404, 409].includes(error.status)) throw error;
198
- }
199
- }
200
-
201
- const registered = await call<RegisteredDevice>('POST', `/v1/apps/${options.appId}/devices`, body);
202
- if (!registered) return null;
203
- const device: StoredDevice = {
204
- appId: options.appId,
205
- id: registered.deviceId,
206
- secret: registered.deviceSecret,
207
- endpoint: subscription.endpoint,
208
- vapidKey,
209
- syncedAt: Date.now(),
210
- };
211
- await store.set(keys.device(options.appId), device);
212
- await store.remove(keys.login(options.appId));
213
- return device;
214
- }
215
-
216
- function syncOnce(force = false): Promise<StoredDevice | null> {
217
- if (!force && synced && synced.device.appId === options.appId && Date.now() - synced.at < SYNC_REUSE_MS) return Promise.resolve(synced.device);
218
- syncing ??= sync()
219
- .then((device) => {
220
- synced = device ? { device, at: Date.now() } : null;
221
- return device;
222
- })
223
- .finally(() => {
224
- syncing = null;
225
- });
226
- return syncing;
227
- }
228
-
229
- /** A login made before the person allowed notifications is applied once there is a device. */
230
- async function applyPendingLogin(device: StoredDevice | null): Promise<void> {
231
- if (!device) return;
232
- const pending = await store.get<StoredLogin>(keys.login(options.appId));
233
- if (pending && pending.at === 0) await Notifications.login(pending.token);
234
- }
235
-
236
- export const Notifications = {
237
- /** True when this browser can receive web push here (a secure context with service workers and the Push API). */
238
- isSupported(): boolean {
239
- return supported();
240
- },
241
-
242
- /** Registers the service worker. When the person already allowed notifications, subscribes quietly. */
243
- async init(init: InitOptions): Promise<void> {
244
- if (!supported()) return;
245
- if (!init.appId) throw new Error('Notifications.init needs the appId.');
246
- options = {
247
- appId: init.appId,
248
- serverUrl: init.serverUrl ?? DEFAULT_SERVER_URL,
249
- serviceWorkerPath: init.serviceWorkerPath ?? DEFAULT_SERVICE_WORKER_PATH,
250
- serviceWorkerScope: init.serviceWorkerScope ?? '/',
251
- language: init.language ?? '',
252
- appVersion: init.appVersion,
253
- };
254
- ready = (async () => {
255
- registration = await navigator.serviceWorker.register(options.serviceWorkerPath, { scope: options.serviceWorkerScope });
256
- listenForClicks();
257
- })();
258
- await ready;
259
- if (Notification.permission === 'granted') await applyPendingLogin(await syncOnce(true));
260
- },
261
-
262
- permission(): PushPermission {
263
- return supported() ? Notification.permission : 'unsupported';
264
- },
265
-
266
- /** Asks the person (call it from a click) and subscribes when they allow. */
267
- async requestPermission(): Promise<PushPermission> {
268
- if (!supported()) return 'unsupported';
269
- const result = await Notification.requestPermission();
270
- if (result === 'granted') {
271
- await ready;
272
- await applyPendingLogin(await syncOnce(true));
273
- }
274
- return result;
275
- },
276
-
277
- /**
278
- * Binds this browser to the user an identity token from the app's backend vouches for. Before the person allows
279
- * notifications there is no device yet: the token is kept and used once they do.
280
- */
281
- async login(identityToken: string): Promise<void> {
282
- if (!supported()) return;
283
- await ready;
284
- const loginKey = keys.login(options.appId);
285
- const previous = await store.get<StoredLogin>(loginKey);
286
- const device = await syncOnce();
287
- if (!device) {
288
- await store.set<StoredLogin>(loginKey, { appId: options.appId, token: identityToken, at: 0 });
289
- return;
290
- }
291
- if (previous && previous.token === identityToken && previous.at > 0 && Date.now() - previous.at < RESYNC_AFTER_MS) return;
292
- await call('POST', `/v1/devices/${device.id}/login`, { identityToken }, device.secret);
293
- await store.set<StoredLogin>(loginKey, { appId: options.appId, token: identityToken, at: Date.now() });
294
- },
295
-
296
- /** The browser keeps receiving the app's broadcasts as a guest, but no longer this user's notifications. */
297
- async logout(): Promise<void> {
298
- if (!supported() || !options.appId) return;
299
- await store.remove(keys.login(options.appId));
300
- const device = await store.get<StoredDevice>(keys.device(options.appId));
301
- if (device) await call('POST', `/v1/devices/${device.id}/logout`, undefined, device.secret);
302
- },
303
-
304
- /** Stops notifications in this browser altogether. */
305
- async unsubscribe(): Promise<void> {
306
- if (!supported() || !options.appId) return;
307
- const device = await store.get<StoredDevice>(keys.device(options.appId));
308
- if (device) {
309
- await call('DELETE', `/v1/devices/${device.id}`, undefined, device.secret);
310
- await store.remove(keys.device(options.appId));
311
- }
312
- await store.remove(keys.login(options.appId));
313
- const subscription = await registration?.pushManager.getSubscription();
314
- await subscription?.unsubscribe();
315
- },
316
-
317
- /** A notification was clicked while the site was open. Without a handler the SDK opens its link. Returns an unsubscribe function. */
318
- onClick(handler: (click: NotificationClick) => void): () => void {
319
- clickHandlers.add(handler);
320
- return () => clickHandlers.delete(handler);
321
- },
322
-
323
- /** Re-runs the subscription check now (e.g. after the person changed the site's permission). */
324
- async refresh(): Promise<void> {
325
- if (!supported()) return;
326
- await ready;
327
- await applyPendingLogin(await syncOnce(true));
328
- },
329
- };
330
-
331
- export type { StoredDevice };
package/src/store.ts DELETED
@@ -1,74 +0,0 @@
1
- /**
2
- * The SDK's state in IndexedDB, shared with the service worker (which reads `config` to report clicks and to renew a
3
- * subscription). Keep the database name, store and keys in step with phoenix-notifications-sw.js.
4
- */
5
-
6
- const DB_NAME = 'phoenix-notifications';
7
- const STORE = 'state';
8
-
9
- /** What the service worker needs: where to report clicks and the key to subscribe with. */
10
- export interface StoredConfig {
11
- appId: string;
12
- serverUrl: string;
13
- vapidKey: string;
14
- }
15
-
16
- /** This browser's device record on the server. The secret authenticates every later call for the device. */
17
- export interface StoredDevice {
18
- appId: string;
19
- id: string;
20
- secret: string;
21
- endpoint: string;
22
- vapidKey: string;
23
- /** When the server last heard from this device (ms), to refresh it about once a day. */
24
- syncedAt: number;
25
- }
26
-
27
- /** The user the device was last bound to, so a page load does not log in again with the same token. */
28
- export interface StoredLogin {
29
- appId: string;
30
- token: string;
31
- at: number;
32
- }
33
-
34
- function open(): Promise<IDBDatabase> {
35
- return new Promise((resolve, reject) => {
36
- const request = indexedDB.open(DB_NAME, 1);
37
- request.onupgradeneeded = () => {
38
- if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE);
39
- };
40
- request.onsuccess = () => resolve(request.result);
41
- request.onerror = () => reject(request.error);
42
- });
43
- }
44
-
45
- async function run<T>(mode: IDBTransactionMode, action: (store: IDBObjectStore) => IDBRequest): Promise<T> {
46
- const db = await open();
47
- try {
48
- return await new Promise<T>((resolve, reject) => {
49
- const request = action(db.transaction(STORE, mode).objectStore(STORE));
50
- request.onsuccess = () => resolve(request.result as T);
51
- request.onerror = () => reject(request.error);
52
- });
53
- } finally {
54
- db.close();
55
- }
56
- }
57
-
58
- export const store = {
59
- get<T>(key: string): Promise<T | undefined> {
60
- return run<T | undefined>('readonly', (s) => s.get(key));
61
- },
62
- set<T>(key: string, value: T): Promise<void> {
63
- return run<void>('readwrite', (s) => s.put(value, key));
64
- },
65
- remove(key: string): Promise<void> {
66
- return run<void>('readwrite', (s) => s.delete(key));
67
- },
68
- };
69
-
70
- export const keys = {
71
- config: 'config',
72
- device: (appId: string) => `device:${appId}`,
73
- login: (appId: string) => `login:${appId}`,
74
- };