@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/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 — the Affiliateo
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 — which
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 — that's the whole point of this helper.
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 — both unavailable during SSR. Methods called before init
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 — that's a vanishingly rare race
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). user_id only — the SDK does NOT accept, collect,
296
- // or transmit email or any other PII. Cross-device stitching uses the
297
- // merchant's own user_id; we never touch identifying personal data.
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
- return;
304
- if (!userId || typeof userId !== 'string')
329
+ if (isOptedOut()) {
330
+ log('identify skipped: opted out');
305
331
  return;
306
- const uid = userId.trim();
307
- // Cap at 128 chars (matches the server). Covers every common
308
- // foreign user-id shape: uuid, ulid, mongo objectId, auth0 sub,
309
- // firebase uid, supabase uuid.
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
- if (!cid || !vid)
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 payload = {
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 — set automatically on the landing URL by
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 — success OR
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(): wipe visitor identity. Mints a fresh UUID and clears the
400
- // affiliate ref so the next pageview is a brand-new anonymous visitor.
401
- // The campaign_id stays (it's a property of the site, not the visitor).
402
- //
403
- // reset() semantics for non-cookieless mode: new
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 — sending
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
- // Neither appId nor the legacy campaignId alias was passed — nothing to
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 — pass your app ID to <AffiliateoProvider 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 — which touches
575
- // localStorage on hydration — only ever runs client-side. The queue
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 — the timer
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 — the server trusts them
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 — fetch may be aborted mid-flight. sendBeacon was
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 — after this much idle time the server
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 — otherwise the next scroll /
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 — the surviving mount's
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 — anything still queued lives in localStorage
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
- // flushInterval, maxQueueSize, allowFileProtocol and the autoCfg.* fields
969
- // are BOOT configuration, read once when the queue and listeners are
970
- // constructed. Listing them would tear the whole pipeline down and rebuild
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 — this is
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 — that's the cost of the
94
+ * sendBeacon couldn't queue are lost - that's the cost of the
95
95
  * guaranteed-fast unload path). */
96
96
  flushOnUnload(): void;
97
- /** Wipe the queue. Called by reset() and optOut(). Removes the
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 — better to
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: uuid(),
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 — flush right away. The isFlushing guard
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 — new enqueue()
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. Anything else (4xx, 5xx) counts as a failure
173
- // and bumps retries. A 4xx is usually a permanent error
174
- // (bad payload, dead campaign) so retrying 3 times then
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 — this is
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 — that's the cost of the
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) — try a sync fetch with
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
- /** Wipe the queue. Called by reset() and optOut(). Removes the
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() {
@@ -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 — the billable path REQUIRES a
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 — re-sending a used token would get the
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 — never rejects, so
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>;