@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
package/src/core/utm.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTM Parameter Manager for tracking campaign attribution
|
|
3
|
+
* Supports first-touch, last-touch, and session-level attribution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { StorageManager } from './storage';
|
|
7
|
+
import { Logger } from '../utils/logger';
|
|
8
|
+
|
|
9
|
+
const FIRST_TOUCH_KEY = 'first_touch_utm';
|
|
10
|
+
const LAST_TOUCH_KEY = 'last_touch_utm';
|
|
11
|
+
const SESSION_UTM_KEY = 'session_utm';
|
|
12
|
+
const SESSION_REFERRER_KEY = 'session_referrer';
|
|
13
|
+
const SESSION_LANDING_PAGE_KEY = 'session_landing_page';
|
|
14
|
+
|
|
15
|
+
export interface UTMParameters {
|
|
16
|
+
utm_source?: string;
|
|
17
|
+
utm_medium?: string;
|
|
18
|
+
utm_campaign?: string;
|
|
19
|
+
utm_content?: string;
|
|
20
|
+
utm_term?: string;
|
|
21
|
+
referrer?: string;
|
|
22
|
+
landing_page?: string;
|
|
23
|
+
captured_at?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UTMData {
|
|
27
|
+
current: UTMParameters | null; // Current session UTM
|
|
28
|
+
firstTouch: UTMParameters | null; // First ever UTM (lifetime attribution)
|
|
29
|
+
lastTouch: UTMParameters | null; // Most recent UTM (last touch attribution)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class UTMManager {
|
|
33
|
+
private storage: StorageManager;
|
|
34
|
+
private logger: Logger;
|
|
35
|
+
private resetSessionOnNewCampaign: boolean;
|
|
36
|
+
// Whether the current URL's UTM differs from the STORED session UTM. Computed
|
|
37
|
+
// BEFORE captureUTMParameters() overwrites SESSION_UTM_KEY, otherwise the
|
|
38
|
+
// comparison always sees the just-written value and returns false.
|
|
39
|
+
private newCampaignDetected: boolean = false;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
storage: StorageManager,
|
|
43
|
+
logger: Logger,
|
|
44
|
+
resetSessionOnNewCampaign: boolean = false,
|
|
45
|
+
) {
|
|
46
|
+
this.storage = storage;
|
|
47
|
+
this.logger = logger;
|
|
48
|
+
this.resetSessionOnNewCampaign = resetSessionOnNewCampaign;
|
|
49
|
+
|
|
50
|
+
// Compare the incoming URL against the previously stored session UTM BEFORE
|
|
51
|
+
// capturing (which overwrites it), so hasNewCampaign() reflects the real
|
|
52
|
+
// transition. Only relevant when the reset gate is on.
|
|
53
|
+
if (this.resetSessionOnNewCampaign) {
|
|
54
|
+
this.newCampaignDetected = this.detectNewCampaign();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Capture UTM parameters on initialization
|
|
58
|
+
this.captureUTMParameters();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Capture UTM parameters from current URL
|
|
63
|
+
*/
|
|
64
|
+
private captureUTMParameters(): void {
|
|
65
|
+
if (typeof window === 'undefined') {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
70
|
+
const utm: UTMParameters = {};
|
|
71
|
+
let hasUTM = false;
|
|
72
|
+
|
|
73
|
+
// Capture all 5 standard UTM parameters
|
|
74
|
+
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
|
|
75
|
+
|
|
76
|
+
for (const param of utmParams) {
|
|
77
|
+
const value = urlParams.get(param);
|
|
78
|
+
if (value) {
|
|
79
|
+
utm[param] = value;
|
|
80
|
+
hasUTM = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Capture referrer (if present)
|
|
85
|
+
if (document.referrer) {
|
|
86
|
+
utm.referrer = document.referrer;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Capture landing page
|
|
90
|
+
utm.landing_page = window.location.href;
|
|
91
|
+
utm.captured_at = Date.now();
|
|
92
|
+
|
|
93
|
+
// If no UTM parameters found, check if we should still track referrer
|
|
94
|
+
if (!hasUTM) {
|
|
95
|
+
// Only track referrer/landing page for session if this is a new session
|
|
96
|
+
const existingSession = this.storage.get<UTMParameters>(SESSION_UTM_KEY);
|
|
97
|
+
if (!existingSession) {
|
|
98
|
+
this.storage.set(SESSION_UTM_KEY, utm);
|
|
99
|
+
this.storage.set(SESSION_REFERRER_KEY, utm.referrer || null);
|
|
100
|
+
this.storage.set(SESSION_LANDING_PAGE_KEY, utm.landing_page);
|
|
101
|
+
this.logger.debug('Captured session referrer and landing page');
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.logger.debug('Captured UTM parameters:', utm);
|
|
107
|
+
|
|
108
|
+
// Store as current session UTM
|
|
109
|
+
this.storage.set(SESSION_UTM_KEY, utm);
|
|
110
|
+
this.storage.set(SESSION_REFERRER_KEY, utm.referrer || null);
|
|
111
|
+
this.storage.set(SESSION_LANDING_PAGE_KEY, utm.landing_page);
|
|
112
|
+
|
|
113
|
+
// Store as first touch if not already set (lifetime attribution)
|
|
114
|
+
const firstTouch = this.storage.get<UTMParameters>(FIRST_TOUCH_KEY);
|
|
115
|
+
if (!firstTouch) {
|
|
116
|
+
this.storage.set(FIRST_TOUCH_KEY, utm);
|
|
117
|
+
this.logger.debug('Set first touch UTM:', utm);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Always update last touch (most recent attribution)
|
|
121
|
+
this.storage.set(LAST_TOUCH_KEY, utm);
|
|
122
|
+
this.logger.debug('Updated last touch UTM:', utm);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get all UTM data (current, first touch, last touch)
|
|
127
|
+
*/
|
|
128
|
+
getUTMData(): UTMData {
|
|
129
|
+
return {
|
|
130
|
+
current: this.storage.get<UTMParameters>(SESSION_UTM_KEY) || null,
|
|
131
|
+
firstTouch: this.storage.get<UTMParameters>(FIRST_TOUCH_KEY) || null,
|
|
132
|
+
lastTouch: this.storage.get<UTMParameters>(LAST_TOUCH_KEY) || null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get current session UTM parameters
|
|
138
|
+
*/
|
|
139
|
+
getCurrentUTM(): UTMParameters | null {
|
|
140
|
+
return this.storage.get<UTMParameters>(SESSION_UTM_KEY) || null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get first touch UTM parameters (lifetime attribution)
|
|
145
|
+
*/
|
|
146
|
+
getFirstTouchUTM(): UTMParameters | null {
|
|
147
|
+
return this.storage.get<UTMParameters>(FIRST_TOUCH_KEY) || null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get last touch UTM parameters (most recent attribution)
|
|
152
|
+
*/
|
|
153
|
+
getLastTouchUTM(): UTMParameters | null {
|
|
154
|
+
return this.storage.get<UTMParameters>(LAST_TOUCH_KEY) || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get session referrer
|
|
159
|
+
*/
|
|
160
|
+
getSessionReferrer(): string | null {
|
|
161
|
+
return this.storage.get<string>(SESSION_REFERRER_KEY) || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get session landing page
|
|
166
|
+
*/
|
|
167
|
+
getSessionLandingPage(): string | null {
|
|
168
|
+
return this.storage.get<string>(SESSION_LANDING_PAGE_KEY) || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Whether a new campaign was detected on the current URL, compared against
|
|
173
|
+
* the session UTM as it was BEFORE this page's capture overwrote it. The
|
|
174
|
+
* comparison is done in the constructor / updateUTM() (pre-capture); this
|
|
175
|
+
* getter just returns the captured result so the answer isn't clobbered.
|
|
176
|
+
*/
|
|
177
|
+
hasNewCampaign(): boolean {
|
|
178
|
+
return this.newCampaignDetected;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Compare the current URL's UTM parameters against the STORED session UTM.
|
|
183
|
+
* Returns true if the URL carries campaign params that differ from session.
|
|
184
|
+
* MUST be called before captureUTMParameters() overwrites SESSION_UTM_KEY.
|
|
185
|
+
*/
|
|
186
|
+
private detectNewCampaign(): boolean {
|
|
187
|
+
if (typeof window === 'undefined') {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
192
|
+
const currentSessionUTM = this.getCurrentUTM();
|
|
193
|
+
|
|
194
|
+
// If no session UTM exists, check if URL has any UTM params
|
|
195
|
+
if (!currentSessionUTM) {
|
|
196
|
+
return urlParams.has('utm_source') ||
|
|
197
|
+
urlParams.has('utm_medium') ||
|
|
198
|
+
urlParams.has('utm_campaign');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check if any UTM parameter has changed
|
|
202
|
+
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const;
|
|
203
|
+
|
|
204
|
+
for (const param of utmParams) {
|
|
205
|
+
const urlValue = urlParams.get(param);
|
|
206
|
+
const sessionValue = currentSessionUTM[param];
|
|
207
|
+
|
|
208
|
+
if (urlValue && urlValue !== sessionValue) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Manually update UTM parameters (for SPAs that change URL without reload).
|
|
218
|
+
* Re-runs the new-campaign detection against the stored session UTM BEFORE
|
|
219
|
+
* capturing overwrites it, so a subsequent hasNewCampaign() is accurate.
|
|
220
|
+
*/
|
|
221
|
+
updateUTM(): void {
|
|
222
|
+
if (this.resetSessionOnNewCampaign) {
|
|
223
|
+
this.newCampaignDetected = this.detectNewCampaign();
|
|
224
|
+
}
|
|
225
|
+
this.captureUTMParameters();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Clear session UTM (keeps first/last touch for attribution)
|
|
230
|
+
*/
|
|
231
|
+
clearSessionUTM(): void {
|
|
232
|
+
this.storage.remove(SESSION_UTM_KEY);
|
|
233
|
+
this.storage.remove(SESSION_REFERRER_KEY);
|
|
234
|
+
this.storage.remove(SESSION_LANDING_PAGE_KEY);
|
|
235
|
+
this.logger.debug('Cleared session UTM');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Clear all UTM data (including first/last touch)
|
|
240
|
+
*/
|
|
241
|
+
clearAllUTM(): void {
|
|
242
|
+
this.storage.remove(SESSION_UTM_KEY);
|
|
243
|
+
this.storage.remove(FIRST_TOUCH_KEY);
|
|
244
|
+
this.storage.remove(LAST_TOUCH_KEY);
|
|
245
|
+
this.storage.remove(SESSION_REFERRER_KEY);
|
|
246
|
+
this.storage.remove(SESSION_LANDING_PAGE_KEY);
|
|
247
|
+
this.logger.debug('Cleared all UTM data');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Reset UTM tracking (used on user logout)
|
|
252
|
+
*/
|
|
253
|
+
reset(): void {
|
|
254
|
+
this.clearAllUTM();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get UTM parameters formatted for event properties
|
|
259
|
+
*/
|
|
260
|
+
getUTMProperties(): Record<string, any> {
|
|
261
|
+
const current = this.getCurrentUTM();
|
|
262
|
+
const firstTouch = this.getFirstTouchUTM();
|
|
263
|
+
const lastTouch = this.getLastTouchUTM();
|
|
264
|
+
|
|
265
|
+
const properties: Record<string, any> = {};
|
|
266
|
+
|
|
267
|
+
// Add current session UTM
|
|
268
|
+
if (current) {
|
|
269
|
+
if (current.utm_source) properties.utm_source = current.utm_source;
|
|
270
|
+
if (current.utm_medium) properties.utm_medium = current.utm_medium;
|
|
271
|
+
if (current.utm_campaign) properties.utm_campaign = current.utm_campaign;
|
|
272
|
+
if (current.utm_content) properties.utm_content = current.utm_content;
|
|
273
|
+
if (current.utm_term) properties.utm_term = current.utm_term;
|
|
274
|
+
if (current.referrer) properties.referrer = current.referrer;
|
|
275
|
+
if (current.landing_page) properties.landing_page = current.landing_page;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Add first touch attribution
|
|
279
|
+
if (firstTouch) {
|
|
280
|
+
if (firstTouch.utm_source) properties.first_touch_utm_source = firstTouch.utm_source;
|
|
281
|
+
if (firstTouch.utm_medium) properties.first_touch_utm_medium = firstTouch.utm_medium;
|
|
282
|
+
if (firstTouch.utm_campaign) properties.first_touch_utm_campaign = firstTouch.utm_campaign;
|
|
283
|
+
if (firstTouch.utm_content) properties.first_touch_utm_content = firstTouch.utm_content;
|
|
284
|
+
if (firstTouch.utm_term) properties.first_touch_utm_term = firstTouch.utm_term;
|
|
285
|
+
if (firstTouch.referrer) properties.first_touch_referrer = firstTouch.referrer;
|
|
286
|
+
if (firstTouch.landing_page) properties.first_touch_landing_page = firstTouch.landing_page;
|
|
287
|
+
if (firstTouch.captured_at) properties.first_touch_captured_at = new Date(firstTouch.captured_at).toISOString();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Add last touch attribution
|
|
291
|
+
if (lastTouch && lastTouch !== current) {
|
|
292
|
+
if (lastTouch.utm_source) properties.last_touch_utm_source = lastTouch.utm_source;
|
|
293
|
+
if (lastTouch.utm_medium) properties.last_touch_utm_medium = lastTouch.utm_medium;
|
|
294
|
+
if (lastTouch.utm_campaign) properties.last_touch_utm_campaign = lastTouch.utm_campaign;
|
|
295
|
+
if (lastTouch.utm_content) properties.last_touch_utm_content = lastTouch.utm_content;
|
|
296
|
+
if (lastTouch.utm_term) properties.last_touch_utm_term = lastTouch.utm_term;
|
|
297
|
+
if (lastTouch.referrer) properties.last_touch_referrer = lastTouch.referrer;
|
|
298
|
+
if (lastTouch.landing_page) properties.last_touch_landing_page = lastTouch.landing_page;
|
|
299
|
+
if (lastTouch.captured_at) properties.last_touch_captured_at = new Date(lastTouch.captured_at).toISOString();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return properties;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Values the SDK can carry to the API (rule 49, the same on every SDK).
|
|
3
|
+
*
|
|
4
|
+
* JSON has no NaN or Infinity: `JSON.stringify` writes them as `null`, so an
|
|
5
|
+
* attribute of NaN silently became null on the profile - and a circular
|
|
6
|
+
* object or a BigInt made `JSON.stringify` THROW, which the network layer
|
|
7
|
+
* took for "offline" and retried forever. Because every attribute request
|
|
8
|
+
* carries the oldest pending write of every key, one such value held every
|
|
9
|
+
* later attribute write (and every in-app sync) of the page.
|
|
10
|
+
*
|
|
11
|
+
* Now a value is checked when it enters the SDK: the key holding a bad value
|
|
12
|
+
* is dropped with a warning, and the rest of the call goes ahead.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Deeper than this is treated as unserializable (it would blow the stack). */
|
|
16
|
+
const MAX_DEPTH = 100;
|
|
17
|
+
|
|
18
|
+
/** Why this value cannot be sent, or null when it can. */
|
|
19
|
+
export function valueProblem(value: unknown): string | null {
|
|
20
|
+
const ancestors: object[] = [];
|
|
21
|
+
const walk = (v: unknown, depth: number): string | null => {
|
|
22
|
+
switch (typeof v) {
|
|
23
|
+
case 'number':
|
|
24
|
+
return Number.isFinite(v) ? null : 'a non-finite number (NaN or Infinity)';
|
|
25
|
+
case 'bigint':
|
|
26
|
+
return 'a BigInt';
|
|
27
|
+
case 'function':
|
|
28
|
+
return 'a function';
|
|
29
|
+
case 'symbol':
|
|
30
|
+
return 'a symbol';
|
|
31
|
+
case 'object': {
|
|
32
|
+
if (v === null) return null;
|
|
33
|
+
if (depth > MAX_DEPTH) return `a value nested deeper than ${MAX_DEPTH} levels`;
|
|
34
|
+
// toJSON (a Date, say) decides its own form; serialized below.
|
|
35
|
+
if (typeof (v as any).toJSON === 'function') {
|
|
36
|
+
try {
|
|
37
|
+
JSON.stringify(v);
|
|
38
|
+
return null;
|
|
39
|
+
} catch {
|
|
40
|
+
return 'a value that cannot be serialized';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (ancestors.includes(v as object)) return 'a circular reference';
|
|
44
|
+
ancestors.push(v as object);
|
|
45
|
+
try {
|
|
46
|
+
const children = Array.isArray(v) ? v : Object.values(v as Record<string, unknown>);
|
|
47
|
+
for (const child of children) {
|
|
48
|
+
const problem = walk(child, depth + 1);
|
|
49
|
+
if (problem) return problem;
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
ancestors.pop();
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
return null; // string, boolean, undefined
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
return walk(value, 1);
|
|
62
|
+
} catch {
|
|
63
|
+
return 'a value that cannot be serialized';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A copy of `values` without the keys whose value cannot be sent; each one
|
|
69
|
+
* dropped is reported through `warn`.
|
|
70
|
+
*/
|
|
71
|
+
export function withoutUnsendableValues(
|
|
72
|
+
values: Record<string, any>,
|
|
73
|
+
warn: (key: string, problem: string) => void,
|
|
74
|
+
): Record<string, any> {
|
|
75
|
+
const out: Record<string, any> = {};
|
|
76
|
+
for (const key of Object.keys(values)) {
|
|
77
|
+
const problem = valueProblem(values[key]);
|
|
78
|
+
if (problem) {
|
|
79
|
+
warn(key, problem);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
out[key] = values[key];
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { NetworkResponse } from './network';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who a write is about, captured when the call was made. `userId` is absent
|
|
5
|
+
* for a write made while the visitor was anonymous; it is filled in at SEND
|
|
6
|
+
* time when that anonymous id is still the current one (contract item 38).
|
|
7
|
+
*/
|
|
8
|
+
export interface WriteOwner {
|
|
9
|
+
anonymousId: string;
|
|
10
|
+
userId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A profile write described as DATA, so it can be kept in storage and sent on
|
|
15
|
+
* a later page view. Closures die with the page; these survive navigation.
|
|
16
|
+
*/
|
|
17
|
+
export type WriteRecord =
|
|
18
|
+
| {
|
|
19
|
+
kind: 'alias';
|
|
20
|
+
payload: { anonymousId: string; userId?: string; aliasLabel: string; aliasName: string };
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
kind: 'subscription';
|
|
24
|
+
payload: { channel: any; status: any; userId?: string; anonymousId: string };
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
kind: 'group';
|
|
28
|
+
payload: { groupId: string; channel: any; action: 'subscribe' | 'unsubscribe'; userId?: string; anonymousId: string };
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
/** The `tracking_opted_out` profile marker (contract items 33 and 36). */
|
|
32
|
+
kind: 'marker';
|
|
33
|
+
payload: { value: boolean; userId?: string; anonymousId: string };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** @deprecated the pre-1.3.0 name; kept for state stored by an earlier build. */
|
|
37
|
+
export type DeferredWriteRecord = WriteRecord;
|
|
38
|
+
|
|
39
|
+
/** Hands a write to the SDK's outbox. Provided by JoryioSDK. */
|
|
40
|
+
export type WriteRunner = (label: string, record: WriteRecord) => void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A WITHDRAWAL is always delivered (contract item 36): an unsubscribe, a
|
|
44
|
+
* group unsubscribe and the opt-out marker set to `true`. Opt-out, consent
|
|
45
|
+
* denied or pending, and wipeData() stop every other write - never these,
|
|
46
|
+
* because they are how the server learns that the person said no.
|
|
47
|
+
*/
|
|
48
|
+
export function isWithdrawal(record: WriteRecord): boolean {
|
|
49
|
+
switch (record.kind) {
|
|
50
|
+
case 'subscription':
|
|
51
|
+
return record.payload.status === 'unsubscribed';
|
|
52
|
+
case 'group':
|
|
53
|
+
return record.payload.action === 'unsubscribe';
|
|
54
|
+
case 'marker':
|
|
55
|
+
return record.payload.value === true;
|
|
56
|
+
default:
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The same caps on every SDK (contract item 40): a 404 "no profile yet" is
|
|
63
|
+
* tried at most 5 times; any other retryable SERVER answer (a 401 for the
|
|
64
|
+
* current user, a 2xx with `success: false`) at most 10 times. Offline,
|
|
65
|
+
* timeouts, 429 and 5xx never count - they are waited out with a growing
|
|
66
|
+
* backoff instead.
|
|
67
|
+
*/
|
|
68
|
+
export const WRITE_MAX_ATTEMPTS = 10;
|
|
69
|
+
export const WRITE_MAX_NOT_FOUND_ATTEMPTS = 5;
|
|
70
|
+
|
|
71
|
+
/** Waits before the 2nd through 10th attempt; later waits stay at the last value. */
|
|
72
|
+
export const WRITE_RETRY_DELAYS_MS = [2000, 5000, 15000, 30000, 60000, 60000, 60000, 60000, 60000];
|
|
73
|
+
/** The longest wait between two attempts of one write, whatever the server or the backoff says. */
|
|
74
|
+
export const WRITE_MAX_RETRY_DELAY_MS = 5 * 60 * 1000;
|
|
75
|
+
|
|
76
|
+
/** Wait before the next attempt, after `failures` failed ones. */
|
|
77
|
+
export function writeRetryDelay(failures: number, retryAfterMs?: number): number {
|
|
78
|
+
const base = WRITE_RETRY_DELAYS_MS[Math.min(Math.max(failures, 1), WRITE_RETRY_DELAYS_MS.length) - 1];
|
|
79
|
+
const grown = failures > WRITE_RETRY_DELAYS_MS.length
|
|
80
|
+
? Math.min(base * Math.pow(2, failures - WRITE_RETRY_DELAYS_MS.length), WRITE_MAX_RETRY_DELAY_MS)
|
|
81
|
+
: base;
|
|
82
|
+
return Math.min(Math.max(grown, retryAfterMs ?? 0), WRITE_MAX_RETRY_DELAY_MS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What to do with a write after one answer. The ONE rule every profile write
|
|
87
|
+
* follows (contract item 40) - subscriptions, aliases, attributes, the
|
|
88
|
+
* opt-out marker, push unregister and identify:
|
|
89
|
+
*
|
|
90
|
+
* - `done`: stored.
|
|
91
|
+
* - `retry`: try again later. `counts` says whether the attempt counts toward
|
|
92
|
+
* the cap (`notFound` selects the 404 cap).
|
|
93
|
+
* - `drop`: refused for good (400, 403, 409, 413, 422, other 4xx), or an
|
|
94
|
+
* SDK-authentication 401 for a user who is no longer the current one - no
|
|
95
|
+
* token for them will ever be set again (contract item 39).
|
|
96
|
+
* - `hold`: the key was refused (401 that is not SDK auth): nothing is sent
|
|
97
|
+
* for the rest of the page view; the write is kept for the next one.
|
|
98
|
+
*
|
|
99
|
+
* A 402 (ingestion paused for the workspace) is a `retry` that never counts
|
|
100
|
+
* (rule 50): it must not stop the outbox, because WITHDRAWALS queued behind
|
|
101
|
+
* the refused write are still accepted by the server. The SDK's gate holds
|
|
102
|
+
* every non-withdrawal while the pause lasts.
|
|
103
|
+
*/
|
|
104
|
+
export type WriteVerdict =
|
|
105
|
+
| { action: 'done' }
|
|
106
|
+
| { action: 'retry'; counts: boolean; notFound?: boolean; retryAfterMs?: number }
|
|
107
|
+
| { action: 'drop'; reason: string }
|
|
108
|
+
| { action: 'hold'; reason: string };
|
|
109
|
+
|
|
110
|
+
export function classifyWriteResponse(
|
|
111
|
+
res: NetworkResponse,
|
|
112
|
+
ctx: { isSdkAuthError: boolean; ownerIsCurrentUser: boolean },
|
|
113
|
+
): WriteVerdict {
|
|
114
|
+
if (res.ok) return { action: 'done' };
|
|
115
|
+
if (res.softFailure) return { action: 'retry', counts: true };
|
|
116
|
+
const status = res.status;
|
|
117
|
+
if (status === 0 || status === 408 || status === 429 || status >= 500) {
|
|
118
|
+
return { action: 'retry', counts: false, retryAfterMs: res.retryAfterMs };
|
|
119
|
+
}
|
|
120
|
+
if (status === 404) return { action: 'retry', counts: true, notFound: true };
|
|
121
|
+
if (status === 401) {
|
|
122
|
+
if (!ctx.isSdkAuthError) return { action: 'hold', reason: 'the SDK key was refused (401)' };
|
|
123
|
+
if (!ctx.ownerIsCurrentUser) {
|
|
124
|
+
return { action: 'drop', reason: 'SDK authentication refused a write of a user who is no longer signed in (401)' };
|
|
125
|
+
}
|
|
126
|
+
return { action: 'retry', counts: true };
|
|
127
|
+
}
|
|
128
|
+
// Paused workspace: wait, never count, never stop the withdrawals behind it.
|
|
129
|
+
if (status === 402) return { action: 'retry', counts: false, retryAfterMs: res.retryAfterMs };
|
|
130
|
+
return { action: 'drop', reason: `refused by the server (HTTP ${status})` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True once a write has used up its attempts. */
|
|
134
|
+
export function writeAttemptsExhausted(attempts: number, notFoundAttempts: number): boolean {
|
|
135
|
+
return attempts >= WRITE_MAX_ATTEMPTS || notFoundAttempts >= WRITE_MAX_NOT_FOUND_ATTEMPTS;
|
|
136
|
+
}
|