@affiliateo/web 4.5.0 → 4.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/provider.js +64 -12
- package/dist/turnstile.d.ts +40 -0
- package/dist/turnstile.js +105 -0
- package/package.json +15 -5
package/dist/provider.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
|
|
4
4
|
import { EventQueue } from './queue';
|
|
5
5
|
import { isLikelyBot } from './bot-detect';
|
|
6
6
|
import { isDevHost } from './dev-host';
|
|
7
|
+
import { mintTurnstileToken, consumeTurnstileToken } from './turnstile';
|
|
7
8
|
const DEFAULT_API_URL = 'https://affiliateo.com';
|
|
8
9
|
const LS_VISITOR_ID = 'affiliateo_visitor_id';
|
|
9
10
|
const LS_REF = 'affiliateo_ref';
|
|
@@ -170,6 +171,23 @@ export function getAttribution() {
|
|
|
170
171
|
// mount (host has the call sites) but we can shout in dev so the
|
|
171
172
|
// mistake is visible.
|
|
172
173
|
let providerMountCount = 0;
|
|
174
|
+
/**
|
|
175
|
+
* True only when we can SEE that this is a production build.
|
|
176
|
+
*
|
|
177
|
+
* Every bundler (webpack, Vite, Next, Rollup) replaces `process.env.NODE_ENV`
|
|
178
|
+
* with a literal before shipping, which is why reading it works for almost
|
|
179
|
+
* everyone. Someone importing the ESM directly has no `process` at all, and
|
|
180
|
+
* touching it there throws a ReferenceError rather than reading undefined.
|
|
181
|
+
*
|
|
182
|
+
* UNKNOWN COUNTS AS NOT-PRODUCTION, deliberately. The only caller is the
|
|
183
|
+
* double-mount warning, and a double mount double-fires every pageview — which
|
|
184
|
+
* corrupts the merchant's own numbers and the affiliate attribution built on
|
|
185
|
+
* them. One stray console line on an unbundled production page is a smaller
|
|
186
|
+
* cost than silently letting that run.
|
|
187
|
+
*/
|
|
188
|
+
function isProductionBuild() {
|
|
189
|
+
return typeof process !== 'undefined' && process?.env?.NODE_ENV === 'production';
|
|
190
|
+
}
|
|
173
191
|
export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }) {
|
|
174
192
|
// Normalize autoCapturePageviews. Three shapes:
|
|
175
193
|
// - undefined / true / object → auto-capture ON (with object overrides)
|
|
@@ -208,10 +226,8 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
208
226
|
const log = useCallback((msg, data) => {
|
|
209
227
|
if (!debug)
|
|
210
228
|
return;
|
|
211
|
-
// eslint-disable-next-line no-console
|
|
212
229
|
if (data !== undefined)
|
|
213
230
|
console.log('[Affiliateo] ' + msg, data);
|
|
214
|
-
// eslint-disable-next-line no-console
|
|
215
231
|
else
|
|
216
232
|
console.log('[Affiliateo] ' + msg);
|
|
217
233
|
}, [debug]);
|
|
@@ -355,6 +371,12 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
355
371
|
gclid: params.get('gclid') || '',
|
|
356
372
|
screen_width: window.innerWidth,
|
|
357
373
|
};
|
|
374
|
+
// Attach the (single-use) Turnstile token if one is still unconsumed.
|
|
375
|
+
// Matters for consent flows where the host disabled auto-capture and
|
|
376
|
+
// this manual call is the session-opening event.
|
|
377
|
+
const tsToken = consumeTurnstileToken();
|
|
378
|
+
if (tsToken)
|
|
379
|
+
payload.cf_turnstile_token = tsToken;
|
|
358
380
|
queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
|
|
359
381
|
log('pageview (manual)', { path: pagePath });
|
|
360
382
|
}
|
|
@@ -436,7 +458,6 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
436
458
|
// Neither appId nor the legacy campaignId alias was passed — nothing to
|
|
437
459
|
// attribute events to, so stay dormant instead of firing junk.
|
|
438
460
|
if (!appIdRef.current) {
|
|
439
|
-
// eslint-disable-next-line no-console
|
|
440
461
|
console.error('[Affiliateo] Missing appId — pass your app ID to <AffiliateoProvider appId="...">.');
|
|
441
462
|
return;
|
|
442
463
|
}
|
|
@@ -444,8 +465,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
444
465
|
// ever mounted in the page lifetime. Dev-only; production swallows
|
|
445
466
|
// the warning to avoid noise in real merchant consoles.
|
|
446
467
|
providerMountCount += 1;
|
|
447
|
-
if (providerMountCount > 1 &&
|
|
448
|
-
// eslint-disable-next-line no-console
|
|
468
|
+
if (providerMountCount > 1 && !isProductionBuild()) {
|
|
449
469
|
console.warn('[Affiliateo] AffiliateoProvider mounted more than once. Mount it exactly once near the root of your app, or pageviews will double-fire.');
|
|
450
470
|
}
|
|
451
471
|
// Client-side bot detection. If the current browser context looks
|
|
@@ -674,6 +694,11 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
674
694
|
ssSet(SS_PV_THROTTLE, JSON.stringify({ url: href, time: now }));
|
|
675
695
|
lastSendTime = now;
|
|
676
696
|
ended = false;
|
|
697
|
+
// Attach the (single-use) Turnstile token to the session-opening
|
|
698
|
+
// pageview. consumeTurnstileToken() returns null after the first
|
|
699
|
+
// consume, so SPA navigations go out bare — the server trusts them
|
|
700
|
+
// because the visitor row was created behind the verified token.
|
|
701
|
+
const tsToken = consumeTurnstileToken();
|
|
677
702
|
queue.enqueue(endpoint, {
|
|
678
703
|
type: 'pageview',
|
|
679
704
|
campaign_id: appIdRef.current,
|
|
@@ -692,6 +717,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
692
717
|
ttclid: params.get('ttclid') || '',
|
|
693
718
|
gclid: params.get('gclid') || '',
|
|
694
719
|
screen_width: window.innerWidth,
|
|
720
|
+
...(tsToken ? { cf_turnstile_token: tsToken } : {}),
|
|
695
721
|
});
|
|
696
722
|
log('pageview', { path: window.location.pathname });
|
|
697
723
|
}
|
|
@@ -761,13 +787,30 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
761
787
|
}
|
|
762
788
|
}
|
|
763
789
|
}
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
790
|
+
// Kick off the Turnstile mint regardless of auto-capture config so a
|
|
791
|
+
// consent flow's later manual trackPageview() finds a token waiting.
|
|
792
|
+
// Never rejects; resolves with or without a token (see turnstile.ts).
|
|
793
|
+
let cancelled = false;
|
|
794
|
+
void mintTurnstileToken().then(() => {
|
|
795
|
+
// Send pageview on mount, deferred until the Turnstile mint settles so
|
|
796
|
+
// the session-opening event carries the token (SPA navigations reuse
|
|
797
|
+
// the resolved state and don't wait). captureInitialPageview === false
|
|
798
|
+
// skips this for cookie-consent flows where the host wants to defer
|
|
799
|
+
// until the user accepts (then call trackPageview() manually).
|
|
800
|
+
//
|
|
801
|
+
// sentRef is set HERE (not before the mint) so a StrictMode
|
|
802
|
+
// double-mount can't mark the pageview as sent and then tear down the
|
|
803
|
+
// queue before the deferred send ever ran — the surviving mount's
|
|
804
|
+
// callback sees sentRef still false and fires.
|
|
805
|
+
if (cancelled)
|
|
806
|
+
return;
|
|
807
|
+
if (sentRef.current)
|
|
808
|
+
return;
|
|
809
|
+
if (autoCfg.captureInitialPageview && autoCfg.enabled) {
|
|
810
|
+
sentRef.current = true;
|
|
811
|
+
sendPageview();
|
|
812
|
+
}
|
|
813
|
+
});
|
|
771
814
|
// Detect user leaving
|
|
772
815
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
773
816
|
window.addEventListener('pagehide', sendEnd);
|
|
@@ -899,6 +942,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
899
942
|
document.addEventListener('keydown', onKeydownForLinkDecoration, true);
|
|
900
943
|
}
|
|
901
944
|
return () => {
|
|
945
|
+
cancelled = true;
|
|
902
946
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
903
947
|
window.removeEventListener('pagehide', sendEnd);
|
|
904
948
|
window.removeEventListener('popstate', onUrlChange);
|
|
@@ -921,6 +965,14 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
921
965
|
queueRef.current = null;
|
|
922
966
|
providerMountCount = Math.max(0, providerMountCount - 1);
|
|
923
967
|
};
|
|
968
|
+
// flushInterval, maxQueueSize, allowFileProtocol and the autoCfg.* fields
|
|
969
|
+
// are BOOT configuration, read once when the queue and listeners are
|
|
970
|
+
// constructed. Listing them would tear the whole pipeline down and rebuild
|
|
971
|
+
// it whenever a host app re-renders with a new inline object, and
|
|
972
|
+
// shutdown() drops the in-memory queue — so honoring the rule here would
|
|
973
|
+
// cost real events. A host that needs different settings should remount
|
|
974
|
+
// the provider.
|
|
975
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
924
976
|
}, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, optOut, optIn, flush]);
|
|
925
977
|
return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
|
|
926
978
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
|
|
3
|
+
* mint ONE invisible token at SDK init and attach it to the first pageview.
|
|
4
|
+
* The server verifies the token before creating the visitor row (and before
|
|
5
|
+
* counting a pay-per-click web_traffic click — the billable path REQUIRES a
|
|
6
|
+
* genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
|
|
7
|
+
* the same visitor_id are trusted because the row was created behind a
|
|
8
|
+
* verified token, so the cost is one mint + one verify per session.
|
|
9
|
+
*
|
|
10
|
+
* Tokens are single-use at Cloudflare's siteverify, so consumeToken()
|
|
11
|
+
* hands the token out exactly once — re-sending a used token would get the
|
|
12
|
+
* event rejected server-side, not warn-allowed.
|
|
13
|
+
*
|
|
14
|
+
* Failure modes: if the Turnstile script is blocked (ad blockers, strict
|
|
15
|
+
* CSP), errors, or times out, minting resolves with no token and events go
|
|
16
|
+
* out bare. The server's warn-allow mode (TURNSTILE_REQUIRE unset) lets
|
|
17
|
+
* those through; commission-on-sale (web_affiliate) tracking never depends
|
|
18
|
+
* on the token at all.
|
|
19
|
+
*/
|
|
20
|
+
interface TurnstileApi {
|
|
21
|
+
render: (el: HTMLElement, opts: Record<string, unknown>) => unknown;
|
|
22
|
+
}
|
|
23
|
+
declare global {
|
|
24
|
+
interface Window {
|
|
25
|
+
turnstile?: TurnstileApi;
|
|
26
|
+
__affiliateoTSLoaded?: () => void;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Load the Turnstile script (once) and run the invisible challenge. Resolves
|
|
31
|
+
* when a token is available OR the mint failed/timed out — never rejects, so
|
|
32
|
+
* callers can unconditionally `.then(sendPageview)`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mintTurnstileToken(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Hand out the minted token exactly once (single-use at siteverify).
|
|
37
|
+
* Returns null when no token was minted or it was already consumed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function consumeTurnstileToken(): string | null;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
|
|
3
|
+
* mint ONE invisible token at SDK init and attach it to the first pageview.
|
|
4
|
+
* The server verifies the token before creating the visitor row (and before
|
|
5
|
+
* counting a pay-per-click web_traffic click — the billable path REQUIRES a
|
|
6
|
+
* genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
|
|
7
|
+
* the same visitor_id are trusted because the row was created behind a
|
|
8
|
+
* verified token, so the cost is one mint + one verify per session.
|
|
9
|
+
*
|
|
10
|
+
* Tokens are single-use at Cloudflare's siteverify, so consumeToken()
|
|
11
|
+
* hands the token out exactly once — re-sending a used token would get the
|
|
12
|
+
* event rejected server-side, not warn-allowed.
|
|
13
|
+
*
|
|
14
|
+
* Failure modes: if the Turnstile script is blocked (ad blockers, strict
|
|
15
|
+
* CSP), errors, or times out, minting resolves with no token and events go
|
|
16
|
+
* out bare. The server's warn-allow mode (TURNSTILE_REQUIRE unset) lets
|
|
17
|
+
* those through; commission-on-sale (web_affiliate) tracking never depends
|
|
18
|
+
* on the token at all.
|
|
19
|
+
*/
|
|
20
|
+
// Public sitekey (safe to expose) — the same one public/t.js ships. The
|
|
21
|
+
// matching secret lives in TURNSTILE_SECRET on the server.
|
|
22
|
+
const TURNSTILE_SITEKEY = '0x4AAAAAADR5UW2x6axFd13p';
|
|
23
|
+
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=__affiliateoTSLoaded&render=explicit';
|
|
24
|
+
// Hard deadline for the whole mint (script load + challenge). t.js relies on
|
|
25
|
+
// Turnstile's own timeout-callback, but a script tag that never loads never
|
|
26
|
+
// fires ANY callback — this timer makes sure the first pageview can't be
|
|
27
|
+
// held hostage by a blocked challenges.cloudflare.com.
|
|
28
|
+
const MINT_TIMEOUT_MS = 3000;
|
|
29
|
+
let token = null;
|
|
30
|
+
let minting = null;
|
|
31
|
+
/**
|
|
32
|
+
* Load the Turnstile script (once) and run the invisible challenge. Resolves
|
|
33
|
+
* when a token is available OR the mint failed/timed out — never rejects, so
|
|
34
|
+
* callers can unconditionally `.then(sendPageview)`.
|
|
35
|
+
*/
|
|
36
|
+
export function mintTurnstileToken() {
|
|
37
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
38
|
+
return Promise.resolve();
|
|
39
|
+
}
|
|
40
|
+
if (minting)
|
|
41
|
+
return minting;
|
|
42
|
+
minting = new Promise((resolve) => {
|
|
43
|
+
let settled = false;
|
|
44
|
+
const settle = () => {
|
|
45
|
+
if (settled)
|
|
46
|
+
return;
|
|
47
|
+
settled = true;
|
|
48
|
+
clearTimeout(deadline);
|
|
49
|
+
resolve();
|
|
50
|
+
};
|
|
51
|
+
const deadline = setTimeout(settle, MINT_TIMEOUT_MS);
|
|
52
|
+
const render = () => {
|
|
53
|
+
if (!window.turnstile) {
|
|
54
|
+
settle();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const holder = document.createElement('div');
|
|
59
|
+
holder.style.display = 'none';
|
|
60
|
+
document.body.appendChild(holder);
|
|
61
|
+
window.turnstile.render(holder, {
|
|
62
|
+
sitekey: TURNSTILE_SITEKEY,
|
|
63
|
+
// Invisible widget — never shows UI. Real browsers pass the
|
|
64
|
+
// challenge in 50-200ms with zero user interaction.
|
|
65
|
+
size: 'invisible',
|
|
66
|
+
callback: (tok) => {
|
|
67
|
+
token = tok;
|
|
68
|
+
settle();
|
|
69
|
+
},
|
|
70
|
+
'error-callback': settle,
|
|
71
|
+
'timeout-callback': settle,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
settle();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
if (window.turnstile) {
|
|
79
|
+
render();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const script = document.createElement('script');
|
|
84
|
+
script.src = TURNSTILE_SCRIPT_SRC;
|
|
85
|
+
script.async = true;
|
|
86
|
+
script.defer = true;
|
|
87
|
+
script.onerror = settle;
|
|
88
|
+
window.__affiliateoTSLoaded = render;
|
|
89
|
+
document.head.appendChild(script);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
settle();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return minting;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Hand out the minted token exactly once (single-use at siteverify).
|
|
99
|
+
* Returns null when no token was minted or it was already consumed.
|
|
100
|
+
*/
|
|
101
|
+
export function consumeTurnstileToken() {
|
|
102
|
+
const t = token;
|
|
103
|
+
token = null;
|
|
104
|
+
return t;
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@affiliateo/web",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "Affiliateo web SDK
|
|
3
|
+
"version": "4.6.1",
|
|
4
|
+
"description": "Affiliateo web SDK \u2014 affiliate attribution and tracking for web apps",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc",
|
|
17
|
-
"
|
|
17
|
+
"prepack": "npm run build"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"react": ">=18.0.0"
|
|
@@ -28,7 +28,17 @@
|
|
|
28
28
|
"@types/react": "^19.0.0",
|
|
29
29
|
"typescript": "^5.0.0"
|
|
30
30
|
},
|
|
31
|
-
"keywords": [
|
|
31
|
+
"keywords": [
|
|
32
|
+
"affiliateo",
|
|
33
|
+
"affiliate",
|
|
34
|
+
"attribution",
|
|
35
|
+
"stripe",
|
|
36
|
+
"web",
|
|
37
|
+
"session",
|
|
38
|
+
"tracking"
|
|
39
|
+
],
|
|
32
40
|
"license": "MIT",
|
|
33
|
-
"files": [
|
|
41
|
+
"files": [
|
|
42
|
+
"dist"
|
|
43
|
+
]
|
|
34
44
|
}
|