@affiliateo/web 4.7.0 → 4.8.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/README.md +274 -238
- package/dist/bot-detect.d.ts +4 -4
- package/dist/bot-detect.js +6 -6
- package/dist/provider.js +122 -80
- package/dist/queue.d.ts +7 -3
- package/dist/queue.js +32 -15
- package/dist/turnstile.d.ts +3 -3
- package/dist/turnstile.js +6 -6
- package/dist/types.d.ts +24 -15
- package/dist/user-id.d.ts +18 -0
- package/dist/user-id.js +40 -0
- package/package.json +46 -44
package/dist/provider.js
CHANGED
|
@@ -5,6 +5,7 @@ import { EventQueue } from './queue';
|
|
|
5
5
|
import { isLikelyBot } from './bot-detect';
|
|
6
6
|
import { isDevHost } from './dev-host';
|
|
7
7
|
import { mintTurnstileToken, consumeTurnstileToken } from './turnstile';
|
|
8
|
+
import { usableUserId } from './user-id';
|
|
8
9
|
const DEFAULT_API_URL = 'https://affiliateo.com';
|
|
9
10
|
const LS_VISITOR_ID = 'affiliateo_visitor_id';
|
|
10
11
|
const LS_REF = 'affiliateo_ref';
|
|
@@ -12,11 +13,16 @@ const LS_REF = 'affiliateo_ref';
|
|
|
12
13
|
// write the same key, and renaming it would orphan existing visitors'
|
|
13
14
|
// attribution mid-session.
|
|
14
15
|
const LS_APP_ID = 'affiliateo_campaign_id';
|
|
15
|
-
// Persistent opt-out flag (affiliateo_opt_out)
|
|
16
|
+
// Persistent opt-out flag (affiliateo_opt_out) -
|
|
16
17
|
// when set to "true" the SDK silently noops every outbound call. Survives
|
|
17
18
|
// page reloads. removed by optIn(). Checked at init time so a previously
|
|
18
19
|
// opted-out visitor never re-fires auto-pageviews after a refresh.
|
|
19
20
|
const LS_OPT_OUT = 'affiliateo_opt_out';
|
|
21
|
+
// Who is signed in (4.8.0), and the visit it was last sent in, so the SDK
|
|
22
|
+
// re-sends it once per visit instead of on every page load. Same keys as
|
|
23
|
+
// public/t.js, so a site that runs both keeps one record.
|
|
24
|
+
const LS_USER_ID = 'affiliateo_user_id';
|
|
25
|
+
const LS_USER_SENT = 'affiliateo_user_sent';
|
|
20
26
|
// Client-owned session id. The BROWSER owns the session,
|
|
21
27
|
// not the server. One id is reused for every event within a sliding
|
|
22
28
|
// 10-minute activity window, then a fresh one is minted. Because the id is
|
|
@@ -126,6 +132,7 @@ const AffiliateoContext = createContext({
|
|
|
126
132
|
identify: asyncNoop,
|
|
127
133
|
trackPageview: asyncNoop,
|
|
128
134
|
reset: asyncNoop,
|
|
135
|
+
logout: asyncNoop,
|
|
129
136
|
optOut: syncNoop,
|
|
130
137
|
optIn: syncNoop,
|
|
131
138
|
flush: asyncNoop,
|
|
@@ -158,7 +165,7 @@ export function getAttribution() {
|
|
|
158
165
|
data.affiliateo_visitor_id = vid;
|
|
159
166
|
if (ref)
|
|
160
167
|
data.affiliateo_ref = ref;
|
|
161
|
-
// Key renamed from affiliateo_campaign_id in 4.5.0
|
|
168
|
+
// Key renamed from affiliateo_campaign_id in 4.5.0 - the Affiliateo
|
|
162
169
|
// webhook accepts both, so checkouts created by older SDKs keep working.
|
|
163
170
|
if (cid)
|
|
164
171
|
data.affiliateo_app_id = cid;
|
|
@@ -180,7 +187,7 @@ let providerMountCount = 0;
|
|
|
180
187
|
* touching it there throws a ReferenceError rather than reading undefined.
|
|
181
188
|
*
|
|
182
189
|
* UNKNOWN COUNTS AS NOT-PRODUCTION, deliberately. The only caller is the
|
|
183
|
-
* double-mount warning, and a double mount double-fires every pageview
|
|
190
|
+
* double-mount warning, and a double mount double-fires every pageview - which
|
|
184
191
|
* corrupts the merchant's own numbers and the affiliate attribution built on
|
|
185
192
|
* them. One stray console line on an unbundled production page is a smaller
|
|
186
193
|
* cost than silently letting that run.
|
|
@@ -215,6 +222,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
215
222
|
identify: asyncNoop,
|
|
216
223
|
trackPageview: asyncNoop,
|
|
217
224
|
reset: asyncNoop,
|
|
225
|
+
logout: asyncNoop,
|
|
218
226
|
optOut: syncNoop,
|
|
219
227
|
optIn: syncNoop,
|
|
220
228
|
flush: asyncNoop,
|
|
@@ -222,7 +230,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
222
230
|
// Debug logger. Closes over the `debug` prop so flipping it on/off at
|
|
223
231
|
// runtime (e.g. by changing a feature flag) takes effect on the next
|
|
224
232
|
// render without remounting. eslint-disable for the intentional
|
|
225
|
-
// console.log
|
|
233
|
+
// console.log - that's the whole point of this helper.
|
|
226
234
|
const log = useCallback((msg, data) => {
|
|
227
235
|
if (!debug)
|
|
228
236
|
return;
|
|
@@ -248,7 +256,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
248
256
|
// forcing the methods (track / identify / trackPageview) to depend
|
|
249
257
|
// on it for useCallback equality. Initialized inside the route
|
|
250
258
|
// effect since EventQueue's constructor touches localStorage and
|
|
251
|
-
// window
|
|
259
|
+
// window - both unavailable during SSR. Methods called before init
|
|
252
260
|
// (during the first render, before useEffect runs) silently noop;
|
|
253
261
|
// this is fine because methods are only ever called from event
|
|
254
262
|
// handlers, which fire after mount.
|
|
@@ -283,7 +291,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
283
291
|
// Page context, same three fields the pageview sender carries.
|
|
284
292
|
// A goal event used to go out without them, and the server's
|
|
285
293
|
// `path || hostname || '[Web]'` fallback then overwrote the
|
|
286
|
-
// visitor's real current_page with the literal string '[Web]'
|
|
294
|
+
// visitor's real current_page with the literal string '[Web]' -
|
|
287
295
|
// which both broke the tracking-page popover and made the visitor
|
|
288
296
|
// invisible to any funnel step matching on a page path. The server
|
|
289
297
|
// no longer clobbers on missing values, but sending the real path
|
|
@@ -297,44 +305,89 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
297
305
|
payload.metadata = metadata;
|
|
298
306
|
// Enqueue. EventQueue handles persist, retry, and flush. If the
|
|
299
307
|
// queue hasn't initialized yet (called during first render before
|
|
300
|
-
// useEffect runs), this noops
|
|
308
|
+
// useEffect runs), this noops - that's a vanishingly rare race
|
|
301
309
|
// since track() is virtually always called from a click handler.
|
|
302
310
|
queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
|
|
303
311
|
log('track', { event: name, metadata });
|
|
304
312
|
}
|
|
305
313
|
catch { /* swallow */ }
|
|
306
314
|
}, [apiBase, log]);
|
|
307
|
-
// identify(userId)
|
|
308
|
-
//
|
|
309
|
-
//
|
|
315
|
+
// identify(userId): say who is signed in (4.8.0). The server keeps one
|
|
316
|
+
// customer per person per app: a sign-in makes this browser that person's
|
|
317
|
+
// (folded in with their phone and other browsers), and a DIFFERENT user
|
|
318
|
+
// signing in here switches the browser to them. user_id only - the SDK
|
|
319
|
+
// does NOT accept, collect, or transmit email or any other PII.
|
|
320
|
+
//
|
|
321
|
+
// The id is stored and re-sent once per visit (the 10-minute session
|
|
322
|
+
// above), on mount, so the server stays in step without a request on
|
|
323
|
+
// every page load. Ids that mean "nobody" are skipped (user-id.ts), and
|
|
324
|
+
// nothing is sent while opted out. A 400 is not retried (queue.ts).
|
|
310
325
|
const identify = useCallback(async (userId) => {
|
|
311
326
|
try {
|
|
312
327
|
if (typeof window === 'undefined')
|
|
313
328
|
return;
|
|
314
|
-
if (isOptedOut())
|
|
315
|
-
|
|
316
|
-
if (!userId || typeof userId !== 'string')
|
|
329
|
+
if (isOptedOut()) {
|
|
330
|
+
log('identify skipped: opted out');
|
|
317
331
|
return;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (!uid || uid.length > 128)
|
|
332
|
+
}
|
|
333
|
+
const uid = usableUserId(userId);
|
|
334
|
+
if (!uid) {
|
|
335
|
+
log('identify skipped: not a user id (empty, over 128 characters, or a placeholder like "null", "guest" or "0")', { user_id: userId });
|
|
323
336
|
return;
|
|
337
|
+
}
|
|
338
|
+
lsSet(LS_USER_ID, uid);
|
|
324
339
|
const cid = appIdRef.current;
|
|
325
340
|
const vid = visitorIdRef.current;
|
|
326
|
-
|
|
341
|
+
const queue = queueRef.current;
|
|
342
|
+
// Not tracking yet: the stored id goes out with the next mount.
|
|
343
|
+
if (!cid || !vid || !queue)
|
|
327
344
|
return;
|
|
328
|
-
const
|
|
345
|
+
const mark = `${vid} ${getSessionId()} ${uid}`;
|
|
346
|
+
if (lsGet(LS_USER_SENT) === mark) {
|
|
347
|
+
log('identify: already sent this visit', { user_id: uid });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
lsSet(LS_USER_SENT, mark);
|
|
351
|
+
// A sign-out still waiting in the queue is replaced by this sign-in;
|
|
352
|
+
// delivered after it, it would sign the new person out again.
|
|
353
|
+
const logoutEndpoint = `${apiBase}/api/v1/web/logout`;
|
|
354
|
+
queue.drop((e) => e.endpoint === logoutEndpoint && e.payload.visitor_id === vid);
|
|
355
|
+
queue.enqueue(`${apiBase}/api/v1/web/identify`, {
|
|
329
356
|
campaign_id: cid,
|
|
330
357
|
visitor_id: vid,
|
|
331
358
|
user_id: uid,
|
|
332
|
-
};
|
|
333
|
-
queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
|
|
359
|
+
});
|
|
334
360
|
log('identify', { user_id: uid });
|
|
335
361
|
}
|
|
336
362
|
catch { /* swallow */ }
|
|
337
363
|
}, [apiBase, log]);
|
|
364
|
+
// logout(): the person signed out (4.8.0). POST /api/v1/web/logout: the
|
|
365
|
+
// browser starts over as a new anonymous customer of the app, so whoever
|
|
366
|
+
// signs in next (or nobody) is not counted as the person who just left,
|
|
367
|
+
// and everything already recorded stays with them. Forgets the stored
|
|
368
|
+
// user id. The visitor id, the affiliate ref, the session and tracking
|
|
369
|
+
// itself all carry on unchanged.
|
|
370
|
+
const logout = useCallback(async () => {
|
|
371
|
+
try {
|
|
372
|
+
if (typeof window === 'undefined')
|
|
373
|
+
return;
|
|
374
|
+
log('logout');
|
|
375
|
+
lsRemove(LS_USER_ID);
|
|
376
|
+
lsRemove(LS_USER_SENT);
|
|
377
|
+
if (isOptedOut())
|
|
378
|
+
return;
|
|
379
|
+
const cid = appIdRef.current;
|
|
380
|
+
const vid = visitorIdRef.current;
|
|
381
|
+
const queue = queueRef.current;
|
|
382
|
+
if (!cid || !vid || !queue)
|
|
383
|
+
return;
|
|
384
|
+
queue.enqueue(`${apiBase}/api/v1/web/logout`, { campaign_id: cid, visitor_id: vid });
|
|
385
|
+
// Sign-out usually comes right before a redirect, so send it now.
|
|
386
|
+
// Still queued if this fails: the next page load delivers it.
|
|
387
|
+
await queue.flush();
|
|
388
|
+
}
|
|
389
|
+
catch { /* swallow */ }
|
|
390
|
+
}, [apiBase, log]);
|
|
338
391
|
// Manual pageview via trackPageview(path?). Same payload
|
|
339
392
|
// shape the auto-fired pageview uses (see sendPageview inside the route
|
|
340
393
|
// effect below) so server-side routing / dedup / IP+UA enrichment behave
|
|
@@ -375,7 +428,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
375
428
|
utm_source: params.get('utm_source') || '',
|
|
376
429
|
utm_medium: params.get('utm_medium') || '',
|
|
377
430
|
utm_campaign: params.get('utm_campaign') || '',
|
|
378
|
-
// Ad-network click ids
|
|
431
|
+
// Ad-network click ids - set automatically on the landing URL by
|
|
379
432
|
// every Meta/TikTok/Google ad click. Label the sale's source and
|
|
380
433
|
// feed server-side conversion matching.
|
|
381
434
|
fbclid: params.get('fbclid') || '',
|
|
@@ -398,7 +451,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
398
451
|
// batches and auto-flushes every 5s; this is the escape hatch for the
|
|
399
452
|
// "I'm about to redirect, don't lose my events" case.
|
|
400
453
|
//
|
|
401
|
-
// Resolves when the underlying queue.flush() completes
|
|
454
|
+
// Resolves when the underlying queue.flush() completes - success OR
|
|
402
455
|
// failure. Events that fail to deliver stay in the queue and get
|
|
403
456
|
// retried on the next normal flush cycle. Safe to await; never throws.
|
|
404
457
|
const flush = useCallback(async () => {
|
|
@@ -408,49 +461,23 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
408
461
|
}
|
|
409
462
|
catch { /* swallow */ }
|
|
410
463
|
}, [log]);
|
|
411
|
-
// reset():
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
// visitor, no event fired, caller handles "tell the server about it"
|
|
417
|
-
// by triggering whatever pageview / navigation comes next.
|
|
418
|
-
//
|
|
419
|
-
// Returns a Promise so callers can `await reset()` before navigating
|
|
420
|
-
// (the localStorage writes are sync but the Promise lets the API stay
|
|
421
|
-
// an async signature, future-proofing for
|
|
422
|
-
// server-minted IDs in cookieless mode).
|
|
423
|
-
const reset = useCallback(async () => {
|
|
424
|
-
try {
|
|
425
|
-
if (typeof window === 'undefined')
|
|
426
|
-
return;
|
|
427
|
-
// Drain any pending events for the OLD visitor before swapping IDs,
|
|
428
|
-
// otherwise they'd land server-side stamped to the OLD visitor_id
|
|
429
|
-
// (correct) but persisted-in-localStorage as if to-be-retried under
|
|
430
|
-
// the new identity — confusing on retry inspection. Best effort.
|
|
431
|
-
void queueRef.current?.flush();
|
|
432
|
-
queueRef.current?.clear();
|
|
433
|
-
const newVid = uuid();
|
|
434
|
-
lsSet(LS_VISITOR_ID, newVid);
|
|
435
|
-
lsRemove(LS_REF);
|
|
436
|
-
lsRemove(LS_SESSION);
|
|
437
|
-
visitorIdRef.current = newVid;
|
|
438
|
-
setState((prev) => ({ ...prev, visitorId: newVid, refCode: null }));
|
|
439
|
-
}
|
|
440
|
-
catch { /* swallow */ }
|
|
441
|
-
}, []);
|
|
464
|
+
// reset(): the same as logout() since 4.8.0. It used to mint a fresh
|
|
465
|
+
// visitor id and clear the affiliate ref and session, which split one
|
|
466
|
+
// person's history into two visitors and lost their affiliate. Kept so
|
|
467
|
+
// existing integrations keep compiling and now get the sign-out they meant.
|
|
468
|
+
const reset = logout;
|
|
442
469
|
// optOut(): persistent "don't track me" flag. After this call every
|
|
443
470
|
// track / identify / trackPageview noops until optIn() is called. The
|
|
444
471
|
// flag survives reloads via localStorage, so a visitor who opts out
|
|
445
472
|
// stays opted out across browser sessions on the same device.
|
|
446
473
|
//
|
|
447
|
-
// We don't actively send a "stop tracking me" signal to the server
|
|
474
|
+
// We don't actively send a "stop tracking me" signal to the server -
|
|
448
475
|
// the server only knows about visitors who ping it, so an opted-out
|
|
449
476
|
// visitor simply stops appearing. The visitor row left behind in
|
|
450
477
|
// Postgres ages out via normal retention.
|
|
451
478
|
const optOut = useCallback(() => {
|
|
452
479
|
lsSet(LS_OPT_OUT, 'true');
|
|
453
|
-
// Drop pending events too. The visitor explicitly said no
|
|
480
|
+
// Drop pending events too. The visitor explicitly said no - sending
|
|
454
481
|
// queued events from before the opt-out would still violate consent
|
|
455
482
|
// even if those events were captured prior to the user's decision.
|
|
456
483
|
queueRef.current?.clear();
|
|
@@ -464,13 +491,26 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
464
491
|
lsRemove(LS_OPT_OUT);
|
|
465
492
|
}, []);
|
|
466
493
|
const sentRef = useRef(false);
|
|
494
|
+
// Boot configuration, read once when the queue and the listeners are
|
|
495
|
+
// constructed. A ref kept current on every render rather than four more
|
|
496
|
+
// dependencies: listing them would tear the whole pipeline down and
|
|
497
|
+
// rebuild it whenever a host re-renders with a new inline object, and
|
|
498
|
+
// shutdown() drops the in-memory queue, so honoring the rule that way
|
|
499
|
+
// would cost real events. A host that needs different settings should
|
|
500
|
+
// remount the provider. (Hosts run React 18, so this is the latest-ref
|
|
501
|
+
// pattern rather than useEffectEvent.)
|
|
502
|
+
const bootRef = useRef({ flushInterval, maxQueueSize, allowFileProtocol, autoCfg });
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
bootRef.current = { flushInterval, maxQueueSize, allowFileProtocol, autoCfg };
|
|
505
|
+
});
|
|
467
506
|
useEffect(() => {
|
|
468
507
|
if (typeof window === 'undefined')
|
|
469
508
|
return;
|
|
470
|
-
|
|
509
|
+
const { flushInterval, maxQueueSize, allowFileProtocol, autoCfg } = bootRef.current;
|
|
510
|
+
// Neither appId nor the legacy campaignId alias was passed - nothing to
|
|
471
511
|
// attribute events to, so stay dormant instead of firing junk.
|
|
472
512
|
if (!appIdRef.current) {
|
|
473
|
-
console.error('[Affiliateo] Missing appId
|
|
513
|
+
console.error('[Affiliateo] Missing appId - pass your app ID to <AffiliateoProvider appId="...">.');
|
|
474
514
|
return;
|
|
475
515
|
}
|
|
476
516
|
// Mount-count warning. Fires only when more than one Provider has
|
|
@@ -483,7 +523,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
483
523
|
// Client-side bot detection. If the current browser context looks
|
|
484
524
|
// like Selenium / Puppeteer / curl / a known automation tool, skip
|
|
485
525
|
// ALL tracking before we even mint a queue. The server-side UA
|
|
486
|
-
// regex + Turnstile catch anything that sneaks past this check
|
|
526
|
+
// regex + Turnstile catch anything that sneaks past this check -
|
|
487
527
|
// this just saves the bot a round-trip and saves us a request.
|
|
488
528
|
//
|
|
489
529
|
// The hook still returns the four control methods (reset / optOut /
|
|
@@ -499,6 +539,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
499
539
|
identify: asyncNoop,
|
|
500
540
|
trackPageview: asyncNoop,
|
|
501
541
|
reset: asyncNoop,
|
|
542
|
+
logout: asyncNoop,
|
|
502
543
|
optOut: syncNoop,
|
|
503
544
|
optIn: syncNoop,
|
|
504
545
|
flush: asyncNoop,
|
|
@@ -523,6 +564,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
523
564
|
identify: asyncNoop,
|
|
524
565
|
trackPageview: asyncNoop,
|
|
525
566
|
reset: asyncNoop,
|
|
567
|
+
logout: asyncNoop,
|
|
526
568
|
optOut: syncNoop,
|
|
527
569
|
optIn: syncNoop,
|
|
528
570
|
flush: asyncNoop,
|
|
@@ -541,6 +583,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
541
583
|
identify: asyncNoop,
|
|
542
584
|
trackPageview: asyncNoop,
|
|
543
585
|
reset: asyncNoop,
|
|
586
|
+
logout: asyncNoop,
|
|
544
587
|
optOut: syncNoop,
|
|
545
588
|
optIn: syncNoop,
|
|
546
589
|
flush: asyncNoop,
|
|
@@ -575,6 +618,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
575
618
|
identify: asyncNoop,
|
|
576
619
|
trackPageview: asyncNoop,
|
|
577
620
|
reset: asyncNoop,
|
|
621
|
+
logout: asyncNoop,
|
|
578
622
|
optOut: syncNoop,
|
|
579
623
|
optIn: syncNoop,
|
|
580
624
|
flush: asyncNoop,
|
|
@@ -583,8 +627,8 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
583
627
|
}
|
|
584
628
|
}
|
|
585
629
|
// Initialize the event queue. Done inside useEffect (not in a useRef
|
|
586
|
-
// initializer) so the EventQueue constructor
|
|
587
|
-
// localStorage on hydration
|
|
630
|
+
// initializer) so the EventQueue constructor - which touches
|
|
631
|
+
// localStorage on hydration - only ever runs client-side. The queue
|
|
588
632
|
// takes over delivery for track / identify / trackPageview AND the
|
|
589
633
|
// auto-fired pageview below; only session_end stays on sendBeacon
|
|
590
634
|
// because tab-close urgency beats queue-retry reliability.
|
|
@@ -613,9 +657,9 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
613
657
|
setState({
|
|
614
658
|
refCode: null,
|
|
615
659
|
visitorId: optedOutVid,
|
|
616
|
-
track, identify, trackPageview, reset, optOut, optIn, flush,
|
|
660
|
+
track, identify, trackPageview, reset, logout, optOut, optIn, flush,
|
|
617
661
|
});
|
|
618
|
-
// Don't leave a queue running for an opted-out visitor
|
|
662
|
+
// Don't leave a queue running for an opted-out visitor - the timer
|
|
619
663
|
// would just keep waking up to flush nothing.
|
|
620
664
|
queue.shutdown();
|
|
621
665
|
queueRef.current = null;
|
|
@@ -673,9 +717,13 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
673
717
|
setState({
|
|
674
718
|
refCode: ref,
|
|
675
719
|
visitorId: vid,
|
|
676
|
-
track, identify, trackPageview, reset, optOut, optIn, flush,
|
|
720
|
+
track, identify, trackPageview, reset, logout, optOut, optIn, flush,
|
|
677
721
|
});
|
|
678
722
|
log('init', { app: appIdRef.current, visitor: vid, ref: ref || null });
|
|
723
|
+
// This visit's re-send of the signed-in user (see identify).
|
|
724
|
+
const storedUser = usableUserId(lsGet(LS_USER_ID));
|
|
725
|
+
if (storedUser)
|
|
726
|
+
void identify(storedUser);
|
|
679
727
|
// lastSendTime drives the visibility-refire heuristic below (refire a
|
|
680
728
|
// pageview when a tab returns after the session window). Duplicate
|
|
681
729
|
// suppression itself is the persisted same-URL throttle inside
|
|
@@ -708,7 +756,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
708
756
|
ended = false;
|
|
709
757
|
// Attach the (single-use) Turnstile token to the session-opening
|
|
710
758
|
// pageview. consumeTurnstileToken() returns null after the first
|
|
711
|
-
// consume, so SPA navigations go out bare
|
|
759
|
+
// consume, so SPA navigations go out bare - the server trusts them
|
|
712
760
|
// because the visitor row was created behind the verified token.
|
|
713
761
|
const tsToken = consumeTurnstileToken();
|
|
714
762
|
queue.enqueue(endpoint, {
|
|
@@ -739,7 +787,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
739
787
|
// directly. The queue's fetch-with-keepalive is great for normal
|
|
740
788
|
// events because it returns a status code we can retry on, but it
|
|
741
789
|
// can't guarantee the request lands when the tab is in the act of
|
|
742
|
-
// closing
|
|
790
|
+
// closing - fetch may be aborted mid-flight. sendBeacon was
|
|
743
791
|
// designed exactly for this case and CANNOT be cancelled by the
|
|
744
792
|
// unload handler. We also try to drain the queue's pending events
|
|
745
793
|
// here, which uses sendBeacon under the hood for the same reason.
|
|
@@ -772,7 +820,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
772
820
|
}
|
|
773
821
|
}
|
|
774
822
|
// Server-side session window. Matches STALE_VISITOR_MS in the
|
|
775
|
-
// tracking Durable Object
|
|
823
|
+
// tracking Durable Object - after this much idle time the server
|
|
776
824
|
// treats the visitor as a new session, rotates session_id, and
|
|
777
825
|
// expects a fresh pageview to anchor the new visit in
|
|
778
826
|
// campaign_events. Without that anchor, funnel steps starting on
|
|
@@ -789,7 +837,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
789
837
|
// If the SDK's last pageview was longer ago than the server's
|
|
790
838
|
// 10-min session window, the server has already rolled the
|
|
791
839
|
// visitor over to a new session_id. Refire pageview so the new
|
|
792
|
-
// session has a proper anchor
|
|
840
|
+
// session has a proper anchor - otherwise the next scroll /
|
|
793
841
|
// click / custom event arrives orphaned and the funnel overlay
|
|
794
842
|
// sees the visitor enter "mid-funnel" (e.g. on a scroll step)
|
|
795
843
|
// without ever passing through the page-step that should
|
|
@@ -812,7 +860,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
812
860
|
//
|
|
813
861
|
// sentRef is set HERE (not before the mint) so a StrictMode
|
|
814
862
|
// double-mount can't mark the pageview as sent and then tear down the
|
|
815
|
-
// queue before the deferred send ever ran
|
|
863
|
+
// queue before the deferred send ever ran - the surviving mount's
|
|
816
864
|
// callback sees sentRef still false and fires.
|
|
817
865
|
if (cancelled)
|
|
818
866
|
return;
|
|
@@ -971,20 +1019,14 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
971
1019
|
document.removeEventListener('keydown', onKeydownForLinkDecoration, true);
|
|
972
1020
|
}
|
|
973
1021
|
// Stop the queue's flush timer. The queue tries one final flush
|
|
974
|
-
// before going quiet
|
|
1022
|
+
// before going quiet - anything still queued lives in localStorage
|
|
975
1023
|
// and will be picked up by the next page load's queue init.
|
|
976
1024
|
queue.shutdown();
|
|
977
1025
|
queueRef.current = null;
|
|
978
1026
|
providerMountCount = Math.max(0, providerMountCount - 1);
|
|
979
1027
|
};
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
|
|
983
|
-
// it whenever a host app re-renders with a new inline object, and
|
|
984
|
-
// shutdown() drops the in-memory queue — so honoring the rule here would
|
|
985
|
-
// cost real events. A host that needs different settings should remount
|
|
986
|
-
// the provider.
|
|
987
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
988
|
-
}, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, optOut, optIn, flush]);
|
|
1028
|
+
// The boot configuration comes off bootRef above, so this list is what
|
|
1029
|
+
// legitimately rebuilds the pipeline.
|
|
1030
|
+
}, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, logout, optOut, optIn, flush]);
|
|
989
1031
|
return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
|
|
990
1032
|
}
|
package/dist/queue.d.ts
CHANGED
|
@@ -85,16 +85,20 @@ export declare class EventQueue {
|
|
|
85
85
|
* flush at a time. */
|
|
86
86
|
flush(): Promise<void>;
|
|
87
87
|
/** Best-effort sync flush triggered on page unload. Uses sendBeacon
|
|
88
|
-
* for each event so it survives the tab closing. No retry
|
|
88
|
+
* for each event so it survives the tab closing. No retry - this is
|
|
89
89
|
* the last chance.
|
|
90
90
|
*
|
|
91
91
|
* Called from the provider's pagehide / visibilitychange=hidden
|
|
92
92
|
* handlers so any events still queued at unload get one last shot.
|
|
93
93
|
* After this call, the in-memory queue is cleared (events that
|
|
94
|
-
* sendBeacon couldn't queue are lost
|
|
94
|
+
* sendBeacon couldn't queue are lost - that's the cost of the
|
|
95
95
|
* guaranteed-fast unload path). */
|
|
96
96
|
flushOnUnload(): void;
|
|
97
|
-
/**
|
|
97
|
+
/** Remove the queued events `predicate` picks (4.8.0). A sign-in uses it
|
|
98
|
+
* to drop a sign-out still waiting to be delivered: the sign-in replaces
|
|
99
|
+
* it, and a sign-out retried after it would undo it. */
|
|
100
|
+
drop(predicate: (event: QueuedEvent) => boolean): void;
|
|
101
|
+
/** Wipe the queue. Called by optOut(). Removes the
|
|
98
102
|
* localStorage entry entirely so a reload doesn't re-hydrate the
|
|
99
103
|
* wiped events. */
|
|
100
104
|
clear(): void;
|
package/dist/queue.js
CHANGED
|
@@ -88,7 +88,7 @@ export class EventQueue {
|
|
|
88
88
|
this.maxQueueSize = Math.max(10, Math.min(1000, opts.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
|
|
89
89
|
// Hydrate from localStorage so events from a prior page load survive
|
|
90
90
|
// a refresh / navigation. Guards against corrupt JSON or wrong-shape
|
|
91
|
-
// entries by resetting to empty if anything looks off
|
|
91
|
+
// entries by resetting to empty if anything looks off - better to
|
|
92
92
|
// lose a few stuck events than crash the host app on init.
|
|
93
93
|
try {
|
|
94
94
|
const raw = lsGet(STORAGE_KEY);
|
|
@@ -125,8 +125,8 @@ export class EventQueue {
|
|
|
125
125
|
id,
|
|
126
126
|
endpoint,
|
|
127
127
|
// Delivery metadata is baked into the PERSISTED payload rather than
|
|
128
|
-
// added at flush time, so every retry
|
|
129
|
-
// page reload
|
|
128
|
+
// added at flush time, so every retry - and every recovery after a
|
|
129
|
+
// page reload - sends a byte-identical body:
|
|
130
130
|
//
|
|
131
131
|
// event_id makes the server insert idempotent. Without it, a 5xx
|
|
132
132
|
// raised after the row was already written, or a network timeout
|
|
@@ -137,7 +137,7 @@ export class EventQueue {
|
|
|
137
137
|
//
|
|
138
138
|
// client_ts is when the event HAPPENED. The server used to stamp
|
|
139
139
|
// arrival time, but the 5s flush cadence, the size-threshold
|
|
140
|
-
// burst flush, and retries all reorder delivery freely
|
|
140
|
+
// burst flush, and retries all reorder delivery freely - so
|
|
141
141
|
// arrival order is not user order. Stamping at enqueue is the
|
|
142
142
|
// only point that still knows the real sequence.
|
|
143
143
|
payload: { ...payload, event_id: id, client_ts: Date.now() },
|
|
@@ -153,7 +153,7 @@ export class EventQueue {
|
|
|
153
153
|
this.persist();
|
|
154
154
|
// Burst protection: when many events fire in quick succession
|
|
155
155
|
// (auto-pageview + immediate track + identify on app boot), don't
|
|
156
|
-
// wait for the 5s timer
|
|
156
|
+
// wait for the 5s timer - flush right away. The isFlushing guard
|
|
157
157
|
// inside flush() makes overlapping calls safe.
|
|
158
158
|
if (this.queue.length >= SIZE_FLUSH_THRESHOLD) {
|
|
159
159
|
void this.flush();
|
|
@@ -170,13 +170,21 @@ export class EventQueue {
|
|
|
170
170
|
if (this.isFlushing || this.queue.length === 0)
|
|
171
171
|
return;
|
|
172
172
|
this.isFlushing = true;
|
|
173
|
-
// Snapshot the queue so we iterate a stable list
|
|
173
|
+
// Snapshot the queue so we iterate a stable list - new enqueue()
|
|
174
174
|
// calls during the flush land in the live queue and get picked up
|
|
175
175
|
// by the next flush rather than this one (avoids infinite loops
|
|
176
176
|
// and reasoning headaches).
|
|
177
177
|
const batch = this.queue.slice();
|
|
178
178
|
for (const event of batch) {
|
|
179
|
+
// Dropped or cleared since the batch was taken (optOut(), or a
|
|
180
|
+
// sign-in replacing a sign-out still waiting here): not sent.
|
|
181
|
+
if (!this.queue.some((e) => e.id === event.id))
|
|
182
|
+
continue;
|
|
179
183
|
let ok = false;
|
|
184
|
+
// A 400 says the request itself is wrong (a placeholder user id, a
|
|
185
|
+
// malformed body), so sending it again cannot succeed: dropped
|
|
186
|
+
// rather than retried (4.8.0).
|
|
187
|
+
let rejected = false;
|
|
180
188
|
try {
|
|
181
189
|
const res = await fetch(event.endpoint, {
|
|
182
190
|
method: 'POST',
|
|
@@ -186,16 +194,16 @@ export class EventQueue {
|
|
|
186
194
|
// with the queue so even if this fetch errors, we'll retry.
|
|
187
195
|
keepalive: true,
|
|
188
196
|
});
|
|
189
|
-
// 2xx = success.
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// dropping is the right move — keeps the queue clean.
|
|
197
|
+
// 2xx = success. A 400 is dropped at once (above). Anything else
|
|
198
|
+
// (other 4xx, 5xx) counts as a failure and bumps retries; after
|
|
199
|
+
// 3 it is dropped, which keeps the queue clean.
|
|
193
200
|
ok = res.ok;
|
|
201
|
+
rejected = res.status === 400;
|
|
194
202
|
}
|
|
195
203
|
catch {
|
|
196
204
|
ok = false;
|
|
197
205
|
}
|
|
198
|
-
if (ok) {
|
|
206
|
+
if (ok || rejected) {
|
|
199
207
|
// Remove from live queue (not the snapshot) by id
|
|
200
208
|
this.queue = this.queue.filter((e) => e.id !== event.id);
|
|
201
209
|
}
|
|
@@ -216,19 +224,19 @@ export class EventQueue {
|
|
|
216
224
|
this.isFlushing = false;
|
|
217
225
|
}
|
|
218
226
|
/** Best-effort sync flush triggered on page unload. Uses sendBeacon
|
|
219
|
-
* for each event so it survives the tab closing. No retry
|
|
227
|
+
* for each event so it survives the tab closing. No retry - this is
|
|
220
228
|
* the last chance.
|
|
221
229
|
*
|
|
222
230
|
* Called from the provider's pagehide / visibilitychange=hidden
|
|
223
231
|
* handlers so any events still queued at unload get one last shot.
|
|
224
232
|
* After this call, the in-memory queue is cleared (events that
|
|
225
|
-
* sendBeacon couldn't queue are lost
|
|
233
|
+
* sendBeacon couldn't queue are lost - that's the cost of the
|
|
226
234
|
* guaranteed-fast unload path). */
|
|
227
235
|
flushOnUnload() {
|
|
228
236
|
if (this.queue.length === 0)
|
|
229
237
|
return;
|
|
230
238
|
if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
|
|
231
|
-
// No sendBeacon (very old browser)
|
|
239
|
+
// No sendBeacon (very old browser) - try a sync fetch with
|
|
232
240
|
// keepalive as a fallback. Won't actually finish, but it's our
|
|
233
241
|
// last shot.
|
|
234
242
|
for (const event of this.queue) {
|
|
@@ -255,7 +263,16 @@ export class EventQueue {
|
|
|
255
263
|
this.queue = [];
|
|
256
264
|
this.persist();
|
|
257
265
|
}
|
|
258
|
-
/**
|
|
266
|
+
/** Remove the queued events `predicate` picks (4.8.0). A sign-in uses it
|
|
267
|
+
* to drop a sign-out still waiting to be delivered: the sign-in replaces
|
|
268
|
+
* it, and a sign-out retried after it would undo it. */
|
|
269
|
+
drop(predicate) {
|
|
270
|
+
const before = this.queue.length;
|
|
271
|
+
this.queue = this.queue.filter((e) => !predicate(e));
|
|
272
|
+
if (this.queue.length !== before)
|
|
273
|
+
this.persist();
|
|
274
|
+
}
|
|
275
|
+
/** Wipe the queue. Called by optOut(). Removes the
|
|
259
276
|
* localStorage entry entirely so a reload doesn't re-hydrate the
|
|
260
277
|
* wiped events. */
|
|
261
278
|
clear() {
|
package/dist/turnstile.d.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
|
|
3
3
|
* mint ONE invisible token at SDK init and attach it to the first pageview.
|
|
4
4
|
* The server verifies the token before creating the visitor row (and before
|
|
5
|
-
* counting a pay-per-click web_traffic click
|
|
5
|
+
* counting a pay-per-click web_traffic click - the billable path REQUIRES a
|
|
6
6
|
* genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
|
|
7
7
|
* the same visitor_id are trusted because the row was created behind a
|
|
8
8
|
* verified token, so the cost is one mint + one verify per session.
|
|
9
9
|
*
|
|
10
10
|
* Tokens are single-use at Cloudflare's siteverify, so consumeToken()
|
|
11
|
-
* hands the token out exactly once
|
|
11
|
+
* hands the token out exactly once - re-sending a used token would get the
|
|
12
12
|
* event rejected server-side, not warn-allowed.
|
|
13
13
|
*
|
|
14
14
|
* Failure modes: if the Turnstile script is blocked (ad blockers, strict
|
|
@@ -28,7 +28,7 @@ declare global {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Load the Turnstile script (once) and run the invisible challenge. Resolves
|
|
31
|
-
* when a token is available OR the mint failed/timed out
|
|
31
|
+
* when a token is available OR the mint failed/timed out - never rejects, so
|
|
32
32
|
* callers can unconditionally `.then(sendPageview)`.
|
|
33
33
|
*/
|
|
34
34
|
export declare function mintTurnstileToken(): Promise<void>;
|