@affiliateo/web 2.3.1 → 3.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.
- package/dist/bot-detect.d.ts +37 -0
- package/dist/bot-detect.js +138 -0
- package/dist/provider.js +282 -144
- package/dist/queue.d.ts +99 -0
- package/dist/queue.js +266 -0
- package/dist/types.d.ts +50 -0
- package/package.json +34 -34
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side bot detection. Mirrors Datafast / PostHog / Plausible's
|
|
3
|
+
* approach: catch the obvious automation tools at SDK init so they
|
|
4
|
+
* never fire a single network request.
|
|
5
|
+
*
|
|
6
|
+
* Defense in depth:
|
|
7
|
+
* 1. This check — catches Selenium / Puppeteer / curl / etc
|
|
8
|
+
* BEFORE anything hits the wire. Saves bandwidth.
|
|
9
|
+
* Can be bypassed by a determined attacker who
|
|
10
|
+
* edits the bundled SDK.
|
|
11
|
+
* 2. Server UA regex — catches the same patterns plus legit crawlers
|
|
12
|
+
* (Googlebot, FB crawler, etc). Server is
|
|
13
|
+
* authoritative. Can't be bypassed.
|
|
14
|
+
* 3. Cloudflare — invisible browser-attestation challenge. Real
|
|
15
|
+
* Turnstile browsers pass in 50-200ms; scripts that don't
|
|
16
|
+
* run real JS can't pass it. Catches sophisticated
|
|
17
|
+
* attackers who spoofed their UA past layer 2.
|
|
18
|
+
*
|
|
19
|
+
* This file implements layer 1 only. Order: layer 1 first (cheap), then
|
|
20
|
+
* layer 3 (kicks in on the request), then layer 2 (server checks before
|
|
21
|
+
* writing). If any one layer rejects, the event drops.
|
|
22
|
+
*
|
|
23
|
+
* Patterns sourced from the Datafast script tag + npm SDK (publicly
|
|
24
|
+
* inspectable). We include their full list because it represents years
|
|
25
|
+
* of accumulated bot-shape knowledge that's expensive to reproduce.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if the current browser context looks like a bot or
|
|
29
|
+
* automation tool. Safe to call in SSR (returns true since there's no
|
|
30
|
+
* real visitor in that context anyway — a server-rendered page
|
|
31
|
+
* shouldn't be firing analytics).
|
|
32
|
+
*
|
|
33
|
+
* Wraps every check in try/catch so a hostile environment (frozen
|
|
34
|
+
* navigator, locked-down iframe) can't crash the SDK init. Any throw
|
|
35
|
+
* == "not a bot, let them through" so we err on the side of measuring.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isLikelyBot(): boolean;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side bot detection. Mirrors Datafast / PostHog / Plausible's
|
|
3
|
+
* approach: catch the obvious automation tools at SDK init so they
|
|
4
|
+
* never fire a single network request.
|
|
5
|
+
*
|
|
6
|
+
* Defense in depth:
|
|
7
|
+
* 1. This check — catches Selenium / Puppeteer / curl / etc
|
|
8
|
+
* BEFORE anything hits the wire. Saves bandwidth.
|
|
9
|
+
* Can be bypassed by a determined attacker who
|
|
10
|
+
* edits the bundled SDK.
|
|
11
|
+
* 2. Server UA regex — catches the same patterns plus legit crawlers
|
|
12
|
+
* (Googlebot, FB crawler, etc). Server is
|
|
13
|
+
* authoritative. Can't be bypassed.
|
|
14
|
+
* 3. Cloudflare — invisible browser-attestation challenge. Real
|
|
15
|
+
* Turnstile browsers pass in 50-200ms; scripts that don't
|
|
16
|
+
* run real JS can't pass it. Catches sophisticated
|
|
17
|
+
* attackers who spoofed their UA past layer 2.
|
|
18
|
+
*
|
|
19
|
+
* This file implements layer 1 only. Order: layer 1 first (cheap), then
|
|
20
|
+
* layer 3 (kicks in on the request), then layer 2 (server checks before
|
|
21
|
+
* writing). If any one layer rejects, the event drops.
|
|
22
|
+
*
|
|
23
|
+
* Patterns sourced from the Datafast script tag + npm SDK (publicly
|
|
24
|
+
* inspectable). We include their full list because it represents years
|
|
25
|
+
* of accumulated bot-shape knowledge that's expensive to reproduce.
|
|
26
|
+
*/
|
|
27
|
+
// Selenium/WebDriver/headless flags set on window or navigator. Modern
|
|
28
|
+
// automation frameworks try to hide these but the defaults still leak.
|
|
29
|
+
const AUTOMATION_GLOBALS = [
|
|
30
|
+
'__webdriver_evaluate',
|
|
31
|
+
'__selenium_evaluate',
|
|
32
|
+
'__webdriver_script_function',
|
|
33
|
+
'__webdriver_unwrapped',
|
|
34
|
+
'__fxdriver_evaluate',
|
|
35
|
+
'__driver_evaluate',
|
|
36
|
+
'_Selenium_IDE_Recorder',
|
|
37
|
+
'_selenium',
|
|
38
|
+
'calledSelenium',
|
|
39
|
+
// ChromeDriver's signature global. The dollar-sign-cdc prefix is
|
|
40
|
+
// a known fingerprint that vanilla undetected-chromedriver tries to
|
|
41
|
+
// strip but base ChromeDriver still includes.
|
|
42
|
+
'$cdc_asdjflasutopfhvcZLmcfl_',
|
|
43
|
+
];
|
|
44
|
+
// User-agent substrings (lowercased before match). These cover both the
|
|
45
|
+
// headless browser families AND the tool families (curl/python/etc).
|
|
46
|
+
// Real human UAs do contain things like "Mobile/15E148" so we match on
|
|
47
|
+
// distinctive automation tokens only.
|
|
48
|
+
const BOT_UA_PATTERNS = [
|
|
49
|
+
'headlesschrome',
|
|
50
|
+
'phantomjs',
|
|
51
|
+
'selenium',
|
|
52
|
+
'webdriver',
|
|
53
|
+
'puppeteer',
|
|
54
|
+
'playwright',
|
|
55
|
+
'python',
|
|
56
|
+
'curl',
|
|
57
|
+
'wget',
|
|
58
|
+
'java/',
|
|
59
|
+
'go-http',
|
|
60
|
+
'node.js',
|
|
61
|
+
'axios',
|
|
62
|
+
'postman',
|
|
63
|
+
];
|
|
64
|
+
// documentElement attributes some Selenium variants set. Cheap to check.
|
|
65
|
+
const BOT_DOC_ATTRS = ['webdriver', 'selenium', 'driver'];
|
|
66
|
+
/**
|
|
67
|
+
* Returns true if the current browser context looks like a bot or
|
|
68
|
+
* automation tool. Safe to call in SSR (returns true since there's no
|
|
69
|
+
* real visitor in that context anyway — a server-rendered page
|
|
70
|
+
* shouldn't be firing analytics).
|
|
71
|
+
*
|
|
72
|
+
* Wraps every check in try/catch so a hostile environment (frozen
|
|
73
|
+
* navigator, locked-down iframe) can't crash the SDK init. Any throw
|
|
74
|
+
* == "not a bot, let them through" so we err on the side of measuring.
|
|
75
|
+
*/
|
|
76
|
+
export function isLikelyBot() {
|
|
77
|
+
// SSR or non-browser context. We can't run analytics here anyway
|
|
78
|
+
// because there's no localStorage / sendBeacon / DOM events.
|
|
79
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined')
|
|
80
|
+
return true;
|
|
81
|
+
try {
|
|
82
|
+
const nav = window.navigator;
|
|
83
|
+
const win = window;
|
|
84
|
+
// Selenium / Puppeteer / Playwright signature on navigator.
|
|
85
|
+
// Modern stealth plugins try to override this but the override
|
|
86
|
+
// is detectable too (the property descriptor looks wrong) — we
|
|
87
|
+
// catch the easy case here and let Turnstile catch the rest.
|
|
88
|
+
if (nav.webdriver === true)
|
|
89
|
+
return true;
|
|
90
|
+
// Legacy headless flags (PhantomJS / Nightmare). Still present in
|
|
91
|
+
// some old scraper toolchains; cheap to check.
|
|
92
|
+
if (win.callPhantom !== undefined)
|
|
93
|
+
return true;
|
|
94
|
+
if (win._phantom !== undefined)
|
|
95
|
+
return true;
|
|
96
|
+
if (win.__nightmare !== undefined)
|
|
97
|
+
return true;
|
|
98
|
+
// Missing / suspicious UA. Real browsers always set a useful UA
|
|
99
|
+
// string; a missing or stub UA strongly implies a HTTP client
|
|
100
|
+
// (curl --user-agent "" / a python script that forgot to set one).
|
|
101
|
+
if (!nav.userAgent
|
|
102
|
+
|| nav.userAgent === ''
|
|
103
|
+
|| nav.userAgent === 'undefined'
|
|
104
|
+
|| nav.userAgent.length < 5) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
const ua = nav.userAgent.toLowerCase();
|
|
108
|
+
// Substring match on the known automation tokens. We split into a
|
|
109
|
+
// single loop instead of one giant regex for readability — the cost
|
|
110
|
+
// difference at <20 patterns is microseconds either way.
|
|
111
|
+
for (const pattern of BOT_UA_PATTERNS) {
|
|
112
|
+
if (ua.includes(pattern))
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
// Selenium globals on window. Some frameworks reset navigator but
|
|
116
|
+
// forget about these.
|
|
117
|
+
for (const key of AUTOMATION_GLOBALS) {
|
|
118
|
+
if (win[key] !== undefined)
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
// documentElement attributes. Cheap belt-and-suspenders check; some
|
|
122
|
+
// Selenium variants annotate the HTML root for their own bookkeeping.
|
|
123
|
+
const docEl = document.documentElement;
|
|
124
|
+
if (docEl) {
|
|
125
|
+
for (const attr of BOT_DOC_ATTRS) {
|
|
126
|
+
if (docEl.getAttribute(attr))
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Defensive: if any of the checks throw (locked-down iframe,
|
|
134
|
+
// proxy-wrapped navigator, etc.), don't block the visitor. Server
|
|
135
|
+
// regex + Turnstile are still in place to catch whatever this is.
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/provider.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { EventQueue } from './queue';
|
|
5
|
+
import { isLikelyBot } from './bot-detect';
|
|
4
6
|
const DEFAULT_API_URL = 'https://affiliateo.com';
|
|
5
7
|
const LS_VISITOR_ID = 'affiliateo_visitor_id';
|
|
6
8
|
const LS_REF = 'affiliateo_ref';
|
|
7
9
|
const LS_CAMPAIGN_ID = 'affiliateo_campaign_id';
|
|
10
|
+
// Persistent opt-out flag. Mirrors Datafast's DATAFAST_IGNORE_TRACKING key —
|
|
11
|
+
// when set to "true" the SDK silently noops every outbound call. Survives
|
|
12
|
+
// page reloads. removed by optIn(). Checked at init time so a previously
|
|
13
|
+
// opted-out visitor never re-fires auto-pageviews after a refresh.
|
|
14
|
+
const LS_OPT_OUT = 'affiliateo_opt_out';
|
|
8
15
|
// localStorage helpers
|
|
9
16
|
function lsGet(key) {
|
|
10
17
|
try {
|
|
@@ -20,6 +27,18 @@ function lsSet(key, value) {
|
|
|
20
27
|
}
|
|
21
28
|
catch { }
|
|
22
29
|
}
|
|
30
|
+
function lsRemove(key) {
|
|
31
|
+
try {
|
|
32
|
+
localStorage.removeItem(key);
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
}
|
|
36
|
+
// True when the visitor has opted out via optOut(). Single source of truth
|
|
37
|
+
// read by every outbound call so the flag works even across tabs (each tab's
|
|
38
|
+
// next call re-reads localStorage).
|
|
39
|
+
function isOptedOut() {
|
|
40
|
+
return lsGet(LS_OPT_OUT) === 'true';
|
|
41
|
+
}
|
|
23
42
|
// Generate UUID
|
|
24
43
|
function uuid() {
|
|
25
44
|
if (typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
@@ -31,11 +50,16 @@ function uuid() {
|
|
|
31
50
|
});
|
|
32
51
|
}
|
|
33
52
|
const asyncNoop = async () => { };
|
|
53
|
+
const syncNoop = () => { };
|
|
34
54
|
const AffiliateoContext = createContext({
|
|
35
55
|
refCode: null,
|
|
36
56
|
visitorId: null,
|
|
37
57
|
track: asyncNoop,
|
|
38
58
|
identify: asyncNoop,
|
|
59
|
+
trackPageview: asyncNoop,
|
|
60
|
+
reset: asyncNoop,
|
|
61
|
+
optOut: syncNoop,
|
|
62
|
+
optIn: syncNoop,
|
|
39
63
|
});
|
|
40
64
|
/**
|
|
41
65
|
* Returns the affiliate ref code and visitor ID.
|
|
@@ -82,6 +106,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
82
106
|
visitorId: null,
|
|
83
107
|
track: asyncNoop,
|
|
84
108
|
identify: asyncNoop,
|
|
109
|
+
trackPageview: asyncNoop,
|
|
110
|
+
reset: asyncNoop,
|
|
111
|
+
optOut: syncNoop,
|
|
112
|
+
optIn: syncNoop,
|
|
85
113
|
});
|
|
86
114
|
const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
|
|
87
115
|
// campaignIdRef stays current so callbacks defined inside useEffect
|
|
@@ -93,6 +121,15 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
93
121
|
// prop changes without forcing a re-fetch / re-mint cycle. Same UUID
|
|
94
122
|
// for the lifetime of the localStorage entry across the whole tab.
|
|
95
123
|
const visitorIdRef = useRef(null);
|
|
124
|
+
// EventQueue lives in a ref so it persists across renders without
|
|
125
|
+
// forcing the methods (track / identify / trackPageview) to depend
|
|
126
|
+
// on it for useCallback equality. Initialized inside the route
|
|
127
|
+
// effect since EventQueue's constructor touches localStorage and
|
|
128
|
+
// window — both unavailable during SSR. Methods called before init
|
|
129
|
+
// (during the first render, before useEffect runs) silently noop;
|
|
130
|
+
// this is fine because methods are only ever called from event
|
|
131
|
+
// handlers, which fire after mount.
|
|
132
|
+
const queueRef = useRef(null);
|
|
96
133
|
// track + identify are defined OUTSIDE the route-effect so they
|
|
97
134
|
// close over refs (campaignIdRef, visitorIdRef) rather than the
|
|
98
135
|
// captured-once values from inside the effect. A consumer caching
|
|
@@ -101,6 +138,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
101
138
|
try {
|
|
102
139
|
if (typeof window === 'undefined')
|
|
103
140
|
return;
|
|
141
|
+
// Opt-out is checked at call time (not init time) so a visitor who
|
|
142
|
+
// opts out mid-session stops firing immediately on the next track().
|
|
143
|
+
if (isOptedOut())
|
|
144
|
+
return;
|
|
104
145
|
if (!eventName || typeof eventName !== 'string')
|
|
105
146
|
return;
|
|
106
147
|
const name = eventName.trim().toLowerCase();
|
|
@@ -118,19 +159,11 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
118
159
|
};
|
|
119
160
|
if (metadata && typeof metadata === 'object')
|
|
120
161
|
payload.metadata = metadata;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
fetch(endpoint, {
|
|
128
|
-
method: 'POST',
|
|
129
|
-
headers: { 'Content-Type': 'application/json' },
|
|
130
|
-
body: data,
|
|
131
|
-
keepalive: true,
|
|
132
|
-
}).catch(() => { });
|
|
133
|
-
}
|
|
162
|
+
// Enqueue. EventQueue handles persist, retry, and flush. If the
|
|
163
|
+
// queue hasn't initialized yet (called during first render before
|
|
164
|
+
// useEffect runs), this noops — that's a vanishingly rare race
|
|
165
|
+
// since track() is virtually always called from a click handler.
|
|
166
|
+
queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
|
|
134
167
|
}
|
|
135
168
|
catch { /* swallow */ }
|
|
136
169
|
}, [apiBase]);
|
|
@@ -141,6 +174,8 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
141
174
|
try {
|
|
142
175
|
if (typeof window === 'undefined')
|
|
143
176
|
return;
|
|
177
|
+
if (isOptedOut())
|
|
178
|
+
return;
|
|
144
179
|
if (!userId || typeof userId !== 'string')
|
|
145
180
|
return;
|
|
146
181
|
const uid = userId.trim();
|
|
@@ -158,22 +193,109 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
158
193
|
visitor_id: vid,
|
|
159
194
|
user_id: uid,
|
|
160
195
|
};
|
|
161
|
-
|
|
162
|
-
const url = `${apiBase}/api/v1/web/identify`;
|
|
163
|
-
if (navigator.sendBeacon) {
|
|
164
|
-
navigator.sendBeacon(url, new Blob([data], { type: 'application/json' }));
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
fetch(url, {
|
|
168
|
-
method: 'POST',
|
|
169
|
-
headers: { 'Content-Type': 'application/json' },
|
|
170
|
-
body: data,
|
|
171
|
-
keepalive: true,
|
|
172
|
-
}).catch(() => { });
|
|
173
|
-
}
|
|
196
|
+
queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
|
|
174
197
|
}
|
|
175
198
|
catch { /* swallow */ }
|
|
176
199
|
}, [apiBase]);
|
|
200
|
+
// Manual pageview. Mirrors Datafast's trackPageview(path?). Same payload
|
|
201
|
+
// shape the auto-fired pageview uses (see sendPageview inside the route
|
|
202
|
+
// effect below) so server-side routing / dedup / IP+UA enrichment behave
|
|
203
|
+
// identically for both code paths.
|
|
204
|
+
//
|
|
205
|
+
// Defined here as a useCallback (not inside the effect) so React consumers
|
|
206
|
+
// who cache the function reference across renders aren't pointing at a
|
|
207
|
+
// stale closure. Reads from refs for the per-render-stable bits and
|
|
208
|
+
// localStorage for the persisted attribution (ref code), so the call
|
|
209
|
+
// always sees the latest values.
|
|
210
|
+
const trackPageview = useCallback(async (path) => {
|
|
211
|
+
try {
|
|
212
|
+
if (typeof window === 'undefined')
|
|
213
|
+
return;
|
|
214
|
+
if (isOptedOut())
|
|
215
|
+
return;
|
|
216
|
+
const cid = campaignIdRef.current;
|
|
217
|
+
const vid = visitorIdRef.current;
|
|
218
|
+
if (!cid || !vid)
|
|
219
|
+
return;
|
|
220
|
+
const params = new URLSearchParams(window.location.search);
|
|
221
|
+
const pagePath = path || window.location.pathname;
|
|
222
|
+
// url: if caller passed a path, resolve it against origin for a full
|
|
223
|
+
// href the server can dedup on. Otherwise use the actual location.
|
|
224
|
+
const url = path
|
|
225
|
+
? new URL(path, window.location.origin).href
|
|
226
|
+
: window.location.href;
|
|
227
|
+
const payload = {
|
|
228
|
+
type: 'pageview',
|
|
229
|
+
campaign_id: cid,
|
|
230
|
+
visitor_id: vid,
|
|
231
|
+
url,
|
|
232
|
+
path: pagePath,
|
|
233
|
+
hostname: window.location.hostname,
|
|
234
|
+
referrer: document.referrer || '',
|
|
235
|
+
ref: lsGet(LS_REF) || '',
|
|
236
|
+
utm_source: params.get('utm_source') || '',
|
|
237
|
+
utm_medium: params.get('utm_medium') || '',
|
|
238
|
+
utm_campaign: params.get('utm_campaign') || '',
|
|
239
|
+
screen_width: window.innerWidth,
|
|
240
|
+
};
|
|
241
|
+
queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
|
|
242
|
+
}
|
|
243
|
+
catch { /* swallow */ }
|
|
244
|
+
}, [apiBase]);
|
|
245
|
+
// reset(): wipe visitor identity. Mints a fresh UUID and clears the
|
|
246
|
+
// affiliate ref so the next pageview is a brand-new anonymous visitor.
|
|
247
|
+
// The campaign_id stays (it's a property of the site, not the visitor).
|
|
248
|
+
//
|
|
249
|
+
// Matches Datafast's reset() semantics for non-cookieless mode: new
|
|
250
|
+
// visitor, no event fired, caller handles "tell the server about it"
|
|
251
|
+
// by triggering whatever pageview / navigation comes next.
|
|
252
|
+
//
|
|
253
|
+
// Returns a Promise so callers can `await reset()` before navigating
|
|
254
|
+
// (the localStorage writes are sync but the Promise lets the API stay
|
|
255
|
+
// consistent with Datafast's async signature, future-proofing for
|
|
256
|
+
// server-minted IDs in cookieless mode).
|
|
257
|
+
const reset = useCallback(async () => {
|
|
258
|
+
try {
|
|
259
|
+
if (typeof window === 'undefined')
|
|
260
|
+
return;
|
|
261
|
+
// Drain any pending events for the OLD visitor before swapping IDs,
|
|
262
|
+
// otherwise they'd land server-side stamped to the OLD visitor_id
|
|
263
|
+
// (correct) but persisted-in-localStorage as if to-be-retried under
|
|
264
|
+
// the new identity — confusing on retry inspection. Best effort.
|
|
265
|
+
void queueRef.current?.flush();
|
|
266
|
+
queueRef.current?.clear();
|
|
267
|
+
const newVid = uuid();
|
|
268
|
+
lsSet(LS_VISITOR_ID, newVid);
|
|
269
|
+
lsRemove(LS_REF);
|
|
270
|
+
visitorIdRef.current = newVid;
|
|
271
|
+
setState((prev) => ({ ...prev, visitorId: newVid, refCode: null }));
|
|
272
|
+
}
|
|
273
|
+
catch { /* swallow */ }
|
|
274
|
+
}, []);
|
|
275
|
+
// optOut(): persistent "don't track me" flag. After this call every
|
|
276
|
+
// track / identify / trackPageview noops until optIn() is called. The
|
|
277
|
+
// flag survives reloads via localStorage, so a visitor who opts out
|
|
278
|
+
// stays opted out across browser sessions on the same device.
|
|
279
|
+
//
|
|
280
|
+
// We don't actively send a "stop tracking me" signal to the server —
|
|
281
|
+
// the server only knows about visitors who ping it, so an opted-out
|
|
282
|
+
// visitor simply stops appearing. The visitor row left behind in
|
|
283
|
+
// Postgres ages out via normal retention.
|
|
284
|
+
const optOut = useCallback(() => {
|
|
285
|
+
lsSet(LS_OPT_OUT, 'true');
|
|
286
|
+
// Drop pending events too. The visitor explicitly said no — sending
|
|
287
|
+
// queued events from before the opt-out would still violate consent
|
|
288
|
+
// even if those events were captured prior to the user's decision.
|
|
289
|
+
queueRef.current?.clear();
|
|
290
|
+
}, []);
|
|
291
|
+
// optIn(): remove the opt-out flag. Tracking RESUMES for any future
|
|
292
|
+
// calls (track / identify / trackPageview) but the in-flight auto-
|
|
293
|
+
// pageview that already noop'd on this render won't retroactively
|
|
294
|
+
// send. A page reload is the cleanest way to get a fresh auto-fired
|
|
295
|
+
// pageview after opting back in.
|
|
296
|
+
const optIn = useCallback(() => {
|
|
297
|
+
lsRemove(LS_OPT_OUT);
|
|
298
|
+
}, []);
|
|
177
299
|
const sentRef = useRef(false);
|
|
178
300
|
useEffect(() => {
|
|
179
301
|
if (typeof window === 'undefined')
|
|
@@ -186,6 +308,65 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
186
308
|
// eslint-disable-next-line no-console
|
|
187
309
|
console.warn('[Affiliateo] AffiliateoProvider mounted more than once. Mount it exactly once near the root of your app, or pageviews will double-fire.');
|
|
188
310
|
}
|
|
311
|
+
// Client-side bot detection. If the current browser context looks
|
|
312
|
+
// like Selenium / Puppeteer / curl / a known automation tool, skip
|
|
313
|
+
// ALL tracking before we even mint a queue. The server-side UA
|
|
314
|
+
// regex + Turnstile catch anything that sneaks past this check —
|
|
315
|
+
// this just saves the bot a round-trip and saves us a request.
|
|
316
|
+
//
|
|
317
|
+
// The hook still returns the four control methods (reset / optOut /
|
|
318
|
+
// etc) so a host UI that happens to render in a headless context
|
|
319
|
+
// doesn't crash on `useAffiliateRef().reset()`. They're noops, which
|
|
320
|
+
// is the correct behavior since there's no real visitor to act on.
|
|
321
|
+
if (isLikelyBot()) {
|
|
322
|
+
setState({
|
|
323
|
+
refCode: null,
|
|
324
|
+
visitorId: null,
|
|
325
|
+
track: asyncNoop,
|
|
326
|
+
identify: asyncNoop,
|
|
327
|
+
trackPageview: asyncNoop,
|
|
328
|
+
reset: asyncNoop,
|
|
329
|
+
optOut: syncNoop,
|
|
330
|
+
optIn: syncNoop,
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
// Initialize the event queue. Done inside useEffect (not in a useRef
|
|
335
|
+
// initializer) so the EventQueue constructor — which touches
|
|
336
|
+
// localStorage on hydration — only ever runs client-side. The queue
|
|
337
|
+
// takes over delivery for track / identify / trackPageview AND the
|
|
338
|
+
// auto-fired pageview below; only session_end stays on sendBeacon
|
|
339
|
+
// because tab-close urgency beats queue-retry reliability.
|
|
340
|
+
if (queueRef.current === null) {
|
|
341
|
+
queueRef.current = new EventQueue();
|
|
342
|
+
}
|
|
343
|
+
const queue = queueRef.current;
|
|
344
|
+
// Opt-out short-circuit. A visitor who previously called optOut()
|
|
345
|
+
// gets ZERO outbound traffic on this page load. We still mint the
|
|
346
|
+
// visitor_id (so optIn() + reload picks up the same identity rather
|
|
347
|
+
// than starting fresh, which would split their history), but skip
|
|
348
|
+
// ALL beacons including the auto-pageview, plus skip the event
|
|
349
|
+
// listeners (no point in firing session_end for a visitor we never
|
|
350
|
+
// told the server about). The hook still returns the four control
|
|
351
|
+
// methods so the host's banner UI can call optIn() / reset().
|
|
352
|
+
if (isOptedOut()) {
|
|
353
|
+
let optedOutVid = lsGet(LS_VISITOR_ID);
|
|
354
|
+
if (!optedOutVid) {
|
|
355
|
+
optedOutVid = uuid();
|
|
356
|
+
lsSet(LS_VISITOR_ID, optedOutVid);
|
|
357
|
+
}
|
|
358
|
+
visitorIdRef.current = optedOutVid;
|
|
359
|
+
setState({
|
|
360
|
+
refCode: null,
|
|
361
|
+
visitorId: optedOutVid,
|
|
362
|
+
track, identify, trackPageview, reset, optOut, optIn,
|
|
363
|
+
});
|
|
364
|
+
// Don't leave a queue running for an opted-out visitor — the timer
|
|
365
|
+
// would just keep waking up to flush nothing.
|
|
366
|
+
queue.shutdown();
|
|
367
|
+
queueRef.current = null;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
189
370
|
// Get or create visitor ID
|
|
190
371
|
let vid = lsGet(LS_VISITOR_ID);
|
|
191
372
|
if (!vid) {
|
|
@@ -202,21 +383,11 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
202
383
|
lsSet(LS_REF, ref);
|
|
203
384
|
}
|
|
204
385
|
const endpoint = `${apiBase}/api/v1/web/event`;
|
|
205
|
-
setState({
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
fetch(endpoint, {
|
|
213
|
-
method: 'POST',
|
|
214
|
-
headers: { 'Content-Type': 'application/json' },
|
|
215
|
-
body: data,
|
|
216
|
-
keepalive: true,
|
|
217
|
-
}).catch(() => { });
|
|
218
|
-
}
|
|
219
|
-
}
|
|
386
|
+
setState({
|
|
387
|
+
refCode: ref,
|
|
388
|
+
visitorId: vid,
|
|
389
|
+
track, identify, trackPageview, reset, optOut, optIn,
|
|
390
|
+
});
|
|
220
391
|
// Throttle: max 1 pageview per second
|
|
221
392
|
let lastSendTime = 0;
|
|
222
393
|
function sendPageview() {
|
|
@@ -225,7 +396,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
225
396
|
return;
|
|
226
397
|
lastSendTime = now;
|
|
227
398
|
ended = false;
|
|
228
|
-
|
|
399
|
+
queue.enqueue(endpoint, {
|
|
229
400
|
type: 'pageview',
|
|
230
401
|
campaign_id: campaignIdRef.current,
|
|
231
402
|
visitor_id: vid,
|
|
@@ -240,21 +411,71 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
240
411
|
screen_width: window.innerWidth,
|
|
241
412
|
});
|
|
242
413
|
}
|
|
243
|
-
// Send session_end when user leaves
|
|
414
|
+
// Send session_end when user leaves.
|
|
415
|
+
//
|
|
416
|
+
// session_end intentionally bypasses the queue and uses sendBeacon
|
|
417
|
+
// directly. The queue's fetch-with-keepalive is great for normal
|
|
418
|
+
// events because it returns a status code we can retry on, but it
|
|
419
|
+
// can't guarantee the request lands when the tab is in the act of
|
|
420
|
+
// closing — fetch may be aborted mid-flight. sendBeacon was
|
|
421
|
+
// designed exactly for this case and CANNOT be cancelled by the
|
|
422
|
+
// unload handler. We also try to drain the queue's pending events
|
|
423
|
+
// here, which uses sendBeacon under the hood for the same reason.
|
|
244
424
|
let ended = false;
|
|
245
425
|
function sendEnd() {
|
|
246
426
|
if (ended)
|
|
247
427
|
return;
|
|
248
428
|
ended = true;
|
|
249
|
-
|
|
429
|
+
// Drain the queue first so any pending track / identify events
|
|
430
|
+
// get a last shot before the tab closes. flushOnUnload uses
|
|
431
|
+
// sendBeacon internally, no retry needed because there's no
|
|
432
|
+
// retry opportunity after this.
|
|
433
|
+
queue.flushOnUnload();
|
|
434
|
+
const data = JSON.stringify({
|
|
435
|
+
type: 'session_end',
|
|
436
|
+
campaign_id: campaignIdRef.current,
|
|
437
|
+
visitor_id: vid,
|
|
438
|
+
ref: ref || lsGet(LS_REF) || '',
|
|
439
|
+
});
|
|
440
|
+
if (navigator.sendBeacon) {
|
|
441
|
+
navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
fetch(endpoint, {
|
|
445
|
+
method: 'POST',
|
|
446
|
+
headers: { 'Content-Type': 'application/json' },
|
|
447
|
+
body: data,
|
|
448
|
+
keepalive: true,
|
|
449
|
+
}).catch(() => { });
|
|
450
|
+
}
|
|
250
451
|
}
|
|
452
|
+
// Server-side session window. Matches STALE_VISITOR_MS in the
|
|
453
|
+
// tracking Durable Object — after this much idle time the server
|
|
454
|
+
// treats the visitor as a new session, rotates session_id, and
|
|
455
|
+
// expects a fresh pageview to anchor the new visit in
|
|
456
|
+
// campaign_events. Without that anchor, funnel steps starting on
|
|
457
|
+
// `page` matches (e.g. the homepage `/`) silently get skipped for
|
|
458
|
+
// visitors who keep a tab open and come back.
|
|
459
|
+
const SESSION_REFIRE_MS = 10 * 60 * 1000;
|
|
251
460
|
function onVisibilityChange() {
|
|
252
461
|
if (document.visibilityState === 'hidden') {
|
|
253
462
|
sendEnd();
|
|
254
463
|
}
|
|
255
464
|
else {
|
|
465
|
+
// Tab becomes visible again.
|
|
256
466
|
ended = false;
|
|
257
|
-
|
|
467
|
+
// If the SDK's last pageview was longer ago than the server's
|
|
468
|
+
// 10-min session window, the server has already rolled the
|
|
469
|
+
// visitor over to a new session_id. Refire pageview so the new
|
|
470
|
+
// session has a proper anchor — otherwise the next scroll /
|
|
471
|
+
// click / custom event arrives orphaned and the funnel overlay
|
|
472
|
+
// sees the visitor enter "mid-funnel" (e.g. on a scroll step)
|
|
473
|
+
// without ever passing through the page-step that should
|
|
474
|
+
// bracket the visit.
|
|
475
|
+
if (Date.now() - lastSendTime > SESSION_REFIRE_MS) {
|
|
476
|
+
sendPageview();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
258
479
|
}
|
|
259
480
|
// Send pageview on mount
|
|
260
481
|
if (!sentRef.current) {
|
|
@@ -283,113 +504,30 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
283
504
|
origReplaceState.apply(this, args);
|
|
284
505
|
onUrlChange();
|
|
285
506
|
};
|
|
286
|
-
// HTML-attribute goal
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
507
|
+
// HTML-attribute goal scanning (`data-affiliateo-goal`,
|
|
508
|
+
// `data-affiliateo-scroll`) and the IntersectionObserver that drove
|
|
509
|
+
// them were removed in 3.0.0 to match Datafast's npm SDK ergonomics:
|
|
510
|
+
// SDK does pageview + track() + identify(); merchants fire goals
|
|
511
|
+
// themselves via onClick={() => track('signup_clicked')} or by
|
|
512
|
+
// wiring their own IntersectionObserver and calling track() from it.
|
|
290
513
|
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
const target = e.target;
|
|
296
|
-
if (!(target instanceof Element))
|
|
297
|
-
return;
|
|
298
|
-
const el = target.closest('[data-affiliateo-goal]');
|
|
299
|
-
if (!el)
|
|
300
|
-
return;
|
|
301
|
-
const goal = el.getAttribute('data-affiliateo-goal');
|
|
302
|
-
if (!goal)
|
|
303
|
-
return;
|
|
304
|
-
const meta = {};
|
|
305
|
-
let count = 0;
|
|
306
|
-
for (const { name, value } of Array.from(el.attributes)) {
|
|
307
|
-
if (!name.startsWith('data-affiliateo-goal-'))
|
|
308
|
-
continue;
|
|
309
|
-
// Skip the bare "data-affiliateo-goal" attribute itself
|
|
310
|
-
// (slice would produce an empty key).
|
|
311
|
-
if (name === 'data-affiliateo-goal')
|
|
312
|
-
continue;
|
|
313
|
-
if (count >= 10)
|
|
314
|
-
break;
|
|
315
|
-
const key = name.slice('data-affiliateo-goal-'.length).replace(/-/g, '_');
|
|
316
|
-
// Skip the reserved `event` key so an attribute like
|
|
317
|
-
// `data-affiliateo-goal-event="x"` can't override the actual
|
|
318
|
-
// goal name when the SDK / server folds metadata.event in.
|
|
319
|
-
if (!key || key === 'event')
|
|
320
|
-
continue;
|
|
321
|
-
if (value) {
|
|
322
|
-
meta[key] = String(value).slice(0, 255);
|
|
323
|
-
count++;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
track(goal, count ? meta : undefined);
|
|
327
|
-
}
|
|
328
|
-
document.addEventListener('click', onDocClick);
|
|
329
|
-
// Scroll-depth goal tracking. `data-affiliateo-scroll="event_name"`
|
|
330
|
-
// on any element fires that goal the first time the element enters
|
|
331
|
-
// the viewport. Use IntersectionObserver (cheap, off the scroll hot
|
|
332
|
-
// path) and unobserve after the first fire so the same element can't
|
|
333
|
-
// double-count if the user scrolls back up.
|
|
334
|
-
//
|
|
335
|
-
// No options object → IntersectionObserver defaults to
|
|
336
|
-
// { threshold: 0, rootMargin: '0px 0px 0px 0px' }, which fires the
|
|
337
|
-
// instant ANY pixel of the section overlaps the viewport. That's
|
|
338
|
-
// exactly the semantic merchants want for "did the visitor reach
|
|
339
|
-
// this section": if they scrolled enough to see even a sliver of
|
|
340
|
-
// it, count it. Earlier versions used { threshold: 0.5 }, which
|
|
341
|
-
// was unreachable for tall sections (a 1600px pricing section in
|
|
342
|
-
// an 800px viewport tops out at ~50% intersection ratio and never
|
|
343
|
-
// crossed the threshold) — funnel steps tied to those sections
|
|
344
|
-
// silently dropped from every merchant's conversion count.
|
|
345
|
-
const scrollFired = new WeakSet();
|
|
346
|
-
let scrollObserver = null;
|
|
347
|
-
let scrollMo = null;
|
|
348
|
-
if (typeof IntersectionObserver !== 'undefined') {
|
|
349
|
-
scrollObserver = new IntersectionObserver((entries) => {
|
|
350
|
-
for (const entry of entries) {
|
|
351
|
-
if (!entry.isIntersecting)
|
|
352
|
-
continue;
|
|
353
|
-
if (scrollFired.has(entry.target))
|
|
354
|
-
continue;
|
|
355
|
-
scrollFired.add(entry.target);
|
|
356
|
-
const goal = entry.target.getAttribute('data-affiliateo-scroll');
|
|
357
|
-
if (goal)
|
|
358
|
-
track(goal);
|
|
359
|
-
scrollObserver?.unobserve(entry.target);
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
// Observe everything that exists at mount time.
|
|
363
|
-
document.querySelectorAll('[data-affiliateo-scroll]').forEach((el) => {
|
|
364
|
-
scrollObserver?.observe(el);
|
|
365
|
-
});
|
|
366
|
-
// SPA-rendered sections that appear later get observed via
|
|
367
|
-
// MutationObserver. Cheap because we only scan added nodes, not
|
|
368
|
-
// the whole tree on each mutation.
|
|
369
|
-
scrollMo = new MutationObserver((mutations) => {
|
|
370
|
-
for (const m of mutations) {
|
|
371
|
-
m.addedNodes.forEach((node) => {
|
|
372
|
-
if (!(node instanceof Element))
|
|
373
|
-
return;
|
|
374
|
-
if (node.hasAttribute('data-affiliateo-scroll'))
|
|
375
|
-
scrollObserver?.observe(node);
|
|
376
|
-
node.querySelectorAll?.('[data-affiliateo-scroll]').forEach((el) => scrollObserver?.observe(el));
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
});
|
|
380
|
-
scrollMo.observe(document.body, { childList: true, subtree: true });
|
|
381
|
-
}
|
|
514
|
+
// Why removed: the scanning layer was the source of every funnel
|
|
515
|
+
// bug we hit (threshold:0 firing on hero sections, SPA mutation
|
|
516
|
+
// observer races, client-side step matching from HTML). Removing it
|
|
517
|
+
// also drops ~80 lines of code and one extra DOM observer per page.
|
|
382
518
|
return () => {
|
|
383
519
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
384
520
|
window.removeEventListener('pagehide', sendEnd);
|
|
385
521
|
window.removeEventListener('popstate', onUrlChange);
|
|
386
|
-
document.removeEventListener('click', onDocClick);
|
|
387
|
-
scrollMo?.disconnect();
|
|
388
|
-
scrollObserver?.disconnect();
|
|
389
522
|
history.pushState = origPushState;
|
|
390
523
|
history.replaceState = origReplaceState;
|
|
524
|
+
// Stop the queue's flush timer. The queue tries one final flush
|
|
525
|
+
// before going quiet — anything still queued lives in localStorage
|
|
526
|
+
// and will be picked up by the next page load's queue init.
|
|
527
|
+
queue.shutdown();
|
|
528
|
+
queueRef.current = null;
|
|
391
529
|
providerMountCount = Math.max(0, providerMountCount - 1);
|
|
392
530
|
};
|
|
393
|
-
}, [apiBase, track, identify]);
|
|
531
|
+
}, [apiBase, track, identify, trackPageview, reset, optOut, optIn]);
|
|
394
532
|
return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
|
|
395
533
|
}
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventQueue: durable best-effort delivery for analytics events.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
* Before this module, every track / identify / pageview was fire-and-forget
|
|
6
|
+
* via navigator.sendBeacon. If the visitor's network blinked, the server
|
|
7
|
+
* was briefly overloaded, or any other transient failure hit, the event
|
|
8
|
+
* was lost with no retry, no log, no observability. On flaky mobile
|
|
9
|
+
* networks (train wifi, subway transitions, dropping 4G) merchants saw
|
|
10
|
+
* ~10-15% fewer events than they should have.
|
|
11
|
+
*
|
|
12
|
+
* This queue mirrors the pattern PostHog / Segment / Amplitude / Datafast
|
|
13
|
+
* all converged on: in-memory queue, localStorage persistence so events
|
|
14
|
+
* survive page reload, periodic auto-flush, retry on failure up to N
|
|
15
|
+
* times, drop after N tries to bound memory.
|
|
16
|
+
*
|
|
17
|
+
* Design notes:
|
|
18
|
+
* - Persisted state is JSON in localStorage under STORAGE_KEY. The
|
|
19
|
+
* persisted shape is intentionally simple (id + payload + retries)
|
|
20
|
+
* so a future schema bump doesn't break a visitor who upgraded
|
|
21
|
+
* mid-session and has events from the old shape sitting around.
|
|
22
|
+
* - Failures are silent. The host app should never see an analytics
|
|
23
|
+
* error bubble up. Console-noise during dev is the only signal.
|
|
24
|
+
* - sendBeacon is NOT used here. sendBeacon is great for "fire on
|
|
25
|
+
* unload" but it doesn't return a response we can branch on for
|
|
26
|
+
* retry. fetch + keepalive gives us a status code AND lets the
|
|
27
|
+
* browser keep the request alive across navigation. Trade-off: a
|
|
28
|
+
* queued event fired right before the visitor closes the tab might
|
|
29
|
+
* not land. That's why session_end stays on sendBeacon (see
|
|
30
|
+
* provider.tsx, sendEnd helper).
|
|
31
|
+
* - The flush timer uses setInterval. Stopped on shutdown(). The
|
|
32
|
+
* provider only ever creates one queue per mount, so timer leaks
|
|
33
|
+
* aren't a practical concern.
|
|
34
|
+
*
|
|
35
|
+
* Caps:
|
|
36
|
+
* - MAX_RETRIES = 3 per-event retry ceiling before drop
|
|
37
|
+
* - MAX_QUEUE_SIZE hard cap so a long network outage can't fill
|
|
38
|
+
* localStorage and break the host app. Events
|
|
39
|
+
* beyond the cap are dropped FIFO (oldest first).
|
|
40
|
+
* - FLUSH_INTERVAL_MS = 5_000 matches Datafast's web SDK default
|
|
41
|
+
* - SIZE_FLUSH_THRESHOLD = 10 triggers a flush when queue grows
|
|
42
|
+
* past this even before the timer fires
|
|
43
|
+
*/
|
|
44
|
+
export interface QueuedEvent {
|
|
45
|
+
/** Stable id used to identify the event across retries. UUID v4
|
|
46
|
+
* generated client-side; collisions are astronomically unlikely so
|
|
47
|
+
* we don't bother with a fallback. */
|
|
48
|
+
id: string;
|
|
49
|
+
/** The exact body the server expects. We persist the full payload
|
|
50
|
+
* (not a delta) so a visitor who reloads mid-flush can still recover
|
|
51
|
+
* every queued event without needing to know the original call site. */
|
|
52
|
+
payload: Record<string, unknown>;
|
|
53
|
+
/** Endpoint to POST to. Keyed per-event so a single queue can hold
|
|
54
|
+
* both /api/v1/web/event (track + pageview) and /api/v1/web/identify
|
|
55
|
+
* events without a separate queue per endpoint. */
|
|
56
|
+
endpoint: string;
|
|
57
|
+
/** Retry counter. Incremented on each failed flush. Event is dropped
|
|
58
|
+
* when this hits MAX_RETRIES. */
|
|
59
|
+
retries: number;
|
|
60
|
+
}
|
|
61
|
+
export declare class EventQueue {
|
|
62
|
+
private queue;
|
|
63
|
+
private flushTimer;
|
|
64
|
+
private isFlushing;
|
|
65
|
+
private shuttingDown;
|
|
66
|
+
constructor();
|
|
67
|
+
/** Add an event. Persists synchronously to localStorage so a tab
|
|
68
|
+
* close right after enqueue doesn't lose the event from memory
|
|
69
|
+
* before it's safely on disk. */
|
|
70
|
+
enqueue(endpoint: string, payload: Record<string, unknown>): void;
|
|
71
|
+
/** Try to deliver every queued event. Each event gets one attempt
|
|
72
|
+
* per flush; failures bump retries and stay in the queue for the
|
|
73
|
+
* next flush. Events that hit MAX_RETRIES get dropped.
|
|
74
|
+
*
|
|
75
|
+
* Concurrency: guarded by isFlushing so overlapping calls (the
|
|
76
|
+
* burst path + the timer firing simultaneously) only run one
|
|
77
|
+
* flush at a time. */
|
|
78
|
+
flush(): Promise<void>;
|
|
79
|
+
/** Best-effort sync flush triggered on page unload. Uses sendBeacon
|
|
80
|
+
* for each event so it survives the tab closing. No retry — this is
|
|
81
|
+
* the last chance.
|
|
82
|
+
*
|
|
83
|
+
* Called from the provider's pagehide / visibilitychange=hidden
|
|
84
|
+
* handlers so any events still queued at unload get one last shot.
|
|
85
|
+
* After this call, the in-memory queue is cleared (events that
|
|
86
|
+
* sendBeacon couldn't queue are lost — that's the cost of the
|
|
87
|
+
* guaranteed-fast unload path). */
|
|
88
|
+
flushOnUnload(): void;
|
|
89
|
+
/** Wipe the queue. Called by reset() and optOut(). Removes the
|
|
90
|
+
* localStorage entry entirely so a reload doesn't re-hydrate the
|
|
91
|
+
* wiped events. */
|
|
92
|
+
clear(): void;
|
|
93
|
+
/** Stop the flush timer and try one final flush. Idempotent. */
|
|
94
|
+
shutdown(): void;
|
|
95
|
+
/** Read-only size getter. Used by tests / debug consoles. */
|
|
96
|
+
get size(): number;
|
|
97
|
+
private persist;
|
|
98
|
+
private startFlushTimer;
|
|
99
|
+
}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventQueue: durable best-effort delivery for analytics events.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists:
|
|
5
|
+
* Before this module, every track / identify / pageview was fire-and-forget
|
|
6
|
+
* via navigator.sendBeacon. If the visitor's network blinked, the server
|
|
7
|
+
* was briefly overloaded, or any other transient failure hit, the event
|
|
8
|
+
* was lost with no retry, no log, no observability. On flaky mobile
|
|
9
|
+
* networks (train wifi, subway transitions, dropping 4G) merchants saw
|
|
10
|
+
* ~10-15% fewer events than they should have.
|
|
11
|
+
*
|
|
12
|
+
* This queue mirrors the pattern PostHog / Segment / Amplitude / Datafast
|
|
13
|
+
* all converged on: in-memory queue, localStorage persistence so events
|
|
14
|
+
* survive page reload, periodic auto-flush, retry on failure up to N
|
|
15
|
+
* times, drop after N tries to bound memory.
|
|
16
|
+
*
|
|
17
|
+
* Design notes:
|
|
18
|
+
* - Persisted state is JSON in localStorage under STORAGE_KEY. The
|
|
19
|
+
* persisted shape is intentionally simple (id + payload + retries)
|
|
20
|
+
* so a future schema bump doesn't break a visitor who upgraded
|
|
21
|
+
* mid-session and has events from the old shape sitting around.
|
|
22
|
+
* - Failures are silent. The host app should never see an analytics
|
|
23
|
+
* error bubble up. Console-noise during dev is the only signal.
|
|
24
|
+
* - sendBeacon is NOT used here. sendBeacon is great for "fire on
|
|
25
|
+
* unload" but it doesn't return a response we can branch on for
|
|
26
|
+
* retry. fetch + keepalive gives us a status code AND lets the
|
|
27
|
+
* browser keep the request alive across navigation. Trade-off: a
|
|
28
|
+
* queued event fired right before the visitor closes the tab might
|
|
29
|
+
* not land. That's why session_end stays on sendBeacon (see
|
|
30
|
+
* provider.tsx, sendEnd helper).
|
|
31
|
+
* - The flush timer uses setInterval. Stopped on shutdown(). The
|
|
32
|
+
* provider only ever creates one queue per mount, so timer leaks
|
|
33
|
+
* aren't a practical concern.
|
|
34
|
+
*
|
|
35
|
+
* Caps:
|
|
36
|
+
* - MAX_RETRIES = 3 per-event retry ceiling before drop
|
|
37
|
+
* - MAX_QUEUE_SIZE hard cap so a long network outage can't fill
|
|
38
|
+
* localStorage and break the host app. Events
|
|
39
|
+
* beyond the cap are dropped FIFO (oldest first).
|
|
40
|
+
* - FLUSH_INTERVAL_MS = 5_000 matches Datafast's web SDK default
|
|
41
|
+
* - SIZE_FLUSH_THRESHOLD = 10 triggers a flush when queue grows
|
|
42
|
+
* past this even before the timer fires
|
|
43
|
+
*/
|
|
44
|
+
const STORAGE_KEY = 'affiliateo_event_queue';
|
|
45
|
+
const MAX_RETRIES = 3;
|
|
46
|
+
const MAX_QUEUE_SIZE = 100;
|
|
47
|
+
const FLUSH_INTERVAL_MS = 5000;
|
|
48
|
+
const SIZE_FLUSH_THRESHOLD = 10;
|
|
49
|
+
function lsGet(key) {
|
|
50
|
+
try {
|
|
51
|
+
return localStorage.getItem(key);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function lsSet(key, value) {
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(key, value);
|
|
60
|
+
}
|
|
61
|
+
catch { /* quota / private mode */ }
|
|
62
|
+
}
|
|
63
|
+
function lsRemove(key) {
|
|
64
|
+
try {
|
|
65
|
+
localStorage.removeItem(key);
|
|
66
|
+
}
|
|
67
|
+
catch { /* swallow */ }
|
|
68
|
+
}
|
|
69
|
+
function uuid() {
|
|
70
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
71
|
+
return crypto.randomUUID();
|
|
72
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
73
|
+
const r = (Math.random() * 16) | 0;
|
|
74
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
75
|
+
return v.toString(16);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export class EventQueue {
|
|
79
|
+
constructor() {
|
|
80
|
+
this.queue = [];
|
|
81
|
+
this.flushTimer = null;
|
|
82
|
+
this.isFlushing = false;
|
|
83
|
+
this.shuttingDown = false;
|
|
84
|
+
// Hydrate from localStorage so events from a prior page load survive
|
|
85
|
+
// a refresh / navigation. Guards against corrupt JSON or wrong-shape
|
|
86
|
+
// entries by resetting to empty if anything looks off — better to
|
|
87
|
+
// lose a few stuck events than crash the host app on init.
|
|
88
|
+
try {
|
|
89
|
+
const raw = lsGet(STORAGE_KEY);
|
|
90
|
+
if (raw) {
|
|
91
|
+
const parsed = JSON.parse(raw);
|
|
92
|
+
if (Array.isArray(parsed)) {
|
|
93
|
+
this.queue = parsed.filter((e) => !!e && typeof e === 'object'
|
|
94
|
+
&& typeof e.id === 'string'
|
|
95
|
+
&& typeof e.endpoint === 'string'
|
|
96
|
+
&& typeof e.payload === 'object'
|
|
97
|
+
&& typeof e.retries === 'number');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
this.queue = [];
|
|
103
|
+
}
|
|
104
|
+
this.startFlushTimer();
|
|
105
|
+
// Try an immediate flush on construction so events left over from
|
|
106
|
+
// a previous session get a head start (especially useful on the
|
|
107
|
+
// page-load-after-an-outage scenario).
|
|
108
|
+
if (this.queue.length > 0) {
|
|
109
|
+
void this.flush();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Add an event. Persists synchronously to localStorage so a tab
|
|
113
|
+
* close right after enqueue doesn't lose the event from memory
|
|
114
|
+
* before it's safely on disk. */
|
|
115
|
+
enqueue(endpoint, payload) {
|
|
116
|
+
if (this.shuttingDown)
|
|
117
|
+
return;
|
|
118
|
+
this.queue.push({
|
|
119
|
+
id: uuid(),
|
|
120
|
+
endpoint,
|
|
121
|
+
payload,
|
|
122
|
+
retries: 0,
|
|
123
|
+
});
|
|
124
|
+
// FIFO drop when over cap. Network outage edge case: if we're way
|
|
125
|
+
// over budget the oldest events are likely already stale (the
|
|
126
|
+
// visitor moved on, the page they were on isn't current) so
|
|
127
|
+
// 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);
|
|
130
|
+
}
|
|
131
|
+
this.persist();
|
|
132
|
+
// Burst protection: when many events fire in quick succession
|
|
133
|
+
// (auto-pageview + immediate track + identify on app boot), don't
|
|
134
|
+
// wait for the 5s timer — flush right away. The isFlushing guard
|
|
135
|
+
// inside flush() makes overlapping calls safe.
|
|
136
|
+
if (this.queue.length >= SIZE_FLUSH_THRESHOLD) {
|
|
137
|
+
void this.flush();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Try to deliver every queued event. Each event gets one attempt
|
|
141
|
+
* per flush; failures bump retries and stay in the queue for the
|
|
142
|
+
* next flush. Events that hit MAX_RETRIES get dropped.
|
|
143
|
+
*
|
|
144
|
+
* Concurrency: guarded by isFlushing so overlapping calls (the
|
|
145
|
+
* burst path + the timer firing simultaneously) only run one
|
|
146
|
+
* flush at a time. */
|
|
147
|
+
async flush() {
|
|
148
|
+
if (this.isFlushing || this.queue.length === 0)
|
|
149
|
+
return;
|
|
150
|
+
this.isFlushing = true;
|
|
151
|
+
// Snapshot the queue so we iterate a stable list — new enqueue()
|
|
152
|
+
// calls during the flush land in the live queue and get picked up
|
|
153
|
+
// by the next flush rather than this one (avoids infinite loops
|
|
154
|
+
// and reasoning headaches).
|
|
155
|
+
const batch = this.queue.slice();
|
|
156
|
+
for (const event of batch) {
|
|
157
|
+
let ok = false;
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch(event.endpoint, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: JSON.stringify(event.payload),
|
|
163
|
+
// keepalive lets the request survive a navigation. paired
|
|
164
|
+
// with the queue so even if this fetch errors, we'll retry.
|
|
165
|
+
keepalive: true,
|
|
166
|
+
});
|
|
167
|
+
// 2xx = success. Anything else (4xx, 5xx) counts as a failure
|
|
168
|
+
// and bumps retries. A 4xx is usually a permanent error
|
|
169
|
+
// (bad payload, dead campaign) so retrying 3 times then
|
|
170
|
+
// dropping is the right move — keeps the queue clean.
|
|
171
|
+
ok = res.ok;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
ok = false;
|
|
175
|
+
}
|
|
176
|
+
if (ok) {
|
|
177
|
+
// Remove from live queue (not the snapshot) by id
|
|
178
|
+
this.queue = this.queue.filter((e) => e.id !== event.id);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// Find the live entry and bump its retry counter. Use id
|
|
182
|
+
// because the index may have shifted if other events were
|
|
183
|
+
// already removed on this pass.
|
|
184
|
+
const live = this.queue.find((e) => e.id === event.id);
|
|
185
|
+
if (live) {
|
|
186
|
+
live.retries++;
|
|
187
|
+
if (live.retries >= MAX_RETRIES) {
|
|
188
|
+
this.queue = this.queue.filter((e) => e.id !== event.id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.persist();
|
|
194
|
+
this.isFlushing = false;
|
|
195
|
+
}
|
|
196
|
+
/** Best-effort sync flush triggered on page unload. Uses sendBeacon
|
|
197
|
+
* for each event so it survives the tab closing. No retry — this is
|
|
198
|
+
* the last chance.
|
|
199
|
+
*
|
|
200
|
+
* Called from the provider's pagehide / visibilitychange=hidden
|
|
201
|
+
* handlers so any events still queued at unload get one last shot.
|
|
202
|
+
* After this call, the in-memory queue is cleared (events that
|
|
203
|
+
* sendBeacon couldn't queue are lost — that's the cost of the
|
|
204
|
+
* guaranteed-fast unload path). */
|
|
205
|
+
flushOnUnload() {
|
|
206
|
+
if (this.queue.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
|
|
209
|
+
// No sendBeacon (very old browser) — try a sync fetch with
|
|
210
|
+
// keepalive as a fallback. Won't actually finish, but it's our
|
|
211
|
+
// last shot.
|
|
212
|
+
for (const event of this.queue) {
|
|
213
|
+
try {
|
|
214
|
+
fetch(event.endpoint, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/json' },
|
|
217
|
+
body: JSON.stringify(event.payload),
|
|
218
|
+
keepalive: true,
|
|
219
|
+
}).catch(() => { });
|
|
220
|
+
}
|
|
221
|
+
catch { /* swallow */ }
|
|
222
|
+
}
|
|
223
|
+
this.queue = [];
|
|
224
|
+
this.persist();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const event of this.queue) {
|
|
228
|
+
try {
|
|
229
|
+
navigator.sendBeacon(event.endpoint, new Blob([JSON.stringify(event.payload)], { type: 'application/json' }));
|
|
230
|
+
}
|
|
231
|
+
catch { /* swallow */ }
|
|
232
|
+
}
|
|
233
|
+
this.queue = [];
|
|
234
|
+
this.persist();
|
|
235
|
+
}
|
|
236
|
+
/** Wipe the queue. Called by reset() and optOut(). Removes the
|
|
237
|
+
* localStorage entry entirely so a reload doesn't re-hydrate the
|
|
238
|
+
* wiped events. */
|
|
239
|
+
clear() {
|
|
240
|
+
this.queue = [];
|
|
241
|
+
lsRemove(STORAGE_KEY);
|
|
242
|
+
}
|
|
243
|
+
/** Stop the flush timer and try one final flush. Idempotent. */
|
|
244
|
+
shutdown() {
|
|
245
|
+
this.shuttingDown = true;
|
|
246
|
+
if (this.flushTimer !== null) {
|
|
247
|
+
clearInterval(this.flushTimer);
|
|
248
|
+
this.flushTimer = null;
|
|
249
|
+
}
|
|
250
|
+
void this.flush();
|
|
251
|
+
}
|
|
252
|
+
/** Read-only size getter. Used by tests / debug consoles. */
|
|
253
|
+
get size() {
|
|
254
|
+
return this.queue.length;
|
|
255
|
+
}
|
|
256
|
+
persist() {
|
|
257
|
+
lsSet(STORAGE_KEY, JSON.stringify(this.queue));
|
|
258
|
+
}
|
|
259
|
+
startFlushTimer() {
|
|
260
|
+
if (this.flushTimer !== null)
|
|
261
|
+
return;
|
|
262
|
+
this.flushTimer = setInterval(() => {
|
|
263
|
+
void this.flush();
|
|
264
|
+
}, FLUSH_INTERVAL_MS);
|
|
265
|
+
}
|
|
266
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -34,4 +34,54 @@ export interface AffiliateoContextValue {
|
|
|
34
34
|
* const { identify } = useAffiliateRef()
|
|
35
35
|
* useEffect(() => { if (user) identify(user.id) }, [user]) */
|
|
36
36
|
identify: (userId: string) => Promise<void>;
|
|
37
|
+
/** Manually fire a pageview. The SDK already auto-fires on mount and on
|
|
38
|
+
* SPA route changes; use this only when you want extra control (e.g.
|
|
39
|
+
* tracking modal opens as virtual pageviews, or merchants who disabled
|
|
40
|
+
* auto-pageviews for custom routing).
|
|
41
|
+
*
|
|
42
|
+
* - With no args: tracks the CURRENT page (same as the auto-fired one).
|
|
43
|
+
* - With a path: tracks that path. Useful for virtual pages.
|
|
44
|
+
*
|
|
45
|
+
* Example:
|
|
46
|
+
* const { trackPageview } = useAffiliateRef()
|
|
47
|
+
* function PricingModal({ open }: { open: boolean }) {
|
|
48
|
+
* useEffect(() => { if (open) trackPageview('/pricing-modal') }, [open])
|
|
49
|
+
* } */
|
|
50
|
+
trackPageview: (path?: string) => Promise<void>;
|
|
51
|
+
/** Wipe the visitor identity. Mints a fresh visitor_id and clears the
|
|
52
|
+
* affiliate ref + campaign_id from localStorage so the next pageview
|
|
53
|
+
* is treated as a brand-new anonymous visitor.
|
|
54
|
+
*
|
|
55
|
+
* Use case: call on user logout so a different person sitting down at
|
|
56
|
+
* the same browser doesn't get their actions merged into the previous
|
|
57
|
+
* user's funnel. Without this, shared family computers / kiosks /
|
|
58
|
+
* hot-desks silently pollute attribution.
|
|
59
|
+
*
|
|
60
|
+
* Example:
|
|
61
|
+
* const { reset } = useAffiliateRef()
|
|
62
|
+
* async function handleLogout() {
|
|
63
|
+
* await supabase.auth.signOut()
|
|
64
|
+
* await reset()
|
|
65
|
+
* router.push('/')
|
|
66
|
+
* } */
|
|
67
|
+
reset: () => Promise<void>;
|
|
68
|
+
/** Stop tracking this visitor. Sets a persistent opt-out flag in
|
|
69
|
+
* localStorage and silences all subsequent track / identify / pageview
|
|
70
|
+
* calls until optIn() is called. Survives reloads, doesn't survive a
|
|
71
|
+
* hard browser reset.
|
|
72
|
+
*
|
|
73
|
+
* Use case: GDPR / CCPA cookie banner where the visitor picked
|
|
74
|
+
* "Don't track me." Call optOut() once and the SDK noops everything
|
|
75
|
+
* for that visitor.
|
|
76
|
+
*
|
|
77
|
+
* Example:
|
|
78
|
+
* const { optOut } = useAffiliateRef()
|
|
79
|
+
* <button onClick={() => optOut()}>Don't track me</button> */
|
|
80
|
+
optOut: () => void;
|
|
81
|
+
/** Re-enable tracking after a previous optOut(). Removes the persistent
|
|
82
|
+
* flag. Note: tracking only resumes for events fired AFTER this call,
|
|
83
|
+
* the current pageview won't retroactively send. A full page reload is
|
|
84
|
+
* the cleanest way to bring the SDK back to its normal "auto-fire on
|
|
85
|
+
* mount" state. */
|
|
86
|
+
optIn: () => void;
|
|
37
87
|
}
|
package/package.json
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@affiliateo/web",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"module": "dist/index.js",
|
|
7
|
-
"types": "dist/index.d.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
"types": "./dist/index.d.ts",
|
|
11
|
-
"import": "./dist/index.js",
|
|
12
|
-
"require": "./dist/index.js"
|
|
13
|
-
}
|
|
14
|
-
},
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc",
|
|
17
|
-
"prepublishOnly": "npm run build"
|
|
18
|
-
},
|
|
19
|
-
"peerDependencies": {
|
|
20
|
-
"react": ">=18.0.0"
|
|
21
|
-
},
|
|
22
|
-
"peerDependenciesMeta": {
|
|
23
|
-
"react": {
|
|
24
|
-
"optional": true
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
"devDependencies": {
|
|
28
|
-
"@types/react": "^19.0.0",
|
|
29
|
-
"typescript": "^5.0.0"
|
|
30
|
-
},
|
|
31
|
-
"keywords": ["affiliateo", "affiliate", "attribution", "stripe", "web", "session", "tracking"],
|
|
32
|
-
"license": "MIT",
|
|
33
|
-
"files": ["dist"]
|
|
34
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@affiliateo/web",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18.0.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"react": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/react": "^19.0.0",
|
|
29
|
+
"typescript": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"keywords": ["affiliateo", "affiliate", "attribution", "stripe", "web", "session", "tracking"],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"files": ["dist"]
|
|
34
|
+
}
|