@drip-apex/sdk 0.1.0 → 0.2.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 (245) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19262 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19246 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16621 -13539
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +27 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2070 -0
  128. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  129. package/src/consent-evaluation.ts +138 -0
  130. package/src/css.test.ts +30 -0
  131. package/src/css.ts +120 -6
  132. package/src/cwv.test.ts +60 -77
  133. package/src/debug-loader.test.ts +214 -0
  134. package/src/debug-loader.ts +41 -0
  135. package/src/decide-assignments.test.ts +196 -0
  136. package/src/decide-assignments.ts +204 -0
  137. package/src/delivery-timing.test.ts +104 -0
  138. package/src/delivery-timing.ts +89 -0
  139. package/src/early-interaction-capture.test.ts +451 -0
  140. package/src/early-interaction-capture.ts +230 -0
  141. package/src/early-reassert.test.ts +207 -0
  142. package/src/early-reassert.ts +152 -0
  143. package/src/edge-adoption.test.ts +435 -0
  144. package/src/edge-runtime.ts +30 -0
  145. package/src/editor/api.test.ts +109 -0
  146. package/src/editor/api.ts +34 -3
  147. package/src/editor/controller.test.ts +297 -14
  148. package/src/editor/controller.ts +69 -5
  149. package/src/editor/index.test.ts +2 -0
  150. package/src/editor/index.ts +1 -0
  151. package/src/editor/publication-result.ts +58 -0
  152. package/src/editor/types.ts +10 -1
  153. package/src/editor/view-kit.ts +2 -1
  154. package/src/editor/view.test.ts +108 -0
  155. package/src/editor/view.ts +49 -7
  156. package/src/endpoint.test.ts +15 -0
  157. package/src/endpoint.ts +10 -0
  158. package/src/exclusion-groups.ts +8 -18
  159. package/src/flags.test.ts +88 -1
  160. package/src/flags.ts +29 -29
  161. package/src/global-runtime-reconciler.test.ts +271 -0
  162. package/src/goal-eligibility.test.ts +29 -0
  163. package/src/goal-eligibility.ts +17 -0
  164. package/src/goals.test.ts +144 -1
  165. package/src/goals.ts +97 -18
  166. package/src/guards.test.ts +99 -1
  167. package/src/guards.ts +34 -15
  168. package/src/handover-cache-order.test.ts +6 -3
  169. package/src/handover.test.ts +154 -8
  170. package/src/handover.ts +122 -12
  171. package/src/hash.ts +29 -54
  172. package/src/holdout.test.ts +26 -0
  173. package/src/hydration.test.ts +73 -2
  174. package/src/hydration.ts +18 -9
  175. package/src/index.ts +130 -7287
  176. package/src/init-analytics.test.ts +1694 -0
  177. package/src/init-anti-flicker.test.ts +1211 -0
  178. package/src/init-cart-attributes.test.ts +2003 -0
  179. package/src/init-config-resolution.test.ts +999 -0
  180. package/src/init-consent.test.ts +971 -0
  181. package/src/init-empty.test.ts +11 -5406
  182. package/src/init-integrations.test.ts +846 -0
  183. package/src/init-page-activation.test.ts +5064 -0
  184. package/src/init-qa-loading.test.ts +740 -0
  185. package/src/init-redirects.test.ts +2156 -0
  186. package/src/init-test-helpers.ts +287 -0
  187. package/src/inline-bootstrap.test.ts +184 -0
  188. package/src/inline-bootstrap.ts +73 -0
  189. package/src/js.test.ts +51 -1
  190. package/src/js.ts +86 -2
  191. package/src/money.test.ts +55 -1
  192. package/src/money.ts +104 -2
  193. package/src/money.web-parity.contract.test.ts +85 -0
  194. package/src/mutate.test.ts +474 -10
  195. package/src/mutate.ts +739 -230
  196. package/src/page-trigger.test.ts +218 -8
  197. package/src/performance.ts +10 -0
  198. package/src/persistence-decoders.ts +18 -0
  199. package/src/public-api.ts +1828 -0
  200. package/src/qa-assistant-state.test.ts +249 -3
  201. package/src/qa-assistant-state.ts +146 -6
  202. package/src/qa-assistant-view.test.ts +220 -2
  203. package/src/qa-assistant-view.ts +102 -5
  204. package/src/react/index.test.tsx +37 -0
  205. package/src/react/index.tsx +19 -3
  206. package/src/reassert-sentinel.test.ts +155 -1
  207. package/src/reassert-sentinel.ts +168 -8
  208. package/src/redirect.test.ts +541 -0
  209. package/src/redirect.ts +572 -0
  210. package/src/replay-recorder-entry.ts +115 -0
  211. package/src/replay-signal-state.ts +100 -0
  212. package/src/replay-signals.test.ts +249 -0
  213. package/src/replay-signals.ts +151 -0
  214. package/src/replay.test.ts +764 -0
  215. package/src/replay.ts +514 -0
  216. package/src/router.test.ts +61 -2
  217. package/src/router.ts +50 -12
  218. package/src/runtime-state.ts +507 -0
  219. package/src/runtime.test.ts +174 -7
  220. package/src/runtime.ts +88 -14
  221. package/src/sanitize.test.ts +34 -0
  222. package/src/sanitize.ts +23 -12
  223. package/src/shadow.test.ts +187 -0
  224. package/src/shadow.ts +99 -0
  225. package/src/shopify-cart.test.ts +733 -23
  226. package/src/shopify-cart.ts +402 -49
  227. package/src/storefront-vitals.test.ts +217 -0
  228. package/src/storefront-vitals.ts +206 -0
  229. package/src/surface-signals.ts +6 -0
  230. package/src/targeting-conformance.test.ts +9 -0
  231. package/src/targeting-diagnostics.ts +38 -8
  232. package/src/targeting-parity-corpus.json +559 -0
  233. package/src/targeting-parity-corpus.test.ts +18 -0
  234. package/src/targeting.detailed.test.ts +105 -0
  235. package/src/targeting.test.ts +211 -0
  236. package/src/targeting.ts +88 -207
  237. package/src/track.test.ts +147 -0
  238. package/src/track.ts +125 -26
  239. package/src/types.ts +120 -13
  240. package/src/wait.test.ts +23 -0
  241. package/src/wait.ts +129 -13
  242. package/src/weekday-delivery.test.ts +56 -0
  243. package/src/weekday-schedule-parity-corpus.json +92 -0
  244. package/src/weekday-schedule.test.ts +69 -0
  245. package/src/weekday-schedule.ts +20 -0
package/src/track.test.ts CHANGED
@@ -18,6 +18,7 @@ describe("Tracker flush resilience", () => {
18
18
  const realSessionStorage = (globalThis as any).sessionStorage;
19
19
  const realLocalStorage = (globalThis as any).localStorage;
20
20
  const realNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
21
+ const realLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, "location");
21
22
 
22
23
  function setNavigator(value: unknown): void {
23
24
  Object.defineProperty(globalThis, "navigator", {
@@ -39,6 +40,10 @@ describe("Tracker flush resilience", () => {
39
40
  addEventListener: () => {},
40
41
  removeEventListener: () => {},
41
42
  };
43
+ Object.defineProperty(globalThis, "location", {
44
+ configurable: true,
45
+ value: new URL("https://store.example/"),
46
+ });
42
47
  });
43
48
 
44
49
  afterEach(() => {
@@ -51,6 +56,8 @@ describe("Tracker flush resilience", () => {
51
56
  } else {
52
57
  delete (globalThis as any).navigator;
53
58
  }
59
+ if (realLocationDescriptor) Object.defineProperty(globalThis, "location", realLocationDescriptor);
60
+ else delete (globalThis as any).location;
54
61
  (globalThis as any).sessionStorage = realSessionStorage;
55
62
  (globalThis as any).localStorage = realLocalStorage;
56
63
  vi.restoreAllMocks();
@@ -71,6 +78,73 @@ describe("Tracker flush resilience", () => {
71
78
  }
72
79
  });
73
80
 
81
+ it("tags both holdout and active events when program holdout is enabled", async () => {
82
+ const bodies: string[] = [];
83
+ globalThis.fetch = vi.fn(async (_url, init) => {
84
+ bodies.push(String(init?.body));
85
+ return new Response(null, { status: 202 });
86
+ }) as typeof fetch;
87
+ const holdout = new Tracker({
88
+ endpoint: "https://e.test/events",
89
+ shopId: "s",
90
+ cohort: "holdout",
91
+ holdoutConfigEpoch: 1_785_104_515_000,
92
+ maxBatchSize: 1,
93
+ });
94
+ holdout.track({ type: "pageview", data: {} });
95
+ await holdout.flush();
96
+ const active = new Tracker({
97
+ endpoint: "https://e.test/events",
98
+ shopId: "s",
99
+ cohort: "active",
100
+ holdoutConfigEpoch: 1_785_104_515_000,
101
+ maxBatchSize: 1,
102
+ });
103
+ active.track({ type: "pageview", data: {} });
104
+ await active.flush();
105
+ expect(bodies[0]).toContain('"cohort":"holdout"');
106
+ expect(bodies[0]).toContain('"holdout_config_epoch":1785104515000');
107
+ expect(bodies[1]).toContain('"cohort":"active"');
108
+ expect(bodies[1]).toContain('"holdout_config_epoch":1785104515000');
109
+ });
110
+
111
+ it("keeps SDK cohort metadata ahead of the Worker property cap", async () => {
112
+ let eventData: Record<string, unknown> | undefined;
113
+ globalThis.fetch = vi.fn(async (_url, init) => {
114
+ const payload = JSON.parse(String(init?.body)) as {
115
+ events: Array<{ data?: Record<string, unknown> }>;
116
+ };
117
+ eventData = payload.events[0]?.data;
118
+ return new Response(null, { status: 202 });
119
+ }) as typeof fetch;
120
+ const merchantData = Object.fromEntries(
121
+ Array.from({ length: 85 }, (_, index) => [`merchant_${index}`, index]),
122
+ );
123
+ const tracker = new Tracker({
124
+ endpoint: "https://e.test/events",
125
+ shopId: "s",
126
+ cohort: "holdout",
127
+ holdoutConfigEpoch: 1_785_104_515_000,
128
+ maxBatchSize: 1,
129
+ });
130
+
131
+ tracker.track({
132
+ type: "custom",
133
+ data: { ...merchantData, cohort: "merchant-override" },
134
+ });
135
+ await tracker.flush();
136
+
137
+ expect(eventData?.cohort).toBe("holdout");
138
+ expect(eventData?.holdout_config_epoch).toBe(1_785_104_515_000);
139
+ expect(Object.keys(eventData ?? {})[0]).toBe("cohort");
140
+ const workerCappedData = Object.fromEntries(
141
+ Object.entries(eventData ?? {}).slice(0, 80),
142
+ );
143
+ expect(workerCappedData.cohort).toBe("holdout");
144
+ expect(workerCappedData.holdout_config_epoch).toBe(1_785_104_515_000);
145
+ expect(workerCappedData.merchant_84).toBeUndefined();
146
+ });
147
+
74
148
  it("counts lifetime visitor sessions without double-counting tracker re-initialization", () => {
75
149
  sessionStorage.setItem("drip_sid", "session-one");
76
150
 
@@ -792,6 +866,79 @@ describe("Tracker flush resilience", () => {
792
866
  expect(payload.events[0]).toMatchObject({ type: "checkout_started" });
793
867
  });
794
868
 
869
+ it("uses the Shopify proxy and does not direct-send after a 2xx", async () => {
870
+ const fetchMock = vi.fn(async () => new Response(null, { status: 202 }));
871
+ globalThis.fetch = fetchMock as typeof fetch;
872
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s", maxBatchSize: 1, proxyEventDelivery: { enabled: true } });
873
+ tracker.track({ type: "pageview" });
874
+ await tracker.flush();
875
+ expect(fetchMock).toHaveBeenCalledTimes(1);
876
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/apps/apex/events");
877
+ });
878
+
879
+ it("rejects an absolute proxy path and keeps delivery on the storefront origin", async () => {
880
+ const fetchMock = vi.fn(async () => new Response(null, { status: 202 }));
881
+ globalThis.fetch = fetchMock as typeof fetch;
882
+ const tracker = new Tracker({
883
+ endpoint: "https://e.test/events",
884
+ shopId: "s",
885
+ maxBatchSize: 1,
886
+ proxyEventDelivery: { enabled: true, path: "https://evil.example/events" },
887
+ });
888
+ tracker.track({ type: "pageview" });
889
+ await tracker.flush();
890
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://store.example/apps/apex/events");
891
+ });
892
+
893
+ it("retries the same proxy batch directly once with a downgrade marker", async () => {
894
+ const fetchMock = vi.fn()
895
+ .mockResolvedValueOnce(new Response(null, { status: 502 }))
896
+ .mockResolvedValueOnce(new Response(null, { status: 202 }));
897
+ globalThis.fetch = fetchMock as typeof fetch;
898
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s", maxBatchSize: 1, proxyEventDelivery: { enabled: true } });
899
+ tracker.track({ type: "pageview", data: { original: true } });
900
+ await tracker.flush();
901
+ expect(fetchMock).toHaveBeenCalledTimes(2);
902
+ expect(fetchMock.mock.calls[1]?.[0]).toBe("https://e.test/events");
903
+ expect(String(fetchMock.mock.calls[1]?.[1]?.body)).toContain('"delivery_downgrade":"proxy_failed"');
904
+ });
905
+
906
+ it("marks every event after an asset fallback", async () => {
907
+ const fetchMock = vi.fn(async () => new Response(null, { status: 202 }));
908
+ globalThis.fetch = fetchMock as typeof fetch;
909
+ const tracker = new Tracker({ endpoint: "https://e.test/events", shopId: "s", maxBatchSize: 2, assetFallback: true });
910
+ tracker.track({ type: "sdk_perf" });
911
+ tracker.track({ type: "pageview", data: { asset_fallback: false } });
912
+ await tracker.flush();
913
+ const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
914
+ expect(payload.events).toHaveLength(2);
915
+ expect(payload.events.every((event: { data: Record<string, unknown> }) => event.data.asset_fallback === true)).toBe(true);
916
+ });
917
+
918
+ it("falls back synchronously to the direct beacon when the proxy unload beacon is refused", () => {
919
+ let pagehide: (() => void) | undefined;
920
+ (globalThis as any).window.addEventListener = (name: string, handler: () => void) => {
921
+ if (name === "pagehide") pagehide = handler;
922
+ };
923
+ const sendBeacon = vi.fn()
924
+ .mockReturnValueOnce(false)
925
+ .mockReturnValueOnce(true);
926
+ setNavigator({ sendBeacon });
927
+ const tracker = new Tracker({
928
+ endpoint: "https://e.test/events",
929
+ shopId: "s",
930
+ proxyEventDelivery: { enabled: true },
931
+ });
932
+ tracker.start();
933
+ tracker.track({ type: "pageview" });
934
+ pagehide?.();
935
+
936
+ expect(sendBeacon).toHaveBeenCalledTimes(2);
937
+ expect(sendBeacon.mock.calls[0]?.[0]).toBe("https://store.example/apps/apex/events");
938
+ expect(sendBeacon.mock.calls[1]?.[0]).toBe("https://e.test/events");
939
+ expect(sendBeacon.mock.calls[1]?.[1]).toBeInstanceOf(Blob);
940
+ });
941
+
795
942
  it("removes unload listeners when stopped", () => {
796
943
  const addEventListener = vi.fn();
797
944
  const removeEventListener = vi.fn();
package/src/track.ts CHANGED
@@ -4,6 +4,8 @@ import { TrackEvent } from "./types";
4
4
 
5
5
  type TrackerOptions = {
6
6
  endpoint: string;
7
+ proxyEventDelivery?: { enabled: boolean; path?: string };
8
+ assetFallback?: boolean;
7
9
  shopId?: string;
8
10
  ingestShopId?: string;
9
11
  ingestSignature?: string;
@@ -20,8 +22,24 @@ type TrackerOptions = {
20
22
  qaSessionId?: string;
21
23
  /** Active Apex environment stamped onto every outgoing event. */
22
24
  environment?: string;
25
+ /** Global cohort stamped on every event while program holdout is enabled. */
26
+ cohort?: "holdout" | "active";
27
+ /** Reporting epoch of the configuration that produced the global cohort. */
28
+ holdoutConfigEpoch?: number;
23
29
  };
24
30
 
31
+ export function getOrCreateSessionId(): string {
32
+ try {
33
+ const existing = sessionStorage.getItem("drip_sid");
34
+ if (existing) return existing;
35
+ const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
36
+ sessionStorage.setItem("drip_sid", id);
37
+ return id;
38
+ } catch {
39
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
40
+ }
41
+ }
42
+
25
43
  const RECOVERY_KEY = "drip_events";
26
44
  const VISITOR_SESSIONS_KEY = "drip_visitor_sessions_v1";
27
45
 
@@ -79,7 +97,7 @@ function visitorSessionCount(sessionId: string): number | undefined {
79
97
  }
80
98
  }
81
99
 
82
- function createEventId(): string {
100
+ export function createEventId(): string {
83
101
  const cryptoApi = globalThis.crypto as Crypto | undefined;
84
102
  if (typeof cryptoApi?.randomUUID === "function") {
85
103
  return cryptoApi.randomUUID();
@@ -93,6 +111,7 @@ function createEventId(): string {
93
111
 
94
112
  export class Tracker {
95
113
  private endpoint: string;
114
+ private proxyEndpoint: string | null;
96
115
  private shopId: string;
97
116
  private ingestShopId: string;
98
117
  private ingestSignature: string;
@@ -114,6 +133,9 @@ export class Tracker {
114
133
  private qaMode: boolean;
115
134
  private qaSessionId: string;
116
135
  private environment: string;
136
+ private cohort: "holdout" | "active" | undefined;
137
+ private holdoutConfigEpoch: number | undefined;
138
+ private assetFallback: boolean;
117
139
  private pagehideHandler: ((event?: PageTransitionEvent) => void) | null = null;
118
140
  private beforeUnloadHandler: (() => void) | null = null;
119
141
  private pageshowHandler: (() => void) | null = null;
@@ -124,11 +146,12 @@ export class Tracker {
124
146
 
125
147
  constructor(options: TrackerOptions) {
126
148
  this.endpoint = options.endpoint;
149
+ this.proxyEndpoint = resolveProxyEventEndpoint(options.proxyEventDelivery);
127
150
  this.shopId = options.shopId ?? "";
128
151
  this.ingestShopId = options.ingestShopId ?? options.shopId ?? "";
129
152
  this.ingestSignature = options.ingestSignature ?? "";
130
153
  this.visitorId = options.visitorId ?? "";
131
- this.sessionId = this.getOrCreateSessionId();
154
+ this.sessionId = getOrCreateSessionId();
132
155
  this.visitorSessions = visitorSessionCount(this.sessionId);
133
156
  this.flushIntervalMs = options.flushIntervalMs ?? 5000;
134
157
  this.maxBatchSize = options.maxBatchSize ?? 20;
@@ -137,20 +160,16 @@ export class Tracker {
137
160
  this.qaMode = options.qaMode ?? false;
138
161
  this.qaSessionId = options.qaSessionId ?? "";
139
162
  this.environment = options.environment || "production";
163
+ this.cohort = options.cohort;
164
+ this.holdoutConfigEpoch =
165
+ Number.isSafeInteger(options.holdoutConfigEpoch) &&
166
+ (options.holdoutConfigEpoch ?? 0) > 0
167
+ ? options.holdoutConfigEpoch
168
+ : undefined;
169
+ this.assetFallback = options.assetFallback === true;
140
170
  }
141
171
 
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
- }
172
+
154
173
 
155
174
  /** Public accessor for the session ID (used by the Shopify pixel bridge). */
156
175
  getSessionId(): string {
@@ -196,8 +215,32 @@ export class Tracker {
196
215
  }
197
216
 
198
217
  private enrichEvent(event: TrackEvent): TrackEvent {
218
+ const systemData: Record<string, unknown> = {};
219
+ if (this.assetFallback) systemData.asset_fallback = true;
220
+ if (this.cohort) {
221
+ systemData.cohort = this.cohort;
222
+ if (this.holdoutConfigEpoch) {
223
+ systemData.holdout_config_epoch = this.holdoutConfigEpoch;
224
+ }
225
+ }
226
+ if (
227
+ this.visitorSessions &&
228
+ carriesVisitorSessions(event.type) &&
229
+ !("visitor_sessions" in (event.data ?? {}))
230
+ ) {
231
+ systemData.visitor_sessions = this.visitorSessions;
232
+ }
199
233
  const enriched: TrackEvent = {
200
234
  ...event,
235
+ ...(Object.keys(systemData).length > 0
236
+ ? {
237
+ // Worker sanitization retains the first 80 insertion-ordered
238
+ // properties. Insert trusted SDK fields before merchant data, then
239
+ // re-stamp them so user input cannot override their values without
240
+ // moving their insertion position behind the cap.
241
+ data: { ...systemData, ...event.data, ...systemData },
242
+ }
243
+ : {}),
201
244
  event_id:
202
245
  typeof event.event_id === "string" && event.event_id.trim()
203
246
  ? event.event_id
@@ -209,13 +252,6 @@ export class Tracker {
209
252
  ts: event.ts ?? Date.now(),
210
253
  environment: this.environment,
211
254
  };
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
255
  if (this.qaMode) {
220
256
  enriched.qa_mode = true;
221
257
  if (this.qaSessionId) enriched.qa_session_id = this.qaSessionId;
@@ -267,10 +303,31 @@ export class Tracker {
267
303
  const batch = this.queue.splice(0, this.maxBatchSize);
268
304
  this.activeBatch = batch;
269
305
  if (!batch.length) return;
270
- const res = await fetch(this.endpoint, {
306
+ const payload = this.buildPayload(batch);
307
+ let res: Response;
308
+ if (this.proxyEndpoint) {
309
+ try {
310
+ res = await fetch(this.proxyEndpoint, {
311
+ method: "POST",
312
+ headers: { "Content-Type": "application/json" },
313
+ body: payload,
314
+ keepalive: true,
315
+ });
316
+ } catch {
317
+ res = new Response(null, { status: 503 });
318
+ }
319
+ if (!res.ok) {
320
+ res = await fetch(this.endpoint, {
321
+ method: "POST",
322
+ headers: this.buildHeaders(),
323
+ body: this.buildPayload(batch, "proxy_failed"),
324
+ keepalive: true,
325
+ });
326
+ }
327
+ } else res = await fetch(this.endpoint, {
271
328
  method: "POST",
272
329
  headers: this.buildHeaders(),
273
- body: this.buildPayload(batch),
330
+ body: payload,
274
331
  keepalive: true,
275
332
  });
276
333
  if (res.ok) {
@@ -323,8 +380,11 @@ export class Tracker {
323
380
  }
324
381
  }
325
382
 
326
- private buildPayload(batch: TrackEvent[]): string {
327
- const body: Record<string, unknown> = { events: batch };
383
+ private buildPayload(batch: TrackEvent[], deliveryDowngrade?: string): string {
384
+ const events = deliveryDowngrade
385
+ ? batch.map((event) => ({ ...event, data: { ...event.data, delivery_downgrade: deliveryDowngrade } }))
386
+ : batch;
387
+ const body: Record<string, unknown> = { events };
328
388
  // Include auth in payload body so sendBeacon (which can't set headers) works.
329
389
  if (this.ingestShopId) body._shopId = this.ingestShopId;
330
390
  if (this.ingestSignature) body._signature = this.ingestSignature;
@@ -431,9 +491,27 @@ export class Tracker {
431
491
 
432
492
  while (lifecycleEvents.length) {
433
493
  const batch = lifecycleEvents.splice(0, this.maxBatchSize);
434
- const payload = this.buildPayload(batch);
494
+ let payload = this.buildPayload(batch);
435
495
  let queued = false;
436
496
 
497
+ if (this.proxyEndpoint) {
498
+ try {
499
+ const nav = typeof navigator !== "undefined" ? navigator : undefined;
500
+ if (typeof nav?.sendBeacon === "function") {
501
+ queued = nav.sendBeacon(
502
+ this.proxyEndpoint,
503
+ new Blob([payload], { type: "text/plain" }),
504
+ );
505
+ }
506
+ } catch {
507
+ queued = false;
508
+ }
509
+ if (queued) continue;
510
+ // Fall through to the established direct unload lane with an explicit
511
+ // downgrade marker when the same-origin proxy beacon cannot be queued.
512
+ payload = this.buildPayload(batch, "proxy_failed");
513
+ }
514
+
437
515
  try {
438
516
  const nav = typeof navigator !== "undefined" ? navigator : undefined;
439
517
  if (typeof nav?.sendBeacon === "function") {
@@ -517,3 +595,24 @@ export class Tracker {
517
595
  }
518
596
  }
519
597
  }
598
+
599
+ function resolveProxyEventEndpoint(config: TrackerOptions["proxyEventDelivery"]): string | null {
600
+ if (!config?.enabled || typeof location === "undefined") return null;
601
+ const host = location.hostname.toLowerCase();
602
+ if (
603
+ host === "app.drip-apex.com"
604
+ || host === "app-staging.drip-apex.com"
605
+ || host === "commerce.drip-apex.com"
606
+ || host === "preview.drip-apex.com"
607
+ || host.endsWith(".workers.dev")
608
+ ) return null;
609
+ const configuredPath = config.path?.trim();
610
+ const path = configuredPath && configuredPath.startsWith("/") && !configuredPath.startsWith("//")
611
+ ? configuredPath
612
+ : "/apps/apex/events";
613
+ try {
614
+ return new URL(path, location.origin).toString();
615
+ } catch {
616
+ return null;
617
+ }
618
+ }