@drip-apex/sdk 0.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.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
package/src/track.ts ADDED
@@ -0,0 +1,519 @@
1
+ import { cleanUrl, onPageExit } from "./analytics";
2
+ import { lifetimeIdentityStorage } from "./hash";
3
+ import { TrackEvent } from "./types";
4
+
5
+ type TrackerOptions = {
6
+ endpoint: string;
7
+ shopId?: string;
8
+ ingestShopId?: string;
9
+ ingestSignature?: string;
10
+ visitorId?: string;
11
+ flushIntervalMs?: number;
12
+ maxBatchSize?: number;
13
+ /** Returns the active experiment/variation at unload time for page_exit attribution. */
14
+ getActiveAssignment?: () => { experimentId: string; variationId: string } | null;
15
+ /** Builds global and experiment-scoped page-exit events without flushing during unload. */
16
+ buildPageExitEvents?: (type: string, data?: Record<string, unknown>) => TrackEvent[];
17
+ /** When true, all events are tagged as QA traffic. */
18
+ qaMode?: boolean;
19
+ /** Unique QA session identifier for grouping QA events. */
20
+ qaSessionId?: string;
21
+ /** Active Apex environment stamped onto every outgoing event. */
22
+ environment?: string;
23
+ };
24
+
25
+ const RECOVERY_KEY = "drip_events";
26
+ const VISITOR_SESSIONS_KEY = "drip_visitor_sessions_v1";
27
+
28
+ function carriesVisitorSessions(type: string): boolean {
29
+ return /^(experiment_viewed|personalization_rendered|goal|conversion|pageview)$/.test(type);
30
+ }
31
+
32
+ // Enough remembered session ids that parallel tabs (each with its own
33
+ // per-tab session) cannot ping-pong the counter upward on reloads.
34
+ const VISITOR_SESSIONS_REMEMBERED = 8;
35
+
36
+ function visitorSessionCount(sessionId: string): number | undefined {
37
+ try {
38
+ // Respect the configured storage policy: without a lifetime store
39
+ // (sessionStorage/memory/cookie modes) a cross-session count would lie
40
+ // ("new" every session), so the field is simply not stamped.
41
+ const store = lifetimeIdentityStorage();
42
+ if (!store) return undefined;
43
+ const raw = store.getItem(VISITOR_SESSIONS_KEY);
44
+ let stored: { count?: unknown; lastSessionId?: unknown; recentSessionIds?: unknown } | undefined;
45
+ try {
46
+ stored = JSON.parse(raw || "0");
47
+ } catch {
48
+ // Malformed state is equivalent to a missing counter and recovers at one.
49
+ }
50
+ const storedRecent = stored?.recentSessionIds;
51
+ const storedLast = stored?.lastSessionId;
52
+ const recent: string[] = Array.isArray(storedRecent)
53
+ ? (storedRecent as unknown[]).filter(
54
+ (id): id is string => typeof id === "string",
55
+ )
56
+ : typeof storedLast === "string"
57
+ ? [storedLast]
58
+ : [];
59
+ const storedCount = stored?.count;
60
+ const count =
61
+ Number.isInteger(storedCount) && Number(storedCount) > 0 && recent.length > 0
62
+ ? Number(storedCount)
63
+ : 0;
64
+ if (count && recent.includes(sessionId)) return count;
65
+ const next = count + 1;
66
+ store.setItem(
67
+ VISITOR_SESSIONS_KEY,
68
+ JSON.stringify({
69
+ count: next,
70
+ recentSessionIds: [sessionId, ...recent].slice(0, VISITOR_SESSIONS_REMEMBERED),
71
+ }),
72
+ );
73
+ // sessionStorage is per-tab, so parallel tabs mint separate sessions.
74
+ // This deliberately follows the SDK's existing session model; clearing
75
+ // browser storage restarts the lifetime counter at one.
76
+ return next;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ function createEventId(): string {
83
+ const cryptoApi = globalThis.crypto as Crypto | undefined;
84
+ if (typeof cryptoApi?.randomUUID === "function") {
85
+ return cryptoApi.randomUUID();
86
+ }
87
+ return [
88
+ Date.now().toString(36),
89
+ Math.random().toString(36).slice(2),
90
+ Math.random().toString(36).slice(2),
91
+ ].join("-");
92
+ }
93
+
94
+ export class Tracker {
95
+ private endpoint: string;
96
+ private shopId: string;
97
+ private ingestShopId: string;
98
+ private ingestSignature: string;
99
+ private visitorId: string;
100
+ private sessionId: string;
101
+ private visitorSessions: number | undefined;
102
+ private flushIntervalMs: number;
103
+ private maxBatchSize: number;
104
+ private queue: TrackEvent[] = [];
105
+ private activeBatch: TrackEvent[] | null = null;
106
+ private timer: number | null = null;
107
+ private flushInFlight = false;
108
+ private flushPromise: Promise<void> | null = null;
109
+ private nextFlushAt = 0;
110
+ private backoffMs = 0;
111
+ private readonly maxQueueSize = 2000;
112
+ private getActiveAssignment: (() => { experimentId: string; variationId: string } | null) | null;
113
+ private buildPageExitEvents: ((type: string, data?: Record<string, unknown>) => TrackEvent[]) | null;
114
+ private qaMode: boolean;
115
+ private qaSessionId: string;
116
+ private environment: string;
117
+ private pagehideHandler: ((event?: PageTransitionEvent) => void) | null = null;
118
+ private beforeUnloadHandler: (() => void) | null = null;
119
+ private pageshowHandler: (() => void) | null = null;
120
+ private beforeUnloadResetTimer: number | null = null;
121
+ private visibilityHandler: (() => void) | null = null;
122
+ private pageLifecycleFlushed = false;
123
+ private pageTeardownStarted = false;
124
+
125
+ constructor(options: TrackerOptions) {
126
+ this.endpoint = options.endpoint;
127
+ this.shopId = options.shopId ?? "";
128
+ this.ingestShopId = options.ingestShopId ?? options.shopId ?? "";
129
+ this.ingestSignature = options.ingestSignature ?? "";
130
+ this.visitorId = options.visitorId ?? "";
131
+ this.sessionId = this.getOrCreateSessionId();
132
+ this.visitorSessions = visitorSessionCount(this.sessionId);
133
+ this.flushIntervalMs = options.flushIntervalMs ?? 5000;
134
+ this.maxBatchSize = options.maxBatchSize ?? 20;
135
+ this.getActiveAssignment = options.getActiveAssignment ?? null;
136
+ this.buildPageExitEvents = options.buildPageExitEvents ?? null;
137
+ this.qaMode = options.qaMode ?? false;
138
+ this.qaSessionId = options.qaSessionId ?? "";
139
+ this.environment = options.environment || "production";
140
+ }
141
+
142
+ private getOrCreateSessionId(): string {
143
+ const key = "drip_sid";
144
+ try {
145
+ const existing = sessionStorage.getItem(key);
146
+ if (existing) return existing;
147
+ const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
148
+ sessionStorage.setItem(key, id);
149
+ return id;
150
+ } catch {
151
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
152
+ }
153
+ }
154
+
155
+ /** Public accessor for the session ID (used by the Shopify pixel bridge). */
156
+ getSessionId(): string {
157
+ return this.sessionId;
158
+ }
159
+
160
+ start(): void {
161
+ if (this.timer !== null) return;
162
+ this.recoverEvents();
163
+ this.timer = window.setInterval(() => this.flush(), this.flushIntervalMs);
164
+ this.listenUnload();
165
+ }
166
+
167
+ stop(): void {
168
+ if (this.timer !== null) {
169
+ window.clearInterval(this.timer);
170
+ this.timer = null;
171
+ }
172
+ if (this.pagehideHandler) window.removeEventListener("pagehide", this.pagehideHandler);
173
+ if (this.beforeUnloadHandler) window.removeEventListener("beforeunload", this.beforeUnloadHandler);
174
+ if (this.pageshowHandler) window.removeEventListener("pageshow", this.pageshowHandler);
175
+ if (this.beforeUnloadResetTimer !== null) window.clearTimeout(this.beforeUnloadResetTimer);
176
+ this.pagehideHandler = null;
177
+ this.beforeUnloadHandler = null;
178
+ this.pageshowHandler = null;
179
+ this.beforeUnloadResetTimer = null;
180
+ if (this.visibilityHandler && typeof document !== "undefined") {
181
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
182
+ this.visibilityHandler = null;
183
+ }
184
+ }
185
+
186
+ track(event: TrackEvent): void {
187
+ const enriched = this.enrichEvent(event);
188
+ this.queue.push(enriched);
189
+ // Prevent unbounded growth during outages (Black Friday spikes, offline, ad blockers).
190
+ if (this.queue.length > this.maxQueueSize) {
191
+ this.queue.splice(0, this.queue.length - this.maxQueueSize);
192
+ }
193
+ if (this.queue.length >= this.maxBatchSize) {
194
+ void this.flush();
195
+ }
196
+ }
197
+
198
+ private enrichEvent(event: TrackEvent): TrackEvent {
199
+ const enriched: TrackEvent = {
200
+ ...event,
201
+ event_id:
202
+ typeof event.event_id === "string" && event.event_id.trim()
203
+ ? event.event_id
204
+ : createEventId(),
205
+ shopId: event.shopId ?? this.shopId,
206
+ visitorId: event.visitorId ?? this.visitorId,
207
+ sessionId: event.sessionId ?? this.sessionId,
208
+ url: event.url ?? (typeof location !== "undefined" ? location.href : ""),
209
+ ts: event.ts ?? Date.now(),
210
+ environment: this.environment,
211
+ };
212
+ if (
213
+ this.visitorSessions &&
214
+ carriesVisitorSessions(event.type) &&
215
+ !("visitor_sessions" in (event.data ?? {}))
216
+ ) {
217
+ enriched.data = { ...event.data, visitor_sessions: this.visitorSessions };
218
+ }
219
+ if (this.qaMode) {
220
+ enriched.qa_mode = true;
221
+ if (this.qaSessionId) enriched.qa_session_id = this.qaSessionId;
222
+ }
223
+ return enriched;
224
+ }
225
+
226
+ private scheduleBackoff(ms: number): void {
227
+ const jitter = Math.floor(Math.random() * 250);
228
+ this.backoffMs = Math.max(this.backoffMs, ms);
229
+ this.nextFlushAt = Date.now() + this.backoffMs + jitter;
230
+ }
231
+
232
+ private resetBackoff(): void {
233
+ this.backoffMs = 0;
234
+ this.nextFlushAt = 0;
235
+ }
236
+
237
+ private parseRetryAfterSeconds(value: string | null): number | null {
238
+ if (!value) return null;
239
+ const n = Number.parseInt(value, 10);
240
+ if (Number.isFinite(n) && n > 0) return n;
241
+ return null;
242
+ }
243
+
244
+ async flush(): Promise<void> {
245
+ if (this.flushPromise) return this.flushPromise;
246
+ if (!this.queue.length || !this.endpoint) return;
247
+ if (this.nextFlushAt && Date.now() < this.nextFlushAt) return;
248
+
249
+ const flushPromise = this.drainQueue();
250
+ this.flushPromise = flushPromise;
251
+ try {
252
+ await flushPromise;
253
+ } finally {
254
+ if (this.flushPromise === flushPromise) {
255
+ this.flushPromise = null;
256
+ }
257
+ }
258
+ }
259
+
260
+ private async drainQueue(): Promise<void> {
261
+ try {
262
+ this.flushInFlight = true;
263
+ while (this.queue.length && this.endpoint) {
264
+ if (this.nextFlushAt && Date.now() < this.nextFlushAt) return;
265
+
266
+ // Never send unbounded payloads. Worker rejects >200 events; keep batches small.
267
+ const batch = this.queue.splice(0, this.maxBatchSize);
268
+ this.activeBatch = batch;
269
+ if (!batch.length) return;
270
+ const res = await fetch(this.endpoint, {
271
+ method: "POST",
272
+ headers: this.buildHeaders(),
273
+ body: this.buildPayload(batch),
274
+ keepalive: true,
275
+ });
276
+ if (res.ok) {
277
+ this.removeRecovery(batch);
278
+ this.activeBatch = null;
279
+ this.resetBackoff();
280
+ continue;
281
+ }
282
+
283
+ // Re-queue on any non-2xx response.
284
+ this.queue.unshift(...batch);
285
+ this.activeBatch = null;
286
+ if (this.pageTeardownStarted) this.saveRecovery(batch);
287
+
288
+ if (res.status === 429) {
289
+ const retryAfter = this.parseRetryAfterSeconds(res.headers.get("Retry-After"));
290
+ const waitMs = retryAfter ? retryAfter * 1000 : 5_000;
291
+ this.scheduleBackoff(waitMs);
292
+ return;
293
+ }
294
+
295
+ if (res.status === 401 || res.status === 403) {
296
+ // Likely expired/invalid signature. Keep events in memory/sessionStorage and retry rarely.
297
+ this.scheduleBackoff(5 * 60_000);
298
+ return;
299
+ }
300
+
301
+ if (res.status >= 500) {
302
+ // Exponential backoff up to 60s.
303
+ const next = this.backoffMs ? Math.min(60_000, this.backoffMs * 2) : 1_000;
304
+ this.scheduleBackoff(next);
305
+ return;
306
+ }
307
+
308
+ // For other 4xx (e.g. bad payload), don't spin. Retry slowly.
309
+ this.scheduleBackoff(30_000);
310
+ return;
311
+ }
312
+ } catch {
313
+ // Network failure (offline, blocked, transient). Requeue and back off.
314
+ if (this.activeBatch?.length) {
315
+ this.queue.unshift(...this.activeBatch);
316
+ if (this.pageTeardownStarted) this.saveRecovery(this.activeBatch);
317
+ this.activeBatch = null;
318
+ }
319
+ const next = this.backoffMs ? Math.min(60_000, this.backoffMs * 2) : 1_000;
320
+ this.scheduleBackoff(next);
321
+ } finally {
322
+ this.flushInFlight = false;
323
+ }
324
+ }
325
+
326
+ private buildPayload(batch: TrackEvent[]): string {
327
+ const body: Record<string, unknown> = { events: batch };
328
+ // Include auth in payload body so sendBeacon (which can't set headers) works.
329
+ if (this.ingestShopId) body._shopId = this.ingestShopId;
330
+ if (this.ingestSignature) body._signature = this.ingestSignature;
331
+ return JSON.stringify(body);
332
+ }
333
+
334
+ private buildHeaders(): Record<string, string> {
335
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
336
+ if (this.ingestShopId) headers["X-Drip-Shop"] = this.ingestShopId;
337
+ if (this.ingestSignature) headers["X-Drip-Signature"] = this.ingestSignature;
338
+ return headers;
339
+ }
340
+
341
+ private recoveryEventKey(event: TrackEvent): string | null {
342
+ const eventId = typeof event.event_id === "string" ? event.event_id.trim() : "";
343
+ if (!eventId) return null;
344
+ return JSON.stringify([
345
+ eventId,
346
+ event.type,
347
+ event.experimentId ?? null,
348
+ event.variationId ?? null,
349
+ event.environment || "production",
350
+ ]);
351
+ }
352
+
353
+ private readRecovery(): TrackEvent[] {
354
+ try {
355
+ const raw = sessionStorage.getItem(RECOVERY_KEY);
356
+ if (!raw) return [];
357
+ const parsed = JSON.parse(raw);
358
+ return Array.isArray(parsed) ? parsed as TrackEvent[] : [];
359
+ } catch {
360
+ return [];
361
+ }
362
+ }
363
+
364
+ private saveRecovery(events: TrackEvent[]): void {
365
+ if (!events.length) return;
366
+ const merged = [...this.readRecovery(), ...events];
367
+ const keyed = new Map<string, TrackEvent>();
368
+ const unkeyed: TrackEvent[] = [];
369
+ for (const event of merged) {
370
+ const key = this.recoveryEventKey(event);
371
+ if (key) keyed.set(key, event);
372
+ else unkeyed.push(event);
373
+ }
374
+ const bounded = [...keyed.values(), ...unkeyed].slice(-this.maxQueueSize);
375
+ try {
376
+ sessionStorage.setItem(RECOVERY_KEY, JSON.stringify(bounded));
377
+ } catch {}
378
+ }
379
+
380
+ private removeRecovery(events: TrackEvent[]): void {
381
+ const deliveredKeys = new Set(
382
+ events
383
+ .map((event) => this.recoveryEventKey(event))
384
+ .filter((key): key is string => key !== null),
385
+ );
386
+ if (deliveredKeys.size === 0) return;
387
+ try {
388
+ const remaining = this.readRecovery().filter((event) => {
389
+ const key = this.recoveryEventKey(event);
390
+ return key === null || !deliveredKeys.has(key);
391
+ });
392
+ if (remaining.length) sessionStorage.setItem(RECOVERY_KEY, JSON.stringify(remaining));
393
+ else sessionStorage.removeItem(RECOVERY_KEY);
394
+ } catch {}
395
+ }
396
+
397
+ private queuePageExitEvents(): void {
398
+ const assignment = this.getActiveAssignment?.() ?? null;
399
+ onPageExit((type, data) => {
400
+ const events = this.buildPageExitEvents
401
+ ? this.buildPageExitEvents(type, data)
402
+ : [{
403
+ type,
404
+ data,
405
+ experimentId: assignment?.experimentId,
406
+ variationId: assignment?.variationId,
407
+ url: cleanUrl(),
408
+ }];
409
+ this.queue.push(...events.map((event) => this.enrichEvent(event)));
410
+ });
411
+ }
412
+
413
+ private flushForPageLifecycle(terminal = false): void {
414
+ const alreadyFlushed = this.pageLifecycleFlushed;
415
+ if (terminal) this.pageTeardownStarted = true;
416
+ if (alreadyFlushed && (!terminal || this.queue.length === 0)) return;
417
+ if (!alreadyFlushed) {
418
+ this.pageLifecycleFlushed = true;
419
+ this.queuePageExitEvents();
420
+ }
421
+ // drainQueue already handed activeBatch to a keepalive fetch. Sending that
422
+ // same batch again with beacon creates a guaranteed overlap when both
423
+ // requests reach the Worker. If the active request later fails, drainQueue
424
+ // persists its stable event IDs because the page has entered teardown.
425
+ const lifecycleEvents = this.queue.splice(0);
426
+ if (!lifecycleEvents.length) return;
427
+ if (!this.endpoint) {
428
+ this.saveRecovery(lifecycleEvents);
429
+ return;
430
+ }
431
+
432
+ while (lifecycleEvents.length) {
433
+ const batch = lifecycleEvents.splice(0, this.maxBatchSize);
434
+ const payload = this.buildPayload(batch);
435
+ let queued = false;
436
+
437
+ try {
438
+ const nav = typeof navigator !== "undefined" ? navigator : undefined;
439
+ if (typeof nav?.sendBeacon === "function") {
440
+ queued = nav.sendBeacon(
441
+ this.endpoint,
442
+ new Blob([payload], { type: "text/plain" }),
443
+ );
444
+ }
445
+ } catch {
446
+ queued = false;
447
+ }
448
+
449
+ if (queued) continue;
450
+
451
+ try {
452
+ void fetch(this.endpoint, {
453
+ method: "POST",
454
+ headers: this.buildHeaders(),
455
+ body: payload,
456
+ keepalive: true,
457
+ })
458
+ .then((response) => {
459
+ if (!response.ok) this.saveRecovery(batch);
460
+ })
461
+ .catch(() => {
462
+ this.saveRecovery(batch);
463
+ });
464
+ } catch {
465
+ this.saveRecovery(batch);
466
+ }
467
+ }
468
+ }
469
+
470
+ private recoverEvents(): void {
471
+ try {
472
+ const events = this.readRecovery().map((event) => ({
473
+ ...event,
474
+ environment: event.environment || this.environment,
475
+ }));
476
+ if (events.length) {
477
+ sessionStorage.removeItem(RECOVERY_KEY);
478
+ this.queue.unshift(...events);
479
+ }
480
+ } catch {}
481
+ }
482
+
483
+ private resetPageLifecycle(): void {
484
+ this.pageLifecycleFlushed = false;
485
+ this.pageTeardownStarted = false;
486
+ this.beforeUnloadResetTimer = null;
487
+ }
488
+
489
+ private listenUnload(): void {
490
+ if (this.pagehideHandler) return;
491
+ const onPagehide = (event?: PageTransitionEvent) => {
492
+ this.flushForPageLifecycle(event?.persisted !== true);
493
+ };
494
+ const onBeforeUnload = () => {
495
+ this.flushForPageLifecycle(true);
496
+ // A beforeunload prompt can be canceled. A real navigation prevents this
497
+ // task from running (and pagehide confirms terminal teardown); a live tab
498
+ // clears the provisional terminal state before later retries.
499
+ if (this.beforeUnloadResetTimer === null && typeof window.setTimeout === "function") {
500
+ this.beforeUnloadResetTimer = window.setTimeout(() => this.resetPageLifecycle(), 0);
501
+ }
502
+ };
503
+ const onPageShow = () => this.resetPageLifecycle();
504
+ this.pagehideHandler = onPagehide;
505
+ this.beforeUnloadHandler = onBeforeUnload;
506
+ this.pageshowHandler = onPageShow;
507
+ window.addEventListener("pagehide", onPagehide);
508
+ window.addEventListener("beforeunload", onBeforeUnload);
509
+ window.addEventListener("pageshow", onPageShow);
510
+ if (typeof document !== "undefined") {
511
+ const onVisibilityChange = () => {
512
+ if (document.visibilityState === "hidden") this.flushForPageLifecycle();
513
+ else this.resetPageLifecycle();
514
+ };
515
+ this.visibilityHandler = onVisibilityChange;
516
+ document.addEventListener("visibilitychange", onVisibilityChange);
517
+ }
518
+ }
519
+ }