@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.
- 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 +19262 -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 +19246 -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 +16621 -13539
- 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 +27 -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 +2070 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +138 -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
package/src/replay.ts
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { normalizeEndpoint } from "./endpoint";
|
|
2
|
+
import {
|
|
3
|
+
REPLAY_CONTRACT_VERSION,
|
|
4
|
+
REPLAY_MAX_BLOCKS,
|
|
5
|
+
REPLAY_MAX_PAYLOAD_B64_CHARS,
|
|
6
|
+
} from "@drip/event-schema/replay-contract";
|
|
7
|
+
import type { Assignment, Experiment } from "./types";
|
|
8
|
+
import { normalizeReplaySignalState } from "./replay-signal-state";
|
|
9
|
+
|
|
10
|
+
export const REPLAY_RECORDER_FILENAME = "drip.replay-recorder.js";
|
|
11
|
+
export const REPLAY_FLUSH_INTERVAL_MS = 10_000;
|
|
12
|
+
export const REPLAY_UNCOMPRESSED_BLOCK_BYTES = 512 * 1024;
|
|
13
|
+
export const REPLAY_MAX_DURATION_MS = 15 * 60 * 1000;
|
|
14
|
+
export const REPLAY_MAX_COMPRESSED_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
export const REPLAY_SAMPLE_KEY_PREFIX = "drip_rp:";
|
|
16
|
+
export const REPLAY_PROGRESS_KEY_PREFIX = "drip_replay_progress:";
|
|
17
|
+
|
|
18
|
+
const REPLAY_SCRIPT_ID = "drip-replay-recorder";
|
|
19
|
+
// Shopify and common hosted checkout paths stay excluded until a later unit
|
|
20
|
+
// adds an explicit checkout policy.
|
|
21
|
+
export const REPLAY_EXCLUDED_CHECKOUT_PATHS = [
|
|
22
|
+
"/checkout",
|
|
23
|
+
"/checkouts/",
|
|
24
|
+
"/cart/checkout",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type ReplayAssignment = {
|
|
28
|
+
experiment_id: string;
|
|
29
|
+
variation_id: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ReplayEvent = {
|
|
33
|
+
type?: number;
|
|
34
|
+
timestamp?: number;
|
|
35
|
+
data?: Record<string, unknown>;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ReplayRecorderEmission = {
|
|
40
|
+
event?: ReplayEvent;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ReplayRecorderApi = {
|
|
44
|
+
start: (options: {
|
|
45
|
+
emit: (emission: ReplayRecorderEmission) => void;
|
|
46
|
+
signalState?: ReplaySignals;
|
|
47
|
+
}) => () => void;
|
|
48
|
+
compress: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
49
|
+
compressSync: (bytes: Uint8Array) => Uint8Array;
|
|
50
|
+
takeSignals: () => ReplaySignals;
|
|
51
|
+
version: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ReplayClickSignal = {
|
|
55
|
+
ts: number;
|
|
56
|
+
selector: string;
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
mut_ms?: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type ReplaySignals = {
|
|
63
|
+
clicks: ReplayClickSignal[];
|
|
64
|
+
nav: Array<{ ts: number; url: string }>;
|
|
65
|
+
errors: Array<{ ts: number; message: string }>;
|
|
66
|
+
form_focus: Array<{ ts: number; selector: string }>;
|
|
67
|
+
form_submit: Array<{ ts: number; selector: string }>;
|
|
68
|
+
scroll_max_pct: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type ReplayEnvelope = {
|
|
72
|
+
contract: 1;
|
|
73
|
+
shop_id: string;
|
|
74
|
+
visitor_id: string;
|
|
75
|
+
session_id: string;
|
|
76
|
+
seq: number;
|
|
77
|
+
first_ts: number;
|
|
78
|
+
last_ts: number;
|
|
79
|
+
event_count: number;
|
|
80
|
+
url: string;
|
|
81
|
+
environment: string;
|
|
82
|
+
qa_mode: 0 | 1;
|
|
83
|
+
qa_session_id?: string;
|
|
84
|
+
sdk_version: string;
|
|
85
|
+
assignments: ReplayAssignment[];
|
|
86
|
+
codec: "deflate";
|
|
87
|
+
payload_b64: string;
|
|
88
|
+
signals: ReplaySignals;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type ReplayController = {
|
|
92
|
+
stop: (discard?: boolean) => void;
|
|
93
|
+
isStopped: () => boolean;
|
|
94
|
+
update: (assignments: Assignment[], experiments: Experiment[]) => void;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type ReplayControllerOptions = {
|
|
98
|
+
assignments: Assignment[];
|
|
99
|
+
experiments: Experiment[];
|
|
100
|
+
recorderUrl: string;
|
|
101
|
+
endpoint: string;
|
|
102
|
+
shopId: string;
|
|
103
|
+
visitorId: string;
|
|
104
|
+
sessionId: string;
|
|
105
|
+
environment: string;
|
|
106
|
+
qaMode: boolean;
|
|
107
|
+
qaSessionId?: string;
|
|
108
|
+
sdkVersion: string;
|
|
109
|
+
ingestSignature?: string;
|
|
110
|
+
isTrackingBlocked: () => boolean;
|
|
111
|
+
loadRecorder?: (url: string) => Promise<ReplayRecorderApi>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function clamp(value: number, min: number, max: number): number {
|
|
115
|
+
return Math.max(min, Math.min(max, value));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function normalizeReplaySampleRate(value: unknown): number {
|
|
119
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
120
|
+
? clamp(value, 0, 1)
|
|
121
|
+
: 0.05;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function replaySampleKey(assignment: Assignment): string {
|
|
125
|
+
return `${REPLAY_SAMPLE_KEY_PREFIX}${assignment.experimentId}:${assignment.variationId}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sampledIn(assignment: Assignment, sampleRate: number): boolean {
|
|
129
|
+
try {
|
|
130
|
+
const key = replaySampleKey(assignment);
|
|
131
|
+
const existing = sessionStorage.getItem(key);
|
|
132
|
+
if (existing === "1") return true;
|
|
133
|
+
if (existing === "0") return false;
|
|
134
|
+
const selected = sampleRate >= 1
|
|
135
|
+
? true
|
|
136
|
+
: sampleRate <= 0
|
|
137
|
+
? false
|
|
138
|
+
: Math.random() < sampleRate;
|
|
139
|
+
sessionStorage.setItem(key, selected ? "1" : "0");
|
|
140
|
+
return selected;
|
|
141
|
+
} catch {
|
|
142
|
+
if (sampleRate >= 1) return true;
|
|
143
|
+
if (sampleRate <= 0) return false;
|
|
144
|
+
return Math.random() < sampleRate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function resolveReplayAssignments(
|
|
149
|
+
assignments: Assignment[],
|
|
150
|
+
experiments: Experiment[],
|
|
151
|
+
): ReplayAssignment[] {
|
|
152
|
+
const experimentsById = new Map(experiments.map((experiment) => [experiment.id, experiment]));
|
|
153
|
+
return assignments.flatMap((assignment) => {
|
|
154
|
+
const capture = experimentsById.get(assignment.experimentId)?.replayCapture;
|
|
155
|
+
if (!capture?.enabled) return [];
|
|
156
|
+
if (!sampledIn(assignment, normalizeReplaySampleRate(capture.sampleRate))) return [];
|
|
157
|
+
return [{
|
|
158
|
+
experiment_id: assignment.experimentId,
|
|
159
|
+
variation_id: assignment.variationId,
|
|
160
|
+
}];
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isReplayCheckoutPath(pathname: string): boolean {
|
|
165
|
+
const path = pathname.toLowerCase();
|
|
166
|
+
return REPLAY_EXCLUDED_CHECKOUT_PATHS.some((excluded) =>
|
|
167
|
+
excluded.endsWith("/") ? path.startsWith(excluded) : path === excluded || path.startsWith(`${excluded}/`),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function resolveReplayRecorderUrl(input: {
|
|
172
|
+
configuredUrl?: string;
|
|
173
|
+
scriptSrc?: string;
|
|
174
|
+
pageUrl?: string;
|
|
175
|
+
}): string | null {
|
|
176
|
+
if (input.configuredUrl) {
|
|
177
|
+
try {
|
|
178
|
+
const configuredUrl = new URL(input.configuredUrl);
|
|
179
|
+
if (configuredUrl.protocol === "http:" || configuredUrl.protocol === "https:") {
|
|
180
|
+
return configuredUrl.toString();
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
}
|
|
184
|
+
if (!input.scriptSrc) return null;
|
|
185
|
+
try {
|
|
186
|
+
const scriptUrl = new URL(input.scriptSrc, input.pageUrl);
|
|
187
|
+
if (/^\/sdk\/[^/]+\/[^/]+\.js$/.test(scriptUrl.pathname)) {
|
|
188
|
+
scriptUrl.pathname = scriptUrl.pathname.replace(/[^/]+\.js$/, REPLAY_RECORDER_FILENAME);
|
|
189
|
+
} else if (/^\/drip(?:\.[A-Za-z0-9_-]+)*\.js$/.test(scriptUrl.pathname)) {
|
|
190
|
+
scriptUrl.pathname = scriptUrl.pathname.replace(/[^/]+\.js$/, REPLAY_RECORDER_FILENAME);
|
|
191
|
+
} else {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
scriptUrl.search = "";
|
|
195
|
+
scriptUrl.hash = "";
|
|
196
|
+
return scriptUrl.toString();
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
type ReplayRecorderGlobal = typeof globalThis & {
|
|
203
|
+
__dripReplayRecorder?: ReplayRecorderApi;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export function loadReplayRecorder(
|
|
207
|
+
recorderUrl: string,
|
|
208
|
+
documentLike: Document = document,
|
|
209
|
+
): Promise<ReplayRecorderApi> {
|
|
210
|
+
const existingApi = (globalThis as ReplayRecorderGlobal).__dripReplayRecorder;
|
|
211
|
+
if (existingApi?.start) return Promise.resolve(existingApi);
|
|
212
|
+
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
documentLike.getElementById(REPLAY_SCRIPT_ID)?.remove();
|
|
215
|
+
const script = documentLike.createElement("script");
|
|
216
|
+
script.id = REPLAY_SCRIPT_ID;
|
|
217
|
+
script.src = recorderUrl;
|
|
218
|
+
script.async = true;
|
|
219
|
+
script.crossOrigin = "anonymous";
|
|
220
|
+
script.onload = () => {
|
|
221
|
+
const api = (globalThis as ReplayRecorderGlobal).__dripReplayRecorder;
|
|
222
|
+
if (!api || typeof api.start !== "function") {
|
|
223
|
+
script.remove();
|
|
224
|
+
reject(new Error("Replay recorder did not expose a start function"));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
resolve(api);
|
|
228
|
+
};
|
|
229
|
+
script.onerror = () => {
|
|
230
|
+
script.remove();
|
|
231
|
+
reject(new Error("Replay recorder failed to load"));
|
|
232
|
+
};
|
|
233
|
+
(documentLike.head || documentLike.documentElement).appendChild(script);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function eventTimestamp(event: ReplayEvent | undefined, fallback: number): number {
|
|
238
|
+
return typeof event?.timestamp === "number" && Number.isFinite(event.timestamp)
|
|
239
|
+
? event.timestamp
|
|
240
|
+
: fallback;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
244
|
+
let binary = "";
|
|
245
|
+
for (let offset = 0; offset < bytes.length; offset += 32_768) {
|
|
246
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + 32_768));
|
|
247
|
+
}
|
|
248
|
+
return btoa(binary);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function replayEndpoint(endpoint: string): string {
|
|
252
|
+
return normalizeEndpoint(endpoint) + "/replay-blocks";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
type BufferedReplayBlock = {
|
|
256
|
+
seq: number;
|
|
257
|
+
events: ReplayEvent[];
|
|
258
|
+
signals: ReplaySignals;
|
|
259
|
+
url: string;
|
|
260
|
+
assignments: ReplayAssignment[];
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
type ReplayProgress = ReplaySignals & {
|
|
264
|
+
nextSeq: number;
|
|
265
|
+
totalCompressedBytes: number;
|
|
266
|
+
elapsedMs: number;
|
|
267
|
+
terminal: boolean;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
function finiteNonnegativeInteger(value: unknown): number {
|
|
271
|
+
return Number.isFinite(value) && (value as number) >= 0 ? Math.floor(value as number) : 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readReplayProgress(key: string): ReplayProgress {
|
|
275
|
+
let stored: Record<string, unknown> = {};
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(sessionStorage.getItem(key) ?? "{}");
|
|
278
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
279
|
+
stored = parsed as Record<string, unknown>;
|
|
280
|
+
}
|
|
281
|
+
} catch {}
|
|
282
|
+
const signalState = normalizeReplaySignalState(stored);
|
|
283
|
+
return {
|
|
284
|
+
nextSeq: finiteNonnegativeInteger(stored.nextSeq),
|
|
285
|
+
totalCompressedBytes: finiteNonnegativeInteger(stored.totalCompressedBytes),
|
|
286
|
+
elapsedMs: finiteNonnegativeInteger(stored.elapsedMs),
|
|
287
|
+
terminal: stored.terminal === true,
|
|
288
|
+
...signalState,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function startReplayCapture(
|
|
293
|
+
options: ReplayControllerOptions,
|
|
294
|
+
): Promise<ReplayController | null> {
|
|
295
|
+
if (options.isTrackingBlocked()) return null;
|
|
296
|
+
if (typeof location === "undefined" || isReplayCheckoutPath(location.pathname)) return null;
|
|
297
|
+
let activeAssignments = resolveReplayAssignments(options.assignments, options.experiments);
|
|
298
|
+
if (activeAssignments.length === 0) return null;
|
|
299
|
+
|
|
300
|
+
const progressKey = `${REPLAY_PROGRESS_KEY_PREFIX}${options.sessionId}`;
|
|
301
|
+
const progress = readReplayProgress(progressKey);
|
|
302
|
+
const writeProgress = () => {
|
|
303
|
+
try { sessionStorage.setItem(progressKey, JSON.stringify(progress)); } catch {}
|
|
304
|
+
};
|
|
305
|
+
if (
|
|
306
|
+
progress.terminal
|
|
307
|
+
|| progress.nextSeq >= REPLAY_MAX_BLOCKS
|
|
308
|
+
|| progress.totalCompressedBytes >= REPLAY_MAX_COMPRESSED_BYTES
|
|
309
|
+
|| progress.elapsedMs >= REPLAY_MAX_DURATION_MS
|
|
310
|
+
) {
|
|
311
|
+
progress.terminal = true;
|
|
312
|
+
writeProgress();
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const recorder = await (options.loadRecorder ?? loadReplayRecorder)(options.recorderUrl);
|
|
317
|
+
if (options.isTrackingBlocked() || isReplayCheckoutPath(location.pathname)) return null;
|
|
318
|
+
|
|
319
|
+
const encoder = new TextEncoder();
|
|
320
|
+
let bufferedEvents: ReplayEvent[] = [];
|
|
321
|
+
let bufferedBytes = 2;
|
|
322
|
+
let bufferedUrl = location.href.slice(0, 2_000);
|
|
323
|
+
let stopped = false;
|
|
324
|
+
let discarded = false;
|
|
325
|
+
let flushChain = Promise.resolve();
|
|
326
|
+
const pageStartedAt = Date.now();
|
|
327
|
+
const elapsedBeforePage = progress.elapsedMs;
|
|
328
|
+
|
|
329
|
+
const persistProgress = (terminal = progress.terminal) => {
|
|
330
|
+
progress.elapsedMs = Math.min(
|
|
331
|
+
REPLAY_MAX_DURATION_MS,
|
|
332
|
+
elapsedBeforePage + Math.max(0, Date.now() - pageStartedAt),
|
|
333
|
+
);
|
|
334
|
+
progress.terminal = terminal;
|
|
335
|
+
writeProgress();
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const postBlock = async (block: BufferedReplayBlock, sync: boolean) => {
|
|
339
|
+
if (discarded) return;
|
|
340
|
+
if (options.isTrackingBlocked()) {
|
|
341
|
+
stop(true);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const raw = encoder.encode(JSON.stringify(block.events));
|
|
345
|
+
const compressed = sync ? recorder.compressSync(raw) : await recorder.compress(raw);
|
|
346
|
+
if (discarded) return;
|
|
347
|
+
if (options.isTrackingBlocked()) {
|
|
348
|
+
stop(true);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const payloadB64 = bytesToBase64(compressed);
|
|
352
|
+
if (payloadB64.length > REPLAY_MAX_PAYLOAD_B64_CHARS) {
|
|
353
|
+
stop(true);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (progress.totalCompressedBytes + compressed.length > REPLAY_MAX_COMPRESSED_BYTES) {
|
|
357
|
+
stopForBudget();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
progress.totalCompressedBytes += compressed.length;
|
|
361
|
+
persistProgress();
|
|
362
|
+
const firstTs = eventTimestamp(block.events[0], Date.now());
|
|
363
|
+
const lastTs = eventTimestamp(block.events[block.events.length - 1], firstTs);
|
|
364
|
+
const envelope: ReplayEnvelope = {
|
|
365
|
+
contract: REPLAY_CONTRACT_VERSION,
|
|
366
|
+
shop_id: options.shopId,
|
|
367
|
+
visitor_id: options.visitorId,
|
|
368
|
+
session_id: options.sessionId,
|
|
369
|
+
seq: block.seq,
|
|
370
|
+
first_ts: firstTs,
|
|
371
|
+
last_ts: lastTs,
|
|
372
|
+
event_count: block.events.length,
|
|
373
|
+
url: block.url,
|
|
374
|
+
environment: options.environment,
|
|
375
|
+
qa_mode: options.qaMode ? 1 : 0,
|
|
376
|
+
...(options.qaMode && options.qaSessionId ? { qa_session_id: options.qaSessionId } : {}),
|
|
377
|
+
sdk_version: options.sdkVersion,
|
|
378
|
+
assignments: block.assignments,
|
|
379
|
+
codec: "deflate",
|
|
380
|
+
payload_b64: payloadB64,
|
|
381
|
+
signals: block.signals,
|
|
382
|
+
};
|
|
383
|
+
const body = JSON.stringify(envelope);
|
|
384
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
385
|
+
if (options.shopId) headers["X-Drip-Shop"] = options.shopId;
|
|
386
|
+
if (options.ingestSignature) headers["X-Drip-Signature"] = options.ingestSignature;
|
|
387
|
+
try {
|
|
388
|
+
await fetch(replayEndpoint(options.endpoint), {
|
|
389
|
+
method: "POST",
|
|
390
|
+
headers,
|
|
391
|
+
body,
|
|
392
|
+
keepalive: sync && encoder.encode(body).length < 60_000,
|
|
393
|
+
});
|
|
394
|
+
} catch {}
|
|
395
|
+
if (progress.totalCompressedBytes >= REPLAY_MAX_COMPRESSED_BYTES) stop();
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const takeBlock = (): BufferedReplayBlock | null => {
|
|
399
|
+
if (!bufferedEvents.length || progress.nextSeq >= REPLAY_MAX_BLOCKS) return null;
|
|
400
|
+
const signals = recorder.takeSignals();
|
|
401
|
+
const block = {
|
|
402
|
+
seq: progress.nextSeq,
|
|
403
|
+
events: bufferedEvents,
|
|
404
|
+
signals,
|
|
405
|
+
url: bufferedUrl,
|
|
406
|
+
assignments: [...activeAssignments],
|
|
407
|
+
};
|
|
408
|
+
progress.nextSeq += 1;
|
|
409
|
+
Object.assign(progress, normalizeReplaySignalState(signals));
|
|
410
|
+
persistProgress();
|
|
411
|
+
bufferedEvents = [];
|
|
412
|
+
bufferedBytes = 2;
|
|
413
|
+
bufferedUrl = location.href.slice(0, 2_000);
|
|
414
|
+
return block;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const flush = (sync = false) => {
|
|
418
|
+
const block = takeBlock();
|
|
419
|
+
if (!block) {
|
|
420
|
+
if (progress.nextSeq >= REPLAY_MAX_BLOCKS) stop();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (sync) {
|
|
424
|
+
void postBlock(block, true);
|
|
425
|
+
} else {
|
|
426
|
+
flushChain = flushChain
|
|
427
|
+
.then(() => postBlock(block, false))
|
|
428
|
+
.catch(() => undefined);
|
|
429
|
+
}
|
|
430
|
+
if (progress.nextSeq >= REPLAY_MAX_BLOCKS) stop();
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const onEmission = (emission: ReplayRecorderEmission) => {
|
|
434
|
+
if (stopped || discarded || !emission.event) return;
|
|
435
|
+
if (options.isTrackingBlocked() || isReplayCheckoutPath(location.pathname)) {
|
|
436
|
+
stop(true);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (!bufferedEvents.length) bufferedUrl = location.href.slice(0, 2_000);
|
|
440
|
+
bufferedBytes += encoder.encode(JSON.stringify(emission.event)).length
|
|
441
|
+
+ (bufferedEvents.length ? 1 : 0);
|
|
442
|
+
bufferedEvents.push(emission.event);
|
|
443
|
+
if (bufferedBytes >= REPLAY_UNCOMPRESSED_BLOCK_BYTES) flush();
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
let stopRecorder = recorder.start({
|
|
447
|
+
emit: onEmission,
|
|
448
|
+
signalState: normalizeReplaySignalState(progress),
|
|
449
|
+
});
|
|
450
|
+
Object.assign(progress, normalizeReplaySignalState(recorder.takeSignals()));
|
|
451
|
+
persistProgress();
|
|
452
|
+
const interval = window.setInterval(() => flush(), REPLAY_FLUSH_INTERVAL_MS);
|
|
453
|
+
const duration = window.setTimeout(() => {
|
|
454
|
+
flush();
|
|
455
|
+
stop();
|
|
456
|
+
}, REPLAY_MAX_DURATION_MS - progress.elapsedMs);
|
|
457
|
+
const onPageLifecycle = () => {
|
|
458
|
+
persistProgress();
|
|
459
|
+
flush(true);
|
|
460
|
+
};
|
|
461
|
+
const onVisibilityChange = () => {
|
|
462
|
+
if (document.visibilityState === "hidden") {
|
|
463
|
+
persistProgress();
|
|
464
|
+
flush(true);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
window.addEventListener("pagehide", onPageLifecycle);
|
|
468
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
469
|
+
|
|
470
|
+
function stopCapture(discard: boolean, terminal: boolean): void {
|
|
471
|
+
if (discard) {
|
|
472
|
+
discarded = true;
|
|
473
|
+
bufferedEvents = [];
|
|
474
|
+
bufferedBytes = 2;
|
|
475
|
+
}
|
|
476
|
+
if (stopped) return;
|
|
477
|
+
stopped = true;
|
|
478
|
+
if (!discard || terminal) persistProgress(terminal);
|
|
479
|
+
window.clearInterval(interval);
|
|
480
|
+
window.clearTimeout(duration);
|
|
481
|
+
window.removeEventListener("pagehide", onPageLifecycle);
|
|
482
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
483
|
+
stopRecorder();
|
|
484
|
+
stopRecorder = () => {};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function stopForBudget(): void {
|
|
488
|
+
stopCapture(true, true);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function stop(discard = false): void {
|
|
492
|
+
stopCapture(discard, discard ? progress.terminal : true);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
stop,
|
|
497
|
+
isStopped: () => stopped,
|
|
498
|
+
update(assignments, experiments) {
|
|
499
|
+
if (options.isTrackingBlocked()) {
|
|
500
|
+
stop(true);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const known = new Set(activeAssignments.map((assignment) =>
|
|
504
|
+
`${assignment.experiment_id}:${assignment.variation_id}`));
|
|
505
|
+
for (const assignment of resolveReplayAssignments(assignments, experiments)) {
|
|
506
|
+
const key = `${assignment.experiment_id}:${assignment.variation_id}`;
|
|
507
|
+
if (!known.has(key)) {
|
|
508
|
+
known.add(key);
|
|
509
|
+
activeAssignments.push(assignment);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
package/src/router.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { observeRouteChanges } from "./router";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { observePollingChanges, observeRouteChanges } from "./router";
|
|
3
3
|
|
|
4
4
|
describe("route observer", () => {
|
|
5
5
|
const originalWindow = (globalThis as any).window;
|
|
@@ -40,15 +40,53 @@ describe("route observer", () => {
|
|
|
40
40
|
removeEventListener(type: string, listener: () => void) {
|
|
41
41
|
listeners.get(type)?.delete(listener);
|
|
42
42
|
},
|
|
43
|
+
clearInterval(id: number) { globalThis.clearInterval(id); },
|
|
44
|
+
setInterval(callback: TimerHandler, ms?: number) {
|
|
45
|
+
return globalThis.setInterval(callback, ms);
|
|
46
|
+
},
|
|
43
47
|
};
|
|
44
48
|
});
|
|
45
49
|
|
|
46
50
|
afterEach(() => {
|
|
51
|
+
vi.clearAllTimers();
|
|
52
|
+
vi.useRealTimers();
|
|
47
53
|
(globalThis as any).window = originalWindow;
|
|
48
54
|
(globalThis as any).location = originalLocation;
|
|
49
55
|
(globalThis as any).history = originalHistory;
|
|
50
56
|
});
|
|
51
57
|
|
|
58
|
+
it("observes URL changes without a History event and stops after unsubscribe", async () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
const events: string[] = [];
|
|
61
|
+
const stop = observePollingChanges((event) => events.push(event.nextUrl), 25);
|
|
62
|
+
|
|
63
|
+
setLocation("https://example.test/collections/all");
|
|
64
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
65
|
+
expect(events).toEqual(["https://example.test/collections/all"]);
|
|
66
|
+
|
|
67
|
+
stop();
|
|
68
|
+
setLocation("https://example.test/pages/about");
|
|
69
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
70
|
+
expect(events).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("shares URL comparison between History and polling detectors", async () => {
|
|
74
|
+
vi.useFakeTimers();
|
|
75
|
+
const events: string[] = [];
|
|
76
|
+
const stop = observeRouteChanges(
|
|
77
|
+
"history",
|
|
78
|
+
(event) => events.push(event.reason),
|
|
79
|
+
{ pollingIntervalMs: 25 },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
setLocation("https://example.test/collections/all");
|
|
83
|
+
(globalThis as any).history.pushState({}, "", "/collections/all");
|
|
84
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
85
|
+
|
|
86
|
+
expect(events).toEqual(["pushState"]);
|
|
87
|
+
stop();
|
|
88
|
+
});
|
|
89
|
+
|
|
52
90
|
it("observes pure hashchange events when history does not emit popstate", () => {
|
|
53
91
|
const events: Array<{
|
|
54
92
|
previousUrl: string;
|
|
@@ -75,4 +113,25 @@ describe("route observer", () => {
|
|
|
75
113
|
dispatch("hashchange");
|
|
76
114
|
expect(events).toHaveLength(1);
|
|
77
115
|
});
|
|
116
|
+
|
|
117
|
+
it("contains observer failures and continues delivering later history changes", () => {
|
|
118
|
+
const events: string[] = [];
|
|
119
|
+
const errors: unknown[] = [];
|
|
120
|
+
const stop = observeRouteChanges("history", (event) => {
|
|
121
|
+
events.push(event.nextUrl);
|
|
122
|
+
if (events.length === 1) throw new Error("route observer failed");
|
|
123
|
+
}, { onObserverError: (error) => errors.push(error) });
|
|
124
|
+
|
|
125
|
+
setLocation("https://example.test/collections/first");
|
|
126
|
+
expect(() => (globalThis as any).history.pushState({}, "", "/collections/first")).not.toThrow();
|
|
127
|
+
setLocation("https://example.test/collections/second");
|
|
128
|
+
expect(() => (globalThis as any).history.pushState({}, "", "/collections/second")).not.toThrow();
|
|
129
|
+
|
|
130
|
+
expect(events).toEqual([
|
|
131
|
+
"https://example.test/collections/first",
|
|
132
|
+
"https://example.test/collections/second",
|
|
133
|
+
]);
|
|
134
|
+
expect(errors).toHaveLength(1);
|
|
135
|
+
stop();
|
|
136
|
+
});
|
|
78
137
|
});
|