@joryio/web-sdk 0.0.0-stage → 1.3.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 +5 -0
- package/LICENSE +54 -0
- package/README.md +497 -2
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/device.d.ts +14 -0
- package/dist/core/device.d.ts.map +1 -0
- package/dist/core/ecommerce.d.ts +67 -0
- package/dist/core/ecommerce.d.ts.map +1 -0
- package/dist/core/identity.d.ts +36 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/inapp.d.ts +169 -0
- package/dist/core/inapp.d.ts.map +1 -0
- package/dist/core/network.d.ts +158 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/outbox.d.ts +79 -0
- package/dist/core/outbox.d.ts.map +1 -0
- package/dist/core/pending-attributes.d.ts +43 -0
- package/dist/core/pending-attributes.d.ts.map +1 -0
- package/dist/core/push.d.ts +59 -0
- package/dist/core/push.d.ts.map +1 -0
- package/dist/core/queue.d.ts +107 -0
- package/dist/core/queue.d.ts.map +1 -0
- package/dist/core/sdk.d.ts +169 -0
- package/dist/core/sdk.d.ts.map +1 -0
- package/dist/core/session.d.ts +36 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/user.d.ts +28 -0
- package/dist/core/user.d.ts.map +1 -0
- package/dist/core/utm.d.ts +39 -0
- package/dist/core/utm.d.ts.map +1 -0
- package/dist/core/values.d.ts +3 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/write-retry.d.ts +66 -0
- package/dist/core/write-retry.d.ts.map +1 -0
- package/dist/index.d.mts +711 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/joryio-sw.js +6 -0
- package/dist/joryio.cjs.js +7265 -0
- package/dist/joryio.min.js +7 -0
- package/dist/joryio.min.js.map +1 -0
- package/dist/joryio.mjs +7254 -0
- package/dist/joryio.umd.js +7271 -0
- package/dist/utils/bucket-hash.d.ts +2 -0
- package/dist/utils/bucket-hash.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/uuid.d.ts +3 -0
- package/dist/utils/uuid.d.ts.map +1 -0
- package/package.json +90 -4
- package/src/core/config.ts +362 -0
- package/src/core/device.ts +50 -0
- package/src/core/ecommerce.ts +482 -0
- package/src/core/identity.ts +251 -0
- package/src/core/inapp.ts +2419 -0
- package/src/core/network.ts +1261 -0
- package/src/core/outbox.ts +707 -0
- package/src/core/pending-attributes.ts +350 -0
- package/src/core/push.ts +667 -0
- package/src/core/queue.ts +894 -0
- package/src/core/sdk.ts +3108 -0
- package/src/core/session.ts +311 -0
- package/src/core/storage.ts +157 -0
- package/src/core/user.ts +203 -0
- package/src/core/utm.ts +304 -0
- package/src/core/values.ts +85 -0
- package/src/core/write-retry.ts +136 -0
- package/src/index.ts +305 -0
- package/src/snippet/loader.js +134 -0
- package/src/utils/bucket-hash.ts +44 -0
- package/src/utils/logger.ts +58 -0
- package/src/utils/uuid.ts +29 -0
- package/src/worker/joryio-sw.js +218 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joryio service worker — hosted at sdk.joryio.com/web/1/joryio-sw.js.
|
|
3
|
+
*
|
|
4
|
+
* Customers drop a 1-line stub at their domain root (`joryio-sw.js`) that
|
|
5
|
+
* does `importScripts('https://sdk.joryio.com/web/1/joryio-sw.js')`.
|
|
6
|
+
* Browsers enforce same-origin on the service worker file itself, but
|
|
7
|
+
* importScripts bypasses that for the imported body — so customers host the
|
|
8
|
+
* stub, we ship the logic.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - `push` event → render notification from the payload
|
|
12
|
+
* - `notificationclick` event → open the target URL + beacon a click event
|
|
13
|
+
* - `pushsubscriptionchange` → silent. The SDK page re-registers on next load
|
|
14
|
+
* (PushManager.syncExistingSubscription).
|
|
15
|
+
* - a visitor the page may not track (opted out, consent denied or still
|
|
16
|
+
* pending, Do Not Track - a flag in Cache Storage) still sees the
|
|
17
|
+
* notification, but no delivery or click receipt is sent.
|
|
18
|
+
*
|
|
19
|
+
* Payload keys: the push data contract (packages/backend/src/common/push/
|
|
20
|
+
* push-payload.contract.ts) - the same names Android and iOS read. The display
|
|
21
|
+
* fields (title, body, icon, image, actions) sit at the top level of the JSON;
|
|
22
|
+
* the contract keys travel in its `data`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The contract keys this worker reads. Grepped by the backend parity spec. */
|
|
26
|
+
const KEY_TRACKING_ID = 'trackingId';
|
|
27
|
+
const KEY_DEEP_LINK = 'jry_deep_link';
|
|
28
|
+
const KEY_IMAGE_URL = 'jry_image_url';
|
|
29
|
+
// Web only: where to report. The worker has no SDK config of its own - it read
|
|
30
|
+
// an `apiEndpoint` key that nothing sent, so receipts and clicks never fired.
|
|
31
|
+
const KEY_API_ORIGIN = 'jry_api_origin';
|
|
32
|
+
|
|
33
|
+
// The page records a tracking opt-out here (PushManager.setTrackingOptedOut):
|
|
34
|
+
// a worker cannot read localStorage, Cache Storage is shared. Keep in step with
|
|
35
|
+
// SW_STATE_CACHE / SW_OPT_OUT_URL in src/core/push.ts.
|
|
36
|
+
const SW_STATE_CACHE = 'joryio-sdk-state';
|
|
37
|
+
const SW_OPT_OUT_URL = '/__joryio/tracking-opted-out';
|
|
38
|
+
|
|
39
|
+
/** True when this visitor opted out of tracking: show pushes, report nothing. */
|
|
40
|
+
function isTrackingOptedOut() {
|
|
41
|
+
try {
|
|
42
|
+
if (typeof caches === 'undefined') return Promise.resolve(false);
|
|
43
|
+
return caches
|
|
44
|
+
.open(SW_STATE_CACHE)
|
|
45
|
+
.then((cache) => cache.match(SW_OPT_OUT_URL))
|
|
46
|
+
.then((hit) => !!hit)
|
|
47
|
+
.catch(() => false);
|
|
48
|
+
} catch (_) {
|
|
49
|
+
return Promise.resolve(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
self.addEventListener('push', (event) => {
|
|
54
|
+
let payload = {};
|
|
55
|
+
try {
|
|
56
|
+
payload = event.data ? event.data.json() : {};
|
|
57
|
+
} catch (_) {
|
|
58
|
+
payload = { title: 'Notification', body: event.data ? event.data.text() : '' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const title = payload.title || 'Notification';
|
|
62
|
+
const options = buildNotificationOptions(payload);
|
|
63
|
+
// Report that it ARRIVED, then draw it.
|
|
64
|
+
//
|
|
65
|
+
// The service worker is the ONLY place a browser learns a push was received -
|
|
66
|
+
// the page may not be open, and there is no other hook. Web push otherwise
|
|
67
|
+
// had no delivery signal at all, the same hole FCM and APNs leave.
|
|
68
|
+
//
|
|
69
|
+
// Before display on purpose: delivery is "the browser received it", not "the
|
|
70
|
+
// user saw it". A notification the OS collapses or the user never looks at
|
|
71
|
+
// still arrived.
|
|
72
|
+
event.waitUntil(
|
|
73
|
+
Promise.all([
|
|
74
|
+
beaconDeliveredEvent(options.data),
|
|
75
|
+
self.registration.showNotification(title, options),
|
|
76
|
+
]),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The notification exactly as the composer set it up. The backend sends
|
|
82
|
+
* `badge`, `tag` (from the group key - a newer push with the same tag replaces
|
|
83
|
+
* the one on screen instead of stacking) and `requireInteraction` (stay until
|
|
84
|
+
* dismissed); this worker used to drop all three, so those composer settings
|
|
85
|
+
* did nothing on web.
|
|
86
|
+
*/
|
|
87
|
+
function buildNotificationOptions(payload) {
|
|
88
|
+
const data = (payload && payload.data) || {};
|
|
89
|
+
const options = {
|
|
90
|
+
body: payload.body,
|
|
91
|
+
icon: payload.icon,
|
|
92
|
+
actions: payload.actions,
|
|
93
|
+
data,
|
|
94
|
+
};
|
|
95
|
+
// Chrome/Edge only; ignored elsewhere.
|
|
96
|
+
options.image = payload.image || data[KEY_IMAGE_URL];
|
|
97
|
+
if (payload.badge) options.badge = payload.badge;
|
|
98
|
+
if (payload.tag) {
|
|
99
|
+
options.tag = payload.tag;
|
|
100
|
+
// A replacement should still alert, not swap silently.
|
|
101
|
+
options.renotify = true;
|
|
102
|
+
}
|
|
103
|
+
if (payload.requireInteraction === true) options.requireInteraction = true;
|
|
104
|
+
return options;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
self.addEventListener('notificationclick', (event) => {
|
|
108
|
+
event.notification.close();
|
|
109
|
+
const data = event.notification.data || {};
|
|
110
|
+
// `url` is the pre-contract key, still sent for one release so a worker
|
|
111
|
+
// cached from before the rename keeps opening the right page.
|
|
112
|
+
const url = data[KEY_DEEP_LINK] || data.url || '/';
|
|
113
|
+
|
|
114
|
+
// The click report is part of the work waitUntil() waits for. It was fired
|
|
115
|
+
// and forgotten after the window opened, so the browser was free to stop
|
|
116
|
+
// the worker before the request left - and the click was not counted.
|
|
117
|
+
event.waitUntil((async () => {
|
|
118
|
+
const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
119
|
+
for (const client of clientList) {
|
|
120
|
+
if (client.url === url && 'focus' in client) {
|
|
121
|
+
await Promise.all([client.focus(), beaconClickEvent(data)]);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const opened = self.clients.openWindow ? self.clients.openWindow(url) : Promise.resolve();
|
|
126
|
+
await Promise.all([opened, beaconClickEvent(data)]);
|
|
127
|
+
})());
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Browser may silently rotate the subscription. We can't re-post to the
|
|
132
|
+
* customer backend from here — the SW doesn't know the SDK key/endpoint.
|
|
133
|
+
* The SDK page picks this up on next load via getExistingSubscription().
|
|
134
|
+
*/
|
|
135
|
+
self.addEventListener('pushsubscriptionchange', () => {
|
|
136
|
+
// no-op
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Best-effort delivery receipt, same shape as the click beacon above.
|
|
141
|
+
*
|
|
142
|
+
* Uses the root-mounted `/track/push/delivered/:trackingId`, which needs NO SDK
|
|
143
|
+
* key - which is the whole reason that route is keyless. A service worker has
|
|
144
|
+
* the trackingId and the API origin and nothing else; a keyed endpoint would
|
|
145
|
+
* have been unreachable from the one place web push can report at all.
|
|
146
|
+
*
|
|
147
|
+
* The server deduplicates by tracking id, so a browser replaying a push after a
|
|
148
|
+
* restart collapses to one event rather than inflating delivery past sends.
|
|
149
|
+
*/
|
|
150
|
+
function beaconDeliveredEvent(data) {
|
|
151
|
+
return isTrackingOptedOut().then((optedOut) => (optedOut ? undefined : sendDeliveredEvent(data)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sendDeliveredEvent(data) {
|
|
155
|
+
const endpoint = data && data[KEY_API_ORIGIN];
|
|
156
|
+
const trackingId = data && data[KEY_TRACKING_ID];
|
|
157
|
+
if (!endpoint || !trackingId) return Promise.resolve();
|
|
158
|
+
|
|
159
|
+
let origin;
|
|
160
|
+
try {
|
|
161
|
+
origin = new URL(endpoint).origin;
|
|
162
|
+
} catch (_) {
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// no-cors: /track/* is not in the SDK CORS allowlist, and we do not read the
|
|
167
|
+
// response. Never rejects - a receipt must not be able to stop the
|
|
168
|
+
// notification it is reporting.
|
|
169
|
+
return fetch(`${origin}/track/push/delivered/${encodeURIComponent(trackingId)}`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
mode: 'no-cors',
|
|
172
|
+
keepalive: true,
|
|
173
|
+
}).catch(() => {});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Best-effort click beacon. The push `data` payload carries a signed
|
|
178
|
+
* `trackingId` that encodes the RECIPIENT (the contact the push was sent to)
|
|
179
|
+
* plus campaign/canvas/variant ids. We hit the dedicated push-click route,
|
|
180
|
+
* which decodes it server-side and logs a `message.clicked` event attributed
|
|
181
|
+
* to that recipient.
|
|
182
|
+
*
|
|
183
|
+
* Why this route and not /v1/track/batch:
|
|
184
|
+
* - It needs NO SDK key and NO client identity. The previous beacon posted an
|
|
185
|
+
* identity-less `notification_clicked` to /v1/track/batch, which is rejected
|
|
186
|
+
* (the batch DTO requires an anonymousId) AND would misattribute anyway — the
|
|
187
|
+
* browser's current anonymousId can differ from the recipient (e.g. after the
|
|
188
|
+
* visitor cleared storage). The trackingId is the authoritative recipient.
|
|
189
|
+
* - The route is mounted at the ROOT (excluded from the /api prefix), so we
|
|
190
|
+
* take the origin of `jry_api_origin` and drop any path.
|
|
191
|
+
*/
|
|
192
|
+
function beaconClickEvent(data) {
|
|
193
|
+
return isTrackingOptedOut()
|
|
194
|
+
.then((optedOut) => (optedOut ? undefined : sendClickEvent(data)))
|
|
195
|
+
.catch(() => {});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sendClickEvent(data) {
|
|
199
|
+
const endpoint = data && data[KEY_API_ORIGIN];
|
|
200
|
+
const trackingId = data && data[KEY_TRACKING_ID];
|
|
201
|
+
if (!endpoint || !trackingId) return Promise.resolve();
|
|
202
|
+
|
|
203
|
+
let origin;
|
|
204
|
+
try {
|
|
205
|
+
origin = new URL(endpoint).origin;
|
|
206
|
+
} catch (_) {
|
|
207
|
+
return Promise.resolve();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// GET. no-cors: /track/* isn't in the SDK CORS allowlist, but the
|
|
211
|
+
// server-side click attribution still runs (we don't read the response).
|
|
212
|
+
// Never rejects - a lost report must not break the click.
|
|
213
|
+
return fetch(`${origin}/track/push/click/${encodeURIComponent(trackingId)}`, {
|
|
214
|
+
method: 'GET',
|
|
215
|
+
mode: 'no-cors',
|
|
216
|
+
keepalive: true,
|
|
217
|
+
}).catch(() => {});
|
|
218
|
+
}
|