@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +54 -0
  3. package/README.md +497 -2
  4. package/dist/core/config.d.ts +101 -0
  5. package/dist/core/config.d.ts.map +1 -0
  6. package/dist/core/device.d.ts +14 -0
  7. package/dist/core/device.d.ts.map +1 -0
  8. package/dist/core/ecommerce.d.ts +67 -0
  9. package/dist/core/ecommerce.d.ts.map +1 -0
  10. package/dist/core/identity.d.ts +36 -0
  11. package/dist/core/identity.d.ts.map +1 -0
  12. package/dist/core/inapp.d.ts +169 -0
  13. package/dist/core/inapp.d.ts.map +1 -0
  14. package/dist/core/network.d.ts +158 -0
  15. package/dist/core/network.d.ts.map +1 -0
  16. package/dist/core/outbox.d.ts +79 -0
  17. package/dist/core/outbox.d.ts.map +1 -0
  18. package/dist/core/pending-attributes.d.ts +43 -0
  19. package/dist/core/pending-attributes.d.ts.map +1 -0
  20. package/dist/core/push.d.ts +59 -0
  21. package/dist/core/push.d.ts.map +1 -0
  22. package/dist/core/queue.d.ts +107 -0
  23. package/dist/core/queue.d.ts.map +1 -0
  24. package/dist/core/sdk.d.ts +169 -0
  25. package/dist/core/sdk.d.ts.map +1 -0
  26. package/dist/core/session.d.ts +36 -0
  27. package/dist/core/session.d.ts.map +1 -0
  28. package/dist/core/storage.d.ts +17 -0
  29. package/dist/core/storage.d.ts.map +1 -0
  30. package/dist/core/user.d.ts +28 -0
  31. package/dist/core/user.d.ts.map +1 -0
  32. package/dist/core/utm.d.ts +39 -0
  33. package/dist/core/utm.d.ts.map +1 -0
  34. package/dist/core/values.d.ts +3 -0
  35. package/dist/core/values.d.ts.map +1 -0
  36. package/dist/core/write-retry.d.ts +66 -0
  37. package/dist/core/write-retry.d.ts.map +1 -0
  38. package/dist/index.d.mts +711 -0
  39. package/dist/index.d.ts +21 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/joryio-sw.js +6 -0
  42. package/dist/joryio.cjs.js +7265 -0
  43. package/dist/joryio.min.js +7 -0
  44. package/dist/joryio.min.js.map +1 -0
  45. package/dist/joryio.mjs +7254 -0
  46. package/dist/joryio.umd.js +7271 -0
  47. package/dist/utils/bucket-hash.d.ts +2 -0
  48. package/dist/utils/bucket-hash.d.ts.map +1 -0
  49. package/dist/utils/logger.d.ts +14 -0
  50. package/dist/utils/logger.d.ts.map +1 -0
  51. package/dist/utils/uuid.d.ts +3 -0
  52. package/dist/utils/uuid.d.ts.map +1 -0
  53. package/package.json +90 -4
  54. package/src/core/config.ts +362 -0
  55. package/src/core/device.ts +50 -0
  56. package/src/core/ecommerce.ts +482 -0
  57. package/src/core/identity.ts +251 -0
  58. package/src/core/inapp.ts +2419 -0
  59. package/src/core/network.ts +1261 -0
  60. package/src/core/outbox.ts +707 -0
  61. package/src/core/pending-attributes.ts +350 -0
  62. package/src/core/push.ts +667 -0
  63. package/src/core/queue.ts +894 -0
  64. package/src/core/sdk.ts +3108 -0
  65. package/src/core/session.ts +311 -0
  66. package/src/core/storage.ts +157 -0
  67. package/src/core/user.ts +203 -0
  68. package/src/core/utm.ts +304 -0
  69. package/src/core/values.ts +85 -0
  70. package/src/core/write-retry.ts +136 -0
  71. package/src/index.ts +305 -0
  72. package/src/snippet/loader.js +134 -0
  73. package/src/utils/bucket-hash.ts +44 -0
  74. package/src/utils/logger.ts +58 -0
  75. package/src/utils/uuid.ts +29 -0
  76. 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
+ }