@rivium/push-web 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Internal helpers kept free of SDK state so they can be unit-tested.
3
+ * Not part of the public API.
4
+ */
5
+ export interface DeviceInfo {
6
+ /** OS name + version, e.g. "Windows 10.0", "Android 14", "macOS 14.5" */
7
+ osVersion?: string;
8
+ /** Browser name + major version, e.g. "Chrome 128", "Safari 17" */
9
+ deviceModel?: string;
10
+ }
11
+ /**
12
+ * Best-effort, synchronous OS / browser detection. Prefers User-Agent Client
13
+ * Hints (`navigator.userAgentData`) and falls back to minimal UA parsing.
14
+ * Never throws; unknown values are left undefined.
15
+ */
16
+ export declare function detectDeviceInfo(nav?: any): DeviceInfo;
17
+ /** Re-register at least this often, even if nothing changed. */
18
+ export declare const REFRESH_INTERVAL_MS: number;
19
+ /** What the server last saw from this browser. Persisted in localStorage. */
20
+ export interface RegistrationFingerprint {
21
+ /** Time of the last successful registration (ms since epoch) */
22
+ registeredAt: number;
23
+ /** Web Push endpoint, or null when registered MQTT-only */
24
+ endpoint: string | null;
25
+ appVersion: string | null;
26
+ sdkVersion: string;
27
+ userId: string | null;
28
+ }
29
+ export type RefreshReason = 'no_fingerprint' | 'interval' | 'endpoint_changed' | 'app_version_changed' | 'sdk_version_changed' | 'user_changed';
30
+ /**
31
+ * Decide whether a previously registered browser should silently re-register.
32
+ * Returns the reason, or null when the server is already up to date.
33
+ */
34
+ export declare function getRefreshReason(previous: RegistrationFingerprint | null, current: Omit<RegistrationFingerprint, 'registeredAt'>, now: number, intervalMs?: number): RefreshReason | null;
35
+ export declare function parseFingerprint(raw: string | null): RegistrationFingerprint | null;
36
+ /** Insertion-ordered set that forgets its oldest entries past `limit`. */
37
+ export declare class BoundedSet {
38
+ private readonly limit;
39
+ private readonly items;
40
+ constructor(limit: number);
41
+ has(value: string): boolean;
42
+ /** Adds the value. Returns false if it was already present. */
43
+ add(value: string): boolean;
44
+ delete(value: string): void;
45
+ get size(): number;
46
+ }
@@ -0,0 +1,2 @@
1
+ export declare const SDK_NAME = "web";
2
+ export declare const SDK_VERSION = "0.1.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rivium/push-web",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Web Push SDK for browsers - Firebase alternative that works everywhere",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,15 +8,18 @@
8
8
  "types": "dist/index.d.ts",
9
9
  "files": [
10
10
  "dist",
11
+ "!dist/test",
11
12
  "service-worker.js"
12
13
  ],
13
14
  "scripts": {
15
+ "prebuild": "node scripts/gen-version.mjs",
14
16
  "build": "rollup -c",
15
17
  "dev": "rollup -c -w",
16
18
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest --passWithNoTests",
17
19
  "test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch",
18
20
  "test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
19
- "prepublishOnly": "npm run build"
21
+ "prepublishOnly": "npm run build",
22
+ "pretest": "node scripts/gen-version.mjs"
20
23
  },
21
24
  "keywords": [
22
25
  "push-notifications",
package/service-worker.js CHANGED
@@ -6,7 +6,75 @@
6
6
  */
7
7
 
8
8
  // Cache name for offline support
9
- const CACHE_NAME = 'rivium-push-v1';
9
+ const CACHE_NAME = 'rivium-push-v0.1.5';
10
+
11
+ /**
12
+ * Config passed by the SDK on the registration URL.
13
+ *
14
+ * A service worker is terminated between pushes, so anything kept in memory is
15
+ * gone by the time the next one arrives. The script URL is persisted by the
16
+ * browser, so reading config from its query string works on every wake-up
17
+ * without needing IndexedDB.
18
+ */
19
+ const RIVIUM_CONFIG = (() => {
20
+ try {
21
+ const params = new URL(self.location.href).searchParams;
22
+ return {
23
+ apiKey: params.get('riviumApiKey') || null,
24
+ serverUrl: params.get('riviumServerUrl') || 'https://push-api.rivium.co',
25
+ deviceId: params.get('riviumDeviceId') || null,
26
+ // Added in 0.1.5. SDKs older than 0.1.5 don't pass it.
27
+ sdkVersion: params.get('riviumSdkVersion') || null,
28
+ };
29
+ } catch (e) {
30
+ return { apiKey: null, serverUrl: 'https://push-api.rivium.co', deviceId: null, sdkVersion: null };
31
+ }
32
+ })();
33
+
34
+ /**
35
+ * Confirm delivery to Rivium Push.
36
+ *
37
+ * Web Push, APNs and FCM all report only that the push service *accepted* a
38
+ * notification — none of them confirm it reached the device. This ack is the
39
+ * only signal that it actually arrived, so the dashboard can distinguish
40
+ * "accepted" from "delivered".
41
+ *
42
+ * Best-effort: a failure here must never stop the notification being shown.
43
+ */
44
+ function riviumReportDelivered(messageId) {
45
+ if (!messageId || !RIVIUM_CONFIG.apiKey || !RIVIUM_CONFIG.deviceId) {
46
+ console.warn('[RiviumPush SW] Delivery ack skipped — missing messageId or SDK config', {
47
+ messageId: messageId || null,
48
+ hasApiKey: !!RIVIUM_CONFIG.apiKey,
49
+ hasDeviceId: !!RIVIUM_CONFIG.deviceId,
50
+ });
51
+ return Promise.resolve();
52
+ }
53
+
54
+ // No `keepalive`: event.waitUntil already holds the worker open, and
55
+ // keepalive is unreliable for fetches issued from a service worker.
56
+ return fetch(`${RIVIUM_CONFIG.serverUrl}/receipts/delivered`, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ 'x-api-key': RIVIUM_CONFIG.apiKey,
61
+ },
62
+ body: JSON.stringify({ messageId, deviceId: RIVIUM_CONFIG.deviceId }),
63
+ })
64
+ .then((res) => {
65
+ // A non-2xx is a failure — resolving on it would hide the problem the
66
+ // same way the server used to hide an ack for a receipt that did not
67
+ // exist yet.
68
+ if (!res.ok) {
69
+ console.warn('[RiviumPush SW] Delivery ack rejected', res.status);
70
+ return;
71
+ }
72
+ console.log('[RiviumPush SW] Delivery confirmed', messageId);
73
+ })
74
+ .catch((err) => {
75
+ console.warn('[RiviumPush SW] Delivery ack failed:', err && err.message);
76
+ });
77
+ }
10
78
 
11
79
  // Install event
12
80
  self.addEventListener('install', (event) => {
@@ -50,10 +118,33 @@ self.addEventListener('push', (event) => {
50
118
 
51
119
  const options = {
52
120
  body: localizedBody || data.body,
53
- icon: data.iconUrl || data.icon,
54
- badge: data.iconUrl || data.icon,
121
+ // Icons must resolve to a real file. Android Chrome silently drops a
122
+ // notification when the icon 404s (desktop is more forgiving), so keep
123
+ // /icon.png and /badge.png present in your public directory, or always
124
+ // send iconUrl from the backend.
125
+ icon: data.iconUrl || data.icon || '/icon.png',
126
+ // `badge` is the small monochrome icon Android shows in the status bar.
127
+ badge: data.badgeIcon || data.iconUrl || data.icon || '/badge.png',
55
128
  image: data.imageUrl || data.image,
56
- tag: data.tag || data.collapseKey || data.threadId || 'rivium-push-notification',
129
+ // Per-message uniqueness by default. A fixed tag collapses every push
130
+ // into one slot in the Android shade — new notifications silently replace
131
+ // older ones and the user never sees the second, third, … arrive. The
132
+ // backend still opts into grouping by sending an explicit tag or
133
+ // collapseKey (e.g. to fold "N liked your post" into one).
134
+ tag:
135
+ data.tag ||
136
+ data.collapseKey ||
137
+ data.threadId ||
138
+ data.messageId ||
139
+ `rivium-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
140
+ // renotify forces Android Chrome to actually surface the notification
141
+ // while Chrome is backgrounded but not closed. Without it Chrome treats
142
+ // the origin as "about to be focused" and swallows showNotification — the
143
+ // worker logs success and the shade stays empty. Harmless elsewhere.
144
+ renotify: true,
145
+ // A second signal to Android that this is a fresh user-visible event.
146
+ // Ignored on desktop.
147
+ vibrate: [200, 100, 200],
57
148
  data: {
58
149
  ...data.data,
59
150
  deepLink: data.deepLink,
@@ -78,8 +169,108 @@ self.addEventListener('push', (event) => {
78
169
  // Show notification with potentially localized title
79
170
  const title = localizedTitle || data.title;
80
171
 
172
+ event.waitUntil((async () => {
173
+ // Mirror the payload to any visible page. On mobile the app is usually
174
+ // backgrounded or the screen is locked, so the page-side connection is
175
+ // asleep and only the worker sees the push. Pages should dedupe by
176
+ // messageId, since a foreground client may receive it both ways.
177
+ try {
178
+ const clientsList = await self.clients.matchAll({
179
+ type: 'window',
180
+ includeUncontrolled: true,
181
+ });
182
+ for (const client of clientsList) {
183
+ client.postMessage({
184
+ type: 'rivium-push-message',
185
+ message: {
186
+ title,
187
+ body: localizedBody || data.body,
188
+ data: data.data || {},
189
+ deepLink: data.deepLink,
190
+ messageId: data.messageId,
191
+ campaignId: data.campaignId,
192
+ },
193
+ });
194
+ }
195
+ } catch (err) {
196
+ console.warn('[RiviumPush SW] client postMessage failed:', err);
197
+ }
198
+
199
+ // Always show the OS notification: Chrome's user-visible contract revokes
200
+ // subscriptions that push silently. Wrapped so an Android quirk that would
201
+ // otherwise fail silently surfaces here; the retry drops the optional
202
+ // fields (icon/badge/image/actions) that are the usual cause.
203
+ try {
204
+ await self.registration.showNotification(title, options);
205
+ } catch (err) {
206
+ console.warn('[RiviumPush SW] showNotification failed, retrying minimal', err);
207
+ try {
208
+ await self.registration.showNotification(title, {
209
+ body: options.body,
210
+ tag: options.tag,
211
+ data: options.data,
212
+ });
213
+ } catch (err2) {
214
+ console.error('[RiviumPush SW] showNotification (minimal) failed too', err2);
215
+ }
216
+ }
217
+
218
+ // Confirm arrival so the dashboard can show `delivered`, not only `sent`.
219
+ await riviumReportDelivered(data.messageId);
220
+ })());
221
+ });
222
+
223
+ /**
224
+ * The browser can rotate a push subscription at any time — after a long idle
225
+ * period, a browser update, or when it decides the old endpoint is stale.
226
+ * Without this handler the old endpoint stays registered server-side, keeps
227
+ * returning 410 Gone, and the user silently stops receiving notifications.
228
+ *
229
+ * Re-subscribe with the same VAPID key and register the new endpoint.
230
+ */
231
+ self.addEventListener('pushsubscriptionchange', (event) => {
232
+ console.log('[RiviumPush SW] Push subscription changed, re-subscribing');
233
+
234
+ const applicationServerKey =
235
+ (event.oldSubscription &&
236
+ event.oldSubscription.options &&
237
+ event.oldSubscription.options.applicationServerKey) ||
238
+ null;
239
+
240
+ if (!applicationServerKey || !RIVIUM_CONFIG.apiKey || !RIVIUM_CONFIG.deviceId) {
241
+ console.warn(
242
+ '[RiviumPush SW] Cannot re-subscribe: missing VAPID key or SDK config',
243
+ );
244
+ return;
245
+ }
246
+
81
247
  event.waitUntil(
82
- self.registration.showNotification(title, options)
248
+ self.registration.pushManager
249
+ .subscribe({ userVisibleOnly: true, applicationServerKey })
250
+ .then((subscription) =>
251
+ fetch(`${RIVIUM_CONFIG.serverUrl}/devices/register`, {
252
+ method: 'POST',
253
+ headers: {
254
+ 'Content-Type': 'application/json',
255
+ 'x-api-key': RIVIUM_CONFIG.apiKey,
256
+ },
257
+ body: JSON.stringify({
258
+ deviceId: RIVIUM_CONFIG.deviceId,
259
+ platform: 'web',
260
+ appIdentifier: self.location.origin,
261
+ webPushSubscription: subscription.toJSON(),
262
+ // 0.1.5: SDK identity (body, not header, to avoid a CORS preflight).
263
+ sdkName: 'web',
264
+ ...(RIVIUM_CONFIG.sdkVersion ? { sdkVersion: RIVIUM_CONFIG.sdkVersion } : {}),
265
+ }),
266
+ }),
267
+ )
268
+ .then(() => {
269
+ console.log('[RiviumPush SW] Re-subscribed and re-registered');
270
+ })
271
+ .catch((err) => {
272
+ console.error('[RiviumPush SW] Re-subscribe failed:', err && err.message);
273
+ }),
83
274
  );
84
275
  });
85
276
 
@@ -1,54 +0,0 @@
1
- /**
2
- * Mock for @rivium/pn-protocol
3
- */
4
- export declare enum PNState {
5
- CONNECTING = "connecting",
6
- CONNECTED = "connected",
7
- DISCONNECTED = "disconnected",
8
- ERROR = "error"
9
- }
10
- export declare enum PNDeliveryMode {
11
- AT_MOST_ONCE = 0,
12
- AT_LEAST_ONCE = 1,
13
- EXACTLY_ONCE = 2
14
- }
15
- export declare class PNMessage {
16
- topic: string;
17
- payload: any;
18
- }
19
- export declare class PNError extends Error {
20
- constructor(message?: string);
21
- }
22
- export interface PNConnectionListener {
23
- onStateChange?: (state: PNState) => void;
24
- onMessage?: (message: PNMessage) => void;
25
- onError?: (error: PNError) => void;
26
- }
27
- export declare class PNAuthFactory {
28
- static token(t: string): {
29
- type: string;
30
- value: string;
31
- };
32
- }
33
- export declare class PNConfigBuilder {
34
- private config;
35
- gateway(g: string): this;
36
- port(p: number): this;
37
- secure(s: boolean): this;
38
- clientId(c: string): this;
39
- auth(a: any): this;
40
- keepAlive(k: number): this;
41
- autoReconnect(a: boolean): this;
42
- maxReconnectDelay(m: number): this;
43
- build(): any;
44
- }
45
- export declare class PNSocket {
46
- private listener;
47
- constructor(_config: any);
48
- setListener(listener: PNConnectionListener): void;
49
- connect(): void;
50
- disconnect(): void;
51
- subscribe(_topic: string, _qos: PNDeliveryMode): void;
52
- unsubscribe(_topic: string, _qos: PNDeliveryMode): void;
53
- publish(_topic: string, _payload: any, _qos: PNDeliveryMode): void;
54
- }
@@ -1,4 +0,0 @@
1
- /**
2
- * RiviumPush Web SDK Unit Tests
3
- */
4
- export {};
@@ -1,5 +0,0 @@
1
- /**
2
- * Jest test setup file
3
- * Sets up browser API mocks for testing
4
- */
5
- export {};