@affiliateo/web 3.1.0 → 4.1.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.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Dev-host detection for the SDK. Returns true when the current hostname
3
+ * is obviously a developer's machine and not a real production site.
4
+ *
5
+ * The SDK uses this to short-circuit tracking by default (allowLocalhost
6
+ * defaults to false). Stops the "Quegly funnel pollution" pattern where
7
+ * a developer running `npm run dev` against localhost:3000 fires hundreds
8
+ * of pageviews into the production funnel.
9
+ *
10
+ * IMPORTANT: this list is INTENTIONALLY TIGHTER than the server-side
11
+ * `src/lib/analytics/dev-host.ts` helper. The server helper is used for
12
+ * domain-binding logic and can be aggressive about catching staging /
13
+ * preview deploys (.vercel.app, .pages.dev). The SDK helper cannot,
14
+ * because some merchants legitimately run their production site on a
15
+ * .vercel.app subdomain (no custom domain) and we'd silently break their
16
+ * tracking. So we only catch the unambiguous cases:
17
+ *
18
+ * - localhost / 127.0.0.1 / 0.0.0.0 / ::1 (literal local)
19
+ * - RFC1918 private IPs (10.x, 192.168.x, 172.16-31.x)
20
+ * - .local / .test / .example / .invalid / .localhost (RFC-reserved
21
+ * dev TLDs that cannot exist in production)
22
+ * - .lvh.me / .localtest.me (well-known dev wildcards
23
+ * that ALWAYS resolve to 127.0.0.1)
24
+ *
25
+ * NOT included on the SDK side: .ngrok.io, .vercel.app, .netlify.app,
26
+ * .pages.dev, .workers.dev, .trycloudflare.com. A merchant doing a
27
+ * staging-on-Vercel preview deploy should still see tracking work; if
28
+ * they want it OFF for staging they can set allowLocalhost explicitly.
29
+ */
30
+ export declare function isDevHost(host: string | null | undefined): boolean;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Dev-host detection for the SDK. Returns true when the current hostname
3
+ * is obviously a developer's machine and not a real production site.
4
+ *
5
+ * The SDK uses this to short-circuit tracking by default (allowLocalhost
6
+ * defaults to false). Stops the "Quegly funnel pollution" pattern where
7
+ * a developer running `npm run dev` against localhost:3000 fires hundreds
8
+ * of pageviews into the production funnel.
9
+ *
10
+ * IMPORTANT: this list is INTENTIONALLY TIGHTER than the server-side
11
+ * `src/lib/analytics/dev-host.ts` helper. The server helper is used for
12
+ * domain-binding logic and can be aggressive about catching staging /
13
+ * preview deploys (.vercel.app, .pages.dev). The SDK helper cannot,
14
+ * because some merchants legitimately run their production site on a
15
+ * .vercel.app subdomain (no custom domain) and we'd silently break their
16
+ * tracking. So we only catch the unambiguous cases:
17
+ *
18
+ * - localhost / 127.0.0.1 / 0.0.0.0 / ::1 (literal local)
19
+ * - RFC1918 private IPs (10.x, 192.168.x, 172.16-31.x)
20
+ * - .local / .test / .example / .invalid / .localhost (RFC-reserved
21
+ * dev TLDs that cannot exist in production)
22
+ * - .lvh.me / .localtest.me (well-known dev wildcards
23
+ * that ALWAYS resolve to 127.0.0.1)
24
+ *
25
+ * NOT included on the SDK side: .ngrok.io, .vercel.app, .netlify.app,
26
+ * .pages.dev, .workers.dev, .trycloudflare.com. A merchant doing a
27
+ * staging-on-Vercel preview deploy should still see tracking work; if
28
+ * they want it OFF for staging they can set allowLocalhost explicitly.
29
+ */
30
+ const RFC1918_RE = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
31
+ const DEV_TLD_SUFFIXES = ['.local', '.test', '.example', '.invalid', '.localhost'];
32
+ const DEV_HOST_SUFFIXES = ['.lvh.me', '.localtest.me'];
33
+ export function isDevHost(host) {
34
+ if (!host)
35
+ return false;
36
+ const h = host.toLowerCase();
37
+ if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0' || h === '::1')
38
+ return true;
39
+ if (RFC1918_RE.test(h))
40
+ return true;
41
+ for (const s of DEV_TLD_SUFFIXES)
42
+ if (h.endsWith(s))
43
+ return true;
44
+ for (const s of DEV_HOST_SUFFIXES)
45
+ if (h.endsWith(s))
46
+ return true;
47
+ return false;
48
+ }
@@ -18,6 +18,6 @@ export declare function useAffiliateRef(): AffiliateoContextValue;
18
18
  * ```
19
19
  */
20
20
  export declare function getAttribution(): Record<string, string>;
21
- export declare function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, children, }: AffiliateoConfig & {
21
+ export declare function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }: AffiliateoConfig & {
22
22
  children: React.ReactNode;
23
23
  }): import("react/jsx-runtime").JSX.Element;
package/dist/provider.js CHANGED
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
4
4
  import { EventQueue } from './queue';
5
5
  import { isLikelyBot } from './bot-detect';
6
+ import { isDevHost } from './dev-host';
6
7
  const DEFAULT_API_URL = 'https://affiliateo.com';
7
8
  const LS_VISITOR_ID = 'affiliateo_visitor_id';
8
9
  const LS_REF = 'affiliateo_ref';
@@ -60,6 +61,7 @@ const AffiliateoContext = createContext({
60
61
  reset: asyncNoop,
61
62
  optOut: syncNoop,
62
63
  optIn: syncNoop,
64
+ flush: asyncNoop,
63
65
  });
64
66
  /**
65
67
  * Returns the affiliate ref code and visitor ID.
@@ -100,7 +102,26 @@ export function getAttribution() {
100
102
  // mount (host has the call sites) but we can shout in dev so the
101
103
  // mistake is visible.
102
104
  let providerMountCount = 0;
103
- export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, children, }) {
105
+ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }) {
106
+ // Normalize autoCapturePageviews. Three shapes:
107
+ // - undefined / true / object → auto-capture ON (with object overrides)
108
+ // - false → auto-capture OFF entirely (host must call trackPageview)
109
+ // Defaults match Datafast: captureInitialPageview true, trackHashChanges
110
+ // false, debounceMs 100.
111
+ const autoCfg = autoCapturePageviews === false
112
+ ? { enabled: false, captureInitialPageview: false, trackHashChanges: false, debounceMs: 100 }
113
+ : {
114
+ enabled: true,
115
+ captureInitialPageview: typeof autoCapturePageviews === 'object'
116
+ ? autoCapturePageviews?.captureInitialPageview ?? true
117
+ : true,
118
+ trackHashChanges: typeof autoCapturePageviews === 'object'
119
+ ? autoCapturePageviews?.trackHashChanges ?? false
120
+ : false,
121
+ debounceMs: typeof autoCapturePageviews === 'object'
122
+ ? autoCapturePageviews?.debounceMs ?? 100
123
+ : 100,
124
+ };
104
125
  const [state, setState] = useState({
105
126
  refCode: null,
106
127
  visitorId: null,
@@ -110,7 +131,22 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
110
131
  reset: asyncNoop,
111
132
  optOut: syncNoop,
112
133
  optIn: syncNoop,
134
+ flush: asyncNoop,
113
135
  });
136
+ // Debug logger. Closes over the `debug` prop so flipping it on/off at
137
+ // runtime (e.g. by changing a feature flag) takes effect on the next
138
+ // render without remounting. eslint-disable for the intentional
139
+ // console.log — that's the whole point of this helper.
140
+ const log = useCallback((msg, data) => {
141
+ if (!debug)
142
+ return;
143
+ // eslint-disable-next-line no-console
144
+ if (data !== undefined)
145
+ console.log('[Affiliateo] ' + msg, data);
146
+ // eslint-disable-next-line no-console
147
+ else
148
+ console.log('[Affiliateo] ' + msg);
149
+ }, [debug]);
114
150
  const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
115
151
  // campaignIdRef stays current so callbacks defined inside useEffect
116
152
  // always read the latest value, even after a prop change. Closures
@@ -164,9 +200,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
164
200
  // useEffect runs), this noops — that's a vanishingly rare race
165
201
  // since track() is virtually always called from a click handler.
166
202
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
203
+ log('track', { event: name, metadata });
167
204
  }
168
205
  catch { /* swallow */ }
169
- }, [apiBase]);
206
+ }, [apiBase, log]);
170
207
  // identify(userId). user_id only — the SDK does NOT accept, collect,
171
208
  // or transmit email or any other PII. Cross-device stitching uses the
172
209
  // merchant's own user_id; we never touch identifying personal data.
@@ -194,9 +231,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
194
231
  user_id: uid,
195
232
  };
196
233
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
234
+ log('identify', { user_id: uid });
197
235
  }
198
236
  catch { /* swallow */ }
199
- }, [apiBase]);
237
+ }, [apiBase, log]);
200
238
  // Manual pageview. Mirrors Datafast's trackPageview(path?). Same payload
201
239
  // shape the auto-fired pageview uses (see sendPageview inside the route
202
240
  // effect below) so server-side routing / dedup / IP+UA enrichment behave
@@ -239,9 +277,24 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
239
277
  screen_width: window.innerWidth,
240
278
  };
241
279
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
280
+ log('pageview (manual)', { path: pagePath });
242
281
  }
243
282
  catch { /* swallow */ }
244
- }, [apiBase]);
283
+ }, [apiBase, log]);
284
+ // flush(): force immediate delivery of all queued events. The queue
285
+ // batches and auto-flushes every 5s; this is the escape hatch for the
286
+ // "I'm about to redirect, don't lose my events" case.
287
+ //
288
+ // Resolves when the underlying queue.flush() completes — success OR
289
+ // failure. Events that fail to deliver stay in the queue and get
290
+ // retried on the next normal flush cycle. Safe to await; never throws.
291
+ const flush = useCallback(async () => {
292
+ try {
293
+ log('flush requested');
294
+ await queueRef.current?.flush();
295
+ }
296
+ catch { /* swallow */ }
297
+ }, [log]);
245
298
  // reset(): wipe visitor identity. Mints a fresh UUID and clears the
246
299
  // affiliate ref so the next pageview is a brand-new anonymous visitor.
247
300
  // The campaign_id stays (it's a property of the site, not the visitor).
@@ -319,6 +372,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
319
372
  // doesn't crash on `useAffiliateRef().reset()`. They're noops, which
320
373
  // is the correct behavior since there's no real visitor to act on.
321
374
  if (isLikelyBot()) {
375
+ log('blocked: bot detected');
322
376
  setState({
323
377
  refCode: null,
324
378
  visitorId: null,
@@ -328,9 +382,87 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
328
382
  reset: asyncNoop,
329
383
  optOut: syncNoop,
330
384
  optIn: syncNoop,
385
+ flush: asyncNoop,
331
386
  });
332
387
  return;
333
388
  }
389
+ // Dev-host guard. Default behavior is "block localhost / 127.0.0.1 /
390
+ // RFC1918 / .local / .test so merchant doesn't accidentally pollute
391
+ // their production funnel while running `npm run dev`." Merchants who
392
+ // legitimately want dev events flowing (e.g. end-to-end testing
393
+ // against staging that needs server-side events) opt in via
394
+ // <AffiliateoProvider allowLocalhost />.
395
+ //
396
+ // Tighter list than the server-side dev-host helper. see dev-host.ts
397
+ // for the rationale (must not block real merchants on .vercel.app).
398
+ if (!allowLocalhost && isDevHost(window.location.hostname)) {
399
+ log('blocked: localhost (set allowLocalhost={true} on AffiliateoProvider to enable)');
400
+ setState({
401
+ refCode: null,
402
+ visitorId: null,
403
+ track: asyncNoop,
404
+ identify: asyncNoop,
405
+ trackPageview: asyncNoop,
406
+ reset: asyncNoop,
407
+ optOut: syncNoop,
408
+ optIn: syncNoop,
409
+ flush: asyncNoop,
410
+ });
411
+ return;
412
+ }
413
+ // File-protocol guard. Pages served via file:// (someone double-clicking
414
+ // a built HTML file rather than running a server) almost certainly aren't
415
+ // real merchant traffic. Block by default; opt in via allowFileProtocol.
416
+ if (!allowFileProtocol && window.location.protocol === 'file:') {
417
+ log('blocked: file:// protocol (set allowFileProtocol={true} to enable)');
418
+ setState({
419
+ refCode: null,
420
+ visitorId: null,
421
+ track: asyncNoop,
422
+ identify: asyncNoop,
423
+ trackPageview: asyncNoop,
424
+ reset: asyncNoop,
425
+ optOut: syncNoop,
426
+ optIn: syncNoop,
427
+ flush: asyncNoop,
428
+ });
429
+ return;
430
+ }
431
+ // Iframe guard. Default behavior is "if we're inside an iframe, don't
432
+ // track" so scrapers, preview tools, and archive crawlers embedding
433
+ // the merchant's site don't count as pageviews. Cross-origin iframe
434
+ // access throws on the property read, which is ITSELF a signal we're
435
+ // inside a foreign frame, so the catch branch also bails out.
436
+ //
437
+ // Same-origin iframe (e.g. merchant embeds their own checkout) lets
438
+ // window.top return without throwing, and we compare against window.self
439
+ // to detect the embedded case. Merchants who legitimately render the
440
+ // SDK inside an iframe (embed widgets shipped to partners) opt in via
441
+ // <AffiliateoProvider allowIframe />.
442
+ if (!allowIframe) {
443
+ let insideIframe = false;
444
+ try {
445
+ insideIframe = window.self !== window.top;
446
+ }
447
+ catch {
448
+ insideIframe = true;
449
+ }
450
+ if (insideIframe) {
451
+ log('blocked: iframe (set allowIframe={true} on AffiliateoProvider to enable)');
452
+ setState({
453
+ refCode: null,
454
+ visitorId: null,
455
+ track: asyncNoop,
456
+ identify: asyncNoop,
457
+ trackPageview: asyncNoop,
458
+ reset: asyncNoop,
459
+ optOut: syncNoop,
460
+ optIn: syncNoop,
461
+ flush: asyncNoop,
462
+ });
463
+ return;
464
+ }
465
+ }
334
466
  // Initialize the event queue. Done inside useEffect (not in a useRef
335
467
  // initializer) so the EventQueue constructor — which touches
336
468
  // localStorage on hydration — only ever runs client-side. The queue
@@ -338,7 +470,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
338
470
  // auto-fired pageview below; only session_end stays on sendBeacon
339
471
  // because tab-close urgency beats queue-retry reliability.
340
472
  if (queueRef.current === null) {
341
- queueRef.current = new EventQueue();
473
+ queueRef.current = new EventQueue({
474
+ flushIntervalMs: flushInterval,
475
+ maxQueueSize: maxQueueSize,
476
+ });
342
477
  }
343
478
  const queue = queueRef.current;
344
479
  // Opt-out short-circuit. A visitor who previously called optOut()
@@ -359,7 +494,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
359
494
  setState({
360
495
  refCode: null,
361
496
  visitorId: optedOutVid,
362
- track, identify, trackPageview, reset, optOut, optIn,
497
+ track, identify, trackPageview, reset, optOut, optIn, flush,
363
498
  });
364
499
  // Don't leave a queue running for an opted-out visitor — the timer
365
500
  // would just keep waking up to flush nothing.
@@ -419,8 +554,9 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
419
554
  setState({
420
555
  refCode: ref,
421
556
  visitorId: vid,
422
- track, identify, trackPageview, reset, optOut, optIn,
557
+ track, identify, trackPageview, reset, optOut, optIn, flush,
423
558
  });
559
+ log('init', { campaign: campaignIdRef.current, visitor: vid, ref: ref || null });
424
560
  // Throttle: max 1 pageview per second
425
561
  let lastSendTime = 0;
426
562
  function sendPageview() {
@@ -443,6 +579,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
443
579
  utm_campaign: params.get('utm_campaign') || '',
444
580
  screen_width: window.innerWidth,
445
581
  });
582
+ log('pageview', { path: window.location.pathname });
446
583
  }
447
584
  // Send session_end when user leaves.
448
585
  //
@@ -510,23 +647,60 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
510
647
  }
511
648
  }
512
649
  }
513
- // Send pageview on mount
514
- if (!sentRef.current) {
650
+ // Send pageview on mount. captureInitialPageview === false skips this
651
+ // for cookie-consent flows where the host wants to defer until the
652
+ // user accepts (then call trackPageview() manually).
653
+ if (!sentRef.current && autoCfg.captureInitialPageview && autoCfg.enabled) {
515
654
  sentRef.current = true;
516
655
  sendPageview();
517
656
  }
518
657
  // Detect user leaving
519
658
  document.addEventListener('visibilitychange', onVisibilityChange);
520
659
  window.addEventListener('pagehide', sendEnd);
521
- // SPA support: detect URL changes
660
+ // SPA support: detect URL changes. We track BOTH pathname (modern
661
+ // pushState-based routers: React Router v6, Next.js App Router, Vue
662
+ // Router 4) AND hash (legacy hash routing: Vue Router with hash mode,
663
+ // React Router HashRouter, old Angular). Hash tracking is opt-in via
664
+ // autoCapturePageviews.trackHashChanges because modern SPAs never use
665
+ // it and firing on every #anchor click would inflate pageview counts.
666
+ //
667
+ // Debouncing collapses back-to-back navigation events into one. Some
668
+ // frameworks (Next.js, certain Vue routers) fire pushState then
669
+ // replaceState in the same tick to canonicalize the URL; without
670
+ // debouncing that's two pageviews per real navigation. Default 100ms
671
+ // matches Datafast; set debounceMs to 0 to disable.
522
672
  let lastPath = window.location.pathname;
673
+ let lastHash = window.location.hash;
674
+ let pendingPageviewTimer = null;
523
675
  function onUrlChange() {
524
- if (window.location.pathname !== lastPath) {
525
- lastPath = window.location.pathname;
676
+ const pathChanged = window.location.pathname !== lastPath;
677
+ const hashChanged = autoCfg.trackHashChanges && window.location.hash !== lastHash;
678
+ if (!pathChanged && !hashChanged)
679
+ return;
680
+ lastPath = window.location.pathname;
681
+ lastHash = window.location.hash;
682
+ if (!autoCfg.enabled)
683
+ return;
684
+ // Debounce: if a pending pageview is already scheduled, the later
685
+ // urlchange wins (the URL the browser ends up on after both calls
686
+ // is what we want to report).
687
+ if (pendingPageviewTimer !== null) {
688
+ clearTimeout(pendingPageviewTimer);
689
+ }
690
+ if (autoCfg.debounceMs > 0) {
691
+ pendingPageviewTimer = setTimeout(() => {
692
+ pendingPageviewTimer = null;
693
+ sendPageview();
694
+ }, autoCfg.debounceMs);
695
+ }
696
+ else {
526
697
  sendPageview();
527
698
  }
528
699
  }
529
700
  window.addEventListener('popstate', onUrlChange);
701
+ if (autoCfg.trackHashChanges) {
702
+ window.addEventListener('hashchange', onUrlChange);
703
+ }
530
704
  const origPushState = history.pushState;
531
705
  const origReplaceState = history.replaceState;
532
706
  history.pushState = function (...args) {
@@ -614,6 +788,12 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
614
788
  document.removeEventListener('visibilitychange', onVisibilityChange);
615
789
  window.removeEventListener('pagehide', sendEnd);
616
790
  window.removeEventListener('popstate', onUrlChange);
791
+ if (autoCfg.trackHashChanges) {
792
+ window.removeEventListener('hashchange', onUrlChange);
793
+ }
794
+ if (pendingPageviewTimer !== null) {
795
+ clearTimeout(pendingPageviewTimer);
796
+ }
617
797
  history.pushState = origPushState;
618
798
  history.replaceState = origReplaceState;
619
799
  if (allowedHosts.length > 0) {
@@ -627,6 +807,6 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
627
807
  queueRef.current = null;
628
808
  providerMountCount = Math.max(0, providerMountCount - 1);
629
809
  };
630
- }, [apiBase, allowedHostnames, track, identify, trackPageview, reset, optOut, optIn]);
810
+ }, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, optOut, optIn, flush]);
631
811
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
632
812
  }
package/dist/queue.d.ts CHANGED
@@ -41,6 +41,12 @@
41
41
  * - SIZE_FLUSH_THRESHOLD = 10 triggers a flush when queue grows
42
42
  * past this even before the timer fires
43
43
  */
44
+ export interface EventQueueOptions {
45
+ /** Override the auto-flush cadence. Defaults to 5000ms. */
46
+ flushIntervalMs?: number;
47
+ /** Override the hard cap on queued events. Defaults to 100. */
48
+ maxQueueSize?: number;
49
+ }
44
50
  export interface QueuedEvent {
45
51
  /** Stable id used to identify the event across retries. UUID v4
46
52
  * generated client-side; collisions are astronomically unlikely so
@@ -63,7 +69,9 @@ export declare class EventQueue {
63
69
  private flushTimer;
64
70
  private isFlushing;
65
71
  private shuttingDown;
66
- constructor();
72
+ private readonly flushIntervalMs;
73
+ private readonly maxQueueSize;
74
+ constructor(opts?: EventQueueOptions);
67
75
  /** Add an event. Persists synchronously to localStorage so a tab
68
76
  * close right after enqueue doesn't lose the event from memory
69
77
  * before it's safely on disk. */
package/dist/queue.js CHANGED
@@ -43,8 +43,8 @@
43
43
  */
44
44
  const STORAGE_KEY = 'affiliateo_event_queue';
45
45
  const MAX_RETRIES = 3;
46
- const MAX_QUEUE_SIZE = 100;
47
- const FLUSH_INTERVAL_MS = 5000;
46
+ const DEFAULT_MAX_QUEUE_SIZE = 100;
47
+ const DEFAULT_FLUSH_INTERVAL_MS = 5000;
48
48
  const SIZE_FLUSH_THRESHOLD = 10;
49
49
  function lsGet(key) {
50
50
  try {
@@ -76,11 +76,16 @@ function uuid() {
76
76
  });
77
77
  }
78
78
  export class EventQueue {
79
- constructor() {
79
+ constructor(opts = {}) {
80
80
  this.queue = [];
81
81
  this.flushTimer = null;
82
82
  this.isFlushing = false;
83
83
  this.shuttingDown = false;
84
+ // Bounds: refuse pathological values silently. Minimum 1s flush so we
85
+ // don't hammer the network; minimum 10 events so the queue is useful;
86
+ // maximum 1000 events so a misconfigured app can't fill localStorage.
87
+ this.flushIntervalMs = Math.max(1000, opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS);
88
+ this.maxQueueSize = Math.max(10, Math.min(1000, opts.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
84
89
  // Hydrate from localStorage so events from a prior page load survive
85
90
  // a refresh / navigation. Guards against corrupt JSON or wrong-shape
86
91
  // entries by resetting to empty if anything looks off — better to
@@ -125,8 +130,8 @@ export class EventQueue {
125
130
  // over budget the oldest events are likely already stale (the
126
131
  // visitor moved on, the page they were on isn't current) so
127
132
  // dropping them preserves more value than dropping the new ones.
128
- if (this.queue.length > MAX_QUEUE_SIZE) {
129
- this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
133
+ if (this.queue.length > this.maxQueueSize) {
134
+ this.queue.splice(0, this.queue.length - this.maxQueueSize);
130
135
  }
131
136
  this.persist();
132
137
  // Burst protection: when many events fire in quick succession
@@ -261,6 +266,6 @@ export class EventQueue {
261
266
  return;
262
267
  this.flushTimer = setInterval(() => {
263
268
  void this.flush();
264
- }, FLUSH_INTERVAL_MS);
269
+ }, this.flushIntervalMs);
265
270
  }
266
271
  }
package/dist/types.d.ts CHANGED
@@ -26,6 +26,70 @@ export interface AffiliateoConfig {
26
26
  * allowedHostnames={['app.merchant.com', 'docs.merchant.com']}
27
27
  * > */
28
28
  allowedHostnames?: string[];
29
+ /** Allow tracking when the page is served from a developer machine
30
+ * (localhost, 127.0.0.1, RFC1918 IPs, .local / .test TLDs). Defaults
31
+ * to false so a developer running `npm run dev` doesn't pollute the
32
+ * merchant's production funnel with test pageviews.
33
+ *
34
+ * Set to true ONLY when you actually want dev traffic in your
35
+ * dashboard (e.g. for an end-to-end test that needs the events to
36
+ * land server-side).
37
+ *
38
+ * Example:
39
+ * <AffiliateoProvider campaignId="..." allowLocalhost> */
40
+ allowLocalhost?: boolean;
41
+ /** Allow tracking when the page is rendered inside an iframe. Defaults
42
+ * to false so scrapers, preview tools, and other sites embedding yours
43
+ * don't count as pageviews.
44
+ *
45
+ * Set to true if you legitimately render your site inside iframes
46
+ * (e.g. an embed widget you ship to partners). */
47
+ allowIframe?: boolean;
48
+ /** Print decision logs to the browser console. Defaults to false. When
49
+ * true, the SDK logs every action it takes (init, pageview fired,
50
+ * goal tracked, blocked by bot detection, blocked by localhost, queue
51
+ * flush attempted, etc.). Only the merchant or anyone manually opening
52
+ * DevTools sees these — normal visitors never look at the console.
53
+ *
54
+ * Turn on temporarily when investigating "why aren't my events
55
+ * showing up?". Leave off in production.
56
+ *
57
+ * Example:
58
+ * <AffiliateoProvider campaignId="..." debug> */
59
+ debug?: boolean;
60
+ /** Allow tracking from pages served via the `file://` protocol (someone
61
+ * double-clicking a built HTML file instead of running a server).
62
+ * Defaults to false. Almost never useful. set true only if you're
63
+ * intentionally testing an offline-first build. */
64
+ allowFileProtocol?: boolean;
65
+ /** How often the event queue tries to flush, in milliseconds. Defaults
66
+ * to 5000 (every 5 seconds). Lower for chattier delivery (more
67
+ * requests, fresher data); raise for a quieter queue (fewer requests,
68
+ * bigger batches). */
69
+ flushInterval?: number;
70
+ /** Maximum number of events the queue holds before dropping the oldest
71
+ * FIFO. Defaults to 100. Acts as a backstop against a long network
72
+ * outage filling localStorage. */
73
+ maxQueueSize?: number;
74
+ /** Pageview auto-capture behavior. Pass `false` to disable entirely
75
+ * (you'll call `trackPageview()` yourself). Pass an object to fine-tune.
76
+ * Defaults to `true` (auto-capture everything). */
77
+ autoCapturePageviews?: boolean | {
78
+ /** Fire the initial pageview on mount. Defaults to true. Set false
79
+ * for cookie-consent flows where you want to defer until the user
80
+ * accepts. */
81
+ captureInitialPageview?: boolean;
82
+ /** Fire pageviews on URL hash changes (e.g. `/#/about` → `/#/pricing`
83
+ * in older Vue / Angular / React Router HashRouter setups). Defaults
84
+ * to false. modern SPAs use pushState (already tracked). Turn on
85
+ * only for legacy hash routing. */
86
+ trackHashChanges?: boolean;
87
+ /** Debounce window for back-to-back pushState / replaceState calls,
88
+ * in milliseconds. Some frameworks (Next.js, some Vue routers) fire
89
+ * both within one tick, producing two pageviews per navigation
90
+ * without debouncing. Defaults to 100ms. Set 0 to disable. */
91
+ debounceMs?: number;
92
+ };
29
93
  }
30
94
  export interface AffiliateoContextValue {
31
95
  /** The affiliate ref code from the URL (if present) */
@@ -108,4 +172,25 @@ export interface AffiliateoContextValue {
108
172
  * the cleanest way to bring the SDK back to its normal "auto-fire on
109
173
  * mount" state. */
110
174
  optIn: () => void;
175
+ /** Force immediate delivery of all queued events. The SDK batches events
176
+ * (track / identify / pageview) and flushes them every 5 seconds; this
177
+ * method flushes RIGHT NOW.
178
+ *
179
+ * Use case: call before a redirect (e.g. window.location.href = stripeUrl)
180
+ * so events queued right before the navigation don't get lost. The
181
+ * default behavior would wait for the 5-second timer, but the page
182
+ * is gone by then.
183
+ *
184
+ * Example:
185
+ * const { track, flush } = useAffiliateRef()
186
+ * async function startCheckout() {
187
+ * track('initiate_checkout')
188
+ * await flush()
189
+ * window.location.href = stripeCheckoutUrl
190
+ * }
191
+ *
192
+ * Returns a Promise that resolves when the flush attempt completes
193
+ * (success OR failure — failed events stay in the queue for the next
194
+ * flush). Safe to await without timeout concerns. */
195
+ flush: () => Promise<void>;
111
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "3.1.0",
3
+ "version": "4.1.0",
4
4
  "description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",