@arsel.sa/web-sdk 1.0.0 → 1.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/dist/index.d.ts CHANGED
@@ -1,14 +1,6 @@
1
1
  import type { ArselConfig, ArselDiagnostics, ArselIdentity, EventProperties } from './types';
2
- export type { ArselConfig, ArselDiagnostics, ArselIdentity, EventProperties, } from './types';
2
+ export type { ArselConfig, ArselDiagnostics, ArselIdentity, EventProperties, InAppOptions, } from './types';
3
3
  export { SDK_VERSION } from './version';
4
- /**
5
- * Start the SDK. Idempotent — a second call returns the first one's promise, so
6
- * a framework that mounts twice does not mint two identities.
7
- *
8
- * Deliberately does **not** request notification permission. A prompt on page
9
- * load is what gets an origin permanently blocked by Chrome's abusive-
10
- * notification heuristics; call {@link promptForPush} from a user gesture.
11
- */
12
4
  export declare function init(config: ArselConfig): Promise<void>;
13
5
  /**
14
6
  * Record something the user did.
@@ -21,6 +13,23 @@ export declare function init(config: ArselConfig): Promise<void>;
21
13
  * Names beginning `arsel.` are reserved for the SDK and ignored.
22
14
  */
23
15
  export declare function track(name: string, properties?: EventProperties): Promise<void>;
16
+ /**
17
+ * Record a screen or page view.
18
+ *
19
+ * One POST, two consumers: the event reaches segments and automations exactly
20
+ * as `track()` would, and it is the trigger source for SCREEN_VIEW in-app
21
+ * messages. Deliberately does NOT call `track()` — that would enqueue a second
22
+ * event and observe the trigger twice.
23
+ */
24
+ export declare function screen(name: string, properties?: EventProperties): Promise<void>;
25
+ /**
26
+ * Hold in-app messages back, or let them through again.
27
+ *
28
+ * For the moments a host app knows are wrong — a checkout step, a video
29
+ * playing full-screen. Not persisted: it describes the current page, not the
30
+ * device.
31
+ */
32
+ export declare function suppressInAppMessages(suppressed: boolean): void;
24
33
  /**
25
34
  * The person using this browser, as an id you already have.
26
35
  *
@@ -69,6 +78,8 @@ export declare function flushNow(): Promise<void>;
69
78
  export declare const Arsel: {
70
79
  init: typeof init;
71
80
  track: typeof track;
81
+ screen: typeof screen;
82
+ suppressInAppMessages: typeof suppressInAppMessages;
72
83
  identify: typeof identify;
73
84
  reset: typeof reset;
74
85
  optOut: typeof optOut;
package/dist/push.d.ts CHANGED
@@ -37,3 +37,19 @@ export declare function optOut(): Promise<boolean>;
37
37
  */
38
38
  export declare function reconcile(): Promise<void>;
39
39
  export declare function isSubscribed(): Promise<boolean>;
40
+ /**
41
+ * Register this browser WITHOUT a push subscription.
42
+ *
43
+ * In-app messaging needs an `installationId` and a `deviceSecret` to
44
+ * authenticate its bundle fetch, and needs no notification permission at all.
45
+ * Gating that behind `promptForPush()` would restrict the whole channel to the
46
+ * minority who accept notifications — throwing away most of its reach.
47
+ *
48
+ * Deliberately does NOT prompt, subscribe, or touch the service worker. The
49
+ * refusal to prompt on load is what keeps an origin out of Chrome's abusive-
50
+ * notification heuristics, and this path must not become a back door to it.
51
+ *
52
+ * Single-flighted: `promptForPush()` racing `init()` must not post twice, which
53
+ * would mint a second `deviceSecret` and strand the first.
54
+ */
55
+ export declare function registerDevice(): Promise<boolean>;
package/dist/session.d.ts CHANGED
@@ -18,4 +18,8 @@ export declare const SESSION_GAP_MS: number;
18
18
  */
19
19
  export declare function onVisible(now?: number): Promise<void>;
20
20
  export declare function onHidden(now?: number): Promise<void>;
21
- export declare function attach(): void;
21
+ /**
22
+ * @param onOpen invoked AFTER `onVisible()` resolves, so the session-start write
23
+ * has already landed and anything keyed on the session window reads the new one.
24
+ */
25
+ export declare function attach(onOpen?: () => void): void;
package/dist/store.d.ts CHANGED
@@ -7,6 +7,19 @@
7
7
  * unreachable from a worker. Splitting state across both stores would put the
8
8
  * two halves of one fact in two places.
9
9
  */
10
+ /**
11
+ * The two durable queues.
12
+ *
13
+ * In-app beacons live in their OWN store, not alongside events. The events
14
+ * drain stops at the first retryable failure to preserve a user's history
15
+ * order — so a single stuck beacon sharing that queue would wedge the entire
16
+ * analytics pipeline behind it.
17
+ */
18
+ export declare const QUEUE: {
19
+ readonly events: "events";
20
+ readonly inAppBeacons: "inapp_beacons";
21
+ };
22
+ export type QueueStore = (typeof QUEUE)[keyof typeof QUEUE];
10
23
  export interface PendingEvent {
11
24
  /** Monotonic within a tab; the auto-increment key doubles as delivery order. */
12
25
  id?: number;
@@ -21,10 +34,12 @@ export interface PendingEvent {
21
34
  export declare function get<T>(key: string): Promise<T | null>;
22
35
  export declare function set(key: string, value: unknown): Promise<unknown>;
23
36
  export declare function remove(key: string): Promise<unknown>;
24
- export declare function addEvent(body: string, idempotencyKey: string): Promise<unknown>;
25
- export declare function allEvents(): Promise<PendingEvent[]>;
26
- export declare function removeEvent(id: number): Promise<unknown>;
27
- export declare function countEvents(): Promise<number>;
37
+ export declare function addEvent(store: QueueStore, body: string, idempotencyKey?: string): Promise<unknown>;
38
+ export declare function allEvents(store: QueueStore): Promise<PendingEvent[]>;
39
+ export declare function removeEvent(store: QueueStore, id: number): Promise<unknown>;
40
+ export declare function countEvents(store: QueueStore): Promise<number>;
41
+ /** Oldest-first trim, so an offline spell cannot grow the queue without bound. */
42
+ export declare function trimQueue(store: QueueStore, max: number): Promise<void>;
28
43
  export declare const KEYS: {
29
44
  readonly clientKey: "client_key";
30
45
  readonly baseUrl: "base_url";
@@ -36,11 +51,22 @@ export declare const KEYS: {
36
51
  readonly deviceSecret: "device_secret";
37
52
  readonly vapidKeyVersion: "vapid_key_version";
38
53
  readonly endpoint: "endpoint";
54
+ readonly subscriptionStatus: "subscription_status";
39
55
  readonly sessionStartedAt: "session_started_at";
40
56
  readonly backgroundedAt: "backgrounded_at";
41
57
  readonly lastResponseCode: "last_response_code";
42
58
  readonly lastResponsePath: "last_response_path";
43
59
  readonly lastResponseAtMs: "last_response_at";
60
+ /** `{ bundleVersion, ttlSeconds, fetchedAtMs, messages }` — the cached bundle. */
61
+ readonly inAppBundle: "inapp_bundle";
62
+ /** messageId -> lifetime counters. Survives `reset()`: it describes the device. */
63
+ readonly inAppState: "inapp_state";
64
+ /**
65
+ * `{ startedAt, counts }`. Persisted rather than held in memory so
66
+ * `maxPerSession` survives a navigation — on a multi-page site an in-memory
67
+ * counter makes the cap per-page, which is not a cap at all.
68
+ */
69
+ readonly inAppSession: "inapp_session";
44
70
  };
45
71
  /**
46
72
  * Person-shaped identity, minted on first use. Distinct from the installation
@@ -19,4 +19,4 @@ export declare const CODE_NO_RESPONSE = -1;
19
19
  */
20
20
  export declare function classify(code: number, authenticated: boolean): Result;
21
21
  export declare function post<T = unknown>(baseUrl: string, path: string, body: unknown, headers?: Record<string, string>, authenticated?: boolean): Promise<Response<T>>;
22
- export declare function getJson<T>(baseUrl: string, path: string): Promise<T | null>;
22
+ export declare function getJson<T>(baseUrl: string, path: string, headers?: Record<string, string>, authenticated?: boolean, init?: RequestInit): Promise<Response<T>>;
package/dist/types.d.ts CHANGED
@@ -26,6 +26,21 @@ export interface ArselConfig {
26
26
  serviceWorker?: 'external';
27
27
  /** Emit SDK diagnostics to the console. Off by default. */
28
28
  debug?: boolean;
29
+ /**
30
+ * In-app messaging. `true` (the default) registers this browser for in-app
31
+ * messages — which needs no notification permission and shows no prompt —
32
+ * and renders them. Pass `false` to disable, or an object to tune the layer.
33
+ */
34
+ inApp?: boolean | InAppOptions;
35
+ }
36
+ export interface InAppOptions {
37
+ /**
38
+ * Stacking context for the message host. Default 2147483000 — deliberately
39
+ * below the maximum, so a host site's own top-most modal still wins.
40
+ */
41
+ zIndex?: number;
42
+ /** Accessible label for the close control. Default `'Close'`. */
43
+ closeLabel?: string;
29
44
  }
30
45
  /** Identifiers accepted by {@link identify}. Supply whichever you hold. */
31
46
  export interface ArselIdentity {
@@ -53,6 +68,12 @@ export interface WebPushConfig {
53
68
  export interface ArselDiagnostics {
54
69
  sdkVersion: string;
55
70
  initialized: boolean;
71
+ /**
72
+ * Why the SDK refused to start, or null. Set when init() was given an invalid
73
+ * configuration: nothing is collected and no call has any effect until it is
74
+ * fixed. Same field, same rules, on all three Arsel SDKs.
75
+ */
76
+ configError: string | null;
56
77
  anonymousId: string | null;
57
78
  hasAssertedIdentity: boolean;
58
79
  installationId: string | null;
@@ -61,8 +82,19 @@ export interface ArselDiagnostics {
61
82
  pendingEvents: number;
62
83
  permission: NotificationPermission | 'unsupported';
63
84
  isSubscribed: boolean;
85
+ /**
86
+ * The backend's last reported `status` for this device, e.g. `REVOKED` after a
87
+ * durable opt-out. Named as on the Android SDK. Null before the first register.
88
+ */
89
+ subscriptionStatus: string | null;
64
90
  vapidKeyVersion: number | null;
65
91
  lastResponseCode: number | null;
66
92
  lastResponsePath: string | null;
67
93
  lastResponseAtMs: number | null;
94
+ /** Messages currently cached for this device. */
95
+ inAppMessages: number;
96
+ inAppBundleVersion: string | null;
97
+ inAppFetchedAtMs: number | null;
98
+ /** Beacons persisted but not yet delivered. A number that only grows is the tell. */
99
+ pendingInAppBeacons: number;
68
100
  }
package/dist/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * In its own module so transport can stamp `X-Arsel-SDK` without importing the
3
3
  * public surface (index → events → transport would be a cycle).
4
4
  */
5
- export declare const SDK_VERSION = "1.0.0";
5
+ export declare const SDK_VERSION = "1.1.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arsel.sa/web-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Arsel web SDK — events, identity and web push.",
5
5
  "license": "MIT",
6
6
  "author": "Arsel (https://arsel.sa)",
@@ -53,6 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.10.0",
55
55
  "fake-indexeddb": "^6.0.0",
56
+ "happy-dom": "^15.11.0",
56
57
  "typescript": "^5.7.2",
57
58
  "vite": "^6.0.0",
58
59
  "vitest": "^2.1.8"
package/sw/arsel-sw.js CHANGED
@@ -15,9 +15,11 @@
15
15
 
16
16
  // Duplicated from src/ on purpose — this file is served as-is, uncompiled, and
17
17
  // cannot import from the SDK build. Change a constant there, change it here.
18
- const SDK_VERSION = '1.0.0';
18
+ const SDK_VERSION = '1.1.0';
19
19
  const DB_NAME = 'arsel';
20
20
  const KV_STORE = 'kv';
21
+ /** Reserved data key: refresh the in-app bundle, render nothing. */
22
+ const IAM_SYNC_KEY = 'arsel_iam_sync';
21
23
 
22
24
  /** `showNotification` silently drops anything past this in Chrome. */
23
25
  const MAX_ACTIONS = 2;
@@ -48,7 +50,14 @@ function openDb() {
48
50
 
49
51
  async function readKeys(keys) {
50
52
  const db = await openDb();
51
- if (!db.objectStoreNames.contains(KV_STORE)) return {};
53
+ // Closed on every path below. openDb() opens WITHOUT a version, so a
54
+ // connection left open here holds the database at its current version and
55
+ // makes the page's next upgrade fire `blocked` — hanging until this worker is
56
+ // terminated.
57
+ if (!db.objectStoreNames.contains(KV_STORE)) {
58
+ db.close();
59
+ return {};
60
+ }
52
61
  const store = db.transaction(KV_STORE, 'readonly').objectStore(KV_STORE);
53
62
  const entries = await Promise.all(
54
63
  keys.map(
@@ -60,6 +69,7 @@ async function readKeys(keys) {
60
69
  }),
61
70
  ),
62
71
  );
72
+ db.close();
63
73
  return Object.fromEntries(entries);
64
74
  }
65
75
 
@@ -132,6 +142,21 @@ self.addEventListener('push', (event) => {
132
142
  } catch {
133
143
  return; // not ours, and not parseable
134
144
  }
145
+ // The in-app sync ping is checked BEFORE the claim test below: it carries no
146
+ // messageId and no title, so it must never reach showNotification and must
147
+ // never report a `delivered` engagement for a message that was never shown.
148
+ //
149
+ // Inert-but-ready — nothing on the backend emits this key yet. Bundle refresh
150
+ // is driven entirely by init, visibility and the bundle's own TTL.
151
+ if (data[IAM_SYNC_KEY]) {
152
+ event.waitUntil(
153
+ (async () => {
154
+ const clients = await self.clients.matchAll({ type: 'window' });
155
+ for (const client of clients) client.postMessage({ type: IAM_SYNC_KEY });
156
+ })(),
157
+ );
158
+ return;
159
+ }
135
160
  // Claimed on arsel_v, with arsel_mid as the fallback — the same test the
136
161
  // Android parser applies. There is no marker key on the wire.
137
162
  if (!data[WIRE.VERSION] && !data[WIRE.MESSAGE_ID]) return;