@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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/arsel.js +600 -0
- package/dist/arsel.js.map +1 -0
- package/dist/arsel.umd.cjs +2 -0
- package/dist/arsel.umd.cjs.map +1 -0
- package/dist/events.d.ts +23 -0
- package/dist/index.d.ts +81 -0
- package/dist/push.d.ts +39 -0
- package/dist/session.d.ts +21 -0
- package/dist/store.d.ts +52 -0
- package/dist/transport.d.ts +22 -0
- package/dist/types.d.ts +68 -0
- package/dist/version.d.ts +5 -0
- package/package.json +60 -0
- package/sw/arsel-sw.js +278 -0
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
});
|