@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.
- package/dist/dev-host.d.ts +30 -0
- package/dist/dev-host.js +48 -0
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +193 -13
- package/dist/queue.d.ts +9 -1
- package/dist/queue.js +11 -6
- package/dist/types.d.ts +85 -0
- package/package.json +1 -1
|
@@ -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;
|
package/dist/dev-host.js
ADDED
|
@@ -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
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
const
|
|
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 >
|
|
129
|
-
this.queue.splice(0, this.queue.length -
|
|
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
|
-
},
|
|
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
|
}
|