@research-ag/ic-web-push 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/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # IC Web Push (browser SDK)
2
+
3
+ IC Web Push is a tiny browser-side SDK that wires your web app to the Internet Computer (IC) notification canister to enable standards-based Web Push notifications.
4
+
5
+ - Works with the standard Push API in Safari, Chromium, Firefox, Edge, and Android browsers.
6
+ - Coexists with your existing service worker by using a separate scope.
7
+ - Handles subscription, unsubscription, and application registration against the IC notification canister.
8
+
9
+ Default notification canister ID: `zjwxf-jyaaa-aaaao-a43ca-cai` (configurable).
10
+
11
+ ## High-level architecture
12
+
13
+ 1. Your app registers a dedicated service worker (SW) under a separate scope (e.g., `/ic-web-push/`). This SW only handles displaying push notifications and reacting to clicks.
14
+ 2. The SDK lists available relayers from the IC notification canister, picks one (random by default), fetches its VAPID public key, and subscribes the browser via `PushManager` using that key.
15
+ 3. The resulting `PushSubscription` plus the chosen relayer principal is sent to the notification canister and associated with your application canister principal.
16
+ 4. Your backend canister sends notifications to the notification canister; each subscription is routed to its chosen relayer's queue for delivery via Web Push.
17
+
18
+ ## Files in this module
19
+
20
+ - `index.ts` — public API surface for initializing and controlling subscriptions.
21
+ - `sw.js` — the service worker that displays notifications and handles clicks.
22
+
23
+ ## Coexisting with your app's service worker
24
+
25
+ Service workers are scoped by path. To avoid conflicts with your main app SW, IC Web Push uses its own scope, by default `/ic-web-push/`, and assumes its file is hosted at `/ic-web-push-sw.js` in your web root.
26
+
27
+ You may keep your app's own `sw.js` for app caching, offline, etc. This SDK's SW will not interfere because it is registered under a separate scope and only handles `push` and `notificationclick` events within that scope.
28
+
29
+ ## Hosting the service worker file
30
+
31
+ Place the service worker file at the web root so it can be served at `/ic-web-push-sw.js`. There are multiple ways to do this depending on your bundler:
32
+
33
+ - Vite: copy `src/ic-web-push/sw.js` into `public/ic-web-push-sw.js` (the `public` folder is served at web root). Example:
34
+ - Copy once manually, or
35
+ - Add a small build step/plugin to copy the file on build
36
+ - CRA/Next.js/others: add a copy step to move `sw.js` to the output root as `ic-web-push-sw.js`.
37
+
38
+ You can also customize the path/scope via `init()` if you prefer a different location.
39
+
40
+ ## Usage
41
+
42
+ 1) Initialize the SDK early in app startup:
43
+
44
+ ```ts
45
+ import icWebPush from 'ic-web-push';
46
+ import { HttpAgent } from '@dfinity/agent';
47
+
48
+ const agent = new HttpAgent({ host: 'https://ic0.app' });
49
+ // If developing locally against a replica, you may need:
50
+ // await agent.fetchRootKey();
51
+
52
+ icWebPush.init({
53
+ agent,
54
+ // Notification canister ID (defaults to mainnet id):
55
+ // notificationCanisterId: 'zjwxf-jyaaa-aaaao-a43ca-cai',
56
+ // Your application canister principal (REQUIRED to subscribe):
57
+ applicationCanisterId: '<your app canister id>',
58
+ // Optional: customize where the service worker is served from
59
+ serviceWorkerPath: '/ic-web-push-sw.js',
60
+ serviceWorkerScope: '/ic-web-push/',
61
+ });
62
+
63
+ icWebPush.setDebug(true); // optional
64
+ ```
65
+
66
+ 2) Register the service worker:
67
+
68
+ ```ts
69
+ await icWebPush.registerServiceWorker();
70
+ ```
71
+
72
+ 3) Request permission (if needed) and subscribe:
73
+
74
+ ```ts
75
+ // One-shot convenience that registers SW, ensures permission, and subscribes
76
+ await icWebPush.ensureSubscribed({ requestPermissionIfNeeded: true });
77
+
78
+ // Or do it step-by-step and pick a relayer explicitly
79
+ if (await icWebPush.getPermissionStatus() !== 'granted') {
80
+ await icWebPush.requestPermission();
81
+ }
82
+
83
+ // Option A: pick a random relayer
84
+ const relayer = await icWebPush.chooseRandomRelayer();
85
+ if (!relayer) throw new Error('No relayers available');
86
+ await icWebPush.subscribe({ relayer });
87
+
88
+ // Option B: list relayers and choose one by your own policy
89
+ const relayers = await icWebPush.listRelayers();
90
+ // e.g., pick the first or prefer by description
91
+ await icWebPush.subscribe({ relayer: relayers[0].relayer });
92
+ ```
93
+
94
+ 4) Unsubscribe later (optional):
95
+
96
+ ```ts
97
+ await icWebPush.unsubscribe();
98
+ // Or to remove all subscriptions for your app principal on-chain:
99
+ await icWebPush.unsubscribeAll();
100
+ ```
101
+
102
+
103
+ ## API reference
104
+
105
+ - `init(config)` — initializes SDK. Options:
106
+ - `agent` (HttpAgent): an agent for interaction with IC. Should use user's identity.
107
+ - `notificationCanisterId` (string): notification canister ID, default mainnet ID.
108
+ - `applicationCanisterId` (string): your app canister principal. Required for `subscribe`/`unsubscribeAll`.
109
+ - `serviceWorkerPath` (string): where the SW file is served from. Default `/ic-web-push-sw.js`.
110
+ - `serviceWorkerScope` (string): SW scope. Default `/ic-web-push/`.
111
+
112
+ - `setDebug(enabled)` — toggles debug logs.
113
+ - `registerServiceWorker()` — registers the SW at the configured path/scope.
114
+ - `getPermissionStatus()` — returns the current `Notification.permission`.
115
+ - `requestPermission()` — prompts the browser permission dialog.
116
+ - `subscribe({ requestPermissionIfNeeded? })` — creates a push subscription and registers it on-chain.
117
+ - `ensureSubscribed({ requestPermissionIfNeeded? })` — convenience wrapper to do SW, permission, and subscription together.
118
+ - `getSubscription()` — resolves the current `PushSubscription` or `null`.
119
+ - `isSubscribed()` — boolean indicating whether a subscription exists locally.
120
+ - `unsubscribe()` — removes the local subscription and tries to unregister it on the canister.
121
+ - `unsubscribeAll()` — unregisters all subscriptions for the configured application principal on the canister.
122
+
123
+ ## Service worker behavior
124
+
125
+ This SDK ships a dedicated service worker (`sw.js`) intended to live under its own scope (recommended: `/ic-web-push/`) and be served from your web root as `/ic-web-push-sw.js`. It is designed to coexist with your app’s main service worker without conflict. The worker implements the following:
126
+
127
+ - `install`
128
+ - Calls `self.skipWaiting()` so that updates to the worker take effect immediately after install.
129
+ - `activate`
130
+ - Calls `self.clients.claim()` to take control of pages under its scope without a manual reload.
131
+ - `push`
132
+ - Parses a JSON payload (if present). Robust to missing/invalid payloads and falls back to a generic notification.
133
+ - Supported payload fields (top-level preferred; `data.*` also accepted for backward compatibility):
134
+ - `title` (string): Notification title. Default: `"New notification"`.
135
+ - `content` (string): Notification text; mapped to Notification API `body`. Default: `"You have a new message"`.
136
+ - `url` (string): A URL to open/focus when the notification is clicked. May also be provided as `data.url`.
137
+ - `actions` (array): Standard Notification API actions.
138
+ - `requireInteraction` (boolean): If `true`, the notification stays until user interaction.
139
+ - `tag` (string): Notification tag for collapsing/updating and for later clearing.
140
+ - `data` (object): Arbitrary extra fields; merged into the notification `data` object. The worker ensures `data.url` is set to the resolved URL.
141
+ - Display options applied by default:
142
+ - `icon` and `badge` default to `/favicon.ico` (override by editing `sw.js`).
143
+ - If `tag` is provided, it is set on the notification so later notifications with the same tag replace or group, per browser behavior.
144
+ - Example payload sent by your canister (matches `NotificationBody`):
145
+ ```json
146
+ {
147
+ "title": "New message",
148
+ "content": "You received a message",
149
+ "url": "/inbox/123",
150
+ "tag": "chat-123"
151
+ }
152
+ ```
153
+ - `message`
154
+ - Listens for `{ type: 'CLEAR_NOTIFICATIONS_BY_TAG', tag: string }` to programmatically close notifications with the given tag.
155
+ - Uses `registration.getNotifications({ includeTriggered: true })` when supported, with a safe fallback to `getNotifications()`.
156
+ - Example from a page context (any controlled client under the SW scope):
157
+ ```ts
158
+ navigator.serviceWorker.controller?.postMessage({
159
+ type: 'CLEAR_NOTIFICATIONS_BY_TAG',
160
+ tag: 'chat-123',
161
+ });
162
+ ```
163
+ - `notificationclick`
164
+ - Closes the clicked notification.
165
+ - Resolves a target URL from `notification.data.url` (defaults to `/`).
166
+ - Looks for an existing same-origin window client and, if found:
167
+ - Focuses it.
168
+ - Tries to `postMessage({ type: 'OPEN_URL', url })` so the app can handle in-app routing/session flags.
169
+ - As a safe fallback, if the client is not at the origin root and the href differs, calls `client.navigate(url)`.
170
+ - If no suitable client exists, opens a new window via `clients.openWindow(url)`.
171
+
172
+ You can customize icons, default texts, or add behavior by editing `src/ic-web-push/sw.js` before copying it into your build’s web root as `ic-web-push-sw.js`.
173
+
174
+ ### Page <-> SW messaging: handling OPEN_URL
175
+
176
+ When a notification is clicked, the SW first tries to notify an existing tab using a message. In your application code, you may listen to this message to perform client-side routing instead of a hard navigation:
177
+
178
+ ```ts
179
+ if ('serviceWorker' in navigator) {
180
+ navigator.serviceWorker.addEventListener('message', (event) => {
181
+ const msg = event.data;
182
+ if (msg?.type === 'OPEN_URL' && typeof msg.url === 'string') {
183
+ // App-specific navigation, e.g., using your router
184
+ // router.push(new URL(msg.url, location.origin).pathname);
185
+ // Or simply: location.href = msg.url;
186
+ }
187
+ });
188
+ }
189
+ ```
190
+
191
+ ## Integration example (Vite + the provided chat_frontend)
192
+
193
+ 1. Copy the service worker to the example's public root:
194
+ - Copy `src/ic-web-push/sw.js` to `example/chat_frontend/public/ic-web-push-sw.js`.
195
+
196
+ 2. Initialize and subscribe in your app code, e.g., `example/chat_frontend/src/App.jsx`:
197
+
198
+ ```jsx
199
+ import { useEffect } from 'react';
200
+ import icWebPush from 'ic-web-push';
201
+ import { HttpAgent } from '@dfinity/agent';
202
+
203
+ export default function App() {
204
+ useEffect(() => {
205
+ icWebPush.init({
206
+ agent: new HttpAgent(),
207
+ applicationCanisterId: '<chat_backend_canister_id>',
208
+ // notificationCanisterId: '<override if not using default>'
209
+ });
210
+ icWebPush.ensureSubscribed({ requestPermissionIfNeeded: true }).catch(console.error);
211
+ }, []);
212
+
213
+ return <div>Chat app with IC Web Push</div>;
214
+ }
215
+ ```
216
+
217
+ 3. Start the dev server. Verify you see the permission prompt and a registered service worker under Application > Service Workers in DevTools.
218
+
219
+ ## Sending notifications
220
+
221
+ From your canister or backend, call `sendNotifications` with a vector of `(principal, NotificationBody)` pairs on the notification canister. The `principal` should be your application canister principal that was used when registering subscriptions.
222
+
223
+ `NotificationBody` schema:
224
+ ```candid
225
+ record {
226
+ title: text;
227
+ content: text;
228
+ url: opt text;
229
+ tag: opt text;
230
+ }
231
+ ```
232
+
233
+ Refer to the canister Candid (`src/notification-canister/notification_canister.did`) for the full interface. On-chain sending uses `sendNotifications(principal, NotificationBody)`.
234
+
235
+ ## Troubleshooting
236
+
237
+ - Ensure HTTPS and a secure origin. Service workers and Push require secure contexts.
238
+ - Make sure the SW file is actually served at the path you configured in `init()`.
239
+ - If subscription fails, check that the VAPID key is being fetched successfully from the notification canister.
240
+ - If notifications do not appear, ensure the notification payload fields match what your SW expects, and that the browser has permission granted.
241
+ - Some desktop browsers block notifications when the window is focused; try sending while unfocused or check OS notification settings.
@@ -0,0 +1,45 @@
1
+ import type { ActorConfig, ActorSubclass, Agent, HttpAgentOptions, } from "@dfinity/agent";
2
+ import type { Principal } from "@dfinity/principal";
3
+ import type { IDL } from "@dfinity/candid";
4
+
5
+ import { _SERVICE } from './notification_canister.did';
6
+
7
+ export declare const idlFactory: IDL.InterfaceFactory;
8
+ export declare const canisterId: string;
9
+
10
+ export declare interface CreateActorOptions {
11
+ /**
12
+ * @see {@link Agent}
13
+ */
14
+ agent?: Agent;
15
+ /**
16
+ * @see {@link HttpAgentOptions}
17
+ */
18
+ agentOptions?: HttpAgentOptions;
19
+ /**
20
+ * @see {@link ActorConfig}
21
+ */
22
+ actorOptions?: ActorConfig;
23
+ }
24
+
25
+ /**
26
+ * Intializes an {@link ActorSubclass}, configured with the provided SERVICE interface of a canister.
27
+ * @constructs {@link ActorSubClass}
28
+ * @param {string | Principal} canisterId - ID of the canister the {@link Actor} will talk to
29
+ * @param {CreateActorOptions} options - see {@link CreateActorOptions}
30
+ * @param {CreateActorOptions["agent"]} options.agent - a pre-configured agent you'd like to use. Supercedes agentOptions
31
+ * @param {CreateActorOptions["agentOptions"]} options.agentOptions - options to set up a new agent
32
+ * @see {@link HttpAgentOptions}
33
+ * @param {CreateActorOptions["actorOptions"]} options.actorOptions - options for the Actor
34
+ * @see {@link ActorConfig}
35
+ */
36
+ export declare const createActor: (
37
+ canisterId: string | Principal,
38
+ options?: CreateActorOptions
39
+ ) => ActorSubclass<_SERVICE>;
40
+
41
+ /**
42
+ * Intialized Actor using default settings, ready to talk to a canister using its candid interface
43
+ * @constructs {@link ActorSubClass}
44
+ */
45
+ export declare const notification_canister: ActorSubclass<_SERVICE>;
@@ -0,0 +1,43 @@
1
+ import {Actor, HttpAgent} from "@dfinity/agent";
2
+
3
+ // Imports and re-exports candid interface
4
+ import {idlFactory} from "./notification_canister.did.js";
5
+
6
+ export {idlFactory} from "./notification_canister.did.js";
7
+
8
+ /* CANISTER_ID is replaced by webpack based on node environment
9
+ * Note: canister environment variable will be standardized as
10
+ * process.env.CANISTER_ID_<CANISTER_NAME_UPPERCASE>
11
+ * beginning in dfx 0.15.0
12
+ */
13
+ export const canisterId =
14
+ process.env.CANISTER_ID_NOTIFICATION_CANISTER;
15
+
16
+ export const createActor = (canisterId, options = {}) => {
17
+ const agent = options.agent || new HttpAgent({...options.agentOptions});
18
+
19
+ if (options.agent && options.agentOptions) {
20
+ console.warn(
21
+ "Detected both agent and agentOptions passed to createActor. Ignoring agentOptions and proceeding with the provided agent."
22
+ );
23
+ }
24
+
25
+ // Fetch root key for certificate validation during development
26
+ if (process.env.DFX_NETWORK !== "ic") {
27
+ agent.fetchRootKey().catch((err) => {
28
+ console.warn(
29
+ "Unable to fetch root key. Check to ensure that your local replica is running"
30
+ );
31
+ console.error(err);
32
+ });
33
+ }
34
+
35
+ // Creates an actor with using the candid interface and the HttpAgent
36
+ return Actor.createActor(idlFactory, {
37
+ agent,
38
+ canisterId,
39
+ ...options.actorOptions,
40
+ });
41
+ };
42
+
43
+ export const notification_canister = canisterId ? createActor(canisterId) : undefined;
@@ -0,0 +1,35 @@
1
+ import type { Principal } from '@dfinity/principal';
2
+ import type { ActorMethod } from '@dfinity/agent';
3
+ import type { IDL } from '@dfinity/candid';
4
+
5
+ export interface RelayerInfo {
6
+ 'relayer': Principal,
7
+ 'description': string,
8
+ 'lastUpdatedAt': bigint,
9
+ 'vapid_public_key': string,
10
+ 'registeredAt': bigint,
11
+ }
12
+
13
+ export interface Subscription {
14
+ 'endpoint': string,
15
+ 'keys': SubscriptionKeys,
16
+ 'relayer': Principal,
17
+ 'expirationTime': [] | [bigint],
18
+ }
19
+
20
+ export interface SubscriptionKeys {
21
+ 'auth': string,
22
+ 'p256dh': string
23
+ }
24
+
25
+ export interface _SERVICE {
26
+ 'getVapidPublicKey': ActorMethod<[Principal], string>,
27
+ 'hasSubscription': ActorMethod<[Principal, string], boolean>,
28
+ 'listRelayers': ActorMethod<[], Array<RelayerInfo>>,
29
+ 'subscribe': ActorMethod<[Principal, Subscription], undefined>,
30
+ 'unsubscribe': ActorMethod<[Principal, string], undefined>,
31
+ 'unsubscribeAll': ActorMethod<[Principal], undefined>,
32
+ }
33
+
34
+ export declare const idlFactory: IDL.InterfaceFactory;
35
+ export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[];
@@ -0,0 +1,34 @@
1
+ export const idlFactory = ({IDL}) => {
2
+ const RelayerInfo = IDL.Record({
3
+ 'relayer': IDL.Principal,
4
+ 'description': IDL.Text,
5
+ 'lastUpdatedAt': IDL.Nat64,
6
+ 'vapid_public_key': IDL.Text,
7
+ 'registeredAt': IDL.Nat64,
8
+ });
9
+ const SubscriptionKeys = IDL.Record({
10
+ 'auth': IDL.Text,
11
+ 'p256dh': IDL.Text,
12
+ });
13
+ const Subscription = IDL.Record({
14
+ 'endpoint': IDL.Text,
15
+ 'keys': SubscriptionKeys,
16
+ 'relayer': IDL.Principal,
17
+ 'expirationTime': IDL.Opt(IDL.Nat),
18
+ });
19
+ return IDL.Service({
20
+ 'getVapidPublicKey': IDL.Func([IDL.Principal], [IDL.Text], ['query']),
21
+ 'hasSubscription': IDL.Func(
22
+ [IDL.Principal, IDL.Text],
23
+ [IDL.Bool],
24
+ ['query'],
25
+ ),
26
+ 'listRelayers': IDL.Func([], [IDL.Vec(RelayerInfo)], ['query']),
27
+ 'subscribe': IDL.Func([IDL.Principal, Subscription], [], []),
28
+ 'unsubscribe': IDL.Func([IDL.Principal, IDL.Text], [], []),
29
+ 'unsubscribeAll': IDL.Func([IDL.Principal], [], []),
30
+ });
31
+ };
32
+ export const init = ({IDL}) => {
33
+ return [];
34
+ };
@@ -0,0 +1,62 @@
1
+ import { HttpAgent } from '@dfinity/agent';
2
+ import { Principal } from '@dfinity/principal';
3
+ export type IcWebPushConfig = {
4
+ agent: HttpAgent;
5
+ /** Notification canister ID (Principal text). Defaults to known mainnet ID. */
6
+ notificationCanisterId?: string;
7
+ /** Your application canister ID (Principal text) to associate subscriptions with. REQUIRED for subscribe(). */
8
+ applicationCanisterId?: string;
9
+ /** Path to the service worker file within your web root. Default: '/ic-web-push-sw.js' */
10
+ serviceWorkerPath?: string;
11
+ /** Service worker scope. Default: '/ic-web-push/' */
12
+ serviceWorkerScope?: string;
13
+ };
14
+ export type SubscribeOptions = {
15
+ requestPermissionIfNeeded?: boolean;
16
+ relayer?: string | Principal;
17
+ };
18
+ export declare function setDebug(enabled: boolean): void;
19
+ export declare function setDebugAlerts(enabled: boolean): void;
20
+ export declare function init(config: IcWebPushConfig): void;
21
+ export declare function getPermissionStatus(): Promise<NotificationPermission>;
22
+ export declare function requestPermission(): Promise<NotificationPermission>;
23
+ export declare function registerServiceWorker(): Promise<ServiceWorkerRegistration | null>;
24
+ export declare function ensurePushSupported(): boolean;
25
+ export type RelayerInfo = {
26
+ relayer: Principal;
27
+ vapid_public_key: string;
28
+ registeredAt: bigint;
29
+ lastUpdatedAt: bigint;
30
+ description: string;
31
+ };
32
+ export declare function listRelayers(): Promise<RelayerInfo[]>;
33
+ export declare function chooseRandomRelayer(): Promise<Principal | null>;
34
+ export declare function getVapidPublicKey(relayer: string | Principal): Promise<string>;
35
+ export declare function getSubscription(): Promise<PushSubscription | null>;
36
+ export declare function isSubscribed(): Promise<boolean>;
37
+ /** Subscribe the browser and register the subscription on the notification canister. */
38
+ export declare function subscribe(options?: SubscribeOptions): Promise<PushSubscription>;
39
+ /** Unsubscribe from browser and unregister from the notification canister. */
40
+ export declare function unsubscribe(): Promise<void>;
41
+ /** Unregister all subscriptions for this application principal on the canister. */
42
+ export declare function unsubscribeAll(): Promise<void>;
43
+ export declare function ensureSubscribed(options?: SubscribeOptions): Promise<PushSubscription>;
44
+ export type IcWebPushPublicAPI = {
45
+ init: typeof init;
46
+ registerServiceWorker: typeof registerServiceWorker;
47
+ requestPermission: typeof requestPermission;
48
+ getPermissionStatus: typeof getPermissionStatus;
49
+ listRelayers: typeof listRelayers;
50
+ chooseRandomRelayer: typeof chooseRandomRelayer;
51
+ getVapidPublicKey: typeof getVapidPublicKey;
52
+ subscribe: typeof subscribe;
53
+ unsubscribe: typeof unsubscribe;
54
+ unsubscribeAll: typeof unsubscribeAll;
55
+ ensureSubscribed: typeof ensureSubscribed;
56
+ getSubscription: typeof getSubscription;
57
+ isSubscribed: typeof isSubscribed;
58
+ setDebug: typeof setDebug;
59
+ setDebugAlerts: typeof setDebugAlerts;
60
+ };
61
+ declare const api: IcWebPushPublicAPI;
62
+ export default api;
package/dist/index.js ADDED
@@ -0,0 +1,365 @@
1
+ import { Actor } from '@dfinity/agent';
2
+ import { Principal } from '@dfinity/principal';
3
+ import { idlFactory as notificationIdlFactory } from './declarations/notification_canister/notification_canister.did.js';
4
+ const DEFAULTS = {
5
+ notificationCanisterId: 'zjwxf-jyaaa-aaaao-a43ca-cai',
6
+ serviceWorkerPath: '/ic-web-push-sw.js',
7
+ serviceWorkerScope: '/ic-web-push/',
8
+ };
9
+ let _config = { ...DEFAULTS };
10
+ let _actor = null;
11
+ let _debug = false;
12
+ let _debugAlerts = false;
13
+ function dbg(...args) {
14
+ if (_debug) {
15
+ console.log('[ic-web-push]', ...args);
16
+ if (_debugAlerts) {
17
+ alert('[ic-web-push] ' + args.map(x => JSON.stringify(x)).join(', '));
18
+ }
19
+ }
20
+ }
21
+ export function setDebug(enabled) {
22
+ _debug = enabled;
23
+ }
24
+ export function setDebugAlerts(enabled) {
25
+ _debugAlerts = enabled;
26
+ }
27
+ export function init(config) {
28
+ _config = {
29
+ agent: config.agent,
30
+ notificationCanisterId: config?.notificationCanisterId ?? DEFAULTS.notificationCanisterId,
31
+ serviceWorkerPath: config?.serviceWorkerPath ?? DEFAULTS.serviceWorkerPath,
32
+ serviceWorkerScope: config?.serviceWorkerScope ?? DEFAULTS.serviceWorkerScope,
33
+ applicationCanisterId: config?.applicationCanisterId,
34
+ };
35
+ _actor = null;
36
+ dbg('Initialized with config', _config);
37
+ }
38
+ function requireWindow() {
39
+ if (typeof window === 'undefined')
40
+ throw new Error('ic-web-push: must be used in a browser context');
41
+ return window;
42
+ }
43
+ async function getActor() {
44
+ if (_actor)
45
+ return _actor;
46
+ if (!_config || !_config.agent) {
47
+ throw new Error('ic-web-push: init() must be called with an HttpAgent before use');
48
+ }
49
+ const agent = _config.agent;
50
+ const canisterId = _config.notificationCanisterId;
51
+ const actor = Actor.createActor(notificationIdlFactory, {
52
+ agent,
53
+ canisterId,
54
+ });
55
+ _actor = actor;
56
+ return _actor;
57
+ }
58
+ export async function getPermissionStatus() {
59
+ requireWindow();
60
+ return Notification.permission;
61
+ }
62
+ export async function requestPermission() {
63
+ requireWindow();
64
+ const res = await Notification.requestPermission();
65
+ dbg('Notification permission result:', res);
66
+ return res;
67
+ }
68
+ export async function registerServiceWorker() {
69
+ const w = requireWindow();
70
+ if (!('serviceWorker' in navigator)) {
71
+ console.warn('[ic-web-push] Service workers are not supported in this browser.');
72
+ return null;
73
+ }
74
+ try {
75
+ const reg = await navigator.serviceWorker.register(_config.serviceWorkerPath, {
76
+ scope: _config.serviceWorkerScope,
77
+ // type: 'module', // our sw is classic to maximize compatibility
78
+ updateViaCache: 'none',
79
+ });
80
+ await reg.update();
81
+ dbg('Service worker registered', reg);
82
+ return reg;
83
+ }
84
+ catch (e) {
85
+ console.error('[ic-web-push] Failed to register service worker:', e);
86
+ throw e;
87
+ }
88
+ }
89
+ export function ensurePushSupported() {
90
+ try {
91
+ requireWindow();
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ const supported = 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
97
+ if (!supported) {
98
+ dbg('[ic-web-push] Push is not supported in this browser.');
99
+ console.warn('[ic-web-push] Push is not supported in this browser.');
100
+ }
101
+ return supported;
102
+ }
103
+ function urlBase64ToUint8Array(base64String) {
104
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
105
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
106
+ const rawData = atob(base64);
107
+ const outputArray = new Uint8Array(rawData.length);
108
+ for (let i = 0; i < rawData.length; ++i) {
109
+ outputArray[i] = rawData.charCodeAt(i);
110
+ }
111
+ return outputArray;
112
+ }
113
+ function toPrincipal(p) {
114
+ return typeof p === 'string' ? Principal.fromText(p) : p;
115
+ }
116
+ export async function listRelayers() {
117
+ const actor = await getActor();
118
+ return actor.listRelayers();
119
+ }
120
+ export async function chooseRandomRelayer() {
121
+ const list = await listRelayers();
122
+ if (!list || list.length === 0)
123
+ return null;
124
+ const idx = Math.floor(Math.random() * list.length);
125
+ return list[idx].relayer;
126
+ }
127
+ export async function getVapidPublicKey(relayer) {
128
+ const actor = await getActor();
129
+ const key = await actor.getVapidPublicKey(toPrincipal(relayer));
130
+ dbg('VAPID public key fetched for relayer');
131
+ return key;
132
+ }
133
+ function subscriptionToRecord(sub, relayer) {
134
+ const json = sub.toJSON();
135
+ return {
136
+ endpoint: sub.endpoint,
137
+ keys: {
138
+ p256dh: json.keys?.p256dh ?? '',
139
+ auth: json.keys?.auth ?? '',
140
+ },
141
+ expirationTime: json.expirationTime ? [BigInt(json.expirationTime)] : [],
142
+ relayer,
143
+ };
144
+ }
145
+ const LS = {
146
+ registered: 'icwp.registered',
147
+ endpoint: 'icwp.endpoint',
148
+ relayer: 'icwp.relayer',
149
+ };
150
+ function setLocalRegistered(endpoint) {
151
+ try {
152
+ if (endpoint) {
153
+ localStorage.setItem(LS.registered, '1');
154
+ localStorage.setItem(LS.endpoint, endpoint);
155
+ }
156
+ else {
157
+ localStorage.removeItem(LS.registered);
158
+ localStorage.removeItem(LS.endpoint);
159
+ }
160
+ }
161
+ catch {
162
+ }
163
+ }
164
+ export async function getSubscription() {
165
+ try {
166
+ requireWindow();
167
+ }
168
+ catch {
169
+ return null; // SSR or non-browser environment
170
+ }
171
+ if (!('serviceWorker' in navigator))
172
+ return null;
173
+ const reg = await navigator.serviceWorker.getRegistration(_config.serviceWorkerScope);
174
+ if (!reg)
175
+ return null;
176
+ return reg.pushManager.getSubscription();
177
+ }
178
+ export async function isSubscribed() {
179
+ const sub = await getSubscription();
180
+ if (!sub)
181
+ return false;
182
+ // Best-effort relayer consistency check: if we stored a relayer locally, ensure it's still registered.
183
+ try {
184
+ const storedRelayerTxt = localStorage.getItem(LS.relayer);
185
+ if (storedRelayerTxt) {
186
+ const relayers = await listRelayers();
187
+ const exists = relayers.some(r => r.relayer.toText() === storedRelayerTxt);
188
+ if (!exists) {
189
+ dbg('[ic-web-push] Stored relayer is no longer registered. Treating as unsubscribed.');
190
+ return false;
191
+ }
192
+ }
193
+ }
194
+ catch {
195
+ }
196
+ // If applicationCanisterId is configured, verify with server canister.
197
+ if (_config.applicationCanisterId) {
198
+ try {
199
+ const actor = await getActor();
200
+ const app = Principal.fromText(_config.applicationCanisterId);
201
+ const ok = await actor.hasSubscription(app, sub.endpoint);
202
+ if (!ok) {
203
+ dbg('[ic-web-push] Local subscription not found on the canister. Removing it locally');
204
+ // Remote canister has no record: revoke local subscription to keep state consistent.
205
+ try {
206
+ await sub.unsubscribe();
207
+ }
208
+ catch (e) {
209
+ dbg('[ic-web-push] Failed to unsubscribe local PushSubscription after remote mismatch:', e);
210
+ console.warn('[ic-web-push] Failed to unsubscribe local PushSubscription after remote mismatch:', e);
211
+ }
212
+ finally {
213
+ setLocalRegistered(null);
214
+ }
215
+ return false;
216
+ }
217
+ }
218
+ catch (e) {
219
+ dbg('[ic-web-push] hasSubscription check failed; assuming local subscription is valid:', e);
220
+ console.warn('[ic-web-push] hasSubscription check failed; assuming local subscription is valid:', e);
221
+ // Fall through and trust local presence
222
+ }
223
+ }
224
+ return true;
225
+ }
226
+ async function ensureServiceWorkerReady() {
227
+ let reg = await navigator.serviceWorker.getRegistration(_config.serviceWorkerScope);
228
+ if (!reg) {
229
+ reg = (await registerServiceWorker());
230
+ }
231
+ if (!reg)
232
+ throw new Error('ic-web-push: service worker is required but was not registered');
233
+ return reg;
234
+ }
235
+ /** Subscribe the browser and register the subscription on the notification canister. */
236
+ export async function subscribe(options) {
237
+ if (!_config.applicationCanisterId)
238
+ throw new Error('ic-web-push: applicationCanisterId is required in init() to subscribe');
239
+ if (!ensurePushSupported())
240
+ throw new Error('ic-web-push: Push not supported');
241
+ const permission = await getPermissionStatus();
242
+ if (permission !== 'granted') {
243
+ if (options?.requestPermissionIfNeeded) {
244
+ const p = await requestPermission();
245
+ if (p !== 'granted')
246
+ throw new Error('ic-web-push: Notification permission was not granted');
247
+ }
248
+ else {
249
+ throw new Error('ic-web-push: Notification permission is not granted');
250
+ }
251
+ }
252
+ // Resolve relayer to use
253
+ let relayer = null;
254
+ try {
255
+ if (options?.relayer)
256
+ relayer = toPrincipal(options.relayer);
257
+ else {
258
+ const stored = localStorage.getItem(LS.relayer);
259
+ if (stored)
260
+ relayer = Principal.fromText(stored);
261
+ }
262
+ }
263
+ catch {
264
+ }
265
+ if (!relayer) {
266
+ relayer = await chooseRandomRelayer();
267
+ }
268
+ if (!relayer) {
269
+ throw new Error('ic-web-push: No relayers are registered on the notification canister');
270
+ }
271
+ const reg = await ensureServiceWorkerReady();
272
+ const existing = await reg.pushManager.getSubscription();
273
+ const vapidKey = await getVapidPublicKey(relayer);
274
+ const appServerKey = urlBase64ToUint8Array(vapidKey);
275
+ let subscription = existing;
276
+ if (!subscription) {
277
+ subscription = await reg.pushManager.subscribe({
278
+ userVisibleOnly: true,
279
+ applicationServerKey: appServerKey
280
+ });
281
+ dbg('Created new PushSubscription');
282
+ }
283
+ else {
284
+ dbg('Existing PushSubscription found');
285
+ }
286
+ const actor = await getActor();
287
+ const app = Principal.fromText(_config.applicationCanisterId);
288
+ await actor.subscribe(app, subscriptionToRecord(subscription, relayer));
289
+ setLocalRegistered(subscription.endpoint);
290
+ try {
291
+ localStorage.setItem(LS.relayer, relayer.toText());
292
+ }
293
+ catch {
294
+ }
295
+ dbg('Subscription registered on canister with relayer', relayer.toText());
296
+ return subscription;
297
+ }
298
+ /** Unsubscribe from browser and unregister from the notification canister. */
299
+ export async function unsubscribe() {
300
+ const sub = await getSubscription();
301
+ if (!sub) {
302
+ dbg('No subscription present');
303
+ return;
304
+ }
305
+ try {
306
+ // Try unregister on canister first
307
+ const actor = await getActor();
308
+ if (_config.applicationCanisterId) {
309
+ const app = Principal.fromText(_config.applicationCanisterId);
310
+ await actor.unsubscribe(app, sub.endpoint);
311
+ dbg('Subscription removed from canister. Endpoint: ', sub.endpoint);
312
+ }
313
+ }
314
+ catch (e) {
315
+ dbg('[ic-web-push] Failed to unregister on canister (will still remove local sub):', e);
316
+ console.warn('[ic-web-push] Failed to unregister on canister (will still remove local sub):', e);
317
+ }
318
+ try {
319
+ const ok = await sub.unsubscribe();
320
+ dbg('Browser PushSubscription unsubscribed:', ok);
321
+ }
322
+ finally {
323
+ setLocalRegistered(null);
324
+ }
325
+ }
326
+ /** Unregister all subscriptions for this application principal on the canister. */
327
+ export async function unsubscribeAll() {
328
+ if (!_config.applicationCanisterId)
329
+ throw new Error('ic-web-push: applicationCanisterId is required in init() to unsubscribeAll');
330
+ const actor = await getActor();
331
+ const app = Principal.fromText(_config.applicationCanisterId);
332
+ await actor.unsubscribeAll(app);
333
+ dbg('[ic-web-push] Removed all subscriptions from the canister');
334
+ // keep local sub as-is; caller may also call unsubscribe()
335
+ }
336
+ // Convenience: combined flow to ensure sw + permission + subscription
337
+ export async function ensureSubscribed(options) {
338
+ await registerServiceWorker();
339
+ if ((await getPermissionStatus()) !== 'granted') {
340
+ if (options?.requestPermissionIfNeeded) {
341
+ await requestPermission();
342
+ }
343
+ }
344
+ return subscribe({ requestPermissionIfNeeded: false });
345
+ }
346
+ const api = {
347
+ init,
348
+ registerServiceWorker,
349
+ requestPermission,
350
+ getPermissionStatus,
351
+ // Relayers
352
+ listRelayers,
353
+ chooseRandomRelayer,
354
+ getVapidPublicKey,
355
+ // Subs
356
+ subscribe,
357
+ unsubscribe,
358
+ unsubscribeAll,
359
+ ensureSubscribed,
360
+ getSubscription,
361
+ isSubscribed,
362
+ setDebug,
363
+ setDebugAlerts,
364
+ };
365
+ export default api;
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@research-ag/ic-web-push",
3
+ "version": "0.1.0",
4
+ "description": "Browser SDK for Internet Computer web push notifications with a companion service worker.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "sw.js",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "clean": "rimraf dist",
25
+ "copy:declarations": "node -e \"const fs=require('fs');const path=require('path');const src=path.join(process.cwd(),'declarations');const dest=path.join(process.cwd(),'dist','declarations');try{if(fs.existsSync(src)){fs.rmSync(dest,{recursive:true,force:true});fs.mkdirSync(dest,{recursive:true});fs.cpSync(src,dest,{recursive:true});console.log('[ic-web-push] Copied',src,'->',dest);}else{console.warn('[ic-web-push] No declarations directory found at',src);}}catch(e){console.error('[ic-web-push] Failed to copy declarations:',e&&e.message||e);process.exitCode=1;}\"",
26
+ "build": "tsc -p tsconfig.json && npm run copy:declarations",
27
+ "prepare": "npm run build"
28
+ },
29
+ "keywords": [
30
+ "internet-computer",
31
+ "dfinity",
32
+ "web-push",
33
+ "service-worker",
34
+ "notifications"
35
+ ],
36
+ "author": "",
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ },
40
+ "devDependencies": {
41
+ "rimraf": "^5.0.5",
42
+ "typescript": "^5.4.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@dfinity/agent": "^3.4.3",
46
+ "@dfinity/principal": "^3.4.3"
47
+ },
48
+ "sideEffects": false
49
+ }
package/sw.js ADDED
@@ -0,0 +1,111 @@
1
+ /*
2
+ IC Web Push Service Worker
3
+
4
+ Scope recommendation: '/ic-web-push/'
5
+ File name recommendation at web root: '/ic-web-push-sw.js'
6
+
7
+ This SW is designed to coexist with your main application SW, by using a dedicated scope.
8
+ It handles 'push' events to display notifications and 'notificationclick' to focus/open the app.
9
+ */
10
+
11
+ self.addEventListener('install', (event) => {
12
+ // Skip waiting so updates take effect quickly
13
+ self.skipWaiting();
14
+ });
15
+
16
+ self.addEventListener('activate', (event) => {
17
+ // Claim the clients in our scope so we can receive messages immediately
18
+ event.waitUntil(self.clients.claim());
19
+ });
20
+
21
+ self.addEventListener('push', (event) => {
22
+ event.waitUntil((async () => {
23
+ try {
24
+ const data = event.data ? event.data.json() : {};
25
+ const title = data.title || 'New notification';
26
+ const urlFromPayload = data?.url || (data?.data?.url) || '/';
27
+ const options = {
28
+ body: data.content || data.body || 'You have a new message',
29
+ icon: '/favicon.ico',
30
+ badge: '/favicon.ico',
31
+ data: {...(data.data || {}), url: urlFromPayload},
32
+ actions: data.actions || [],
33
+ requireInteraction: !!data.requireInteraction,
34
+ };
35
+ let tag = data?.tag || data?.data?.tag;
36
+ if (tag) {
37
+ options.tag = tag;
38
+ }
39
+ return self.registration.showNotification(title, options);
40
+ } catch (e) {
41
+ return self.registration.showNotification('New notification', {
42
+ body: 'You have a new notification',
43
+ icon: '/favicon.ico'
44
+ });
45
+ }
46
+ })());
47
+ });
48
+
49
+ self.addEventListener('message', (event) => {
50
+ try {
51
+ const data = event?.data || {};
52
+ if (data && data.type === 'CLEAR_NOTIFICATIONS_BY_TAG') {
53
+ const tag = data.tag;
54
+ if (!tag) return;
55
+ event.waitUntil((async () => {
56
+ try {
57
+ const list = await self.registration.getNotifications({ includeTriggered: true });
58
+ for (const n of list) {
59
+ if (n?.tag === tag) {
60
+ try { n.close(); } catch (_) {}
61
+ }
62
+ }
63
+ } catch (_) {
64
+ try {
65
+ const list = await self.registration.getNotifications();
66
+ for (const n of list) {
67
+ if (n?.tag === tag) {
68
+ try { n.close(); } catch (_) {}
69
+ }
70
+ }
71
+ } catch (_) {}
72
+ }
73
+ })());
74
+ }
75
+ } catch (_) {}
76
+ });
77
+
78
+ self.addEventListener('notificationclick', (event) => {
79
+ event.notification.close();
80
+ const url = event.notification?.data?.url || '/';
81
+ event.waitUntil(
82
+ (async () => {
83
+ const targetUrl = new URL(url, self.location.origin);
84
+ // Try to find and focus an existing same-origin client
85
+ const allClients = await self.clients.matchAll({type: 'window', includeUncontrolled: true});
86
+ for (const client of allClients) {
87
+ try {
88
+ const clientUrl = new URL(client.url);
89
+ if (clientUrl.origin !== targetUrl.origin) continue;
90
+
91
+ await client.focus();
92
+ // Prefer messaging so the app can set session flags and handle navigation itself
93
+ try {
94
+ client.postMessage({type: 'OPEN_URL', url: targetUrl.href});
95
+ } catch (_) {
96
+ }
97
+
98
+ // Safe fallback: if the app is already in-app (not start_url) and different href, also navigate
99
+ const atRoot = clientUrl.pathname === '/' && clientUrl.search === '' && clientUrl.hash === '';
100
+ if ('navigate' in client && !atRoot && clientUrl.href !== targetUrl.href) {
101
+ return client.navigate(targetUrl.href);
102
+ }
103
+ return;
104
+ } catch (_) {
105
+ }
106
+ }
107
+ // If no client exists, open a new window at the target URL
108
+ return self.clients.openWindow(targetUrl.href);
109
+ })()
110
+ );
111
+ });