@arsel.sa/web-sdk 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.
@@ -0,0 +1,68 @@
1
+ /** Public configuration for {@link init}. */
2
+ export interface ArselConfig {
3
+ /**
4
+ * The org's publishable `pub_…` key. Page-readable by design: it authenticates
5
+ * the events API and the push API, and grants nothing a secret API key does.
6
+ */
7
+ clientKey: string;
8
+ /**
9
+ * Arsel API base, e.g. `https://api.arsel.sa`. HTTPS enforced, except
10
+ * `http://localhost` / `http://127.0.0.1` for a local backend.
11
+ */
12
+ baseUrl: string;
13
+ /**
14
+ * Path to the service worker stub, e.g. `/arsel-sw.js`. **Required for
15
+ * push** — without it (and without `serviceWorker: 'external'`) the SDK
16
+ * registers nothing and only the events API runs. Scope is a browser rule:
17
+ * a worker at `/js/sw.js` can only control `/js/`, so serve it from the
18
+ * root of the area you want push on.
19
+ */
20
+ serviceWorkerPath?: string;
21
+ /**
22
+ * `'external'` when your own service worker `importScripts` arsel-sw.js
23
+ * (e.g. a PWA that already has a worker on this scope). The SDK then
24
+ * registers nothing and uses the worker controlling the page.
25
+ */
26
+ serviceWorker?: 'external';
27
+ /** Emit SDK diagnostics to the console. Off by default. */
28
+ debug?: boolean;
29
+ }
30
+ /** Identifiers accepted by {@link identify}. Supply whichever you hold. */
31
+ export interface ArselIdentity {
32
+ /**
33
+ * Your own id for this person. The documented default: it binds a contact
34
+ * without putting an email address in page script, and it survives the user
35
+ * changing their address.
36
+ */
37
+ externalId?: string;
38
+ email?: string;
39
+ phoneNumber?: string;
40
+ }
41
+ export type EventProperties = Record<string, unknown>;
42
+ /** What the org's web push API is configured with. Served unauthenticated. */
43
+ export interface WebPushConfig {
44
+ vapidPublicKey: string;
45
+ /**
46
+ * Bumped when the org rotates its VAPID keypair. A subscription created
47
+ * against an older version is dead — the SDK re-subscribes rather than
48
+ * leaving the user quietly unreachable.
49
+ */
50
+ keyVersion: number;
51
+ }
52
+ /** A point-in-time snapshot for support tickets. Contains no secrets. */
53
+ export interface ArselDiagnostics {
54
+ sdkVersion: string;
55
+ initialized: boolean;
56
+ anonymousId: string | null;
57
+ hasAssertedIdentity: boolean;
58
+ installationId: string | null;
59
+ hasDeviceSecret: boolean;
60
+ /** Events persisted but not yet delivered. A number that only grows is the tell. */
61
+ pendingEvents: number;
62
+ permission: NotificationPermission | 'unsupported';
63
+ isSubscribed: boolean;
64
+ vapidKeyVersion: number | null;
65
+ lastResponseCode: number | null;
66
+ lastResponsePath: string | null;
67
+ lastResponseAtMs: number | null;
68
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * In its own module so transport can stamp `X-Arsel-SDK` without importing the
3
+ * public surface (index → events → transport would be a cycle).
4
+ */
5
+ export declare const SDK_VERSION = "1.0.0";
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@arsel.sa/web-sdk",
3
+ "version": "1.0.0",
4
+ "description": "Arsel web SDK — events, identity and web push.",
5
+ "license": "MIT",
6
+ "author": "Arsel (https://arsel.sa)",
7
+ "homepage": "https://github.com/BasicsEngage/arsel-web-sdk#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/BasicsEngage/arsel-web-sdk.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/BasicsEngage/arsel-web-sdk/issues"
14
+ },
15
+ "keywords": [
16
+ "arsel",
17
+ "analytics",
18
+ "events",
19
+ "web-push",
20
+ "push-notifications",
21
+ "service-worker",
22
+ "sdk"
23
+ ],
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "main": "./dist/arsel.umd.cjs",
30
+ "module": "./dist/arsel.js",
31
+ "types": "./dist/index.d.ts",
32
+ "unpkg": "./dist/arsel.umd.cjs",
33
+ "jsdelivr": "./dist/arsel.umd.cjs",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/arsel.js",
38
+ "require": "./dist/arsel.umd.cjs"
39
+ },
40
+ "./sw": "./sw/arsel-sw.js"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "sw",
45
+ "CHANGELOG.md"
46
+ ],
47
+ "scripts": {
48
+ "build": "vite build && tsc",
49
+ "typecheck": "tsc --noEmit",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^22.10.0",
55
+ "fake-indexeddb": "^6.0.0",
56
+ "typescript": "^5.7.2",
57
+ "vite": "^6.0.0",
58
+ "vitest": "^2.1.8"
59
+ }
60
+ }
package/sw/arsel-sw.js ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Arsel service worker.
3
+ *
4
+ * Self-hosted: copy this file (exported as `@arsel.sa/web-sdk/sw`, i.e.
5
+ * `node_modules/@arsel.sa/web-sdk/sw/arsel-sw.js`) to your web root as
6
+ * `/arsel-sw.js`, or `importScripts()` it from your own service worker.
7
+ *
8
+ * It has to be served from the root because service worker scope is a browser
9
+ * rule: a worker served from /js/ can only control /js/.
10
+ *
11
+ * This file runs with no page open, which is the whole reason it reads state
12
+ * from IndexedDB rather than being handed it: a `delivered` engagement has to fire
13
+ * for a push that arrives while the browser has no tab on your site at all.
14
+ */
15
+
16
+ // Duplicated from src/ on purpose — this file is served as-is, uncompiled, and
17
+ // cannot import from the SDK build. Change a constant there, change it here.
18
+ const SDK_VERSION = '1.0.0';
19
+ const DB_NAME = 'arsel';
20
+ const KV_STORE = 'kv';
21
+
22
+ /** `showNotification` silently drops anything past this in Chrome. */
23
+ const MAX_ACTIONS = 2;
24
+
25
+ /** Never let a slow engagement delay the notification longer than this. */
26
+ const ENGAGEMENT_DISPLAY_BUDGET_MS = 1500;
27
+
28
+ const WIRE = {
29
+ VERSION: 'arsel_v',
30
+ MESSAGE_ID: 'arsel_mid',
31
+ SIGNATURE: 'arsel_sig',
32
+ SIGNATURE_KEY_ID: 'arsel_kid',
33
+ TITLE: 'arsel_title',
34
+ BODY: 'arsel_body',
35
+ IMAGE: 'arsel_image',
36
+ DEEP_LINK: 'arsel_deep_link',
37
+ ACTIONS: 'arsel_actions',
38
+ };
39
+
40
+ // Read-only here. The page's store.ts owns the schema and creates the stores.
41
+ function openDb() {
42
+ return new Promise((resolve, reject) => {
43
+ const request = indexedDB.open(DB_NAME);
44
+ request.onsuccess = () => resolve(request.result);
45
+ request.onerror = () => reject(request.error);
46
+ });
47
+ }
48
+
49
+ async function readKeys(keys) {
50
+ const db = await openDb();
51
+ if (!db.objectStoreNames.contains(KV_STORE)) return {};
52
+ const store = db.transaction(KV_STORE, 'readonly').objectStore(KV_STORE);
53
+ const entries = await Promise.all(
54
+ keys.map(
55
+ (key) =>
56
+ new Promise((resolve) => {
57
+ const request = store.get(key);
58
+ request.onsuccess = () => resolve([key, request.result ?? null]);
59
+ request.onerror = () => resolve([key, null]);
60
+ }),
61
+ ),
62
+ );
63
+ return Object.fromEntries(entries);
64
+ }
65
+
66
+ /**
67
+ * Engagements authenticate with the device secret, exactly as the page does. A
68
+ * missing secret means registration never landed — there is nothing to send
69
+ * with, and retrying would only produce 404s.
70
+ */
71
+ async function reportEngagements(records) {
72
+ const state = await readKeys([
73
+ 'base_url',
74
+ 'client_key',
75
+ 'installation_id',
76
+ 'device_secret',
77
+ ]);
78
+ if (!state.base_url || !state.client_key || !state.installation_id) return;
79
+ if (!state.device_secret) return;
80
+
81
+ try {
82
+ await fetch(
83
+ `${state.base_url}/api/v1/orgs/${state.client_key}/push/engagements`,
84
+ {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ 'X-Arsel-Device-Auth': state.device_secret,
89
+ 'X-Arsel-SDK': `web/${SDK_VERSION}`,
90
+ },
91
+ credentials: 'omit',
92
+ body: JSON.stringify({
93
+ installationId: state.installation_id,
94
+ events: records,
95
+ }),
96
+ },
97
+ );
98
+ } catch {
99
+ // An engagement is a report, not the delivery itself. Failing it must never stop
100
+ // the notification from being shown.
101
+ }
102
+ }
103
+
104
+ function record(data, eventType, extra = {}) {
105
+ return {
106
+ messageId: data[WIRE.MESSAGE_ID],
107
+ eventType,
108
+ timestamp: new Date().toISOString(),
109
+ // Carried through so push conversion attribution can verify the send.
110
+ // Without them every engagement lands as `signatureStatus: absent` and revenue
111
+ // attribution silently never fires, while engagement counts keep working.
112
+ signature: data[WIRE.SIGNATURE],
113
+ signatureKeyId: data[WIRE.SIGNATURE_KEY_ID],
114
+ ...extra,
115
+ };
116
+ }
117
+
118
+ function parseActions(raw) {
119
+ if (!raw) return [];
120
+ try {
121
+ const parsed = JSON.parse(raw);
122
+ return Array.isArray(parsed) ? parsed : [];
123
+ } catch {
124
+ return [];
125
+ }
126
+ }
127
+
128
+ self.addEventListener('push', (event) => {
129
+ let data = {};
130
+ try {
131
+ data = event.data ? event.data.json() : {};
132
+ } catch {
133
+ return; // not ours, and not parseable
134
+ }
135
+ // Claimed on arsel_v, with arsel_mid as the fallback — the same test the
136
+ // Android parser applies. There is no marker key on the wire.
137
+ if (!data[WIRE.VERSION] && !data[WIRE.MESSAGE_ID]) return;
138
+
139
+ event.waitUntil(
140
+ (async () => {
141
+ // Raced against a short budget: the delivered engagement still goes out, but
142
+ // a slow backend must not hold the notification off the screen.
143
+ const delivered = reportEngagements([record(data, 'delivered')]);
144
+ await Promise.race([
145
+ delivered,
146
+ new Promise((resolve) => setTimeout(resolve, ENGAGEMENT_DISPLAY_BUDGET_MS)),
147
+ ]);
148
+
149
+ const actions = parseActions(data[WIRE.ACTIONS]).map((a) => ({
150
+ action: a.actionId,
151
+ title: a.label,
152
+ }));
153
+
154
+ try {
155
+ await self.registration.showNotification(data[WIRE.TITLE] || '', {
156
+ body: data[WIRE.BODY] || '',
157
+ image: data[WIRE.IMAGE] || undefined,
158
+ data,
159
+ actions: actions.slice(0, MAX_ACTIONS),
160
+ });
161
+ await reportEngagements([record(data, 'displayed')]);
162
+ } catch {
163
+ // Chrome rejects showNotification() when permission was revoked between
164
+ // subscribe and delivery — reported, not thrown, so it shows up in the
165
+ // org's numbers rather than vanishing.
166
+ await reportEngagements([
167
+ record(data, 'suppressed', { suppressionReason: 'permission_denied' }),
168
+ ]);
169
+ }
170
+ await delivered;
171
+ })(),
172
+ );
173
+ });
174
+
175
+ /**
176
+ * Message ids whose notification was closed *by a tap*. Chrome fires
177
+ * `notificationclose` for the programmatic close() in the click handler too;
178
+ * without this every open/click would also report a `dismissed`.
179
+ * Module scope is safe: the close event fires in the same worker instance,
180
+ * queued right behind the click that caused it.
181
+ */
182
+ const tappedMessageIds = new Set();
183
+
184
+ self.addEventListener('notificationclick', (event) => {
185
+ const data = event.notification.data || {};
186
+ const actionId = event.action || null;
187
+ if (data[WIRE.MESSAGE_ID]) tappedMessageIds.add(data[WIRE.MESSAGE_ID]);
188
+ event.notification.close();
189
+
190
+ // Exactly one event. OPENED for the body, CLICKED for an action button — they
191
+ // are separate metrics, and firing both on every tap makes the two counters
192
+ // identical by construction.
193
+ const chosen = parseActions(data[WIRE.ACTIONS]).find(
194
+ (a) => a.actionId === actionId,
195
+ );
196
+ const link = chosen?.deepLink || data[WIRE.DEEP_LINK] || '/';
197
+
198
+ event.waitUntil(
199
+ (async () => {
200
+ await reportEngagements([
201
+ record(data, actionId ? 'clicked' : 'opened', {
202
+ actionId: actionId || undefined,
203
+ deepLink: link,
204
+ }),
205
+ ]);
206
+
207
+ // Focus an existing tab rather than opening a duplicate.
208
+ const clients = await self.clients.matchAll({
209
+ type: 'window',
210
+ includeUncontrolled: true,
211
+ });
212
+ const existing = clients.find((c) => 'focus' in c);
213
+ if (existing) {
214
+ await existing.focus();
215
+ if ('navigate' in existing) await existing.navigate(link).catch(() => {});
216
+ return;
217
+ }
218
+ await self.clients.openWindow(link);
219
+ })(),
220
+ );
221
+ });
222
+
223
+ self.addEventListener('notificationclose', (event) => {
224
+ const data = event.notification.data || {};
225
+ const messageId = data[WIRE.MESSAGE_ID];
226
+ if (!messageId) return;
227
+ // The close that follows a tap is not a dismissal — that tap already sent its
228
+ // one engagement (opened or clicked).
229
+ if (tappedMessageIds.delete(messageId)) return;
230
+ event.waitUntil(reportEngagements([record(data, 'dismissed')]));
231
+ });
232
+
233
+ /**
234
+ * The browser rotates an endpoint on its own schedule. Without this the old
235
+ * endpoint keeps 410-ing and the user goes quietly unreachable — the same
236
+ * failure mode as a VAPID rotation, from the other direction.
237
+ */
238
+ self.addEventListener('pushsubscriptionchange', (event) => {
239
+ event.waitUntil(
240
+ (async () => {
241
+ const state = await readKeys([
242
+ 'base_url',
243
+ 'client_key',
244
+ 'installation_id',
245
+ 'anonymous_id',
246
+ ]);
247
+ if (!state.base_url || !state.client_key || !state.installation_id) return;
248
+
249
+ const fresh = event.newSubscription;
250
+ if (!fresh) return; // nothing to report; the page re-subscribes on next init
251
+
252
+ const json = fresh.toJSON();
253
+ try {
254
+ await fetch(
255
+ `${state.base_url}/api/v1/orgs/${state.client_key}/push/subscriptions`,
256
+ {
257
+ method: 'POST',
258
+ headers: {
259
+ 'Content-Type': 'application/json',
260
+ 'X-Arsel-SDK': `web/${SDK_VERSION}`,
261
+ },
262
+ credentials: 'omit',
263
+ body: JSON.stringify({
264
+ installationId: state.installation_id,
265
+ platform: 'web',
266
+ endpoint: json.endpoint,
267
+ keys: json.keys,
268
+ anonymousId: state.anonymous_id || undefined,
269
+ enablementStatus: 'AUTHORIZED',
270
+ }),
271
+ },
272
+ );
273
+ } catch {
274
+ /* the page's reconcile() picks it up on the next visit */
275
+ }
276
+ })(),
277
+ );
278
+ });