@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
@@ -0,0 +1,309 @@
1
+ import type { Assignment, ExternalTrackingSettings } from "./types";
2
+
3
+ export type HandoverData = {
4
+ visitorId?: string;
5
+ forceMap?: Record<string, string>;
6
+ };
7
+
8
+ const DEFAULT_HANDOVER_TTL_SECONDS = 120;
9
+ const DEFAULT_HANDOVER_MINT_TIMEOUT_MS = 250;
10
+
11
+ function paramName(settings?: ExternalTrackingSettings): string {
12
+ return settings?.handoverQueryParam || "drip_bucket";
13
+ }
14
+
15
+ function ttlSeconds(settings?: ExternalTrackingSettings): number {
16
+ const ttl = Number(settings?.handoverTtlSeconds ?? DEFAULT_HANDOVER_TTL_SECONDS);
17
+ return Number.isFinite(ttl) ? Math.max(30, Math.min(600, Math.floor(ttl))) : DEFAULT_HANDOVER_TTL_SECONDS;
18
+ }
19
+
20
+ export function handoverMintTimeoutMs(settings?: ExternalTrackingSettings): number {
21
+ const timeout = Number(settings?.handoverMintTimeoutMs ?? DEFAULT_HANDOVER_MINT_TIMEOUT_MS);
22
+ return Number.isFinite(timeout) ? Math.max(50, Math.min(2000, Math.floor(timeout))) : DEFAULT_HANDOVER_MINT_TIMEOUT_MS;
23
+ }
24
+
25
+ function enabledDomains(settings?: ExternalTrackingSettings): string[] {
26
+ return (settings?.domains ?? [])
27
+ .map((domain) => domain.trim().replace(/^https?:\/\//i, "").replace(/\/.*$/, "").toLowerCase())
28
+ .filter(Boolean);
29
+ }
30
+
31
+ function encodePayload(value: unknown): string {
32
+ const json = JSON.stringify(value);
33
+ return btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
+ }
35
+
36
+ type VerifiedHandoverPayload = Record<string, unknown>;
37
+
38
+ function decodeVerifiedPayload(parsed: VerifiedHandoverPayload, shopId: string): HandoverData | null {
39
+ try {
40
+ const exp = typeof parsed.exp === "number" ? parsed.exp : 0;
41
+ if (!exp || Math.floor(Date.now() / 1000) > exp || parsed.shop !== shopId) return null;
42
+ const visitorId = typeof parsed.visitorId === "string" ? parsed.visitorId : undefined;
43
+ const visitorIdV2 = typeof parsed.vid === "string" ? parsed.vid : undefined;
44
+ const forceMap: Record<string, string> = {};
45
+ const assignments = Array.isArray(parsed.assignments)
46
+ ? parsed.assignments
47
+ : Array.isArray(parsed.asg)
48
+ ? parsed.asg
49
+ : [];
50
+ if (Array.isArray(assignments)) {
51
+ for (const item of assignments) {
52
+ if (!item || typeof item !== "object") continue;
53
+ const record = item as Record<string, unknown>;
54
+ if (typeof record.experimentId === "string" && typeof record.variationId === "string") {
55
+ forceMap[record.experimentId] = record.variationId;
56
+ } else if (typeof record.e === "string" && typeof record.x === "string") {
57
+ forceMap[record.e] = record.x;
58
+ }
59
+ }
60
+ }
61
+ return { visitorId: visitorIdV2 ?? visitorId, forceMap };
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function readPayloadExp(value: string): number | null {
68
+ try {
69
+ const payloadPart = value.includes(".") ? value.split(".")[0] : value;
70
+ const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
71
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
72
+ const parsed = JSON.parse(atob(padded)) as Record<string, unknown>;
73
+ return typeof parsed.exp === "number" ? parsed.exp : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function hostMatches(hostname: string, domains: string[]): boolean {
80
+ const normalized = hostname.toLowerCase();
81
+ return domains.some((domain) => normalized === domain || normalized.endsWith(`.${domain}`));
82
+ }
83
+
84
+ function appendHandover(url: URL, settings: ExternalTrackingSettings | undefined, payload: string): string {
85
+ url.searchParams.set(paramName(settings), payload);
86
+ return url.toString();
87
+ }
88
+
89
+ function closestLink(target: EventTarget | null): HTMLAnchorElement | null {
90
+ const candidate = target as
91
+ | {
92
+ closest?: (selector: string) => Element | null;
93
+ parentElement?: { closest?: (selector: string) => Element | null };
94
+ }
95
+ | null;
96
+ if (!candidate) return null;
97
+ const direct = typeof candidate.closest === "function"
98
+ ? candidate.closest("a[href]")
99
+ : null;
100
+ if (direct) return direct as HTMLAnchorElement;
101
+ const fromParent = typeof candidate.parentElement?.closest === "function"
102
+ ? candidate.parentElement.closest("a[href]")
103
+ : null;
104
+ return fromParent as HTMLAnchorElement | null;
105
+ }
106
+
107
+ function resumeLinkNavigation(link: HTMLAnchorElement): void {
108
+ const target = link.getAttribute("target");
109
+ if (
110
+ target
111
+ && target.toLowerCase() !== "_self"
112
+ && typeof globalThis.open === "function"
113
+ ) {
114
+ globalThis.open(link.href, target);
115
+ return;
116
+ }
117
+ // A synthetic click is the only general way to reproduce the download
118
+ // attribute, but it would re-dispatch merchant handlers. Direct navigation
119
+ // still honors server-provided Content-Disposition downloads.
120
+ globalThis.location?.assign(link.href);
121
+ }
122
+
123
+ function submissionAction(
124
+ form: HTMLFormElement,
125
+ submitter: HTMLElement | null,
126
+ ): string {
127
+ const submitterAction = submitter?.getAttribute("formaction");
128
+ if (submitterAction != null) {
129
+ return submitterAction || globalThis.location?.href || "";
130
+ }
131
+ return form.getAttribute("action") || globalThis.location?.href || "";
132
+ }
133
+
134
+ export async function readBucketingHandover(
135
+ settings: ExternalTrackingSettings | undefined,
136
+ shopId: string,
137
+ verifyToken: (token: string) => Promise<unknown>,
138
+ ): Promise<HandoverData | null> {
139
+ if (!globalThis.location || !enabledDomains(settings).length || !shopId) return null;
140
+ const value = new URL(globalThis.location.href).searchParams.get(paramName(settings));
141
+ if (!value) return null;
142
+ try {
143
+ const verified = await verifyToken(value);
144
+ return verified && typeof verified === "object"
145
+ ? decodeVerifiedPayload(verified as VerifiedHandoverPayload, shopId)
146
+ : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ export function hasBucketingHandover(settings?: ExternalTrackingSettings): boolean {
153
+ if (!globalThis.location || !enabledDomains(settings).length) return false;
154
+ return Boolean(new URL(globalThis.location.href).searchParams.get(paramName(settings)));
155
+ }
156
+
157
+ export function createBucketingHandoverPayload(
158
+ visitorId: string,
159
+ assignments: Assignment[],
160
+ settings?: ExternalTrackingSettings,
161
+ ): string {
162
+ const iat = Math.floor(Date.now() / 1000);
163
+ return encodePayload({
164
+ v: 2,
165
+ iat,
166
+ exp: iat + ttlSeconds(settings),
167
+ vid: visitorId,
168
+ asg: assignments.map((assignment) => ({
169
+ e: assignment.experimentId,
170
+ x: assignment.variationId,
171
+ })),
172
+ });
173
+ }
174
+
175
+ export function installBucketingHandover(
176
+ settings: ExternalTrackingSettings | undefined,
177
+ getPayload: () => string | null,
178
+ mintPayload?: () => Promise<string | null>,
179
+ ): () => void {
180
+ const domains = enabledDomains(settings);
181
+ if (!domains.length || !globalThis.document) return () => {};
182
+ let cachedSignedPayload: string | null = null;
183
+ let cachedBasePayload: string | null = null;
184
+ let cachedSignedPayloadExp = 0;
185
+ let mintInFlight: Promise<string | null> | null = null;
186
+ const mint = () => {
187
+ if (!mintPayload) return null;
188
+ const basePayload = getPayload();
189
+ if (!basePayload) return null;
190
+ if (!mintInFlight) {
191
+ mintInFlight = mintPayload()
192
+ .then((payload) => {
193
+ cachedSignedPayload = payload;
194
+ cachedBasePayload = payload ? basePayload : null;
195
+ cachedSignedPayloadExp = payload ? readPayloadExp(payload) ?? 0 : 0;
196
+ return payload;
197
+ })
198
+ .catch(() => null)
199
+ .finally(() => {
200
+ mintInFlight = null;
201
+ });
202
+ }
203
+ return mintInFlight;
204
+ };
205
+
206
+ const bestPayload = (): string | null => {
207
+ const basePayload = getPayload();
208
+ const now = Math.floor(Date.now() / 1000);
209
+ if (
210
+ cachedSignedPayload
211
+ && cachedBasePayload === basePayload
212
+ && cachedSignedPayloadExp > now
213
+ ) {
214
+ return cachedSignedPayload;
215
+ }
216
+ // A raw payload is only minting input. Destinations reject it by design,
217
+ // so never put it on a navigation when signing is unavailable or pending.
218
+ return null;
219
+ };
220
+
221
+ const rewriteUrl = (rawUrl: string | null | undefined): string | null => {
222
+ if (!rawUrl) return null;
223
+ try {
224
+ const url = new URL(rawUrl, globalThis.location?.href);
225
+ if (!hostMatches(url.hostname, domains)) return null;
226
+ const payload = bestPayload();
227
+ if (!payload) return null;
228
+ return appendHandover(url, settings, payload);
229
+ } catch {
230
+ return null;
231
+ }
232
+ };
233
+
234
+ const isHandoverTarget = (rawUrl: string | null | undefined): boolean => {
235
+ if (!rawUrl) return false;
236
+ try {
237
+ return hostMatches(new URL(rawUrl, globalThis.location?.href).hostname, domains);
238
+ } catch {
239
+ return false;
240
+ }
241
+ };
242
+
243
+ const handleLinkEvent = (event: Event) => {
244
+ const link = closestLink(event.target);
245
+ if (!link) return;
246
+ if (!isHandoverTarget(link.getAttribute("href"))) return;
247
+ const pending = mint();
248
+ const rewrite = () => {
249
+ const next = rewriteUrl(link.getAttribute("href"));
250
+ if (next) link.href = next;
251
+ };
252
+ if (event.type === "click" && pending && !bestPayload()) {
253
+ event.preventDefault();
254
+ void pending.then(() => {
255
+ rewrite();
256
+ resumeLinkNavigation(link);
257
+ });
258
+ return;
259
+ }
260
+ void pending?.then(rewrite);
261
+ rewrite();
262
+ };
263
+
264
+ const resubmitting = new WeakSet<HTMLFormElement>();
265
+ const handleSubmit = (event: Event) => {
266
+ const form = event.target as HTMLFormElement | null;
267
+ if (!form || form.tagName !== "FORM") return;
268
+ if (resubmitting.delete(form)) return;
269
+ const submitter = (event as SubmitEvent).submitter ?? null;
270
+ const rawAction = submissionAction(form, submitter);
271
+ if (!isHandoverTarget(rawAction)) return;
272
+ const pending = mint();
273
+ if (!pending) return;
274
+ event.preventDefault();
275
+ void pending.then(() => {
276
+ const payload = bestPayload();
277
+ const action = rewriteUrl(rawAction);
278
+ if (payload && action) {
279
+ if (submitter?.hasAttribute("formaction")) {
280
+ submitter.setAttribute("formaction", action);
281
+ } else {
282
+ form.action = action;
283
+ }
284
+ const name = paramName(settings);
285
+ let input = form.querySelector(`input[name="${name}"]`) as HTMLInputElement | null;
286
+ if (!input) {
287
+ input = document.createElement("input");
288
+ input.type = "hidden";
289
+ input.name = name;
290
+ form.appendChild(input);
291
+ }
292
+ input.value = payload;
293
+ }
294
+ resubmitting.add(form);
295
+ if (submitter) {
296
+ form.requestSubmit(submitter);
297
+ } else {
298
+ form.requestSubmit();
299
+ }
300
+ });
301
+ };
302
+
303
+ document.addEventListener("click", handleLinkEvent, true);
304
+ document.addEventListener("submit", handleSubmit, true);
305
+ return () => {
306
+ document.removeEventListener("click", handleLinkEvent, true);
307
+ document.removeEventListener("submit", handleSubmit, true);
308
+ };
309
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,214 @@
1
+ const UID_KEY = "drip_uid";
2
+
3
+ type StorageMode = "localStorage" | "sessionStorage" | "cookie" | "memory";
4
+
5
+ let storageMode: StorageMode = "localStorage";
6
+ let encodeStorageValues = false;
7
+ let cookieDomain = "";
8
+ let memoryUserId: string | null = null;
9
+
10
+ export function murmurhash3_32_gc(key: string, seed = 0): number {
11
+ let remainder = key.length & 3;
12
+ let bytes = key.length - remainder;
13
+ let h1 = seed;
14
+ let c1 = 0xcc9e2d51;
15
+ let c2 = 0x1b873593;
16
+ let i = 0;
17
+
18
+ while (i < bytes) {
19
+ let k1 =
20
+ (key.charCodeAt(i) & 0xff) |
21
+ ((key.charCodeAt(++i) & 0xff) << 8) |
22
+ ((key.charCodeAt(++i) & 0xff) << 16) |
23
+ ((key.charCodeAt(++i) & 0xff) << 24);
24
+ ++i;
25
+
26
+ k1 = Math.imul(k1, c1);
27
+ k1 = (k1 << 15) | (k1 >>> 17);
28
+ k1 = Math.imul(k1, c2);
29
+
30
+ h1 ^= k1;
31
+ h1 = (h1 << 13) | (h1 >>> 19);
32
+ h1 = Math.imul(h1, 5) + 0xe6546b64;
33
+ }
34
+
35
+ let k1 = 0;
36
+ switch (remainder) {
37
+ case 3:
38
+ k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
39
+ case 2:
40
+ k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
41
+ case 1:
42
+ k1 ^= key.charCodeAt(i) & 0xff;
43
+ k1 = Math.imul(k1, c1);
44
+ k1 = (k1 << 15) | (k1 >>> 17);
45
+ k1 = Math.imul(k1, c2);
46
+ h1 ^= k1;
47
+ }
48
+
49
+ h1 ^= key.length;
50
+ h1 ^= h1 >>> 16;
51
+ h1 = Math.imul(h1, 0x85ebca6b);
52
+ h1 ^= h1 >>> 13;
53
+ h1 = Math.imul(h1, 0xc2b2ae35);
54
+ h1 ^= h1 >>> 16;
55
+
56
+ return h1 >>> 0;
57
+ }
58
+
59
+ export function hashToFloat(seed: string, value: string): number {
60
+ const hash = murmurhash3_32_gc(seed + value, 1);
61
+ return (hash % 10000) / 10000;
62
+ }
63
+
64
+ export function variationHashToFloat(experimentId: string, value: string): number {
65
+ return hashToFloat(`${experimentId}:variation`, value);
66
+ }
67
+
68
+ function bytesToHex(bytes: Uint8Array): string {
69
+ let out = "";
70
+ for (let i = 0; i < bytes.length; i++) {
71
+ out += bytes[i].toString(16).padStart(2, "0");
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function generateId(): string {
77
+ if (globalThis.crypto && globalThis.crypto.getRandomValues) {
78
+ const bytes = new Uint8Array(16);
79
+ globalThis.crypto.getRandomValues(bytes);
80
+ return bytesToHex(bytes);
81
+ }
82
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
83
+ }
84
+
85
+ export function configureIdentityStorage(settings?: {
86
+ storageMode?: StorageMode;
87
+ encodeStorageValues?: boolean;
88
+ cookieDomain?: string;
89
+ }): void {
90
+ storageMode = settings?.storageMode ?? "localStorage";
91
+ encodeStorageValues = settings?.encodeStorageValues === true;
92
+ cookieDomain = normalizeCookieDomain(settings?.cookieDomain);
93
+ }
94
+
95
+ function normalizeCookieDomain(value: string | undefined): string {
96
+ const normalized = (value ?? "")
97
+ .trim()
98
+ .replace(/^https?:\/\//i, "")
99
+ .replace(/\/.*$/, "")
100
+ .replace(/[?#].*$/, "")
101
+ .replace(/:\d+$/, "")
102
+ .toLowerCase();
103
+ if (!normalized || normalized === "localhost" || normalized.includes(":")) return "";
104
+ const candidate = normalized.replace(/^\.+/, ".");
105
+ const labels = candidate.replace(/^\./, "").split(".");
106
+ return labels.length > 1
107
+ && labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
108
+ ? candidate
109
+ : "";
110
+ }
111
+
112
+ function encodeStoredValue(value: string): string {
113
+ if (!encodeStorageValues) return value;
114
+ try {
115
+ return "b64:" + btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
116
+ } catch {
117
+ return value;
118
+ }
119
+ }
120
+
121
+ function decodeStoredValue(value: string | null): string | null {
122
+ if (!value) return null;
123
+ if (!value.startsWith("b64:")) return value;
124
+ try {
125
+ const raw = value.slice(4).replace(/-/g, "+").replace(/_/g, "/");
126
+ const padded = raw + "=".repeat((4 - (raw.length % 4)) % 4);
127
+ return atob(padded);
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Lifetime (cross-session) storage permitted by the configured identity
135
+ * storage mode. Only localStorage mode has one; sessionStorage/memory/cookie
136
+ * modes return null so lifetime counters stay unpersisted under them.
137
+ */
138
+ export function lifetimeIdentityStorage(): Storage | null {
139
+ if (storageMode !== "localStorage") return null;
140
+ try {
141
+ return globalThis.localStorage ?? null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function browserStorage(): Storage | null {
148
+ try {
149
+ if (storageMode === "sessionStorage") return globalThis.sessionStorage ?? null;
150
+ if (storageMode === "localStorage") return globalThis.localStorage ?? null;
151
+ return null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ function readStorage(): string | null {
158
+ if (storageMode === "memory") return memoryUserId;
159
+ if (storageMode === "cookie") return null;
160
+ try {
161
+ return decodeStoredValue(browserStorage()?.getItem(UID_KEY) || null);
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ function writeStorage(value: string): void {
168
+ if (storageMode === "memory") {
169
+ memoryUserId = value;
170
+ return;
171
+ }
172
+ if (storageMode === "cookie") return;
173
+ try {
174
+ browserStorage()?.setItem(UID_KEY, encodeStoredValue(value));
175
+ } catch {
176
+ // ignore
177
+ }
178
+ }
179
+
180
+ function readCookie(): string | null {
181
+ if (!globalThis.document?.cookie) return null;
182
+ const match = globalThis.document.cookie.match(
183
+ new RegExp("(^| )" + UID_KEY + "=([^;]+)"),
184
+ );
185
+ return match ? decodeStoredValue(decodeURIComponent(match[2])) : null;
186
+ }
187
+
188
+ function writeCookie(value: string): void {
189
+ if (!globalThis.document) return;
190
+ const expires = new Date(Date.now() + 365 * 864e5).toUTCString();
191
+ const domain = cookieDomain ? "; domain=" + cookieDomain : "";
192
+ globalThis.document.cookie =
193
+ UID_KEY + "=" + encodeURIComponent(encodeStoredValue(value)) + "; expires=" + expires + "; path=/" + domain;
194
+ }
195
+
196
+ export function getUserId(provided?: string): string {
197
+ if (provided) return provided;
198
+ const existing = readStorage() || readCookie();
199
+ if (existing) return existing;
200
+ const created = generateId();
201
+ writeStorage(created);
202
+ if (storageMode !== "memory") writeCookie(created);
203
+ return created;
204
+ }
205
+
206
+ export function getEphemeralUserId(provided?: string): string {
207
+ return provided || generateId();
208
+ }
209
+
210
+ export function adoptUserId(value: string): void {
211
+ if (!value) return;
212
+ writeStorage(value);
213
+ if (storageMode !== "memory") writeCookie(value);
214
+ }