@phoenix-iq/notifications 0.0.0-stage → 1.0.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/README.md +69 -2
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +289 -0
- package/dist/index.js.map +1 -0
- package/dist/store.d.ts +37 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +47 -0
- package/dist/store.js.map +1 -0
- package/package.json +37 -4
- package/phoenix-notifications-sw.js +148 -0
- package/src/index.ts +331 -0
- package/src/store.ts +74 -0
package/README.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @phoenix-iq/notifications (web SDK)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Browser push for apps on `notifications.phnx-iq.com`, our replacement for OneSignal's web SDK. It subscribes the browser
|
|
4
|
+
(standard Web Push with the app's VAPID key), binds it to your signed-in user with an identity token your backend signs,
|
|
5
|
+
and hands notification clicks to your app. TypeScript, no dependencies.
|
|
6
|
+
|
|
7
|
+
How the whole service works, the server API and the identity tokens: [docs/notifications.md](../../docs/notifications.md).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @phoenix-iq/notifications
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 1. Serve the service worker
|
|
14
|
+
|
|
15
|
+
Serve `phoenix-notifications-sw.js` (in this package) at your site's root, so it is reachable at
|
|
16
|
+
`https://your-site/phoenix-notifications-sw.js`. It must be on your own site: a service worker only controls its own origin.
|
|
17
|
+
Copy it from `node_modules/@phoenix-iq/notifications/phoenix-notifications-sw.js` in your build, or with Vite emit it from a
|
|
18
|
+
small plugin (HQ's dashboard `vite.config.ts` does).
|
|
19
|
+
|
|
20
|
+
## 2. Initialise and log in
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Notifications } from '@phoenix-iq/notifications';
|
|
24
|
+
|
|
25
|
+
await Notifications.init({ appId: '<app id from HQ: Push apps>' });
|
|
26
|
+
|
|
27
|
+
// from a click ("Get notifications"): browsers only show the prompt on a user gesture
|
|
28
|
+
const permission = await Notifications.requestPermission(); // 'granted' | 'denied' | 'default' | 'unsupported'
|
|
29
|
+
|
|
30
|
+
// after your own sign-in, with the identity token your backend returned
|
|
31
|
+
await Notifications.login(identityToken);
|
|
32
|
+
|
|
33
|
+
// on sign-out: the browser keeps your broadcasts as a guest, but not that user's notifications
|
|
34
|
+
await Notifications.logout();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`init` subscribes quietly when the person already allowed notifications. `login` before the permission is granted is kept
|
|
38
|
+
and applied once it is. Nothing throws on an unsupported browser; check `Notifications.isSupported()` and
|
|
39
|
+
`Notifications.permission()` to decide what to show.
|
|
40
|
+
|
|
41
|
+
## 3. Handle clicks
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
Notifications.onClick(({ url, data }) => router.navigate(new URL(url ?? '/', location.origin).pathname));
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A click on a notification focuses an open tab of your site and calls your handler; without a handler the SDK opens the link.
|
|
48
|
+
With no tab open, the browser opens the link directly.
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
| Option | Default | |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `appId` | (required) | The app's id: HQ → Push apps → the app |
|
|
55
|
+
| `serverUrl` | `https://notifications.phnx-iq.com` | `''` uses your own origin (development behind a proxy) |
|
|
56
|
+
| `serviceWorkerPath` | `/phoenix-notifications-sw.js` | |
|
|
57
|
+
| `serviceWorkerScope` | `/` | |
|
|
58
|
+
| `language` | the browser's | Picks the Arabic or English text of a notification |
|
|
59
|
+
| `appVersion` | none | Stored with the device; in-app messages can target versions below one |
|
|
60
|
+
|
|
61
|
+
## Notes
|
|
62
|
+
|
|
63
|
+
- iPhone and iPad: web push works only for a site added to the home screen (iOS 16.4+), as with any web push provider.
|
|
64
|
+
- The device secret the server gives the browser is kept in IndexedDB; it only lets this browser manage its own subscription.
|
|
65
|
+
- Your site must be in the app's sites (the app → Settings → Web push), or registration is refused.
|
|
66
|
+
|
|
67
|
+
## Publishing
|
|
68
|
+
|
|
69
|
+
`npm run build` compiles `src` to `dist` (ES modules and type declarations). To release, raise `version` in
|
|
70
|
+
`package.json` and run `npm publish` here (it builds first); the package is public on npmjs.com under `@phoenix-iq`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type StoredDevice } from './store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Phoenix Notifications web SDK: subscribes the browser to web push for an app on notifications.phnx-iq.com, binds it to
|
|
4
|
+
* the signed-in user with an identity token from the app's own backend, and hands notification clicks to the app.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here throws for an unsupported browser or a denied permission: check `isSupported()` / `permission()`.
|
|
7
|
+
* The service worker file (phoenix-notifications-sw.js) must be served from the site, at `serviceWorkerPath`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_SERVER_URL = "https://notifications.phnx-iq.com";
|
|
10
|
+
export declare const DEFAULT_SERVICE_WORKER_PATH = "/phoenix-notifications-sw.js";
|
|
11
|
+
export interface InitOptions {
|
|
12
|
+
/** The app id from the dashboard (App notifications → the app → Settings). */
|
|
13
|
+
appId: string;
|
|
14
|
+
/** Where the notifications API is. '' means this site's own origin (development through a proxy). */
|
|
15
|
+
serverUrl?: string;
|
|
16
|
+
serviceWorkerPath?: string;
|
|
17
|
+
serviceWorkerScope?: string;
|
|
18
|
+
/** Language the device reports (picks Arabic or English text); defaults to the browser's. */
|
|
19
|
+
language?: string;
|
|
20
|
+
appVersion?: string;
|
|
21
|
+
}
|
|
22
|
+
export type PushPermission = 'granted' | 'denied' | 'default' | 'unsupported';
|
|
23
|
+
export interface NotificationClick {
|
|
24
|
+
url?: string;
|
|
25
|
+
data: Record<string, string>;
|
|
26
|
+
messageId?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare class NotificationsError extends Error {
|
|
29
|
+
readonly status: number;
|
|
30
|
+
constructor(status: number, message: string);
|
|
31
|
+
}
|
|
32
|
+
export declare const Notifications: {
|
|
33
|
+
/** True when this browser can receive web push here (a secure context with service workers and the Push API). */
|
|
34
|
+
isSupported(): boolean;
|
|
35
|
+
/** Registers the service worker. When the person already allowed notifications, subscribes quietly. */
|
|
36
|
+
init(init: InitOptions): Promise<void>;
|
|
37
|
+
permission(): PushPermission;
|
|
38
|
+
/** Asks the person (call it from a click) and subscribes when they allow. */
|
|
39
|
+
requestPermission(): Promise<PushPermission>;
|
|
40
|
+
/**
|
|
41
|
+
* Binds this browser to the user an identity token from the app's backend vouches for. Before the person allows
|
|
42
|
+
* notifications there is no device yet: the token is kept and used once they do.
|
|
43
|
+
*/
|
|
44
|
+
login(identityToken: string): Promise<void>;
|
|
45
|
+
/** The browser keeps receiving the app's broadcasts as a guest, but no longer this user's notifications. */
|
|
46
|
+
logout(): Promise<void>;
|
|
47
|
+
/** Stops notifications in this browser altogether. */
|
|
48
|
+
unsubscribe(): Promise<void>;
|
|
49
|
+
/** A notification was clicked while the site was open. Without a handler the SDK opens its link. Returns an unsubscribe function. */
|
|
50
|
+
onClick(handler: (click: NotificationClick) => void): () => void;
|
|
51
|
+
/** Re-runs the subscription check now (e.g. after the person changed the site's permission). */
|
|
52
|
+
refresh(): Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
export type { StoredDevice };
|
|
55
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2B,KAAK,YAAY,EAA2B,MAAM,YAAY,CAAC;AAEjG;;;;;;GAMG;AAEH,eAAO,MAAM,kBAAkB,sCAAsC,CAAC;AACtE,eAAO,MAAM,2BAA2B,iCAAiC,CAAC;AAE1E,MAAM,WAAW,WAAW;IAC1B,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;IACd,qGAAqG;IACrG,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,6FAA6F;IAC7F,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,CAAC;AAE9E,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAqBD,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;gBAEZ,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAK5C;AA+KD,eAAO,MAAM,aAAa;IACxB,iHAAiH;mBAClG,OAAO;IAItB,uGAAuG;eACtF,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;kBAmB9B,cAAc;IAI5B,6EAA6E;yBAClD,OAAO,CAAC,cAAc,CAAC;IAUlD;;;OAGG;yBACwB,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAejD,4GAA4G;cAC5F,OAAO,CAAC,IAAI,CAAC;IAO7B,sDAAsD;mBACjC,OAAO,CAAC,IAAI,CAAC;IAYlC,qIAAqI;qBACpH,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAAG,MAAM,IAAI;IAKhE,gGAAgG;eAC/E,OAAO,CAAC,IAAI,CAAC;CAK/B,CAAC;AAEF,YAAY,EAAE,YAAY,EAAE,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { keys, store } from './store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Phoenix Notifications web SDK: subscribes the browser to web push for an app on notifications.phnx-iq.com, binds it to
|
|
4
|
+
* the signed-in user with an identity token from the app's own backend, and hands notification clicks to the app.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here throws for an unsupported browser or a denied permission: check `isSupported()` / `permission()`.
|
|
7
|
+
* The service worker file (phoenix-notifications-sw.js) must be served from the site, at `serviceWorkerPath`.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_SERVER_URL = 'https://notifications.phnx-iq.com';
|
|
10
|
+
export const DEFAULT_SERVICE_WORKER_PATH = '/phoenix-notifications-sw.js';
|
|
11
|
+
export class NotificationsError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
constructor(status, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'NotificationsError';
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const CLICK_MESSAGE = 'phoenix-notifications:click';
|
|
20
|
+
const RESYNC_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
let options = {
|
|
22
|
+
appId: '',
|
|
23
|
+
serverUrl: DEFAULT_SERVER_URL,
|
|
24
|
+
serviceWorkerPath: DEFAULT_SERVICE_WORKER_PATH,
|
|
25
|
+
serviceWorkerScope: '/',
|
|
26
|
+
language: '',
|
|
27
|
+
};
|
|
28
|
+
let registration = null;
|
|
29
|
+
let ready = null;
|
|
30
|
+
let syncing = null;
|
|
31
|
+
/** The last successful sync of this page, so init and login on the same load ask the server once. */
|
|
32
|
+
let synced = null;
|
|
33
|
+
const SYNC_REUSE_MS = 5 * 60 * 1000;
|
|
34
|
+
const clickHandlers = new Set();
|
|
35
|
+
let listening = false;
|
|
36
|
+
function supported() {
|
|
37
|
+
return (typeof window !== 'undefined' &&
|
|
38
|
+
window.isSecureContext &&
|
|
39
|
+
'serviceWorker' in navigator &&
|
|
40
|
+
'PushManager' in window &&
|
|
41
|
+
'Notification' in window &&
|
|
42
|
+
'indexedDB' in window);
|
|
43
|
+
}
|
|
44
|
+
function url(path) {
|
|
45
|
+
return `${options.serverUrl.replace(/\/+$/, '')}${path}`;
|
|
46
|
+
}
|
|
47
|
+
async function call(method, path, body, deviceSecret) {
|
|
48
|
+
const headers = {};
|
|
49
|
+
if (body !== undefined)
|
|
50
|
+
headers['Content-Type'] = 'application/json';
|
|
51
|
+
if (deviceSecret)
|
|
52
|
+
headers.Authorization = `Device ${deviceSecret}`;
|
|
53
|
+
const response = await fetch(url(path), { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
54
|
+
if (response.status === 204)
|
|
55
|
+
return undefined;
|
|
56
|
+
let payload;
|
|
57
|
+
try {
|
|
58
|
+
payload = (await response.json());
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
payload = undefined;
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok)
|
|
64
|
+
throw new NotificationsError(response.status, payload?.errors?.join(' ') || `Request failed with ${response.status}`);
|
|
65
|
+
return payload?.result;
|
|
66
|
+
}
|
|
67
|
+
function toBytes(base64Url) {
|
|
68
|
+
const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (base64Url.length % 4)) % 4);
|
|
69
|
+
const raw = atob(padded);
|
|
70
|
+
const bytes = new Uint8Array(new ArrayBuffer(raw.length));
|
|
71
|
+
for (let i = 0; i < raw.length; i++)
|
|
72
|
+
bytes[i] = raw.charCodeAt(i);
|
|
73
|
+
return bytes;
|
|
74
|
+
}
|
|
75
|
+
function toBase64Url(buffer) {
|
|
76
|
+
if (!buffer)
|
|
77
|
+
return '';
|
|
78
|
+
let raw = '';
|
|
79
|
+
for (const byte of new Uint8Array(buffer))
|
|
80
|
+
raw += String.fromCharCode(byte);
|
|
81
|
+
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
82
|
+
}
|
|
83
|
+
function sameKey(subscription, vapidKey) {
|
|
84
|
+
return toBase64Url(subscription.options.applicationServerKey) === vapidKey.replace(/=+$/, '');
|
|
85
|
+
}
|
|
86
|
+
function subscriptionBody(subscription, vapidKey) {
|
|
87
|
+
return {
|
|
88
|
+
endpoint: subscription.endpoint,
|
|
89
|
+
p256dh: toBase64Url(subscription.getKey('p256dh')),
|
|
90
|
+
auth: toBase64Url(subscription.getKey('auth')),
|
|
91
|
+
applicationServerKey: vapidKey,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function deviceInfo() {
|
|
95
|
+
return {
|
|
96
|
+
language: options.language || navigator.language,
|
|
97
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
98
|
+
appVersion: options.appVersion,
|
|
99
|
+
os: navigator.platform || undefined,
|
|
100
|
+
deviceModel: navigator.userAgent.slice(0, 200),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function listenForClicks() {
|
|
104
|
+
if (listening)
|
|
105
|
+
return;
|
|
106
|
+
listening = true;
|
|
107
|
+
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
108
|
+
const message = event.data;
|
|
109
|
+
if (message?.type !== CLICK_MESSAGE)
|
|
110
|
+
return;
|
|
111
|
+
const click = { url: message.url, data: message.data ?? {}, messageId: message.messageId };
|
|
112
|
+
if (clickHandlers.size === 0) {
|
|
113
|
+
if (click.url)
|
|
114
|
+
window.location.assign(click.url);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
for (const handler of clickHandlers)
|
|
118
|
+
handler(click);
|
|
119
|
+
});
|
|
120
|
+
// messages from the worker wait in a queue until the page says it is listening
|
|
121
|
+
navigator.serviceWorker.startMessages();
|
|
122
|
+
}
|
|
123
|
+
/** Subscribes (or renews the subscription) and registers or updates the device. Needs the permission granted. */
|
|
124
|
+
async function sync() {
|
|
125
|
+
if (!registration || Notification.permission !== 'granted')
|
|
126
|
+
return null;
|
|
127
|
+
const config = await call('GET', `/v1/apps/${options.appId}/web`);
|
|
128
|
+
if (!config)
|
|
129
|
+
return null;
|
|
130
|
+
const vapidKey = config.vapidPublicKey;
|
|
131
|
+
await store.set(keys.config, { appId: options.appId, serverUrl: options.serverUrl, vapidKey });
|
|
132
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
133
|
+
// a subscription made with another key (an earlier key of this app, or another push provider) cannot receive our pushes
|
|
134
|
+
if (subscription && !sameKey(subscription, vapidKey)) {
|
|
135
|
+
await subscription.unsubscribe();
|
|
136
|
+
subscription = null;
|
|
137
|
+
}
|
|
138
|
+
subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(vapidKey) });
|
|
139
|
+
const stored = await store.get(keys.device(options.appId));
|
|
140
|
+
const body = { subscription: subscriptionBody(subscription, vapidKey), ...deviceInfo() };
|
|
141
|
+
if (stored) {
|
|
142
|
+
const changed = stored.endpoint !== subscription.endpoint || stored.vapidKey !== vapidKey;
|
|
143
|
+
if (!changed && Date.now() - stored.syncedAt < RESYNC_AFTER_MS)
|
|
144
|
+
return stored;
|
|
145
|
+
try {
|
|
146
|
+
await call('PUT', `/v1/devices/${stored.id}`, changed ? body : deviceInfo(), stored.secret);
|
|
147
|
+
const updated = { ...stored, endpoint: subscription.endpoint, vapidKey, syncedAt: Date.now() };
|
|
148
|
+
await store.set(keys.device(options.appId), updated);
|
|
149
|
+
return updated;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
// the server no longer knows this device (or another record owns the subscription): register it again
|
|
153
|
+
if (!(error instanceof NotificationsError) || ![401, 404, 409].includes(error.status))
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const registered = await call('POST', `/v1/apps/${options.appId}/devices`, body);
|
|
158
|
+
if (!registered)
|
|
159
|
+
return null;
|
|
160
|
+
const device = {
|
|
161
|
+
appId: options.appId,
|
|
162
|
+
id: registered.deviceId,
|
|
163
|
+
secret: registered.deviceSecret,
|
|
164
|
+
endpoint: subscription.endpoint,
|
|
165
|
+
vapidKey,
|
|
166
|
+
syncedAt: Date.now(),
|
|
167
|
+
};
|
|
168
|
+
await store.set(keys.device(options.appId), device);
|
|
169
|
+
await store.remove(keys.login(options.appId));
|
|
170
|
+
return device;
|
|
171
|
+
}
|
|
172
|
+
function syncOnce(force = false) {
|
|
173
|
+
if (!force && synced && synced.device.appId === options.appId && Date.now() - synced.at < SYNC_REUSE_MS)
|
|
174
|
+
return Promise.resolve(synced.device);
|
|
175
|
+
syncing ??= sync()
|
|
176
|
+
.then((device) => {
|
|
177
|
+
synced = device ? { device, at: Date.now() } : null;
|
|
178
|
+
return device;
|
|
179
|
+
})
|
|
180
|
+
.finally(() => {
|
|
181
|
+
syncing = null;
|
|
182
|
+
});
|
|
183
|
+
return syncing;
|
|
184
|
+
}
|
|
185
|
+
/** A login made before the person allowed notifications is applied once there is a device. */
|
|
186
|
+
async function applyPendingLogin(device) {
|
|
187
|
+
if (!device)
|
|
188
|
+
return;
|
|
189
|
+
const pending = await store.get(keys.login(options.appId));
|
|
190
|
+
if (pending && pending.at === 0)
|
|
191
|
+
await Notifications.login(pending.token);
|
|
192
|
+
}
|
|
193
|
+
export const Notifications = {
|
|
194
|
+
/** True when this browser can receive web push here (a secure context with service workers and the Push API). */
|
|
195
|
+
isSupported() {
|
|
196
|
+
return supported();
|
|
197
|
+
},
|
|
198
|
+
/** Registers the service worker. When the person already allowed notifications, subscribes quietly. */
|
|
199
|
+
async init(init) {
|
|
200
|
+
if (!supported())
|
|
201
|
+
return;
|
|
202
|
+
if (!init.appId)
|
|
203
|
+
throw new Error('Notifications.init needs the appId.');
|
|
204
|
+
options = {
|
|
205
|
+
appId: init.appId,
|
|
206
|
+
serverUrl: init.serverUrl ?? DEFAULT_SERVER_URL,
|
|
207
|
+
serviceWorkerPath: init.serviceWorkerPath ?? DEFAULT_SERVICE_WORKER_PATH,
|
|
208
|
+
serviceWorkerScope: init.serviceWorkerScope ?? '/',
|
|
209
|
+
language: init.language ?? '',
|
|
210
|
+
appVersion: init.appVersion,
|
|
211
|
+
};
|
|
212
|
+
ready = (async () => {
|
|
213
|
+
registration = await navigator.serviceWorker.register(options.serviceWorkerPath, { scope: options.serviceWorkerScope });
|
|
214
|
+
listenForClicks();
|
|
215
|
+
})();
|
|
216
|
+
await ready;
|
|
217
|
+
if (Notification.permission === 'granted')
|
|
218
|
+
await applyPendingLogin(await syncOnce(true));
|
|
219
|
+
},
|
|
220
|
+
permission() {
|
|
221
|
+
return supported() ? Notification.permission : 'unsupported';
|
|
222
|
+
},
|
|
223
|
+
/** Asks the person (call it from a click) and subscribes when they allow. */
|
|
224
|
+
async requestPermission() {
|
|
225
|
+
if (!supported())
|
|
226
|
+
return 'unsupported';
|
|
227
|
+
const result = await Notification.requestPermission();
|
|
228
|
+
if (result === 'granted') {
|
|
229
|
+
await ready;
|
|
230
|
+
await applyPendingLogin(await syncOnce(true));
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
},
|
|
234
|
+
/**
|
|
235
|
+
* Binds this browser to the user an identity token from the app's backend vouches for. Before the person allows
|
|
236
|
+
* notifications there is no device yet: the token is kept and used once they do.
|
|
237
|
+
*/
|
|
238
|
+
async login(identityToken) {
|
|
239
|
+
if (!supported())
|
|
240
|
+
return;
|
|
241
|
+
await ready;
|
|
242
|
+
const loginKey = keys.login(options.appId);
|
|
243
|
+
const previous = await store.get(loginKey);
|
|
244
|
+
const device = await syncOnce();
|
|
245
|
+
if (!device) {
|
|
246
|
+
await store.set(loginKey, { appId: options.appId, token: identityToken, at: 0 });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (previous && previous.token === identityToken && previous.at > 0 && Date.now() - previous.at < RESYNC_AFTER_MS)
|
|
250
|
+
return;
|
|
251
|
+
await call('POST', `/v1/devices/${device.id}/login`, { identityToken }, device.secret);
|
|
252
|
+
await store.set(loginKey, { appId: options.appId, token: identityToken, at: Date.now() });
|
|
253
|
+
},
|
|
254
|
+
/** The browser keeps receiving the app's broadcasts as a guest, but no longer this user's notifications. */
|
|
255
|
+
async logout() {
|
|
256
|
+
if (!supported() || !options.appId)
|
|
257
|
+
return;
|
|
258
|
+
await store.remove(keys.login(options.appId));
|
|
259
|
+
const device = await store.get(keys.device(options.appId));
|
|
260
|
+
if (device)
|
|
261
|
+
await call('POST', `/v1/devices/${device.id}/logout`, undefined, device.secret);
|
|
262
|
+
},
|
|
263
|
+
/** Stops notifications in this browser altogether. */
|
|
264
|
+
async unsubscribe() {
|
|
265
|
+
if (!supported() || !options.appId)
|
|
266
|
+
return;
|
|
267
|
+
const device = await store.get(keys.device(options.appId));
|
|
268
|
+
if (device) {
|
|
269
|
+
await call('DELETE', `/v1/devices/${device.id}`, undefined, device.secret);
|
|
270
|
+
await store.remove(keys.device(options.appId));
|
|
271
|
+
}
|
|
272
|
+
await store.remove(keys.login(options.appId));
|
|
273
|
+
const subscription = await registration?.pushManager.getSubscription();
|
|
274
|
+
await subscription?.unsubscribe();
|
|
275
|
+
},
|
|
276
|
+
/** A notification was clicked while the site was open. Without a handler the SDK opens its link. Returns an unsubscribe function. */
|
|
277
|
+
onClick(handler) {
|
|
278
|
+
clickHandlers.add(handler);
|
|
279
|
+
return () => clickHandlers.delete(handler);
|
|
280
|
+
},
|
|
281
|
+
/** Re-runs the subscription check now (e.g. after the person changed the site's permission). */
|
|
282
|
+
async refresh() {
|
|
283
|
+
if (!supported())
|
|
284
|
+
return;
|
|
285
|
+
await ready;
|
|
286
|
+
await applyPendingLogin(await syncOnce(true));
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
/** What the service worker needs: where to report clicks and the key to subscribe with. */
|
|
6
|
+
export interface StoredConfig {
|
|
7
|
+
appId: string;
|
|
8
|
+
serverUrl: string;
|
|
9
|
+
vapidKey: string;
|
|
10
|
+
}
|
|
11
|
+
/** This browser's device record on the server. The secret authenticates every later call for the device. */
|
|
12
|
+
export interface StoredDevice {
|
|
13
|
+
appId: string;
|
|
14
|
+
id: string;
|
|
15
|
+
secret: string;
|
|
16
|
+
endpoint: string;
|
|
17
|
+
vapidKey: string;
|
|
18
|
+
/** When the server last heard from this device (ms), to refresh it about once a day. */
|
|
19
|
+
syncedAt: number;
|
|
20
|
+
}
|
|
21
|
+
/** The user the device was last bound to, so a page load does not log in again with the same token. */
|
|
22
|
+
export interface StoredLogin {
|
|
23
|
+
appId: string;
|
|
24
|
+
token: string;
|
|
25
|
+
at: number;
|
|
26
|
+
}
|
|
27
|
+
export declare const store: {
|
|
28
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
29
|
+
set<T>(key: string, value: T): Promise<void>;
|
|
30
|
+
remove(key: string): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
export declare const keys: {
|
|
33
|
+
config: string;
|
|
34
|
+
device: (appId: string) => string;
|
|
35
|
+
login: (appId: string) => string;
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,2FAA2F;AAC3F,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,4GAA4G;AAC5G,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,wFAAwF;IACxF,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,uGAAuG;AACvG,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;CACZ;AA0BD,eAAO,MAAM,KAAK;QACZ,CAAC,OAAO,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;QAGvC,CAAC,OAAO,MAAM,SAAS,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;gBAGhC,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGnC,CAAC;AAEF,eAAO,MAAM,IAAI;;oBAEC,MAAM;mBACP,MAAM;CACtB,CAAC"}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
const DB_NAME = 'phoenix-notifications';
|
|
6
|
+
const STORE = 'state';
|
|
7
|
+
function open() {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const request = indexedDB.open(DB_NAME, 1);
|
|
10
|
+
request.onupgradeneeded = () => {
|
|
11
|
+
if (!request.result.objectStoreNames.contains(STORE))
|
|
12
|
+
request.result.createObjectStore(STORE);
|
|
13
|
+
};
|
|
14
|
+
request.onsuccess = () => resolve(request.result);
|
|
15
|
+
request.onerror = () => reject(request.error);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function run(mode, action) {
|
|
19
|
+
const db = await open();
|
|
20
|
+
try {
|
|
21
|
+
return await new Promise((resolve, reject) => {
|
|
22
|
+
const request = action(db.transaction(STORE, mode).objectStore(STORE));
|
|
23
|
+
request.onsuccess = () => resolve(request.result);
|
|
24
|
+
request.onerror = () => reject(request.error);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
db.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export const store = {
|
|
32
|
+
get(key) {
|
|
33
|
+
return run('readonly', (s) => s.get(key));
|
|
34
|
+
},
|
|
35
|
+
set(key, value) {
|
|
36
|
+
return run('readwrite', (s) => s.put(value, key));
|
|
37
|
+
},
|
|
38
|
+
remove(key) {
|
|
39
|
+
return run('readwrite', (s) => s.delete(key));
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
export const keys = {
|
|
43
|
+
config: 'config',
|
|
44
|
+
device: (appId) => `device:${appId}`,
|
|
45
|
+
login: (appId) => `login:${appId}`,
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,OAAO,GAAG,uBAAuB,CAAC;AACxC,MAAM,KAAK,GAAG,OAAO,CAAC;AA2BtB,SAAS,IAAI;IACX,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QAC3C,OAAO,CAAC,eAAe,GAAG,GAAG,EAAE;YAC7B,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,QAAQ,CAAC,KAAK,CAAC;gBAAE,OAAO,CAAC,MAAM,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC;QAChG,CAAC,CAAC;QACF,OAAO,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAClD,OAAO,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAChD,CAAC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,GAAG,CAAI,IAAwB,EAAE,MAA6C;IAC3F,MAAM,EAAE,GAAG,MAAM,IAAI,EAAE,CAAC;IACxB,IAAI,CAAC;QACH,OAAO,MAAM,IAAI,OAAO,CAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC9C,MAAM,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC;YACvE,OAAO,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,MAAW,CAAC,CAAC;YACvD,OAAO,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;QAChD,CAAC,CAAC,CAAC;IACL,CAAC;YAAS,CAAC;QACT,EAAE,CAAC,KAAK,EAAE,CAAC;IACb,CAAC;AACH,CAAC;AAED,MAAM,CAAC,MAAM,KAAK,GAAG;IACnB,GAAG,CAAI,GAAW;QAChB,OAAO,GAAG,CAAgB,UAAU,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAC3D,CAAC;IACD,GAAG,CAAI,GAAW,EAAE,KAAQ;QAC1B,OAAO,GAAG,CAAO,WAAW,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;IAC1D,CAAC;IACD,MAAM,CAAC,GAAW;QAChB,OAAO,GAAG,CAAO,WAAW,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IACtD,CAAC;CACF,CAAC;AAEF,MAAM,CAAC,MAAM,IAAI,GAAG;IAClB,MAAM,EAAE,QAAQ;IAChB,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,UAAU,KAAK,EAAE;IAC5C,KAAK,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,SAAS,KAAK,EAAE;CAC3C,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phoenix-iq/notifications",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Phoenix Notifications web SDK: browser push subscriptions, verified sign-in and click handling for apps on notifications.phnx-iq.com.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./phoenix-notifications-sw.js": "./phoenix-notifications-sw.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"phoenix-notifications-sw.js",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/Phoenix-IQ/phoenix-notifications.git",
|
|
26
|
+
"directory": "sdk/web"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
34
|
+
"prepublishOnly": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "^5.9.3"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Phoenix Notifications service worker. Serve it from the site's root (default path /phoenix-notifications-sw.js) so it
|
|
3
|
+
* controls the whole site. Plain JavaScript on purpose: copy it as it is, no build step.
|
|
4
|
+
*
|
|
5
|
+
* It shows the pushes the site receives, reports clicks to the notifications API, focuses an open tab of the site (and tells
|
|
6
|
+
* the page, which routes the click) or opens the link, and renews the subscription when the browser replaces it.
|
|
7
|
+
* Its state is shared with the SDK through IndexedDB ("phoenix-notifications" / "state"); keep the two in step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DB_NAME = 'phoenix-notifications';
|
|
11
|
+
const STORE = 'state';
|
|
12
|
+
const CLICK_MESSAGE = 'phoenix-notifications:click';
|
|
13
|
+
|
|
14
|
+
function openDb() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const request = indexedDB.open(DB_NAME, 1);
|
|
17
|
+
request.onupgradeneeded = () => {
|
|
18
|
+
if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE);
|
|
19
|
+
};
|
|
20
|
+
request.onsuccess = () => resolve(request.result);
|
|
21
|
+
request.onerror = () => reject(request.error);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function read(key) {
|
|
26
|
+
const db = await openDb();
|
|
27
|
+
try {
|
|
28
|
+
return await new Promise((resolve, reject) => {
|
|
29
|
+
const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
|
|
30
|
+
request.onsuccess = () => resolve(request.result);
|
|
31
|
+
request.onerror = () => reject(request.error);
|
|
32
|
+
});
|
|
33
|
+
} finally {
|
|
34
|
+
db.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function write(key, value) {
|
|
39
|
+
const db = await openDb();
|
|
40
|
+
try {
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
const request = db.transaction(STORE, 'readwrite').objectStore(STORE).put(value, key);
|
|
43
|
+
request.onsuccess = () => resolve();
|
|
44
|
+
request.onerror = () => reject(request.error);
|
|
45
|
+
});
|
|
46
|
+
} finally {
|
|
47
|
+
db.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function apiUrl(config, path) {
|
|
52
|
+
return new URL(`${(config.serverUrl || '').replace(/\/+$/, '')}${path}`, self.location.origin).toString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toBytes(base64Url) {
|
|
56
|
+
const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (base64Url.length % 4)) % 4);
|
|
57
|
+
const raw = atob(padded);
|
|
58
|
+
const bytes = new Uint8Array(raw.length);
|
|
59
|
+
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toBase64Url(buffer) {
|
|
64
|
+
if (!buffer) return '';
|
|
65
|
+
let raw = '';
|
|
66
|
+
for (const byte of new Uint8Array(buffer)) raw += String.fromCharCode(byte);
|
|
67
|
+
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
self.addEventListener('install', () => self.skipWaiting());
|
|
71
|
+
|
|
72
|
+
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
|
73
|
+
|
|
74
|
+
self.addEventListener('push', (event) => {
|
|
75
|
+
let payload = {};
|
|
76
|
+
try {
|
|
77
|
+
payload = event.data ? event.data.json() : {};
|
|
78
|
+
} catch {
|
|
79
|
+
payload = { title: event.data ? event.data.text() : '' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const options = {
|
|
83
|
+
body: payload.body || '',
|
|
84
|
+
icon: payload.icon || undefined,
|
|
85
|
+
image: payload.image || undefined,
|
|
86
|
+
lang: payload.lang || undefined,
|
|
87
|
+
dir: payload.dir || 'auto',
|
|
88
|
+
// the same message never shows twice
|
|
89
|
+
tag: payload.m || undefined,
|
|
90
|
+
data: { id: payload.id, m: payload.m, url: payload.url, data: payload.data || {} },
|
|
91
|
+
};
|
|
92
|
+
event.waitUntil(self.registration.showNotification(payload.title || '', options));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
self.addEventListener('notificationclick', (event) => {
|
|
96
|
+
event.notification.close();
|
|
97
|
+
const details = event.notification.data || {};
|
|
98
|
+
|
|
99
|
+
event.waitUntil(
|
|
100
|
+
(async () => {
|
|
101
|
+
if (details.id) {
|
|
102
|
+
try {
|
|
103
|
+
const config = await read('config');
|
|
104
|
+
if (config) await fetch(apiUrl(config, `/v1/opened/${details.id}`), { method: 'POST', keepalive: true });
|
|
105
|
+
} catch {
|
|
106
|
+
// counting the click must never stop it from opening
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
111
|
+
const open = windows.find((client) => new URL(client.url).origin === self.location.origin);
|
|
112
|
+
if (open) {
|
|
113
|
+
await open.focus();
|
|
114
|
+
open.postMessage({ type: CLICK_MESSAGE, url: details.url, data: details.data || {}, messageId: details.m });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await self.clients.openWindow(details.url || self.registration.scope);
|
|
118
|
+
})(),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
self.addEventListener('pushsubscriptionchange', (event) => {
|
|
123
|
+
event.waitUntil(
|
|
124
|
+
(async () => {
|
|
125
|
+
const config = await read('config');
|
|
126
|
+
if (!config || !config.vapidKey) return;
|
|
127
|
+
const device = await read(`device:${config.appId}`);
|
|
128
|
+
const subscription =
|
|
129
|
+
event.newSubscription ||
|
|
130
|
+
(await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(config.vapidKey) }));
|
|
131
|
+
if (!device) return;
|
|
132
|
+
|
|
133
|
+
const response = await fetch(apiUrl(config, `/v1/devices/${device.id}`), {
|
|
134
|
+
method: 'PUT',
|
|
135
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Device ${device.secret}` },
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
subscription: {
|
|
138
|
+
endpoint: subscription.endpoint,
|
|
139
|
+
p256dh: toBase64Url(subscription.getKey('p256dh')),
|
|
140
|
+
auth: toBase64Url(subscription.getKey('auth')),
|
|
141
|
+
applicationServerKey: config.vapidKey,
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
if (response.ok) await write(`device:${config.appId}`, { ...device, endpoint: subscription.endpoint, syncedAt: Date.now() });
|
|
146
|
+
})(),
|
|
147
|
+
);
|
|
148
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
};
|