@affiliateo/web 2.4.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -2,6 +2,30 @@ export interface AffiliateoConfig {
2
2
  campaignId: string;
3
3
  /** Base URL for the API. Defaults to https://affiliateo.com */
4
4
  apiUrl?: string;
5
+ /** Other domains that are part of the same merchant property and should
6
+ * share visitor identity + affiliate ref code. When a visitor clicks a
7
+ * link from the current page to any hostname in this list, the SDK
8
+ * rewrites the link URL to append `_affiliateo_vid` and `_affiliateo_ref`
9
+ * search params before the browser navigates. The receiving domain's
10
+ * SDK reads those params on landing, uses them as the visitor identity
11
+ * for that domain, and strips them from the URL bar so the user never
12
+ * sees the tracking junk.
13
+ *
14
+ * Without this, every cross-domain hop creates a brand-new anonymous
15
+ * visitor with no affiliate ref (browsers sandbox localStorage per
16
+ * origin), which silently breaks attribution: `?ref=jane` lands on
17
+ * marketing.com → user clicks → app.com → no ref → affiliate gets $0.
18
+ *
19
+ * Exact-match hostnames only (no wildcards). List every subdomain you
20
+ * want to share identity with explicitly. The current domain doesn't
21
+ * need to be listed.
22
+ *
23
+ * Example:
24
+ * <AffiliateoProvider
25
+ * campaignId="..."
26
+ * allowedHostnames={['app.merchant.com', 'docs.merchant.com']}
27
+ * > */
28
+ allowedHostnames?: string[];
5
29
  }
6
30
  export interface AffiliateoContextValue {
7
31
  /** The affiliate ref code from the URL (if present) */
@@ -34,4 +58,54 @@ export interface AffiliateoContextValue {
34
58
  * const { identify } = useAffiliateRef()
35
59
  * useEffect(() => { if (user) identify(user.id) }, [user]) */
36
60
  identify: (userId: string) => Promise<void>;
61
+ /** Manually fire a pageview. The SDK already auto-fires on mount and on
62
+ * SPA route changes; use this only when you want extra control (e.g.
63
+ * tracking modal opens as virtual pageviews, or merchants who disabled
64
+ * auto-pageviews for custom routing).
65
+ *
66
+ * - With no args: tracks the CURRENT page (same as the auto-fired one).
67
+ * - With a path: tracks that path. Useful for virtual pages.
68
+ *
69
+ * Example:
70
+ * const { trackPageview } = useAffiliateRef()
71
+ * function PricingModal({ open }: { open: boolean }) {
72
+ * useEffect(() => { if (open) trackPageview('/pricing-modal') }, [open])
73
+ * } */
74
+ trackPageview: (path?: string) => Promise<void>;
75
+ /** Wipe the visitor identity. Mints a fresh visitor_id and clears the
76
+ * affiliate ref + campaign_id from localStorage so the next pageview
77
+ * is treated as a brand-new anonymous visitor.
78
+ *
79
+ * Use case: call on user logout so a different person sitting down at
80
+ * the same browser doesn't get their actions merged into the previous
81
+ * user's funnel. Without this, shared family computers / kiosks /
82
+ * hot-desks silently pollute attribution.
83
+ *
84
+ * Example:
85
+ * const { reset } = useAffiliateRef()
86
+ * async function handleLogout() {
87
+ * await supabase.auth.signOut()
88
+ * await reset()
89
+ * router.push('/')
90
+ * } */
91
+ reset: () => Promise<void>;
92
+ /** Stop tracking this visitor. Sets a persistent opt-out flag in
93
+ * localStorage and silences all subsequent track / identify / pageview
94
+ * calls until optIn() is called. Survives reloads, doesn't survive a
95
+ * hard browser reset.
96
+ *
97
+ * Use case: GDPR / CCPA cookie banner where the visitor picked
98
+ * "Don't track me." Call optOut() once and the SDK noops everything
99
+ * for that visitor.
100
+ *
101
+ * Example:
102
+ * const { optOut } = useAffiliateRef()
103
+ * <button onClick={() => optOut()}>Don't track me</button> */
104
+ optOut: () => void;
105
+ /** Re-enable tracking after a previous optOut(). Removes the persistent
106
+ * flag. Note: tracking only resumes for events fired AFTER this call,
107
+ * the current pageview won't retroactively send. A full page reload is
108
+ * the cleanest way to bring the SDK back to its normal "auto-fire on
109
+ * mount" state. */
110
+ optIn: () => void;
37
111
  }
package/package.json CHANGED
@@ -1,34 +1,34 @@
1
- {
2
- "name": "@affiliateo/web",
3
- "version": "2.4.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
- }
1
+ {
2
+ "name": "@affiliateo/web",
3
+ "version": "3.1.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
+ }