@drip-apex/sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/bundle-report.json +584 -226
- package/dist/define.cjs +123 -0
- package/dist/define.cjs.map +7 -0
- package/dist/define.mjs +102 -0
- package/dist/define.mjs.map +7 -0
- package/dist/drip.cjs +19283 -0
- package/dist/drip.cjs.map +7 -0
- package/dist/drip.debug.js +1 -1
- package/dist/drip.debug.js.map +1 -1
- package/dist/drip.heatmap.js +1 -1
- package/dist/drip.heatmap.js.map +1 -1
- package/dist/drip.js +1 -1
- package/dist/drip.js.map +1 -1
- package/dist/drip.live.js +1 -1
- package/dist/drip.live.js.map +1 -1
- package/dist/drip.mjs +19267 -0
- package/dist/drip.mjs.map +7 -0
- package/dist/drip.replay-recorder.js +2 -0
- package/dist/drip.replay-recorder.js.map +1 -0
- package/dist/editor.js +147 -142
- package/dist/editor.js.map +4 -4
- package/dist/flags.cjs +1788 -0
- package/dist/flags.cjs.map +7 -0
- package/dist/flags.mjs +1765 -0
- package/dist/flags.mjs.map +7 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +2 -2
- package/dist/metafiles/define.cjs.meta.json +28 -0
- package/dist/metafiles/define.mjs.meta.json +32 -0
- package/dist/metafiles/drip.cjs.meta.json +3193 -0
- package/dist/metafiles/drip.debug.meta.json +2859 -574
- package/dist/metafiles/drip.heatmap.meta.json +2732 -460
- package/dist/metafiles/drip.live.meta.json +2732 -460
- package/dist/metafiles/drip.meta.json +2859 -574
- package/dist/metafiles/drip.mjs.meta.json +3235 -0
- package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
- package/dist/metafiles/editor.meta.json +67 -27
- package/dist/metafiles/flags.cjs.meta.json +220 -0
- package/dist/metafiles/flags.mjs.meta.json +225 -0
- package/dist/metafiles/index.meta.json +2717 -491
- package/dist/metafiles/index.mjs.meta.json +14 -4
- package/dist/react/index.js +16636 -13533
- package/dist/react/index.js.map +4 -4
- package/dist/sri.json +20 -15
- package/dist/types/activation-runtime.d.ts +91 -0
- package/dist/types/analytics-runtime.d.ts +166 -0
- package/dist/types/antiflicker.d.ts +9 -3
- package/dist/types/assignment-cookie-sync.d.ts +31 -0
- package/dist/types/assignment-runtime.d.ts +257 -0
- package/dist/types/assignment-store.d.ts +4 -8
- package/dist/types/bucket.d.ts +1 -2
- package/dist/types/compact-store.d.ts +1 -0
- package/dist/types/config-runtime.d.ts +313 -0
- package/dist/types/consent-evaluation.d.ts +28 -0
- package/dist/types/css.d.ts +5 -3
- package/dist/types/debug-loader.d.ts +2 -0
- package/dist/types/decide-assignments.d.ts +31 -0
- package/dist/types/delivery-timing.d.ts +8 -0
- package/dist/types/early-interaction-capture.d.ts +79 -0
- package/dist/types/early-reassert.d.ts +32 -0
- package/dist/types/edge-runtime.d.ts +11 -0
- package/dist/types/editor/api.d.ts +3 -0
- package/dist/types/editor/controller.d.ts +3 -1
- package/dist/types/editor/publication-result.d.ts +11 -0
- package/dist/types/editor/types.d.ts +2 -1
- package/dist/types/editor/view-kit.d.ts +1 -1
- package/dist/types/endpoint.d.ts +8 -0
- package/dist/types/exclusion-groups.d.ts +4 -7
- package/dist/types/flags.d.ts +2 -0
- package/dist/types/goal-eligibility.d.ts +4 -0
- package/dist/types/goals.d.ts +10 -1
- package/dist/types/guards.d.ts +7 -7
- package/dist/types/handover.d.ts +12 -1
- package/dist/types/hash.d.ts +8 -4
- package/dist/types/hydration.d.ts +2 -1
- package/dist/types/index.d.ts +24 -128
- package/dist/types/init-test-helpers.d.ts +48 -0
- package/dist/types/inline-bootstrap.d.ts +26 -0
- package/dist/types/js.d.ts +3 -1
- package/dist/types/money.d.ts +12 -0
- package/dist/types/mutate.d.ts +45 -6
- package/dist/types/performance.d.ts +2 -0
- package/dist/types/persistence-decoders.d.ts +12 -0
- package/dist/types/public-api.d.ts +209 -0
- package/dist/types/qa-assistant-state.d.ts +34 -0
- package/dist/types/react/index.d.ts +28 -3
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/redirect.d.ts +48 -0
- package/dist/types/replay-recorder-entry.d.ts +1 -0
- package/dist/types/replay-signal-state.d.ts +6 -0
- package/dist/types/replay-signals.d.ts +13 -0
- package/dist/types/replay.d.ts +110 -0
- package/dist/types/router.d.ts +12 -2
- package/dist/types/runtime-state.d.ts +166 -0
- package/dist/types/sanitize.d.ts +1 -0
- package/dist/types/shadow.d.ts +3 -0
- package/dist/types/shopify-cart.d.ts +25 -1
- package/dist/types/storefront-vitals.d.ts +24 -0
- package/dist/types/targeting-diagnostics.d.ts +1 -0
- package/dist/types/targeting.d.ts +16 -2
- package/dist/types/track.d.ts +15 -1
- package/dist/types/types.d.ts +115 -9
- package/dist/types/wait.d.ts +14 -1
- package/dist/types/weekday-schedule.d.ts +3 -0
- package/package.json +17 -8
- package/src/activation-runtime.ts +2737 -0
- package/src/analytics-runtime.ts +2175 -0
- package/src/antiflicker.test.ts +216 -44
- package/src/antiflicker.ts +103 -28
- package/src/artifact-concurrency.test.ts +1091 -0
- package/src/assignment-cookie-sync.test.ts +202 -0
- package/src/assignment-cookie-sync.ts +189 -0
- package/src/assignment-runtime.ts +3204 -0
- package/src/assignment-store.test.ts +56 -0
- package/src/assignment-store.ts +7 -38
- package/src/bucket.ts +3 -18
- package/src/commerce-analytics-lifecycle.test.ts +75 -15
- package/src/commerce-config-lifecycle.test.ts +81 -1
- package/src/commerce-price-preserve.test.ts +133 -0
- package/src/commerce-price.test.ts +97 -0
- package/src/commerce-price.ts +74 -20
- package/src/commerce-shipping.test.ts +53 -0
- package/src/commerce-shipping.ts +5 -11
- package/src/compact-store.ts +15 -0
- package/src/config-fetch.test.ts +480 -9
- package/src/config-runtime.ts +2071 -0
- package/src/consent-evaluation-borlabs.test.ts +41 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +151 -0
- package/src/css.test.ts +30 -0
- package/src/css.ts +120 -6
- package/src/cwv.test.ts +60 -77
- package/src/debug-loader.test.ts +214 -0
- package/src/debug-loader.ts +41 -0
- package/src/decide-assignments.test.ts +196 -0
- package/src/decide-assignments.ts +204 -0
- package/src/delivery-timing.test.ts +104 -0
- package/src/delivery-timing.ts +89 -0
- package/src/early-interaction-capture.test.ts +451 -0
- package/src/early-interaction-capture.ts +230 -0
- package/src/early-reassert.test.ts +207 -0
- package/src/early-reassert.ts +152 -0
- package/src/edge-adoption.test.ts +435 -0
- package/src/edge-runtime.ts +30 -0
- package/src/editor/api.test.ts +109 -0
- package/src/editor/api.ts +34 -3
- package/src/editor/controller.test.ts +297 -14
- package/src/editor/controller.ts +69 -5
- package/src/editor/index.test.ts +2 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/publication-result.ts +58 -0
- package/src/editor/types.ts +10 -1
- package/src/editor/view-kit.ts +2 -1
- package/src/editor/view.test.ts +108 -0
- package/src/editor/view.ts +49 -7
- package/src/endpoint.test.ts +15 -0
- package/src/endpoint.ts +10 -0
- package/src/exclusion-groups.ts +8 -18
- package/src/flags.test.ts +88 -1
- package/src/flags.ts +29 -29
- package/src/global-runtime-reconciler.test.ts +271 -0
- package/src/goal-eligibility.test.ts +29 -0
- package/src/goal-eligibility.ts +17 -0
- package/src/goals.test.ts +144 -1
- package/src/goals.ts +97 -18
- package/src/guards.test.ts +99 -1
- package/src/guards.ts +34 -15
- package/src/handover-cache-order.test.ts +6 -3
- package/src/handover.test.ts +154 -8
- package/src/handover.ts +122 -12
- package/src/hash.ts +29 -54
- package/src/holdout.test.ts +26 -0
- package/src/hydration.test.ts +73 -2
- package/src/hydration.ts +18 -9
- package/src/index.ts +130 -7287
- package/src/init-analytics.test.ts +1694 -0
- package/src/init-anti-flicker.test.ts +1211 -0
- package/src/init-cart-attributes.test.ts +2003 -0
- package/src/init-config-resolution.test.ts +999 -0
- package/src/init-consent.test.ts +971 -0
- package/src/init-empty.test.ts +11 -5406
- package/src/init-integrations.test.ts +846 -0
- package/src/init-page-activation.test.ts +5064 -0
- package/src/init-qa-loading.test.ts +740 -0
- package/src/init-redirects.test.ts +2156 -0
- package/src/init-test-helpers.ts +287 -0
- package/src/inline-bootstrap.test.ts +184 -0
- package/src/inline-bootstrap.ts +73 -0
- package/src/js.test.ts +51 -1
- package/src/js.ts +86 -2
- package/src/money.test.ts +55 -1
- package/src/money.ts +104 -2
- package/src/money.web-parity.contract.test.ts +85 -0
- package/src/mutate.test.ts +474 -10
- package/src/mutate.ts +739 -230
- package/src/page-trigger.test.ts +218 -8
- package/src/performance.ts +10 -0
- package/src/persistence-decoders.ts +18 -0
- package/src/public-api.ts +1828 -0
- package/src/qa-assistant-state.test.ts +249 -3
- package/src/qa-assistant-state.ts +146 -6
- package/src/qa-assistant-view.test.ts +220 -2
- package/src/qa-assistant-view.ts +102 -5
- package/src/react/index.test.tsx +37 -0
- package/src/react/index.tsx +19 -3
- package/src/reassert-sentinel.test.ts +155 -1
- package/src/reassert-sentinel.ts +168 -8
- package/src/redirect.test.ts +541 -0
- package/src/redirect.ts +572 -0
- package/src/replay-recorder-entry.ts +115 -0
- package/src/replay-signal-state.ts +100 -0
- package/src/replay-signals.test.ts +249 -0
- package/src/replay-signals.ts +151 -0
- package/src/replay.test.ts +764 -0
- package/src/replay.ts +514 -0
- package/src/router.test.ts +61 -2
- package/src/router.ts +50 -12
- package/src/runtime-state.ts +507 -0
- package/src/runtime.test.ts +174 -7
- package/src/runtime.ts +88 -14
- package/src/sanitize.test.ts +34 -0
- package/src/sanitize.ts +23 -12
- package/src/shadow.test.ts +187 -0
- package/src/shadow.ts +99 -0
- package/src/shopify-cart.test.ts +733 -23
- package/src/shopify-cart.ts +402 -49
- package/src/storefront-vitals.test.ts +217 -0
- package/src/storefront-vitals.ts +206 -0
- package/src/surface-signals.ts +6 -0
- package/src/targeting-conformance.test.ts +9 -0
- package/src/targeting-diagnostics.ts +38 -8
- package/src/targeting-parity-corpus.json +559 -0
- package/src/targeting-parity-corpus.test.ts +18 -0
- package/src/targeting.detailed.test.ts +105 -0
- package/src/targeting.test.ts +211 -0
- package/src/targeting.ts +88 -207
- package/src/track.test.ts +147 -0
- package/src/track.ts +125 -26
- package/src/types.ts +120 -13
- package/src/wait.test.ts +23 -0
- package/src/wait.ts +129 -13
- package/src/weekday-delivery.test.ts +56 -0
- package/src/weekday-schedule-parity-corpus.json +92 -0
- package/src/weekday-schedule.test.ts +69 -0
- package/src/weekday-schedule.ts +20 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ASSIGNMENT_SYNC_COOKIE_MAX_BYTES,
|
|
4
|
+
ASSIGNMENT_SYNC_COOKIE_NAME,
|
|
5
|
+
clearAssignmentSyncCookie,
|
|
6
|
+
discoverRegistrableDomain,
|
|
7
|
+
encodeStoredAssignment,
|
|
8
|
+
writeAssignmentSyncCookie,
|
|
9
|
+
type AssignmentSyncPayload,
|
|
10
|
+
} from "./assignment-cookie-sync";
|
|
11
|
+
|
|
12
|
+
function decodeWithPixelFormat(raw: string): { storedAt: number; data: AssignmentSyncPayload } {
|
|
13
|
+
const normalized = raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
14
|
+
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
15
|
+
return JSON.parse(atob(padded));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function payload(assignments: AssignmentSyncPayload["assignments"] = []): AssignmentSyncPayload {
|
|
19
|
+
return {
|
|
20
|
+
visitorId: "visitor-1",
|
|
21
|
+
sessionId: "session-1",
|
|
22
|
+
qaMode: false,
|
|
23
|
+
qaSessionId: "",
|
|
24
|
+
assignments,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function installDomainAwareCookieJar(acceptedDomain?: string) {
|
|
29
|
+
const values = new Map<string, string>();
|
|
30
|
+
const writes: string[] = [];
|
|
31
|
+
const documentRef = {} as Document;
|
|
32
|
+
Object.defineProperty(documentRef, "cookie", {
|
|
33
|
+
configurable: true,
|
|
34
|
+
get: () => Array.from(values, ([name, value]) => `${name}=${value}`).join("; "),
|
|
35
|
+
set: (raw: string) => {
|
|
36
|
+
writes.push(raw);
|
|
37
|
+
const segments = raw.split(";").map((segment) => segment.trim());
|
|
38
|
+
const domain = segments.find((segment) => segment.toLowerCase().startsWith("domain="))
|
|
39
|
+
?.slice("domain=".length)
|
|
40
|
+
.replace(/^\./, "");
|
|
41
|
+
if (domain && domain !== acceptedDomain) return;
|
|
42
|
+
const separator = segments[0].indexOf("=");
|
|
43
|
+
const name = segments[0].slice(0, separator);
|
|
44
|
+
const value = segments[0].slice(separator + 1);
|
|
45
|
+
if (segments.some((segment) => segment.toLowerCase() === "max-age=0")) {
|
|
46
|
+
values.delete(name);
|
|
47
|
+
} else {
|
|
48
|
+
values.set(name, value);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return { documentRef, writes, values };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("assignment cookie sync", () => {
|
|
56
|
+
const originalDocument = globalThis.document;
|
|
57
|
+
const originalLocation = globalThis.location;
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
if (originalDocument) {
|
|
62
|
+
(globalThis as any).document = originalDocument;
|
|
63
|
+
} else {
|
|
64
|
+
delete (globalThis as any).document;
|
|
65
|
+
}
|
|
66
|
+
if (originalLocation) {
|
|
67
|
+
(globalThis as any).location = originalLocation;
|
|
68
|
+
} else {
|
|
69
|
+
delete (globalThis as any).location;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("encodes a payload in the pixel's base64url wrapper format", () => {
|
|
74
|
+
vi.spyOn(Date, "now").mockReturnValue(1_234_567);
|
|
75
|
+
const input = payload([{ experimentId: "exp-1", variationId: "var-1", assignmentEpoch: 4 }]);
|
|
76
|
+
|
|
77
|
+
expect(decodeWithPixelFormat(encodeStoredAssignment(input))).toEqual({
|
|
78
|
+
storedAt: 1_234_567,
|
|
79
|
+
data: input,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("retains the pixel's two-hour staleness boundary", () => {
|
|
84
|
+
vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
85
|
+
const stored = decodeWithPixelFormat(encodeStoredAssignment(payload()));
|
|
86
|
+
const assignmentMaxAgeMs = 2 * 60 * 60 * 1_000;
|
|
87
|
+
|
|
88
|
+
expect(10_000 + assignmentMaxAgeMs - stored.storedAt > assignmentMaxAgeMs).toBe(false);
|
|
89
|
+
expect(10_001 + assignmentMaxAgeMs - stored.storedAt > assignmentMaxAgeMs).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("discovers example.co.uk after the browser rejects co.uk", () => {
|
|
93
|
+
const { documentRef, writes } = installDomainAwareCookieJar("example.co.uk");
|
|
94
|
+
|
|
95
|
+
expect(discoverRegistrableDomain("shop.example.co.uk", documentRef)).toBe("example.co.uk");
|
|
96
|
+
expect(writes[0]).toContain("Domain=.co.uk");
|
|
97
|
+
expect(writes.some((write) => write.includes("Domain=.example.co.uk"))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it.each(["localhost", "127.0.0.1", "[::1]"])("uses a host-only cookie on %s", (hostname) => {
|
|
101
|
+
const { documentRef } = installDomainAwareCookieJar();
|
|
102
|
+
expect(discoverRegistrableDomain(hostname, documentRef)).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("writes the shared-domain cookie with the required attributes", () => {
|
|
106
|
+
const { documentRef, writes } = installDomainAwareCookieJar("example.com");
|
|
107
|
+
(globalThis as any).document = documentRef;
|
|
108
|
+
(globalThis as any).location = { hostname: "store.example.com" };
|
|
109
|
+
|
|
110
|
+
const result = writeAssignmentSyncCookie(payload([
|
|
111
|
+
{ experimentId: "exp-1", variationId: "var-1" },
|
|
112
|
+
]));
|
|
113
|
+
|
|
114
|
+
expect(result).toMatchObject({ written: true, domain: "example.com", truncatedAssignmentCount: 0 });
|
|
115
|
+
const cookieWrite = writes.find((write) => write.startsWith(`${ASSIGNMENT_SYNC_COOKIE_NAME}=`));
|
|
116
|
+
expect(cookieWrite).toMatch(/; Max-Age=7200; Path=\/; SameSite=Lax; Secure; Domain=\.example\.com$/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("drops the oldest assignments until the encoded value fits", () => {
|
|
120
|
+
const { documentRef, writes } = installDomainAwareCookieJar("example.com");
|
|
121
|
+
(globalThis as any).document = documentRef;
|
|
122
|
+
(globalThis as any).location = { hostname: "store.example.com" };
|
|
123
|
+
const assignments = Array.from({ length: 80 }, (_, index) => ({
|
|
124
|
+
experimentId: `exp-${String(index).padStart(3, "0")}-${"x".repeat(80)}`,
|
|
125
|
+
variationId: `var-${String(index).padStart(3, "0")}-${"y".repeat(80)}`,
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
const result = writeAssignmentSyncCookie(payload(assignments));
|
|
129
|
+
const cookieWrite = writes.find((write) => write.startsWith(`${ASSIGNMENT_SYNC_COOKIE_NAME}=`))!;
|
|
130
|
+
const encoded = cookieWrite.slice(cookieWrite.indexOf("=") + 1, cookieWrite.indexOf(";"));
|
|
131
|
+
const decoded = decodeWithPixelFormat(encoded);
|
|
132
|
+
|
|
133
|
+
expect(result.written).toBe(true);
|
|
134
|
+
expect(result.truncatedAssignmentCount).toBeGreaterThan(0);
|
|
135
|
+
expect(result.encodedBytes).toBeLessThanOrEqual(ASSIGNMENT_SYNC_COOKIE_MAX_BYTES);
|
|
136
|
+
expect(decoded.data.assignments).toEqual(assignments.slice(result.truncatedAssignmentCount));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("clearAssignmentSyncCookie", () => {
|
|
141
|
+
it("expires the cookie host-only and across every registrable-domain suffix", () => {
|
|
142
|
+
const writes: string[] = [];
|
|
143
|
+
const doc = {
|
|
144
|
+
get cookie() {
|
|
145
|
+
return "";
|
|
146
|
+
},
|
|
147
|
+
set cookie(value: string) {
|
|
148
|
+
writes.push(value);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const originalDocument = globalThis.document;
|
|
152
|
+
const originalLocation = globalThis.location;
|
|
153
|
+
Object.defineProperty(globalThis, "document", { value: doc, configurable: true });
|
|
154
|
+
Object.defineProperty(globalThis, "location", {
|
|
155
|
+
value: { hostname: "shop.example.co.uk" },
|
|
156
|
+
configurable: true,
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
clearAssignmentSyncCookie();
|
|
160
|
+
} finally {
|
|
161
|
+
Object.defineProperty(globalThis, "document", { value: originalDocument, configurable: true });
|
|
162
|
+
Object.defineProperty(globalThis, "location", { value: originalLocation, configurable: true });
|
|
163
|
+
}
|
|
164
|
+
expect(
|
|
165
|
+
writes.some(
|
|
166
|
+
(w) => w.startsWith(`${ASSIGNMENT_SYNC_COOKIE_NAME}=; Max-Age=0`) && !w.includes("Domain="),
|
|
167
|
+
),
|
|
168
|
+
).toBe(true);
|
|
169
|
+
expect(writes.some((w) => w.includes("Domain=.example.co.uk"))).toBe(true);
|
|
170
|
+
expect(writes.some((w) => w.includes("Domain=.co.uk"))).toBe(true);
|
|
171
|
+
expect(writes.every((w) => w.includes("Max-Age=0"))).toBe(true);
|
|
172
|
+
// Expiries must not carry `Secure`: a Secure write is dropped on http
|
|
173
|
+
// origins, which would leave the cookie behind.
|
|
174
|
+
expect(writes.every((w) => !w.includes("Secure"))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("skips the domain sweep on localhost and IP hosts", () => {
|
|
178
|
+
for (const hostname of ["localhost", "127.0.0.1"]) {
|
|
179
|
+
const writes: string[] = [];
|
|
180
|
+
const doc = {
|
|
181
|
+
get cookie() {
|
|
182
|
+
return "";
|
|
183
|
+
},
|
|
184
|
+
set cookie(value: string) {
|
|
185
|
+
writes.push(value);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const originalDocument = globalThis.document;
|
|
189
|
+
const originalLocation = globalThis.location;
|
|
190
|
+
Object.defineProperty(globalThis, "document", { value: doc, configurable: true });
|
|
191
|
+
Object.defineProperty(globalThis, "location", { value: { hostname }, configurable: true });
|
|
192
|
+
try {
|
|
193
|
+
clearAssignmentSyncCookie();
|
|
194
|
+
} finally {
|
|
195
|
+
Object.defineProperty(globalThis, "document", { value: originalDocument, configurable: true });
|
|
196
|
+
Object.defineProperty(globalThis, "location", { value: originalLocation, configurable: true });
|
|
197
|
+
}
|
|
198
|
+
expect(writes).toHaveLength(1);
|
|
199
|
+
expect(writes[0]).not.toContain("Domain=");
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
export const ASSIGNMENT_SYNC_COOKIE_NAME = "drip_assignment_sync_v1";
|
|
2
|
+
export const ASSIGNMENT_SYNC_COOKIE_MAX_AGE_SECONDS = 7_200;
|
|
3
|
+
export const ASSIGNMENT_SYNC_COOKIE_MAX_BYTES = 3_800;
|
|
4
|
+
|
|
5
|
+
export type AssignmentSyncPayload = {
|
|
6
|
+
visitorId: string | null;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
cartToken?: string;
|
|
9
|
+
qaMode: boolean;
|
|
10
|
+
qaSessionId: string;
|
|
11
|
+
cohort?: "holdout" | "active";
|
|
12
|
+
holdoutConfigEpoch?: number;
|
|
13
|
+
assignments: Array<{
|
|
14
|
+
experimentId: string;
|
|
15
|
+
variationId: string;
|
|
16
|
+
assignmentEpoch?: number;
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type AssignmentSyncCookieWriteResult = {
|
|
21
|
+
written: boolean;
|
|
22
|
+
assignmentCount: number;
|
|
23
|
+
truncatedAssignmentCount: number;
|
|
24
|
+
encodedBytes: number;
|
|
25
|
+
domain?: string;
|
|
26
|
+
reason?: "document_unavailable" | "visitor_id_missing" | "cookie_too_large" | "cookie_write_failed";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type CookieDocument = Pick<Document, "cookie">;
|
|
30
|
+
|
|
31
|
+
function encodeStoredAssignmentAt(data: AssignmentSyncPayload, storedAt: number): string {
|
|
32
|
+
return btoa(JSON.stringify({ storedAt, data }))
|
|
33
|
+
.replace(/\+/g, "-")
|
|
34
|
+
.replace(/\//g, "_")
|
|
35
|
+
.replace(/=+$/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function encodeStoredAssignment(data: AssignmentSyncPayload): string {
|
|
39
|
+
return encodeStoredAssignmentAt(data, Date.now());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasCookie(documentRef: CookieDocument, name: string, value: string): boolean {
|
|
43
|
+
return documentRef.cookie
|
|
44
|
+
.split(";")
|
|
45
|
+
.some((part) => part.trim() === `${name}=${value}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isIpv4(hostname: string): boolean {
|
|
49
|
+
const parts = hostname.split(".");
|
|
50
|
+
return parts.length === 4 && parts.every((part) => {
|
|
51
|
+
if (!/^\d{1,3}$/.test(part)) return false;
|
|
52
|
+
const value = Number(part);
|
|
53
|
+
return value >= 0 && value <= 255;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isIpHost(hostname: string): boolean {
|
|
58
|
+
const unbracketed = hostname.replace(/^\[|\]$/g, "");
|
|
59
|
+
return isIpv4(unbracketed) || unbracketed.includes(":");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cookieAttributes(domain?: string): string {
|
|
63
|
+
return `Path=/; SameSite=Lax; Secure${domain ? `; Domain=.${domain}` : ""}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function discoverRegistrableDomain(
|
|
67
|
+
hostname: string,
|
|
68
|
+
documentRef: CookieDocument,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
const normalized = hostname.trim().replace(/\.$/, "").toLowerCase();
|
|
71
|
+
if (!normalized || normalized === "localhost" || normalized.endsWith(".localhost") || isIpHost(normalized)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const labels = normalized.split(".").filter(Boolean);
|
|
76
|
+
if (labels.length < 2) return undefined;
|
|
77
|
+
|
|
78
|
+
const probeName = "__drip_assignment_domain_probe";
|
|
79
|
+
const probeValue = Date.now().toString(36);
|
|
80
|
+
for (let start = labels.length - 2; start >= 0; start -= 1) {
|
|
81
|
+
const candidate = labels.slice(start).join(".");
|
|
82
|
+
const attributes = cookieAttributes(candidate);
|
|
83
|
+
try {
|
|
84
|
+
documentRef.cookie = `${probeName}=${probeValue}; Max-Age=5; ${attributes}`;
|
|
85
|
+
const accepted = hasCookie(documentRef, probeName, probeValue);
|
|
86
|
+
documentRef.cookie = `${probeName}=; Max-Age=0; ${attributes}`;
|
|
87
|
+
if (accepted) return candidate;
|
|
88
|
+
} catch {
|
|
89
|
+
// Try the next longer suffix when this candidate is not writable.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function writeAssignmentSyncCookie(
|
|
96
|
+
payload: AssignmentSyncPayload,
|
|
97
|
+
): AssignmentSyncCookieWriteResult {
|
|
98
|
+
const originalAssignmentCount = payload.assignments.length;
|
|
99
|
+
if (typeof document === "undefined" || typeof location === "undefined") {
|
|
100
|
+
return {
|
|
101
|
+
written: false,
|
|
102
|
+
assignmentCount: originalAssignmentCount,
|
|
103
|
+
truncatedAssignmentCount: 0,
|
|
104
|
+
encodedBytes: 0,
|
|
105
|
+
reason: "document_unavailable",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (!payload.visitorId) {
|
|
109
|
+
return {
|
|
110
|
+
written: false,
|
|
111
|
+
assignmentCount: originalAssignmentCount,
|
|
112
|
+
truncatedAssignmentCount: 0,
|
|
113
|
+
encodedBytes: 0,
|
|
114
|
+
reason: "visitor_id_missing",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const storedAt = Date.now();
|
|
120
|
+
let assignments = payload.assignments.slice();
|
|
121
|
+
let encoded = encodeStoredAssignmentAt({ ...payload, assignments }, storedAt);
|
|
122
|
+
while (encoded.length > ASSIGNMENT_SYNC_COOKIE_MAX_BYTES && assignments.length > 0) {
|
|
123
|
+
assignments = assignments.slice(1);
|
|
124
|
+
encoded = encodeStoredAssignmentAt({ ...payload, assignments }, storedAt);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const truncatedAssignmentCount = originalAssignmentCount - assignments.length;
|
|
128
|
+
if (encoded.length > ASSIGNMENT_SYNC_COOKIE_MAX_BYTES) {
|
|
129
|
+
return {
|
|
130
|
+
written: false,
|
|
131
|
+
assignmentCount: assignments.length,
|
|
132
|
+
truncatedAssignmentCount,
|
|
133
|
+
encodedBytes: encoded.length,
|
|
134
|
+
reason: "cookie_too_large",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const domain = discoverRegistrableDomain(location.hostname, document);
|
|
139
|
+
document.cookie = `${ASSIGNMENT_SYNC_COOKIE_NAME}=${encoded}; Max-Age=${ASSIGNMENT_SYNC_COOKIE_MAX_AGE_SECONDS}; ${cookieAttributes(domain)}`;
|
|
140
|
+
if (!hasCookie(document, ASSIGNMENT_SYNC_COOKIE_NAME, encoded)) {
|
|
141
|
+
return {
|
|
142
|
+
written: false,
|
|
143
|
+
assignmentCount: assignments.length,
|
|
144
|
+
truncatedAssignmentCount,
|
|
145
|
+
encodedBytes: encoded.length,
|
|
146
|
+
reason: "cookie_write_failed",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
written: true,
|
|
151
|
+
assignmentCount: assignments.length,
|
|
152
|
+
truncatedAssignmentCount,
|
|
153
|
+
encodedBytes: encoded.length,
|
|
154
|
+
...(domain ? { domain } : {}),
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
return {
|
|
158
|
+
written: false,
|
|
159
|
+
assignmentCount: originalAssignmentCount,
|
|
160
|
+
truncatedAssignmentCount: 0,
|
|
161
|
+
encodedBytes: 0,
|
|
162
|
+
reason: "cookie_write_failed",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function clearAssignmentSyncCookie(): void {
|
|
168
|
+
if (typeof document === "undefined" || typeof location === "undefined") return;
|
|
169
|
+
try {
|
|
170
|
+
// No `Secure` on the expiry: deletion matches name+domain+path only, and a
|
|
171
|
+
// Secure-attributed write would be dropped on http origins, leaving the
|
|
172
|
+
// cookie behind exactly where the purge matters.
|
|
173
|
+
const expire = `${ASSIGNMENT_SYNC_COOKIE_NAME}=; Max-Age=0; Path=/; SameSite=Lax`;
|
|
174
|
+
// Host-only first, then every candidate registrable-domain suffix. Deleting
|
|
175
|
+
// with a Domain the cookie was never set on is a no-op, so sweeping all
|
|
176
|
+
// suffixes removes the cookie without re-probing which one was used.
|
|
177
|
+
document.cookie = expire;
|
|
178
|
+
const normalized = location.hostname.trim().replace(/\.$/, "").toLowerCase();
|
|
179
|
+
if (!normalized || normalized === "localhost" || normalized.endsWith(".localhost") || isIpHost(normalized)) {
|
|
180
|
+
return; // Host-only expiry is sufficient; the write path never sets Domain here.
|
|
181
|
+
}
|
|
182
|
+
const labels = normalized.split(".").filter(Boolean);
|
|
183
|
+
for (let start = labels.length - 2; start >= 0; start -= 1) {
|
|
184
|
+
document.cookie = `${expire}; Domain=.${labels.slice(start).join(".")}`;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Best effort: consent purge must never throw.
|
|
188
|
+
}
|
|
189
|
+
}
|