@affiliateo/web 3.1.0 → 4.0.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, debug, 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,7 @@ 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, debug, children, }) {
104
106
  const [state, setState] = useState({
105
107
  refCode: null,
106
108
  visitorId: null,
@@ -110,7 +112,22 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
110
112
  reset: asyncNoop,
111
113
  optOut: syncNoop,
112
114
  optIn: syncNoop,
115
+ flush: asyncNoop,
113
116
  });
117
+ // Debug logger. Closes over the `debug` prop so flipping it on/off at
118
+ // runtime (e.g. by changing a feature flag) takes effect on the next
119
+ // render without remounting. eslint-disable for the intentional
120
+ // console.log — that's the whole point of this helper.
121
+ const log = useCallback((msg, data) => {
122
+ if (!debug)
123
+ return;
124
+ // eslint-disable-next-line no-console
125
+ if (data !== undefined)
126
+ console.log('[Affiliateo] ' + msg, data);
127
+ // eslint-disable-next-line no-console
128
+ else
129
+ console.log('[Affiliateo] ' + msg);
130
+ }, [debug]);
114
131
  const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
115
132
  // campaignIdRef stays current so callbacks defined inside useEffect
116
133
  // always read the latest value, even after a prop change. Closures
@@ -164,9 +181,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
164
181
  // useEffect runs), this noops — that's a vanishingly rare race
165
182
  // since track() is virtually always called from a click handler.
166
183
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
184
+ log('track', { event: name, metadata });
167
185
  }
168
186
  catch { /* swallow */ }
169
- }, [apiBase]);
187
+ }, [apiBase, log]);
170
188
  // identify(userId). user_id only — the SDK does NOT accept, collect,
171
189
  // or transmit email or any other PII. Cross-device stitching uses the
172
190
  // merchant's own user_id; we never touch identifying personal data.
@@ -194,9 +212,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
194
212
  user_id: uid,
195
213
  };
196
214
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
215
+ log('identify', { user_id: uid });
197
216
  }
198
217
  catch { /* swallow */ }
199
- }, [apiBase]);
218
+ }, [apiBase, log]);
200
219
  // Manual pageview. Mirrors Datafast's trackPageview(path?). Same payload
201
220
  // shape the auto-fired pageview uses (see sendPageview inside the route
202
221
  // effect below) so server-side routing / dedup / IP+UA enrichment behave
@@ -239,9 +258,24 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
239
258
  screen_width: window.innerWidth,
240
259
  };
241
260
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
261
+ log('pageview (manual)', { path: pagePath });
242
262
  }
243
263
  catch { /* swallow */ }
244
- }, [apiBase]);
264
+ }, [apiBase, log]);
265
+ // flush(): force immediate delivery of all queued events. The queue
266
+ // batches and auto-flushes every 5s; this is the escape hatch for the
267
+ // "I'm about to redirect, don't lose my events" case.
268
+ //
269
+ // Resolves when the underlying queue.flush() completes — success OR
270
+ // failure. Events that fail to deliver stay in the queue and get
271
+ // retried on the next normal flush cycle. Safe to await; never throws.
272
+ const flush = useCallback(async () => {
273
+ try {
274
+ log('flush requested');
275
+ await queueRef.current?.flush();
276
+ }
277
+ catch { /* swallow */ }
278
+ }, [log]);
245
279
  // reset(): wipe visitor identity. Mints a fresh UUID and clears the
246
280
  // affiliate ref so the next pageview is a brand-new anonymous visitor.
247
281
  // The campaign_id stays (it's a property of the site, not the visitor).
@@ -319,6 +353,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
319
353
  // doesn't crash on `useAffiliateRef().reset()`. They're noops, which
320
354
  // is the correct behavior since there's no real visitor to act on.
321
355
  if (isLikelyBot()) {
356
+ log('blocked: bot detected');
322
357
  setState({
323
358
  refCode: null,
324
359
  visitorId: null,
@@ -328,9 +363,69 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
328
363
  reset: asyncNoop,
329
364
  optOut: syncNoop,
330
365
  optIn: syncNoop,
366
+ flush: asyncNoop,
331
367
  });
332
368
  return;
333
369
  }
370
+ // Dev-host guard. Default behavior is "block localhost / 127.0.0.1 /
371
+ // RFC1918 / .local / .test so merchant doesn't accidentally pollute
372
+ // their production funnel while running `npm run dev`." Merchants who
373
+ // legitimately want dev events flowing (e.g. end-to-end testing
374
+ // against staging that needs server-side events) opt in via
375
+ // <AffiliateoProvider allowLocalhost />.
376
+ //
377
+ // Tighter list than the server-side dev-host helper. see dev-host.ts
378
+ // for the rationale (must not block real merchants on .vercel.app).
379
+ if (!allowLocalhost && isDevHost(window.location.hostname)) {
380
+ log('blocked: localhost (set allowLocalhost={true} on AffiliateoProvider to enable)');
381
+ setState({
382
+ refCode: null,
383
+ visitorId: null,
384
+ track: asyncNoop,
385
+ identify: asyncNoop,
386
+ trackPageview: asyncNoop,
387
+ reset: asyncNoop,
388
+ optOut: syncNoop,
389
+ optIn: syncNoop,
390
+ flush: asyncNoop,
391
+ });
392
+ return;
393
+ }
394
+ // Iframe guard. Default behavior is "if we're inside an iframe, don't
395
+ // track" so scrapers, preview tools, and archive crawlers embedding
396
+ // the merchant's site don't count as pageviews. Cross-origin iframe
397
+ // access throws on the property read, which is ITSELF a signal we're
398
+ // inside a foreign frame, so the catch branch also bails out.
399
+ //
400
+ // Same-origin iframe (e.g. merchant embeds their own checkout) lets
401
+ // window.top return without throwing, and we compare against window.self
402
+ // to detect the embedded case. Merchants who legitimately render the
403
+ // SDK inside an iframe (embed widgets shipped to partners) opt in via
404
+ // <AffiliateoProvider allowIframe />.
405
+ if (!allowIframe) {
406
+ let insideIframe = false;
407
+ try {
408
+ insideIframe = window.self !== window.top;
409
+ }
410
+ catch {
411
+ insideIframe = true;
412
+ }
413
+ if (insideIframe) {
414
+ log('blocked: iframe (set allowIframe={true} on AffiliateoProvider to enable)');
415
+ setState({
416
+ refCode: null,
417
+ visitorId: null,
418
+ track: asyncNoop,
419
+ identify: asyncNoop,
420
+ trackPageview: asyncNoop,
421
+ reset: asyncNoop,
422
+ optOut: syncNoop,
423
+ optIn: syncNoop,
424
+ flush: asyncNoop,
425
+ });
426
+ return;
427
+ }
428
+ }
334
429
  // Initialize the event queue. Done inside useEffect (not in a useRef
335
430
  // initializer) so the EventQueue constructor — which touches
336
431
  // localStorage on hydration — only ever runs client-side. The queue
@@ -359,7 +454,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
359
454
  setState({
360
455
  refCode: null,
361
456
  visitorId: optedOutVid,
362
- track, identify, trackPageview, reset, optOut, optIn,
457
+ track, identify, trackPageview, reset, optOut, optIn, flush,
363
458
  });
364
459
  // Don't leave a queue running for an opted-out visitor — the timer
365
460
  // would just keep waking up to flush nothing.
@@ -419,8 +514,9 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
419
514
  setState({
420
515
  refCode: ref,
421
516
  visitorId: vid,
422
- track, identify, trackPageview, reset, optOut, optIn,
517
+ track, identify, trackPageview, reset, optOut, optIn, flush,
423
518
  });
519
+ log('init', { campaign: campaignIdRef.current, visitor: vid, ref: ref || null });
424
520
  // Throttle: max 1 pageview per second
425
521
  let lastSendTime = 0;
426
522
  function sendPageview() {
@@ -443,6 +539,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
443
539
  utm_campaign: params.get('utm_campaign') || '',
444
540
  screen_width: window.innerWidth,
445
541
  });
542
+ log('pageview', { path: window.location.pathname });
446
543
  }
447
544
  // Send session_end when user leaves.
448
545
  //
@@ -627,6 +724,6 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, child
627
724
  queueRef.current = null;
628
725
  providerMountCount = Math.max(0, providerMountCount - 1);
629
726
  };
630
- }, [apiBase, allowedHostnames, track, identify, trackPageview, reset, optOut, optIn]);
727
+ }, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, optOut, optIn, flush]);
631
728
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
632
729
  }
package/dist/types.d.ts CHANGED
@@ -26,6 +26,37 @@ 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;
29
60
  }
30
61
  export interface AffiliateoContextValue {
31
62
  /** The affiliate ref code from the URL (if present) */
@@ -108,4 +139,25 @@ export interface AffiliateoContextValue {
108
139
  * the cleanest way to bring the SDK back to its normal "auto-fire on
109
140
  * mount" state. */
110
141
  optIn: () => void;
142
+ /** Force immediate delivery of all queued events. The SDK batches events
143
+ * (track / identify / pageview) and flushes them every 5 seconds; this
144
+ * method flushes RIGHT NOW.
145
+ *
146
+ * Use case: call before a redirect (e.g. window.location.href = stripeUrl)
147
+ * so events queued right before the navigation don't get lost. The
148
+ * default behavior would wait for the 5-second timer, but the page
149
+ * is gone by then.
150
+ *
151
+ * Example:
152
+ * const { track, flush } = useAffiliateRef()
153
+ * async function startCheckout() {
154
+ * track('initiate_checkout')
155
+ * await flush()
156
+ * window.location.href = stripeCheckoutUrl
157
+ * }
158
+ *
159
+ * Returns a Promise that resolves when the flush attempt completes
160
+ * (success OR failure — failed events stay in the queue for the next
161
+ * flush). Safe to await without timeout concerns. */
162
+ flush: () => Promise<void>;
111
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "3.1.0",
3
+ "version": "4.0.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",