@affiliateo/web 4.6.1 → 4.8.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/README.md +274 -238
- package/dist/bot-detect.d.ts +4 -4
- package/dist/bot-detect.js +6 -6
- package/dist/provider.js +133 -79
- package/dist/queue.d.ts +7 -3
- package/dist/queue.js +48 -14
- package/dist/turnstile.d.ts +3 -3
- package/dist/turnstile.js +6 -6
- package/dist/types.d.ts +24 -15
- package/dist/user-id.d.ts +18 -0
- package/dist/user-id.js +40 -0
- package/package.json +46 -44
package/dist/provider.js
CHANGED
|
@@ -5,6 +5,7 @@ import { EventQueue } from './queue';
|
|
|
5
5
|
import { isLikelyBot } from './bot-detect';
|
|
6
6
|
import { isDevHost } from './dev-host';
|
|
7
7
|
import { mintTurnstileToken, consumeTurnstileToken } from './turnstile';
|
|
8
|
+
import { usableUserId } from './user-id';
|
|
8
9
|
const DEFAULT_API_URL = 'https://affiliateo.com';
|
|
9
10
|
const LS_VISITOR_ID = 'affiliateo_visitor_id';
|
|
10
11
|
const LS_REF = 'affiliateo_ref';
|
|
@@ -12,11 +13,16 @@ const LS_REF = 'affiliateo_ref';
|
|
|
12
13
|
// write the same key, and renaming it would orphan existing visitors'
|
|
13
14
|
// attribution mid-session.
|
|
14
15
|
const LS_APP_ID = 'affiliateo_campaign_id';
|
|
15
|
-
// Persistent opt-out flag (affiliateo_opt_out)
|
|
16
|
+
// Persistent opt-out flag (affiliateo_opt_out) -
|
|
16
17
|
// when set to "true" the SDK silently noops every outbound call. Survives
|
|
17
18
|
// page reloads. removed by optIn(). Checked at init time so a previously
|
|
18
19
|
// opted-out visitor never re-fires auto-pageviews after a refresh.
|
|
19
20
|
const LS_OPT_OUT = 'affiliateo_opt_out';
|
|
21
|
+
// Who is signed in (4.8.0), and the visit it was last sent in, so the SDK
|
|
22
|
+
// re-sends it once per visit instead of on every page load. Same keys as
|
|
23
|
+
// public/t.js, so a site that runs both keeps one record.
|
|
24
|
+
const LS_USER_ID = 'affiliateo_user_id';
|
|
25
|
+
const LS_USER_SENT = 'affiliateo_user_sent';
|
|
20
26
|
// Client-owned session id. The BROWSER owns the session,
|
|
21
27
|
// not the server. One id is reused for every event within a sliding
|
|
22
28
|
// 10-minute activity window, then a fresh one is minted. Because the id is
|
|
@@ -126,6 +132,7 @@ const AffiliateoContext = createContext({
|
|
|
126
132
|
identify: asyncNoop,
|
|
127
133
|
trackPageview: asyncNoop,
|
|
128
134
|
reset: asyncNoop,
|
|
135
|
+
logout: asyncNoop,
|
|
129
136
|
optOut: syncNoop,
|
|
130
137
|
optIn: syncNoop,
|
|
131
138
|
flush: asyncNoop,
|
|
@@ -158,7 +165,7 @@ export function getAttribution() {
|
|
|
158
165
|
data.affiliateo_visitor_id = vid;
|
|
159
166
|
if (ref)
|
|
160
167
|
data.affiliateo_ref = ref;
|
|
161
|
-
// Key renamed from affiliateo_campaign_id in 4.5.0
|
|
168
|
+
// Key renamed from affiliateo_campaign_id in 4.5.0 - the Affiliateo
|
|
162
169
|
// webhook accepts both, so checkouts created by older SDKs keep working.
|
|
163
170
|
if (cid)
|
|
164
171
|
data.affiliateo_app_id = cid;
|
|
@@ -180,7 +187,7 @@ let providerMountCount = 0;
|
|
|
180
187
|
* touching it there throws a ReferenceError rather than reading undefined.
|
|
181
188
|
*
|
|
182
189
|
* UNKNOWN COUNTS AS NOT-PRODUCTION, deliberately. The only caller is the
|
|
183
|
-
* double-mount warning, and a double mount double-fires every pageview
|
|
190
|
+
* double-mount warning, and a double mount double-fires every pageview - which
|
|
184
191
|
* corrupts the merchant's own numbers and the affiliate attribution built on
|
|
185
192
|
* them. One stray console line on an unbundled production page is a smaller
|
|
186
193
|
* cost than silently letting that run.
|
|
@@ -215,6 +222,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
215
222
|
identify: asyncNoop,
|
|
216
223
|
trackPageview: asyncNoop,
|
|
217
224
|
reset: asyncNoop,
|
|
225
|
+
logout: asyncNoop,
|
|
218
226
|
optOut: syncNoop,
|
|
219
227
|
optIn: syncNoop,
|
|
220
228
|
flush: asyncNoop,
|
|
@@ -222,7 +230,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
222
230
|
// Debug logger. Closes over the `debug` prop so flipping it on/off at
|
|
223
231
|
// runtime (e.g. by changing a feature flag) takes effect on the next
|
|
224
232
|
// render without remounting. eslint-disable for the intentional
|
|
225
|
-
// console.log
|
|
233
|
+
// console.log - that's the whole point of this helper.
|
|
226
234
|
const log = useCallback((msg, data) => {
|
|
227
235
|
if (!debug)
|
|
228
236
|
return;
|
|
@@ -248,7 +256,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
248
256
|
// forcing the methods (track / identify / trackPageview) to depend
|
|
249
257
|
// on it for useCallback equality. Initialized inside the route
|
|
250
258
|
// effect since EventQueue's constructor touches localStorage and
|
|
251
|
-
// window
|
|
259
|
+
// window - both unavailable during SSR. Methods called before init
|
|
252
260
|
// (during the first render, before useEffect runs) silently noop;
|
|
253
261
|
// this is fine because methods are only ever called from event
|
|
254
262
|
// handlers, which fire after mount.
|
|
@@ -280,49 +288,106 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
280
288
|
visitor_id: vid,
|
|
281
289
|
session_id: getSessionId(),
|
|
282
290
|
event_name: name,
|
|
291
|
+
// Page context, same three fields the pageview sender carries.
|
|
292
|
+
// A goal event used to go out without them, and the server's
|
|
293
|
+
// `path || hostname || '[Web]'` fallback then overwrote the
|
|
294
|
+
// visitor's real current_page with the literal string '[Web]' -
|
|
295
|
+
// which both broke the tracking-page popover and made the visitor
|
|
296
|
+
// invisible to any funnel step matching on a page path. The server
|
|
297
|
+
// no longer clobbers on missing values, but sending the real path
|
|
298
|
+
// is what actually keeps current_page correct as a visitor fires
|
|
299
|
+
// goals while navigating a single-page app.
|
|
300
|
+
url: window.location.href,
|
|
301
|
+
path: window.location.pathname,
|
|
302
|
+
hostname: window.location.hostname,
|
|
283
303
|
};
|
|
284
304
|
if (metadata && typeof metadata === 'object')
|
|
285
305
|
payload.metadata = metadata;
|
|
286
306
|
// Enqueue. EventQueue handles persist, retry, and flush. If the
|
|
287
307
|
// queue hasn't initialized yet (called during first render before
|
|
288
|
-
// useEffect runs), this noops
|
|
308
|
+
// useEffect runs), this noops - that's a vanishingly rare race
|
|
289
309
|
// since track() is virtually always called from a click handler.
|
|
290
310
|
queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
|
|
291
311
|
log('track', { event: name, metadata });
|
|
292
312
|
}
|
|
293
313
|
catch { /* swallow */ }
|
|
294
314
|
}, [apiBase, log]);
|
|
295
|
-
// identify(userId)
|
|
296
|
-
//
|
|
297
|
-
//
|
|
315
|
+
// identify(userId): say who is signed in (4.8.0). The server keeps one
|
|
316
|
+
// customer per person per app: a sign-in makes this browser that person's
|
|
317
|
+
// (folded in with their phone and other browsers), and a DIFFERENT user
|
|
318
|
+
// signing in here switches the browser to them. user_id only - the SDK
|
|
319
|
+
// does NOT accept, collect, or transmit email or any other PII.
|
|
320
|
+
//
|
|
321
|
+
// The id is stored and re-sent once per visit (the 10-minute session
|
|
322
|
+
// above), on mount, so the server stays in step without a request on
|
|
323
|
+
// every page load. Ids that mean "nobody" are skipped (user-id.ts), and
|
|
324
|
+
// nothing is sent while opted out. A 400 is not retried (queue.ts).
|
|
298
325
|
const identify = useCallback(async (userId) => {
|
|
299
326
|
try {
|
|
300
327
|
if (typeof window === 'undefined')
|
|
301
328
|
return;
|
|
302
|
-
if (isOptedOut())
|
|
303
|
-
|
|
304
|
-
if (!userId || typeof userId !== 'string')
|
|
329
|
+
if (isOptedOut()) {
|
|
330
|
+
log('identify skipped: opted out');
|
|
305
331
|
return;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (!uid || uid.length > 128)
|
|
332
|
+
}
|
|
333
|
+
const uid = usableUserId(userId);
|
|
334
|
+
if (!uid) {
|
|
335
|
+
log('identify skipped: not a user id (empty, over 128 characters, or a placeholder like "null", "guest" or "0")', { user_id: userId });
|
|
311
336
|
return;
|
|
337
|
+
}
|
|
338
|
+
lsSet(LS_USER_ID, uid);
|
|
312
339
|
const cid = appIdRef.current;
|
|
313
340
|
const vid = visitorIdRef.current;
|
|
314
|
-
|
|
341
|
+
const queue = queueRef.current;
|
|
342
|
+
// Not tracking yet: the stored id goes out with the next mount.
|
|
343
|
+
if (!cid || !vid || !queue)
|
|
315
344
|
return;
|
|
316
|
-
const
|
|
345
|
+
const mark = `${vid} ${getSessionId()} ${uid}`;
|
|
346
|
+
if (lsGet(LS_USER_SENT) === mark) {
|
|
347
|
+
log('identify: already sent this visit', { user_id: uid });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
lsSet(LS_USER_SENT, mark);
|
|
351
|
+
// A sign-out still waiting in the queue is replaced by this sign-in;
|
|
352
|
+
// delivered after it, it would sign the new person out again.
|
|
353
|
+
const logoutEndpoint = `${apiBase}/api/v1/web/logout`;
|
|
354
|
+
queue.drop((e) => e.endpoint === logoutEndpoint && e.payload.visitor_id === vid);
|
|
355
|
+
queue.enqueue(`${apiBase}/api/v1/web/identify`, {
|
|
317
356
|
campaign_id: cid,
|
|
318
357
|
visitor_id: vid,
|
|
319
358
|
user_id: uid,
|
|
320
|
-
};
|
|
321
|
-
queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
|
|
359
|
+
});
|
|
322
360
|
log('identify', { user_id: uid });
|
|
323
361
|
}
|
|
324
362
|
catch { /* swallow */ }
|
|
325
363
|
}, [apiBase, log]);
|
|
364
|
+
// logout(): the person signed out (4.8.0). POST /api/v1/web/logout: the
|
|
365
|
+
// browser starts over as a new anonymous customer of the app, so whoever
|
|
366
|
+
// signs in next (or nobody) is not counted as the person who just left,
|
|
367
|
+
// and everything already recorded stays with them. Forgets the stored
|
|
368
|
+
// user id. The visitor id, the affiliate ref, the session and tracking
|
|
369
|
+
// itself all carry on unchanged.
|
|
370
|
+
const logout = useCallback(async () => {
|
|
371
|
+
try {
|
|
372
|
+
if (typeof window === 'undefined')
|
|
373
|
+
return;
|
|
374
|
+
log('logout');
|
|
375
|
+
lsRemove(LS_USER_ID);
|
|
376
|
+
lsRemove(LS_USER_SENT);
|
|
377
|
+
if (isOptedOut())
|
|
378
|
+
return;
|
|
379
|
+
const cid = appIdRef.current;
|
|
380
|
+
const vid = visitorIdRef.current;
|
|
381
|
+
const queue = queueRef.current;
|
|
382
|
+
if (!cid || !vid || !queue)
|
|
383
|
+
return;
|
|
384
|
+
queue.enqueue(`${apiBase}/api/v1/web/logout`, { campaign_id: cid, visitor_id: vid });
|
|
385
|
+
// Sign-out usually comes right before a redirect, so send it now.
|
|
386
|
+
// Still queued if this fails: the next page load delivers it.
|
|
387
|
+
await queue.flush();
|
|
388
|
+
}
|
|
389
|
+
catch { /* swallow */ }
|
|
390
|
+
}, [apiBase, log]);
|
|
326
391
|
// Manual pageview via trackPageview(path?). Same payload
|
|
327
392
|
// shape the auto-fired pageview uses (see sendPageview inside the route
|
|
328
393
|
// effect below) so server-side routing / dedup / IP+UA enrichment behave
|
|
@@ -363,7 +428,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
363
428
|
utm_source: params.get('utm_source') || '',
|
|
364
429
|
utm_medium: params.get('utm_medium') || '',
|
|
365
430
|
utm_campaign: params.get('utm_campaign') || '',
|
|
366
|
-
// Ad-network click ids
|
|
431
|
+
// Ad-network click ids - set automatically on the landing URL by
|
|
367
432
|
// every Meta/TikTok/Google ad click. Label the sale's source and
|
|
368
433
|
// feed server-side conversion matching.
|
|
369
434
|
fbclid: params.get('fbclid') || '',
|
|
@@ -386,7 +451,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
386
451
|
// batches and auto-flushes every 5s; this is the escape hatch for the
|
|
387
452
|
// "I'm about to redirect, don't lose my events" case.
|
|
388
453
|
//
|
|
389
|
-
// Resolves when the underlying queue.flush() completes
|
|
454
|
+
// Resolves when the underlying queue.flush() completes - success OR
|
|
390
455
|
// failure. Events that fail to deliver stay in the queue and get
|
|
391
456
|
// retried on the next normal flush cycle. Safe to await; never throws.
|
|
392
457
|
const flush = useCallback(async () => {
|
|
@@ -396,49 +461,23 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
396
461
|
}
|
|
397
462
|
catch { /* swallow */ }
|
|
398
463
|
}, [log]);
|
|
399
|
-
// reset():
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
// visitor, no event fired, caller handles "tell the server about it"
|
|
405
|
-
// by triggering whatever pageview / navigation comes next.
|
|
406
|
-
//
|
|
407
|
-
// Returns a Promise so callers can `await reset()` before navigating
|
|
408
|
-
// (the localStorage writes are sync but the Promise lets the API stay
|
|
409
|
-
// an async signature, future-proofing for
|
|
410
|
-
// server-minted IDs in cookieless mode).
|
|
411
|
-
const reset = useCallback(async () => {
|
|
412
|
-
try {
|
|
413
|
-
if (typeof window === 'undefined')
|
|
414
|
-
return;
|
|
415
|
-
// Drain any pending events for the OLD visitor before swapping IDs,
|
|
416
|
-
// otherwise they'd land server-side stamped to the OLD visitor_id
|
|
417
|
-
// (correct) but persisted-in-localStorage as if to-be-retried under
|
|
418
|
-
// the new identity — confusing on retry inspection. Best effort.
|
|
419
|
-
void queueRef.current?.flush();
|
|
420
|
-
queueRef.current?.clear();
|
|
421
|
-
const newVid = uuid();
|
|
422
|
-
lsSet(LS_VISITOR_ID, newVid);
|
|
423
|
-
lsRemove(LS_REF);
|
|
424
|
-
lsRemove(LS_SESSION);
|
|
425
|
-
visitorIdRef.current = newVid;
|
|
426
|
-
setState((prev) => ({ ...prev, visitorId: newVid, refCode: null }));
|
|
427
|
-
}
|
|
428
|
-
catch { /* swallow */ }
|
|
429
|
-
}, []);
|
|
464
|
+
// reset(): the same as logout() since 4.8.0. It used to mint a fresh
|
|
465
|
+
// visitor id and clear the affiliate ref and session, which split one
|
|
466
|
+
// person's history into two visitors and lost their affiliate. Kept so
|
|
467
|
+
// existing integrations keep compiling and now get the sign-out they meant.
|
|
468
|
+
const reset = logout;
|
|
430
469
|
// optOut(): persistent "don't track me" flag. After this call every
|
|
431
470
|
// track / identify / trackPageview noops until optIn() is called. The
|
|
432
471
|
// flag survives reloads via localStorage, so a visitor who opts out
|
|
433
472
|
// stays opted out across browser sessions on the same device.
|
|
434
473
|
//
|
|
435
|
-
// We don't actively send a "stop tracking me" signal to the server
|
|
474
|
+
// We don't actively send a "stop tracking me" signal to the server -
|
|
436
475
|
// the server only knows about visitors who ping it, so an opted-out
|
|
437
476
|
// visitor simply stops appearing. The visitor row left behind in
|
|
438
477
|
// Postgres ages out via normal retention.
|
|
439
478
|
const optOut = useCallback(() => {
|
|
440
479
|
lsSet(LS_OPT_OUT, 'true');
|
|
441
|
-
// Drop pending events too. The visitor explicitly said no
|
|
480
|
+
// Drop pending events too. The visitor explicitly said no - sending
|
|
442
481
|
// queued events from before the opt-out would still violate consent
|
|
443
482
|
// even if those events were captured prior to the user's decision.
|
|
444
483
|
queueRef.current?.clear();
|
|
@@ -452,13 +491,26 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
452
491
|
lsRemove(LS_OPT_OUT);
|
|
453
492
|
}, []);
|
|
454
493
|
const sentRef = useRef(false);
|
|
494
|
+
// Boot configuration, read once when the queue and the listeners are
|
|
495
|
+
// constructed. A ref kept current on every render rather than four more
|
|
496
|
+
// dependencies: listing them would tear the whole pipeline down and
|
|
497
|
+
// rebuild it whenever a host re-renders with a new inline object, and
|
|
498
|
+
// shutdown() drops the in-memory queue, so honoring the rule that way
|
|
499
|
+
// would cost real events. A host that needs different settings should
|
|
500
|
+
// remount the provider. (Hosts run React 18, so this is the latest-ref
|
|
501
|
+
// pattern rather than useEffectEvent.)
|
|
502
|
+
const bootRef = useRef({ flushInterval, maxQueueSize, allowFileProtocol, autoCfg });
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
bootRef.current = { flushInterval, maxQueueSize, allowFileProtocol, autoCfg };
|
|
505
|
+
});
|
|
455
506
|
useEffect(() => {
|
|
456
507
|
if (typeof window === 'undefined')
|
|
457
508
|
return;
|
|
458
|
-
|
|
509
|
+
const { flushInterval, maxQueueSize, allowFileProtocol, autoCfg } = bootRef.current;
|
|
510
|
+
// Neither appId nor the legacy campaignId alias was passed - nothing to
|
|
459
511
|
// attribute events to, so stay dormant instead of firing junk.
|
|
460
512
|
if (!appIdRef.current) {
|
|
461
|
-
console.error('[Affiliateo] Missing appId
|
|
513
|
+
console.error('[Affiliateo] Missing appId - pass your app ID to <AffiliateoProvider appId="...">.');
|
|
462
514
|
return;
|
|
463
515
|
}
|
|
464
516
|
// Mount-count warning. Fires only when more than one Provider has
|
|
@@ -471,7 +523,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
471
523
|
// Client-side bot detection. If the current browser context looks
|
|
472
524
|
// like Selenium / Puppeteer / curl / a known automation tool, skip
|
|
473
525
|
// ALL tracking before we even mint a queue. The server-side UA
|
|
474
|
-
// regex + Turnstile catch anything that sneaks past this check
|
|
526
|
+
// regex + Turnstile catch anything that sneaks past this check -
|
|
475
527
|
// this just saves the bot a round-trip and saves us a request.
|
|
476
528
|
//
|
|
477
529
|
// The hook still returns the four control methods (reset / optOut /
|
|
@@ -487,6 +539,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
487
539
|
identify: asyncNoop,
|
|
488
540
|
trackPageview: asyncNoop,
|
|
489
541
|
reset: asyncNoop,
|
|
542
|
+
logout: asyncNoop,
|
|
490
543
|
optOut: syncNoop,
|
|
491
544
|
optIn: syncNoop,
|
|
492
545
|
flush: asyncNoop,
|
|
@@ -511,6 +564,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
511
564
|
identify: asyncNoop,
|
|
512
565
|
trackPageview: asyncNoop,
|
|
513
566
|
reset: asyncNoop,
|
|
567
|
+
logout: asyncNoop,
|
|
514
568
|
optOut: syncNoop,
|
|
515
569
|
optIn: syncNoop,
|
|
516
570
|
flush: asyncNoop,
|
|
@@ -529,6 +583,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
529
583
|
identify: asyncNoop,
|
|
530
584
|
trackPageview: asyncNoop,
|
|
531
585
|
reset: asyncNoop,
|
|
586
|
+
logout: asyncNoop,
|
|
532
587
|
optOut: syncNoop,
|
|
533
588
|
optIn: syncNoop,
|
|
534
589
|
flush: asyncNoop,
|
|
@@ -563,6 +618,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
563
618
|
identify: asyncNoop,
|
|
564
619
|
trackPageview: asyncNoop,
|
|
565
620
|
reset: asyncNoop,
|
|
621
|
+
logout: asyncNoop,
|
|
566
622
|
optOut: syncNoop,
|
|
567
623
|
optIn: syncNoop,
|
|
568
624
|
flush: asyncNoop,
|
|
@@ -571,8 +627,8 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
571
627
|
}
|
|
572
628
|
}
|
|
573
629
|
// Initialize the event queue. Done inside useEffect (not in a useRef
|
|
574
|
-
// initializer) so the EventQueue constructor
|
|
575
|
-
// localStorage on hydration
|
|
630
|
+
// initializer) so the EventQueue constructor - which touches
|
|
631
|
+
// localStorage on hydration - only ever runs client-side. The queue
|
|
576
632
|
// takes over delivery for track / identify / trackPageview AND the
|
|
577
633
|
// auto-fired pageview below; only session_end stays on sendBeacon
|
|
578
634
|
// because tab-close urgency beats queue-retry reliability.
|
|
@@ -601,9 +657,9 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
601
657
|
setState({
|
|
602
658
|
refCode: null,
|
|
603
659
|
visitorId: optedOutVid,
|
|
604
|
-
track, identify, trackPageview, reset, optOut, optIn, flush,
|
|
660
|
+
track, identify, trackPageview, reset, logout, optOut, optIn, flush,
|
|
605
661
|
});
|
|
606
|
-
// Don't leave a queue running for an opted-out visitor
|
|
662
|
+
// Don't leave a queue running for an opted-out visitor - the timer
|
|
607
663
|
// would just keep waking up to flush nothing.
|
|
608
664
|
queue.shutdown();
|
|
609
665
|
queueRef.current = null;
|
|
@@ -661,9 +717,13 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
661
717
|
setState({
|
|
662
718
|
refCode: ref,
|
|
663
719
|
visitorId: vid,
|
|
664
|
-
track, identify, trackPageview, reset, optOut, optIn, flush,
|
|
720
|
+
track, identify, trackPageview, reset, logout, optOut, optIn, flush,
|
|
665
721
|
});
|
|
666
722
|
log('init', { app: appIdRef.current, visitor: vid, ref: ref || null });
|
|
723
|
+
// This visit's re-send of the signed-in user (see identify).
|
|
724
|
+
const storedUser = usableUserId(lsGet(LS_USER_ID));
|
|
725
|
+
if (storedUser)
|
|
726
|
+
void identify(storedUser);
|
|
667
727
|
// lastSendTime drives the visibility-refire heuristic below (refire a
|
|
668
728
|
// pageview when a tab returns after the session window). Duplicate
|
|
669
729
|
// suppression itself is the persisted same-URL throttle inside
|
|
@@ -696,7 +756,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
696
756
|
ended = false;
|
|
697
757
|
// Attach the (single-use) Turnstile token to the session-opening
|
|
698
758
|
// pageview. consumeTurnstileToken() returns null after the first
|
|
699
|
-
// consume, so SPA navigations go out bare
|
|
759
|
+
// consume, so SPA navigations go out bare - the server trusts them
|
|
700
760
|
// because the visitor row was created behind the verified token.
|
|
701
761
|
const tsToken = consumeTurnstileToken();
|
|
702
762
|
queue.enqueue(endpoint, {
|
|
@@ -727,7 +787,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
727
787
|
// directly. The queue's fetch-with-keepalive is great for normal
|
|
728
788
|
// events because it returns a status code we can retry on, but it
|
|
729
789
|
// can't guarantee the request lands when the tab is in the act of
|
|
730
|
-
// closing
|
|
790
|
+
// closing - fetch may be aborted mid-flight. sendBeacon was
|
|
731
791
|
// designed exactly for this case and CANNOT be cancelled by the
|
|
732
792
|
// unload handler. We also try to drain the queue's pending events
|
|
733
793
|
// here, which uses sendBeacon under the hood for the same reason.
|
|
@@ -760,7 +820,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
760
820
|
}
|
|
761
821
|
}
|
|
762
822
|
// Server-side session window. Matches STALE_VISITOR_MS in the
|
|
763
|
-
// tracking Durable Object
|
|
823
|
+
// tracking Durable Object - after this much idle time the server
|
|
764
824
|
// treats the visitor as a new session, rotates session_id, and
|
|
765
825
|
// expects a fresh pageview to anchor the new visit in
|
|
766
826
|
// campaign_events. Without that anchor, funnel steps starting on
|
|
@@ -777,7 +837,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
777
837
|
// If the SDK's last pageview was longer ago than the server's
|
|
778
838
|
// 10-min session window, the server has already rolled the
|
|
779
839
|
// visitor over to a new session_id. Refire pageview so the new
|
|
780
|
-
// session has a proper anchor
|
|
840
|
+
// session has a proper anchor - otherwise the next scroll /
|
|
781
841
|
// click / custom event arrives orphaned and the funnel overlay
|
|
782
842
|
// sees the visitor enter "mid-funnel" (e.g. on a scroll step)
|
|
783
843
|
// without ever passing through the page-step that should
|
|
@@ -800,7 +860,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
800
860
|
//
|
|
801
861
|
// sentRef is set HERE (not before the mint) so a StrictMode
|
|
802
862
|
// double-mount can't mark the pageview as sent and then tear down the
|
|
803
|
-
// queue before the deferred send ever ran
|
|
863
|
+
// queue before the deferred send ever ran - the surviving mount's
|
|
804
864
|
// callback sees sentRef still false and fires.
|
|
805
865
|
if (cancelled)
|
|
806
866
|
return;
|
|
@@ -959,20 +1019,14 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
959
1019
|
document.removeEventListener('keydown', onKeydownForLinkDecoration, true);
|
|
960
1020
|
}
|
|
961
1021
|
// Stop the queue's flush timer. The queue tries one final flush
|
|
962
|
-
// before going quiet
|
|
1022
|
+
// before going quiet - anything still queued lives in localStorage
|
|
963
1023
|
// and will be picked up by the next page load's queue init.
|
|
964
1024
|
queue.shutdown();
|
|
965
1025
|
queueRef.current = null;
|
|
966
1026
|
providerMountCount = Math.max(0, providerMountCount - 1);
|
|
967
1027
|
};
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
|
|
971
|
-
// it whenever a host app re-renders with a new inline object, and
|
|
972
|
-
// shutdown() drops the in-memory queue — so honoring the rule here would
|
|
973
|
-
// cost real events. A host that needs different settings should remount
|
|
974
|
-
// the provider.
|
|
975
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
976
|
-
}, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, optOut, optIn, flush]);
|
|
1028
|
+
// The boot configuration comes off bootRef above, so this list is what
|
|
1029
|
+
// legitimately rebuilds the pipeline.
|
|
1030
|
+
}, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, logout, optOut, optIn, flush]);
|
|
977
1031
|
return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
|
|
978
1032
|
}
|
package/dist/queue.d.ts
CHANGED
|
@@ -85,16 +85,20 @@ export declare class EventQueue {
|
|
|
85
85
|
* flush at a time. */
|
|
86
86
|
flush(): Promise<void>;
|
|
87
87
|
/** Best-effort sync flush triggered on page unload. Uses sendBeacon
|
|
88
|
-
* for each event so it survives the tab closing. No retry
|
|
88
|
+
* for each event so it survives the tab closing. No retry - this is
|
|
89
89
|
* the last chance.
|
|
90
90
|
*
|
|
91
91
|
* Called from the provider's pagehide / visibilitychange=hidden
|
|
92
92
|
* handlers so any events still queued at unload get one last shot.
|
|
93
93
|
* After this call, the in-memory queue is cleared (events that
|
|
94
|
-
* sendBeacon couldn't queue are lost
|
|
94
|
+
* sendBeacon couldn't queue are lost - that's the cost of the
|
|
95
95
|
* guaranteed-fast unload path). */
|
|
96
96
|
flushOnUnload(): void;
|
|
97
|
-
/**
|
|
97
|
+
/** Remove the queued events `predicate` picks (4.8.0). A sign-in uses it
|
|
98
|
+
* to drop a sign-out still waiting to be delivered: the sign-in replaces
|
|
99
|
+
* it, and a sign-out retried after it would undo it. */
|
|
100
|
+
drop(predicate: (event: QueuedEvent) => boolean): void;
|
|
101
|
+
/** Wipe the queue. Called by optOut(). Removes the
|
|
98
102
|
* localStorage entry entirely so a reload doesn't re-hydrate the
|
|
99
103
|
* wiped events. */
|
|
100
104
|
clear(): void;
|
package/dist/queue.js
CHANGED
|
@@ -88,7 +88,7 @@ export class EventQueue {
|
|
|
88
88
|
this.maxQueueSize = Math.max(10, Math.min(1000, opts.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
|
|
89
89
|
// Hydrate from localStorage so events from a prior page load survive
|
|
90
90
|
// a refresh / navigation. Guards against corrupt JSON or wrong-shape
|
|
91
|
-
// entries by resetting to empty if anything looks off
|
|
91
|
+
// entries by resetting to empty if anything looks off - better to
|
|
92
92
|
// lose a few stuck events than crash the host app on init.
|
|
93
93
|
try {
|
|
94
94
|
const raw = lsGet(STORAGE_KEY);
|
|
@@ -120,10 +120,27 @@ export class EventQueue {
|
|
|
120
120
|
enqueue(endpoint, payload) {
|
|
121
121
|
if (this.shuttingDown)
|
|
122
122
|
return;
|
|
123
|
+
const id = uuid();
|
|
123
124
|
this.queue.push({
|
|
124
|
-
id
|
|
125
|
+
id,
|
|
125
126
|
endpoint,
|
|
126
|
-
payload
|
|
127
|
+
// Delivery metadata is baked into the PERSISTED payload rather than
|
|
128
|
+
// added at flush time, so every retry - and every recovery after a
|
|
129
|
+
// page reload - sends a byte-identical body:
|
|
130
|
+
//
|
|
131
|
+
// event_id makes the server insert idempotent. Without it, a 5xx
|
|
132
|
+
// raised after the row was already written, or a network timeout
|
|
133
|
+
// on a request the server actually processed, lands the same
|
|
134
|
+
// event twice: flush() only removes an entry on res.ok, so
|
|
135
|
+
// anything else bumps retries and sends it again (MAX_RETRIES=3).
|
|
136
|
+
// The server dedups on this via a unique index.
|
|
137
|
+
//
|
|
138
|
+
// client_ts is when the event HAPPENED. The server used to stamp
|
|
139
|
+
// arrival time, but the 5s flush cadence, the size-threshold
|
|
140
|
+
// burst flush, and retries all reorder delivery freely - so
|
|
141
|
+
// arrival order is not user order. Stamping at enqueue is the
|
|
142
|
+
// only point that still knows the real sequence.
|
|
143
|
+
payload: { ...payload, event_id: id, client_ts: Date.now() },
|
|
127
144
|
retries: 0,
|
|
128
145
|
});
|
|
129
146
|
// FIFO drop when over cap. Network outage edge case: if we're way
|
|
@@ -136,7 +153,7 @@ export class EventQueue {
|
|
|
136
153
|
this.persist();
|
|
137
154
|
// Burst protection: when many events fire in quick succession
|
|
138
155
|
// (auto-pageview + immediate track + identify on app boot), don't
|
|
139
|
-
// wait for the 5s timer
|
|
156
|
+
// wait for the 5s timer - flush right away. The isFlushing guard
|
|
140
157
|
// inside flush() makes overlapping calls safe.
|
|
141
158
|
if (this.queue.length >= SIZE_FLUSH_THRESHOLD) {
|
|
142
159
|
void this.flush();
|
|
@@ -153,13 +170,21 @@ export class EventQueue {
|
|
|
153
170
|
if (this.isFlushing || this.queue.length === 0)
|
|
154
171
|
return;
|
|
155
172
|
this.isFlushing = true;
|
|
156
|
-
// Snapshot the queue so we iterate a stable list
|
|
173
|
+
// Snapshot the queue so we iterate a stable list - new enqueue()
|
|
157
174
|
// calls during the flush land in the live queue and get picked up
|
|
158
175
|
// by the next flush rather than this one (avoids infinite loops
|
|
159
176
|
// and reasoning headaches).
|
|
160
177
|
const batch = this.queue.slice();
|
|
161
178
|
for (const event of batch) {
|
|
179
|
+
// Dropped or cleared since the batch was taken (optOut(), or a
|
|
180
|
+
// sign-in replacing a sign-out still waiting here): not sent.
|
|
181
|
+
if (!this.queue.some((e) => e.id === event.id))
|
|
182
|
+
continue;
|
|
162
183
|
let ok = false;
|
|
184
|
+
// A 400 says the request itself is wrong (a placeholder user id, a
|
|
185
|
+
// malformed body), so sending it again cannot succeed: dropped
|
|
186
|
+
// rather than retried (4.8.0).
|
|
187
|
+
let rejected = false;
|
|
163
188
|
try {
|
|
164
189
|
const res = await fetch(event.endpoint, {
|
|
165
190
|
method: 'POST',
|
|
@@ -169,16 +194,16 @@ export class EventQueue {
|
|
|
169
194
|
// with the queue so even if this fetch errors, we'll retry.
|
|
170
195
|
keepalive: true,
|
|
171
196
|
});
|
|
172
|
-
// 2xx = success.
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// dropping is the right move — keeps the queue clean.
|
|
197
|
+
// 2xx = success. A 400 is dropped at once (above). Anything else
|
|
198
|
+
// (other 4xx, 5xx) counts as a failure and bumps retries; after
|
|
199
|
+
// 3 it is dropped, which keeps the queue clean.
|
|
176
200
|
ok = res.ok;
|
|
201
|
+
rejected = res.status === 400;
|
|
177
202
|
}
|
|
178
203
|
catch {
|
|
179
204
|
ok = false;
|
|
180
205
|
}
|
|
181
|
-
if (ok) {
|
|
206
|
+
if (ok || rejected) {
|
|
182
207
|
// Remove from live queue (not the snapshot) by id
|
|
183
208
|
this.queue = this.queue.filter((e) => e.id !== event.id);
|
|
184
209
|
}
|
|
@@ -199,19 +224,19 @@ export class EventQueue {
|
|
|
199
224
|
this.isFlushing = false;
|
|
200
225
|
}
|
|
201
226
|
/** Best-effort sync flush triggered on page unload. Uses sendBeacon
|
|
202
|
-
* for each event so it survives the tab closing. No retry
|
|
227
|
+
* for each event so it survives the tab closing. No retry - this is
|
|
203
228
|
* the last chance.
|
|
204
229
|
*
|
|
205
230
|
* Called from the provider's pagehide / visibilitychange=hidden
|
|
206
231
|
* handlers so any events still queued at unload get one last shot.
|
|
207
232
|
* After this call, the in-memory queue is cleared (events that
|
|
208
|
-
* sendBeacon couldn't queue are lost
|
|
233
|
+
* sendBeacon couldn't queue are lost - that's the cost of the
|
|
209
234
|
* guaranteed-fast unload path). */
|
|
210
235
|
flushOnUnload() {
|
|
211
236
|
if (this.queue.length === 0)
|
|
212
237
|
return;
|
|
213
238
|
if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
|
|
214
|
-
// No sendBeacon (very old browser)
|
|
239
|
+
// No sendBeacon (very old browser) - try a sync fetch with
|
|
215
240
|
// keepalive as a fallback. Won't actually finish, but it's our
|
|
216
241
|
// last shot.
|
|
217
242
|
for (const event of this.queue) {
|
|
@@ -238,7 +263,16 @@ export class EventQueue {
|
|
|
238
263
|
this.queue = [];
|
|
239
264
|
this.persist();
|
|
240
265
|
}
|
|
241
|
-
/**
|
|
266
|
+
/** Remove the queued events `predicate` picks (4.8.0). A sign-in uses it
|
|
267
|
+
* to drop a sign-out still waiting to be delivered: the sign-in replaces
|
|
268
|
+
* it, and a sign-out retried after it would undo it. */
|
|
269
|
+
drop(predicate) {
|
|
270
|
+
const before = this.queue.length;
|
|
271
|
+
this.queue = this.queue.filter((e) => !predicate(e));
|
|
272
|
+
if (this.queue.length !== before)
|
|
273
|
+
this.persist();
|
|
274
|
+
}
|
|
275
|
+
/** Wipe the queue. Called by optOut(). Removes the
|
|
242
276
|
* localStorage entry entirely so a reload doesn't re-hydrate the
|
|
243
277
|
* wiped events. */
|
|
244
278
|
clear() {
|
package/dist/turnstile.d.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
|
|
3
3
|
* mint ONE invisible token at SDK init and attach it to the first pageview.
|
|
4
4
|
* The server verifies the token before creating the visitor row (and before
|
|
5
|
-
* counting a pay-per-click web_traffic click
|
|
5
|
+
* counting a pay-per-click web_traffic click - the billable path REQUIRES a
|
|
6
6
|
* genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
|
|
7
7
|
* the same visitor_id are trusted because the row was created behind a
|
|
8
8
|
* verified token, so the cost is one mint + one verify per session.
|
|
9
9
|
*
|
|
10
10
|
* Tokens are single-use at Cloudflare's siteverify, so consumeToken()
|
|
11
|
-
* hands the token out exactly once
|
|
11
|
+
* hands the token out exactly once - re-sending a used token would get the
|
|
12
12
|
* event rejected server-side, not warn-allowed.
|
|
13
13
|
*
|
|
14
14
|
* Failure modes: if the Turnstile script is blocked (ad blockers, strict
|
|
@@ -28,7 +28,7 @@ declare global {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Load the Turnstile script (once) and run the invisible challenge. Resolves
|
|
31
|
-
* when a token is available OR the mint failed/timed out
|
|
31
|
+
* when a token is available OR the mint failed/timed out - never rejects, so
|
|
32
32
|
* callers can unconditionally `.then(sendPageview)`.
|
|
33
33
|
*/
|
|
34
34
|
export declare function mintTurnstileToken(): Promise<void>;
|