@affiliateo/web 4.7.0 → 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.
@@ -283,7 +291,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
283
291
  // Page context, same three fields the pageview sender carries.
284
292
  // A goal event used to go out without them, and the server's
285
293
  // `path || hostname || '[Web]'` fallback then overwrote the
286
- // visitor's real current_page with the literal string '[Web]' —
294
+ // visitor's real current_page with the literal string '[Web]' -
287
295
  // which both broke the tracking-page popover and made the visitor
288
296
  // invisible to any funnel step matching on a page path. The server
289
297
  // no longer clobbers on missing values, but sending the real path
@@ -297,44 +305,89 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
297
305
  payload.metadata = metadata;
298
306
  // Enqueue. EventQueue handles persist, retry, and flush. If the
299
307
  // queue hasn't initialized yet (called during first render before
300
- // useEffect runs), this noops — that's a vanishingly rare race
308
+ // useEffect runs), this noops - that's a vanishingly rare race
301
309
  // since track() is virtually always called from a click handler.
302
310
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
303
311
  log('track', { event: name, metadata });
304
312
  }
305
313
  catch { /* swallow */ }
306
314
  }, [apiBase, log]);
307
- // identify(userId). user_id only — the SDK does NOT accept, collect,
308
- // or transmit email or any other PII. Cross-device stitching uses the
309
- // 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).
310
325
  const identify = useCallback(async (userId) => {
311
326
  try {
312
327
  if (typeof window === 'undefined')
313
328
  return;
314
- if (isOptedOut())
315
- return;
316
- if (!userId || typeof userId !== 'string')
329
+ if (isOptedOut()) {
330
+ log('identify skipped: opted out');
317
331
  return;
318
- const uid = userId.trim();
319
- // Cap at 128 chars (matches the server). Covers every common
320
- // foreign user-id shape: uuid, ulid, mongo objectId, auth0 sub,
321
- // firebase uid, supabase uuid.
322
- 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 });
323
336
  return;
337
+ }
338
+ lsSet(LS_USER_ID, uid);
324
339
  const cid = appIdRef.current;
325
340
  const vid = visitorIdRef.current;
326
- 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)
327
344
  return;
328
- 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`, {
329
356
  campaign_id: cid,
330
357
  visitor_id: vid,
331
358
  user_id: uid,
332
- };
333
- queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
359
+ });
334
360
  log('identify', { user_id: uid });
335
361
  }
336
362
  catch { /* swallow */ }
337
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]);
338
391
  // Manual pageview via trackPageview(path?). Same payload
339
392
  // shape the auto-fired pageview uses (see sendPageview inside the route
340
393
  // effect below) so server-side routing / dedup / IP+UA enrichment behave
@@ -375,7 +428,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
375
428
  utm_source: params.get('utm_source') || '',
376
429
  utm_medium: params.get('utm_medium') || '',
377
430
  utm_campaign: params.get('utm_campaign') || '',
378
- // Ad-network click ids — set automatically on the landing URL by
431
+ // Ad-network click ids - set automatically on the landing URL by
379
432
  // every Meta/TikTok/Google ad click. Label the sale's source and
380
433
  // feed server-side conversion matching.
381
434
  fbclid: params.get('fbclid') || '',
@@ -398,7 +451,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
398
451
  // batches and auto-flushes every 5s; this is the escape hatch for the
399
452
  // "I'm about to redirect, don't lose my events" case.
400
453
  //
401
- // Resolves when the underlying queue.flush() completes — success OR
454
+ // Resolves when the underlying queue.flush() completes - success OR
402
455
  // failure. Events that fail to deliver stay in the queue and get
403
456
  // retried on the next normal flush cycle. Safe to await; never throws.
404
457
  const flush = useCallback(async () => {
@@ -408,49 +461,23 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
408
461
  }
409
462
  catch { /* swallow */ }
410
463
  }, [log]);
411
- // reset(): wipe visitor identity. Mints a fresh UUID and clears the
412
- // affiliate ref so the next pageview is a brand-new anonymous visitor.
413
- // The campaign_id stays (it's a property of the site, not the visitor).
414
- //
415
- // reset() semantics for non-cookieless mode: new
416
- // visitor, no event fired, caller handles "tell the server about it"
417
- // by triggering whatever pageview / navigation comes next.
418
- //
419
- // Returns a Promise so callers can `await reset()` before navigating
420
- // (the localStorage writes are sync but the Promise lets the API stay
421
- // an async signature, future-proofing for
422
- // server-minted IDs in cookieless mode).
423
- const reset = useCallback(async () => {
424
- try {
425
- if (typeof window === 'undefined')
426
- return;
427
- // Drain any pending events for the OLD visitor before swapping IDs,
428
- // otherwise they'd land server-side stamped to the OLD visitor_id
429
- // (correct) but persisted-in-localStorage as if to-be-retried under
430
- // the new identity — confusing on retry inspection. Best effort.
431
- void queueRef.current?.flush();
432
- queueRef.current?.clear();
433
- const newVid = uuid();
434
- lsSet(LS_VISITOR_ID, newVid);
435
- lsRemove(LS_REF);
436
- lsRemove(LS_SESSION);
437
- visitorIdRef.current = newVid;
438
- setState((prev) => ({ ...prev, visitorId: newVid, refCode: null }));
439
- }
440
- catch { /* swallow */ }
441
- }, []);
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;
442
469
  // optOut(): persistent "don't track me" flag. After this call every
443
470
  // track / identify / trackPageview noops until optIn() is called. The
444
471
  // flag survives reloads via localStorage, so a visitor who opts out
445
472
  // stays opted out across browser sessions on the same device.
446
473
  //
447
- // 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 -
448
475
  // the server only knows about visitors who ping it, so an opted-out
449
476
  // visitor simply stops appearing. The visitor row left behind in
450
477
  // Postgres ages out via normal retention.
451
478
  const optOut = useCallback(() => {
452
479
  lsSet(LS_OPT_OUT, 'true');
453
- // Drop pending events too. The visitor explicitly said no — sending
480
+ // Drop pending events too. The visitor explicitly said no - sending
454
481
  // queued events from before the opt-out would still violate consent
455
482
  // even if those events were captured prior to the user's decision.
456
483
  queueRef.current?.clear();
@@ -464,13 +491,26 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
464
491
  lsRemove(LS_OPT_OUT);
465
492
  }, []);
466
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
+ });
467
506
  useEffect(() => {
468
507
  if (typeof window === 'undefined')
469
508
  return;
470
- // 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
471
511
  // attribute events to, so stay dormant instead of firing junk.
472
512
  if (!appIdRef.current) {
473
- 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="...">.');
474
514
  return;
475
515
  }
476
516
  // Mount-count warning. Fires only when more than one Provider has
@@ -483,7 +523,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
483
523
  // Client-side bot detection. If the current browser context looks
484
524
  // like Selenium / Puppeteer / curl / a known automation tool, skip
485
525
  // ALL tracking before we even mint a queue. The server-side UA
486
- // regex + Turnstile catch anything that sneaks past this check —
526
+ // regex + Turnstile catch anything that sneaks past this check -
487
527
  // this just saves the bot a round-trip and saves us a request.
488
528
  //
489
529
  // The hook still returns the four control methods (reset / optOut /
@@ -499,6 +539,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
499
539
  identify: asyncNoop,
500
540
  trackPageview: asyncNoop,
501
541
  reset: asyncNoop,
542
+ logout: asyncNoop,
502
543
  optOut: syncNoop,
503
544
  optIn: syncNoop,
504
545
  flush: asyncNoop,
@@ -523,6 +564,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
523
564
  identify: asyncNoop,
524
565
  trackPageview: asyncNoop,
525
566
  reset: asyncNoop,
567
+ logout: asyncNoop,
526
568
  optOut: syncNoop,
527
569
  optIn: syncNoop,
528
570
  flush: asyncNoop,
@@ -541,6 +583,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
541
583
  identify: asyncNoop,
542
584
  trackPageview: asyncNoop,
543
585
  reset: asyncNoop,
586
+ logout: asyncNoop,
544
587
  optOut: syncNoop,
545
588
  optIn: syncNoop,
546
589
  flush: asyncNoop,
@@ -575,6 +618,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
575
618
  identify: asyncNoop,
576
619
  trackPageview: asyncNoop,
577
620
  reset: asyncNoop,
621
+ logout: asyncNoop,
578
622
  optOut: syncNoop,
579
623
  optIn: syncNoop,
580
624
  flush: asyncNoop,
@@ -583,8 +627,8 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
583
627
  }
584
628
  }
585
629
  // Initialize the event queue. Done inside useEffect (not in a useRef
586
- // initializer) so the EventQueue constructor — which touches
587
- // 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
588
632
  // takes over delivery for track / identify / trackPageview AND the
589
633
  // auto-fired pageview below; only session_end stays on sendBeacon
590
634
  // because tab-close urgency beats queue-retry reliability.
@@ -613,9 +657,9 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
613
657
  setState({
614
658
  refCode: null,
615
659
  visitorId: optedOutVid,
616
- track, identify, trackPageview, reset, optOut, optIn, flush,
660
+ track, identify, trackPageview, reset, logout, optOut, optIn, flush,
617
661
  });
618
- // 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
619
663
  // would just keep waking up to flush nothing.
620
664
  queue.shutdown();
621
665
  queueRef.current = null;
@@ -673,9 +717,13 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
673
717
  setState({
674
718
  refCode: ref,
675
719
  visitorId: vid,
676
- track, identify, trackPageview, reset, optOut, optIn, flush,
720
+ track, identify, trackPageview, reset, logout, optOut, optIn, flush,
677
721
  });
678
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);
679
727
  // lastSendTime drives the visibility-refire heuristic below (refire a
680
728
  // pageview when a tab returns after the session window). Duplicate
681
729
  // suppression itself is the persisted same-URL throttle inside
@@ -708,7 +756,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
708
756
  ended = false;
709
757
  // Attach the (single-use) Turnstile token to the session-opening
710
758
  // pageview. consumeTurnstileToken() returns null after the first
711
- // consume, so SPA navigations go out bare — the server trusts them
759
+ // consume, so SPA navigations go out bare - the server trusts them
712
760
  // because the visitor row was created behind the verified token.
713
761
  const tsToken = consumeTurnstileToken();
714
762
  queue.enqueue(endpoint, {
@@ -739,7 +787,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
739
787
  // directly. The queue's fetch-with-keepalive is great for normal
740
788
  // events because it returns a status code we can retry on, but it
741
789
  // can't guarantee the request lands when the tab is in the act of
742
- // closing — fetch may be aborted mid-flight. sendBeacon was
790
+ // closing - fetch may be aborted mid-flight. sendBeacon was
743
791
  // designed exactly for this case and CANNOT be cancelled by the
744
792
  // unload handler. We also try to drain the queue's pending events
745
793
  // here, which uses sendBeacon under the hood for the same reason.
@@ -772,7 +820,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
772
820
  }
773
821
  }
774
822
  // Server-side session window. Matches STALE_VISITOR_MS in the
775
- // tracking Durable Object — after this much idle time the server
823
+ // tracking Durable Object - after this much idle time the server
776
824
  // treats the visitor as a new session, rotates session_id, and
777
825
  // expects a fresh pageview to anchor the new visit in
778
826
  // campaign_events. Without that anchor, funnel steps starting on
@@ -789,7 +837,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
789
837
  // If the SDK's last pageview was longer ago than the server's
790
838
  // 10-min session window, the server has already rolled the
791
839
  // visitor over to a new session_id. Refire pageview so the new
792
- // session has a proper anchor — otherwise the next scroll /
840
+ // session has a proper anchor - otherwise the next scroll /
793
841
  // click / custom event arrives orphaned and the funnel overlay
794
842
  // sees the visitor enter "mid-funnel" (e.g. on a scroll step)
795
843
  // without ever passing through the page-step that should
@@ -812,7 +860,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
812
860
  //
813
861
  // sentRef is set HERE (not before the mint) so a StrictMode
814
862
  // double-mount can't mark the pageview as sent and then tear down the
815
- // queue before the deferred send ever ran — the surviving mount's
863
+ // queue before the deferred send ever ran - the surviving mount's
816
864
  // callback sees sentRef still false and fires.
817
865
  if (cancelled)
818
866
  return;
@@ -971,20 +1019,14 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
971
1019
  document.removeEventListener('keydown', onKeydownForLinkDecoration, true);
972
1020
  }
973
1021
  // Stop the queue's flush timer. The queue tries one final flush
974
- // before going quiet — anything still queued lives in localStorage
1022
+ // before going quiet - anything still queued lives in localStorage
975
1023
  // and will be picked up by the next page load's queue init.
976
1024
  queue.shutdown();
977
1025
  queueRef.current = null;
978
1026
  providerMountCount = Math.max(0, providerMountCount - 1);
979
1027
  };
980
- // flushInterval, maxQueueSize, allowFileProtocol and the autoCfg.* fields
981
- // are BOOT configuration, read once when the queue and listeners are
982
- // constructed. Listing them would tear the whole pipeline down and rebuild
983
- // it whenever a host app re-renders with a new inline object, and
984
- // shutdown() drops the in-memory queue — so honoring the rule here would
985
- // cost real events. A host that needs different settings should remount
986
- // the provider.
987
- // eslint-disable-next-line react-hooks/exhaustive-deps
988
- }, [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]);
989
1031
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
990
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);
@@ -125,8 +125,8 @@ export class EventQueue {
125
125
  id,
126
126
  endpoint,
127
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:
128
+ // added at flush time, so every retry - and every recovery after a
129
+ // page reload - sends a byte-identical body:
130
130
  //
131
131
  // event_id makes the server insert idempotent. Without it, a 5xx
132
132
  // raised after the row was already written, or a network timeout
@@ -137,7 +137,7 @@ export class EventQueue {
137
137
  //
138
138
  // client_ts is when the event HAPPENED. The server used to stamp
139
139
  // arrival time, but the 5s flush cadence, the size-threshold
140
- // burst flush, and retries all reorder delivery freely — so
140
+ // burst flush, and retries all reorder delivery freely - so
141
141
  // arrival order is not user order. Stamping at enqueue is the
142
142
  // only point that still knows the real sequence.
143
143
  payload: { ...payload, event_id: id, client_ts: Date.now() },
@@ -153,7 +153,7 @@ export class EventQueue {
153
153
  this.persist();
154
154
  // Burst protection: when many events fire in quick succession
155
155
  // (auto-pageview + immediate track + identify on app boot), don't
156
- // wait for the 5s timer — flush right away. The isFlushing guard
156
+ // wait for the 5s timer - flush right away. The isFlushing guard
157
157
  // inside flush() makes overlapping calls safe.
158
158
  if (this.queue.length >= SIZE_FLUSH_THRESHOLD) {
159
159
  void this.flush();
@@ -170,13 +170,21 @@ export class EventQueue {
170
170
  if (this.isFlushing || this.queue.length === 0)
171
171
  return;
172
172
  this.isFlushing = true;
173
- // Snapshot the queue so we iterate a stable list — new enqueue()
173
+ // Snapshot the queue so we iterate a stable list - new enqueue()
174
174
  // calls during the flush land in the live queue and get picked up
175
175
  // by the next flush rather than this one (avoids infinite loops
176
176
  // and reasoning headaches).
177
177
  const batch = this.queue.slice();
178
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;
179
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;
180
188
  try {
181
189
  const res = await fetch(event.endpoint, {
182
190
  method: 'POST',
@@ -186,16 +194,16 @@ export class EventQueue {
186
194
  // with the queue so even if this fetch errors, we'll retry.
187
195
  keepalive: true,
188
196
  });
189
- // 2xx = success. Anything else (4xx, 5xx) counts as a failure
190
- // and bumps retries. A 4xx is usually a permanent error
191
- // (bad payload, dead campaign) so retrying 3 times then
192
- // 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.
193
200
  ok = res.ok;
201
+ rejected = res.status === 400;
194
202
  }
195
203
  catch {
196
204
  ok = false;
197
205
  }
198
- if (ok) {
206
+ if (ok || rejected) {
199
207
  // Remove from live queue (not the snapshot) by id
200
208
  this.queue = this.queue.filter((e) => e.id !== event.id);
201
209
  }
@@ -216,19 +224,19 @@ export class EventQueue {
216
224
  this.isFlushing = false;
217
225
  }
218
226
  /** Best-effort sync flush triggered on page unload. Uses sendBeacon
219
- * 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
220
228
  * the last chance.
221
229
  *
222
230
  * Called from the provider's pagehide / visibilitychange=hidden
223
231
  * handlers so any events still queued at unload get one last shot.
224
232
  * After this call, the in-memory queue is cleared (events that
225
- * 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
226
234
  * guaranteed-fast unload path). */
227
235
  flushOnUnload() {
228
236
  if (this.queue.length === 0)
229
237
  return;
230
238
  if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
231
- // No sendBeacon (very old browser) — try a sync fetch with
239
+ // No sendBeacon (very old browser) - try a sync fetch with
232
240
  // keepalive as a fallback. Won't actually finish, but it's our
233
241
  // last shot.
234
242
  for (const event of this.queue) {
@@ -255,7 +263,16 @@ export class EventQueue {
255
263
  this.queue = [];
256
264
  this.persist();
257
265
  }
258
- /** 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
259
276
  * localStorage entry entirely so a reload doesn't re-hydrate the
260
277
  * wiped events. */
261
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>;