@createcms/core 0.1.1
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 +169 -0
- package/dist/ab-edge/index.cjs +214 -0
- package/dist/ab-edge/index.d.cts +121 -0
- package/dist/ab-edge/index.d.ts +121 -0
- package/dist/ab-edge/index.js +205 -0
- package/dist/bin/createcms.js +3082 -0
- package/dist/db.cjs +496 -0
- package/dist/db.d.cts +128 -0
- package/dist/db.d.ts +128 -0
- package/dist/db.js +488 -0
- package/dist/index.cjs +13789 -0
- package/dist/index.d.cts +10277 -0
- package/dist/index.d.ts +10277 -0
- package/dist/index.js +13737 -0
- package/dist/nanoid.cjs +50 -0
- package/dist/nanoid.d.cts +29 -0
- package/dist/nanoid.d.ts +29 -0
- package/dist/nanoid.js +47 -0
- package/dist/next/index.cjs +60 -0
- package/dist/next/index.d.cts +141 -0
- package/dist/next/index.d.ts +141 -0
- package/dist/next/index.js +58 -0
- package/dist/next/middleware.cjs +113 -0
- package/dist/next/middleware.d.cts +77 -0
- package/dist/next/middleware.d.ts +77 -0
- package/dist/next/middleware.js +111 -0
- package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
- package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.js +343 -0
- package/dist/plugins/ab-test/client.cjs +686 -0
- package/dist/plugins/ab-test/client.d.cts +233 -0
- package/dist/plugins/ab-test/client.d.ts +233 -0
- package/dist/plugins/ab-test/client.js +684 -0
- package/dist/plugins/ab-test/index.cjs +3400 -0
- package/dist/plugins/ab-test/index.d.cts +1131 -0
- package/dist/plugins/ab-test/index.d.ts +1131 -0
- package/dist/plugins/ab-test/index.js +3367 -0
- package/dist/plugins/client.cjs +20 -0
- package/dist/plugins/client.d.cts +3 -0
- package/dist/plugins/client.d.ts +3 -0
- package/dist/plugins/client.js +3 -0
- package/dist/plugins/consent/client.cjs +315 -0
- package/dist/plugins/consent/client.d.cts +145 -0
- package/dist/plugins/consent/client.d.ts +145 -0
- package/dist/plugins/consent/client.js +313 -0
- package/dist/plugins/consent/index.cjs +267 -0
- package/dist/plugins/consent/index.d.cts +618 -0
- package/dist/plugins/consent/index.d.ts +618 -0
- package/dist/plugins/consent/index.js +258 -0
- package/dist/plugins/i18n/index.cjs +2177 -0
- package/dist/plugins/i18n/index.d.cts +562 -0
- package/dist/plugins/i18n/index.d.ts +562 -0
- package/dist/plugins/i18n/index.js +2150 -0
- package/dist/plugins/media-optimize/index.cjs +315 -0
- package/dist/plugins/media-optimize/index.d.cts +144 -0
- package/dist/plugins/media-optimize/index.d.ts +144 -0
- package/dist/plugins/media-optimize/index.js +311 -0
- package/dist/plugins/multi-tenant/index.cjs +210 -0
- package/dist/plugins/multi-tenant/index.d.cts +431 -0
- package/dist/plugins/multi-tenant/index.d.ts +431 -0
- package/dist/plugins/multi-tenant/index.js +207 -0
- package/dist/plugins/server.cjs +24 -0
- package/dist/plugins/server.d.cts +3 -0
- package/dist/plugins/server.d.ts +3 -0
- package/dist/plugins/server.js +3 -0
- package/dist/react/blocks.cjs +233 -0
- package/dist/react/blocks.d.cts +320 -0
- package/dist/react/blocks.d.ts +320 -0
- package/dist/react/blocks.js +226 -0
- package/dist/react/index.cjs +901 -0
- package/dist/react/index.d.cts +992 -0
- package/dist/react/index.d.ts +992 -0
- package/dist/react/index.js +872 -0
- package/dist/react/tracking.cjs +243 -0
- package/dist/react/tracking.d.cts +364 -0
- package/dist/react/tracking.d.ts +364 -0
- package/dist/react/tracking.js +216 -0
- package/dist/react/variant.cjs +59 -0
- package/dist/react/variant.d.cts +26 -0
- package/dist/react/variant.d.ts +26 -0
- package/dist/react/variant.js +57 -0
- package/package.json +303 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var index_cjs = require('../consent/index.cjs');
|
|
5
|
+
|
|
6
|
+
function safeSend(sink, event) {
|
|
7
|
+
try {
|
|
8
|
+
sink.send(event);
|
|
9
|
+
} catch {
|
|
10
|
+
// A sink must never break the page or its sibling sinks.
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fan one event out to every sink, honoring each sink's consent requirement.
|
|
15
|
+
* Consent-free sinks fire immediately; gated sinks go through the gate's
|
|
16
|
+
* buffer-then-flush and only fire once analytics consent resolves to granted.
|
|
17
|
+
*/ function dispatchEvent(event, sinks, gate) {
|
|
18
|
+
for (const sink of sinks){
|
|
19
|
+
if (!sink.requires) {
|
|
20
|
+
safeSend(sink, event);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const purpose = sink.requires;
|
|
24
|
+
gate.run(()=>{
|
|
25
|
+
if (gate.isGranted(purpose)) safeSend(sink, event);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Built-in sinks
|
|
31
|
+
// ============================================================================
|
|
32
|
+
/**
|
|
33
|
+
* The A/B store leg — the keepalive POST to the CMS event ingest. CONSENT-FREE
|
|
34
|
+
* (no `requires`): it records the anonymous aggregate count that drives the A/B
|
|
35
|
+
* winner. The identity-bearing unique-visitor leg is a separate, gated concern.
|
|
36
|
+
*/ function createAbTestStoreSink($fetch) {
|
|
37
|
+
return {
|
|
38
|
+
id: 'abTestStore',
|
|
39
|
+
send (event) {
|
|
40
|
+
$fetch('/abTest/trackEvent', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
// keepalive: a goal beacon often fires on a CTA click that navigates
|
|
43
|
+
// away — without this the browser cancels the in-flight POST and the
|
|
44
|
+
// count is lost (and the per-session dedup already marked it sent).
|
|
45
|
+
keepalive: true,
|
|
46
|
+
body: {
|
|
47
|
+
eventType: event.name,
|
|
48
|
+
anonymous: event.anonymous,
|
|
49
|
+
...event.ab?.testId ? {
|
|
50
|
+
testId: event.ab.testId
|
|
51
|
+
} : {},
|
|
52
|
+
...event.ab?.branchId ? {
|
|
53
|
+
branchId: event.ab.branchId
|
|
54
|
+
} : {},
|
|
55
|
+
...event.ab?.variantId ? {
|
|
56
|
+
variantId: event.ab.variantId
|
|
57
|
+
} : {},
|
|
58
|
+
...event.visitorId ? {
|
|
59
|
+
visitorId: event.visitorId
|
|
60
|
+
} : {},
|
|
61
|
+
...event.source ? {
|
|
62
|
+
source: event.source
|
|
63
|
+
} : {},
|
|
64
|
+
...event.interactionId ? {
|
|
65
|
+
interactionId: event.interactionId
|
|
66
|
+
} : {},
|
|
67
|
+
...event.transport ? {
|
|
68
|
+
transport: event.transport
|
|
69
|
+
} : {},
|
|
70
|
+
...event.consent ? {
|
|
71
|
+
consent: event.consent
|
|
72
|
+
} : {},
|
|
73
|
+
...event.metadata ? {
|
|
74
|
+
metadata: event.metadata
|
|
75
|
+
} : {}
|
|
76
|
+
}
|
|
77
|
+
}).catch(()=>{});
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The GA4/GTM client sink — a single `window.dataLayer.push`. CONSENT-GATED on
|
|
83
|
+
* `analytics_storage`: this is the GA4-forwarding path (the only M3 sink that
|
|
84
|
+
* needs consent). GTM's own Consent Mode is a second line of defence; gating
|
|
85
|
+
* here keeps the one auditable consent decision on our side too.
|
|
86
|
+
*/ function createGtmClientSink() {
|
|
87
|
+
return {
|
|
88
|
+
id: 'gtm',
|
|
89
|
+
requires: 'analytics_storage',
|
|
90
|
+
send (event) {
|
|
91
|
+
if (typeof window === 'undefined') return;
|
|
92
|
+
const w = window;
|
|
93
|
+
const dataLayer = w.dataLayer = w.dataLayer ?? [];
|
|
94
|
+
dataLayer.push({
|
|
95
|
+
event: event.name,
|
|
96
|
+
...event.ab ? {
|
|
97
|
+
ab_test_id: event.ab.testId,
|
|
98
|
+
ab_variant: event.ab.branchId ?? event.ab.variantId
|
|
99
|
+
} : {},
|
|
100
|
+
...event.source?.handle ? {
|
|
101
|
+
tracking_id: event.source.handle
|
|
102
|
+
} : {},
|
|
103
|
+
...event.source?.type ? {
|
|
104
|
+
block_type: event.source.type
|
|
105
|
+
} : {},
|
|
106
|
+
...event.interactionId ? {
|
|
107
|
+
interaction_id: event.interactionId
|
|
108
|
+
} : {},
|
|
109
|
+
...event.params
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const $ERROR_CODES = {
|
|
116
|
+
AB_TEST_NOT_FOUND: {
|
|
117
|
+
status: 404,
|
|
118
|
+
message: 'A/B test not found'
|
|
119
|
+
},
|
|
120
|
+
AB_TEST_INVALID_STATUS: {
|
|
121
|
+
status: 400,
|
|
122
|
+
message: 'Invalid status transition for this A/B test'
|
|
123
|
+
},
|
|
124
|
+
AB_TEST_WEIGHTS_INVALID: {
|
|
125
|
+
status: 400,
|
|
126
|
+
message: 'Variant weights must sum to 100'
|
|
127
|
+
},
|
|
128
|
+
AB_TEST_DUPLICATE_RUNNING: {
|
|
129
|
+
status: 409,
|
|
130
|
+
message: 'Another test is already running for this root'
|
|
131
|
+
},
|
|
132
|
+
AB_TEST_CROSS_EMBED_CONFLICT: {
|
|
133
|
+
status: 409,
|
|
134
|
+
message: 'Cannot run: a co-rendering root (an embedded reusable block or its host page) already has a running test — at most one A/B axis may vary per render'
|
|
135
|
+
},
|
|
136
|
+
AB_TEST_BRANCH_NOT_PUBLISHED: {
|
|
137
|
+
status: 400,
|
|
138
|
+
message: 'All variant branches must be published'
|
|
139
|
+
},
|
|
140
|
+
AB_TEST_NO_CONTEXT: {
|
|
141
|
+
status: 400,
|
|
142
|
+
message: 'No visitor context set. Call identify() first.'
|
|
143
|
+
},
|
|
144
|
+
AB_TEST_FLUSH_NOT_SUPPORTED: {
|
|
145
|
+
status: 400,
|
|
146
|
+
message: 'Flush is not supported by the current analytics adapter'
|
|
147
|
+
},
|
|
148
|
+
AB_TEST_VARIANT_NOT_FOUND: {
|
|
149
|
+
status: 404,
|
|
150
|
+
message: 'A/B test variant not found'
|
|
151
|
+
},
|
|
152
|
+
AB_TEST_TRACKING_ID_MISSING: {
|
|
153
|
+
status: 400,
|
|
154
|
+
message: 'A functional block (one that declares events) is missing its trackingId — every such block must have a non-empty trackingId before the branch can be published'
|
|
155
|
+
},
|
|
156
|
+
AB_TEST_TRACKING_ID_DUPLICATE: {
|
|
157
|
+
status: 400,
|
|
158
|
+
message: 'Duplicate trackingId in this branch — each functional block must have a unique trackingId'
|
|
159
|
+
},
|
|
160
|
+
AB_TEST_TRACKING_ID_DRIFT: {
|
|
161
|
+
status: 409,
|
|
162
|
+
message: 'trackingId drift across A/B variant branches — the set of functional trackingIds must be identical across all variant branches of a root, so a chosen goal exists in every arm'
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const PLUGIN_ID = 'abTest';
|
|
167
|
+
const LS_CONTEXT_KEY = 'ab_test_context';
|
|
168
|
+
const LS_ASSIGNMENTS_KEY = 'ab_test_assignments';
|
|
169
|
+
const SS_IMPRESSIONS_KEY = 'ab_test_impressions';
|
|
170
|
+
const COOKIE_VID = 'ab_test_vid';
|
|
171
|
+
const ONE_YEAR_SEC = 31_536_000;
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Storage / cookie helpers (browser; all no-op under SSR)
|
|
174
|
+
// ============================================================================
|
|
175
|
+
function safeLocalStorageGet(key) {
|
|
176
|
+
try {
|
|
177
|
+
return localStorage.getItem(key);
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function safeLocalStorageSet(key, value) {
|
|
183
|
+
try {
|
|
184
|
+
localStorage.setItem(key, value);
|
|
185
|
+
} catch {
|
|
186
|
+
// localStorage unavailable (SSR, private browsing, etc.)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function safeLocalStorageRemove(key) {
|
|
190
|
+
try {
|
|
191
|
+
localStorage.removeItem(key);
|
|
192
|
+
} catch {
|
|
193
|
+
// noop
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function safeSessionStorageGet(key) {
|
|
197
|
+
try {
|
|
198
|
+
return sessionStorage.getItem(key);
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function safeSessionStorageSet(key, value) {
|
|
204
|
+
try {
|
|
205
|
+
sessionStorage.setItem(key, value);
|
|
206
|
+
} catch {
|
|
207
|
+
// noop
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function safeSessionStorageRemove(key) {
|
|
211
|
+
try {
|
|
212
|
+
sessionStorage.removeItem(key);
|
|
213
|
+
} catch {
|
|
214
|
+
// noop
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function getCookie(name) {
|
|
218
|
+
if (typeof document === 'undefined') return null;
|
|
219
|
+
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
|
|
220
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
221
|
+
}
|
|
222
|
+
/** The GA4 client_id from the `_ga` cookie (`GA1.1.<id>.<ts>` → `<id>.<ts>`). */ function parseGaClientId() {
|
|
223
|
+
const raw = getCookie('_ga');
|
|
224
|
+
const m = raw?.match(/^GA\d\.\d\.(.+)$/);
|
|
225
|
+
return m ? m[1] : undefined;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* GA4 session_id from a `_ga_<stream>` cookie (`GS1.1.<session_id>.…`).
|
|
229
|
+
*
|
|
230
|
+
* Single-stream assumption: this matches the FIRST `_ga_<stream>` cookie it
|
|
231
|
+
* finds. With one GA4 data stream on the page (the common case) that is the
|
|
232
|
+
* right one. If a page runs MULTIPLE GA4 streams, the picked session_id may
|
|
233
|
+
* belong to a different stream than the server-MP `measurementId` — session
|
|
234
|
+
* stitching could then be off. We don't thread a stream hint because the
|
|
235
|
+
* measurementId is server-only config the client can't see; session_id is
|
|
236
|
+
* optional for the MP hit, so a mismatch degrades stitching, never the forward.
|
|
237
|
+
*/ function parseGaSessionId() {
|
|
238
|
+
if (typeof document === 'undefined') return undefined;
|
|
239
|
+
const m = document.cookie.match(/_ga_[A-Z0-9]+=GS\d\.\d\.(\d+)/);
|
|
240
|
+
return m ? m[1] : undefined;
|
|
241
|
+
}
|
|
242
|
+
function setCookie(name, value, maxAgeSec) {
|
|
243
|
+
if (typeof document === 'undefined') return;
|
|
244
|
+
const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
|
|
245
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAgeSec}; SameSite=Lax${secure}`;
|
|
246
|
+
}
|
|
247
|
+
function removeCookie(name) {
|
|
248
|
+
if (typeof document === 'undefined') return;
|
|
249
|
+
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
250
|
+
}
|
|
251
|
+
function generateAnonKey() {
|
|
252
|
+
const chars = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
253
|
+
let result = '';
|
|
254
|
+
for(let i = 0; i < 24; i++){
|
|
255
|
+
result += chars[Math.floor(Math.random() * chars.length)];
|
|
256
|
+
}
|
|
257
|
+
return `anon_${result}`;
|
|
258
|
+
}
|
|
259
|
+
function readStoredAssignments() {
|
|
260
|
+
const raw = safeLocalStorageGet(LS_ASSIGNMENTS_KEY);
|
|
261
|
+
if (!raw) return {};
|
|
262
|
+
try {
|
|
263
|
+
return JSON.parse(raw);
|
|
264
|
+
} catch {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function readStoredImpressions() {
|
|
269
|
+
const raw = safeSessionStorageGet(SS_IMPRESSIONS_KEY);
|
|
270
|
+
if (!raw) return [];
|
|
271
|
+
try {
|
|
272
|
+
return JSON.parse(raw);
|
|
273
|
+
} catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function abTestClient(options) {
|
|
278
|
+
return {
|
|
279
|
+
id: PLUGIN_ID,
|
|
280
|
+
$ERROR_CODES,
|
|
281
|
+
async init (_$fetch, _$store) {
|
|
282
|
+
// Identity/context is hydrated lazily AFTER consent is granted — never
|
|
283
|
+
// read device storage before then (ePrivacy Art. 5(3) covers reads too).
|
|
284
|
+
return {
|
|
285
|
+
context: {
|
|
286
|
+
[`${PLUGIN_ID}:context`]: null
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
getActions ($fetch, _$store, baseURL) {
|
|
291
|
+
const gate = index_cjs.createConsentGate();
|
|
292
|
+
// M3a — client-side event-bus. A fired event fans to these sinks; each
|
|
293
|
+
// gates on its own consent requirement (see client-sinks.ts). The store
|
|
294
|
+
// leg is consent-free (anonymous aggregate count); the GA4/GTM leg is
|
|
295
|
+
// gated on analytics_storage.
|
|
296
|
+
const sinks = [
|
|
297
|
+
createAbTestStoreSink($fetch),
|
|
298
|
+
// The dataLayer leg is dropped when goals are forwarded server-side
|
|
299
|
+
// (server-MP) to avoid GA4 double-counting — see disableDataLayerSink.
|
|
300
|
+
...options?.disableDataLayerSink ? [] : [
|
|
301
|
+
createGtmClientSink()
|
|
302
|
+
]
|
|
303
|
+
];
|
|
304
|
+
let context = null;
|
|
305
|
+
let memKey = null;
|
|
306
|
+
// In-memory until consent is granted; hydrated from storage on grant.
|
|
307
|
+
const assignmentCache = {};
|
|
308
|
+
const impressionsSent = new Set(); // confirmed emitted
|
|
309
|
+
const impressionsQueued = new Set(); // buffered, not yet decided
|
|
310
|
+
let hydrated = false;
|
|
311
|
+
// Seed the per-session impression dedup from sessionStorage at init
|
|
312
|
+
// (consent-free — see persistImpressions). Without this, the anonymous
|
|
313
|
+
// beacon re-fires on every hard reload / fresh document load (the
|
|
314
|
+
// in-memory Set only survives soft SPA navigations), over-counting exactly
|
|
315
|
+
// the no-consent ad traffic this path measures.
|
|
316
|
+
for (const id of readStoredImpressions())impressionsSent.add(id);
|
|
317
|
+
const analyticsGranted = ()=>gate.isGranted('analytics_storage');
|
|
318
|
+
/**
|
|
319
|
+
* GA4 stitching ids for the server-MP forward (M5). Read the `_ga` cookie
|
|
320
|
+
* ONLY when analytics_storage is granted (it's an identifier) + a `_ga`
|
|
321
|
+
* exists (gtag loaded). Returns undefined otherwise → the server never
|
|
322
|
+
* forwards (the consent-free aggregate path stays identifier-less).
|
|
323
|
+
*/ function gaTransport() {
|
|
324
|
+
if (!analyticsGranted()) return undefined;
|
|
325
|
+
const clientId = parseGaClientId();
|
|
326
|
+
if (!clientId) return undefined;
|
|
327
|
+
const sessionId = parseGaSessionId();
|
|
328
|
+
return {
|
|
329
|
+
clientId,
|
|
330
|
+
...sessionId ? {
|
|
331
|
+
sessionId
|
|
332
|
+
} : {},
|
|
333
|
+
engagementTimeMsec: 1
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function persistAssignments() {
|
|
337
|
+
if (!analyticsGranted()) return;
|
|
338
|
+
safeLocalStorageSet(LS_ASSIGNMENTS_KEY, JSON.stringify(assignmentCache));
|
|
339
|
+
}
|
|
340
|
+
function persistImpressions() {
|
|
341
|
+
// CONSENT-FREE: the per-session impression markers are test ids (which
|
|
342
|
+
// tests this tab already counted), session-only, client-only, never
|
|
343
|
+
// transmitted — not an identifier. Persisting them is what makes the
|
|
344
|
+
// anonymous beacon dedup survive a hard reload, so it must NOT be gated
|
|
345
|
+
// on consent (unlike the identity-bearing assignment/context persists).
|
|
346
|
+
safeSessionStorageSet(SS_IMPRESSIONS_KEY, JSON.stringify([
|
|
347
|
+
...impressionsSent
|
|
348
|
+
]));
|
|
349
|
+
}
|
|
350
|
+
function persistContext() {
|
|
351
|
+
if (!analyticsGranted() || !context) return;
|
|
352
|
+
safeLocalStorageSet(LS_CONTEXT_KEY, JSON.stringify(context));
|
|
353
|
+
}
|
|
354
|
+
/** Resolve (and consent-gated persist) the visitor key. */ function visitorKey() {
|
|
355
|
+
const resolved = index_cjs.resolveVisitorKey({
|
|
356
|
+
granted: analyticsGranted(),
|
|
357
|
+
cookieKey: analyticsGranted() ? getCookie(COOKIE_VID) : null,
|
|
358
|
+
memKey,
|
|
359
|
+
generate: generateAnonKey
|
|
360
|
+
});
|
|
361
|
+
memKey = resolved.memKey;
|
|
362
|
+
if (resolved.persist) setCookie(COOKIE_VID, resolved.key, ONE_YEAR_SEC);
|
|
363
|
+
return resolved.key;
|
|
364
|
+
}
|
|
365
|
+
/** One-time hydrate of prior identity + caches once consent is granted. */ function hydrateOnGrant() {
|
|
366
|
+
if (!hydrated) {
|
|
367
|
+
hydrated = true;
|
|
368
|
+
const storedAssignments = readStoredAssignments();
|
|
369
|
+
for (const [testId, a] of Object.entries(storedAssignments)){
|
|
370
|
+
if (!assignmentCache[testId]) assignmentCache[testId] = a;
|
|
371
|
+
}
|
|
372
|
+
for (const id of readStoredImpressions())impressionsSent.add(id);
|
|
373
|
+
if (!context) {
|
|
374
|
+
const saved = safeLocalStorageGet(LS_CONTEXT_KEY);
|
|
375
|
+
if (saved) {
|
|
376
|
+
try {
|
|
377
|
+
context = JSON.parse(saved);
|
|
378
|
+
if (context) memKey = context.key;
|
|
379
|
+
} catch {
|
|
380
|
+
// corrupted
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Promote the in-memory key to the cookie so a buffered impression and
|
|
386
|
+
// later events share one identity.
|
|
387
|
+
if (memKey && !getCookie(COOKIE_VID)) {
|
|
388
|
+
setCookie(COOKIE_VID, memKey, ONE_YEAR_SEC);
|
|
389
|
+
}
|
|
390
|
+
persistContext();
|
|
391
|
+
persistAssignments();
|
|
392
|
+
persistImpressions();
|
|
393
|
+
}
|
|
394
|
+
gate.onChange((state, resolved)=>{
|
|
395
|
+
if (resolved && state.analytics_storage === 'granted') hydrateOnGrant();
|
|
396
|
+
});
|
|
397
|
+
// Zero-config Consent Mode read + a wait-window fallback. Render never
|
|
398
|
+
// waits on this; only event emission is buffered behind it.
|
|
399
|
+
index_cjs.startConsentAutoRead(gate);
|
|
400
|
+
if (typeof window !== 'undefined') {
|
|
401
|
+
setTimeout(()=>gate.resolve(), index_cjs.CONSENT_WAIT_MS);
|
|
402
|
+
}
|
|
403
|
+
function getContext() {
|
|
404
|
+
if (context) return context;
|
|
405
|
+
throw new Error($ERROR_CODES.AB_TEST_NO_CONTEXT.message);
|
|
406
|
+
}
|
|
407
|
+
/** POST an event, stamping the live consent state at send time. */ function postEvent(body) {
|
|
408
|
+
$fetch('/abTest/trackEvent', {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
// keepalive: conversion beacons frequently fire on a navigating click;
|
|
411
|
+
// survive the unload so the count is not lost.
|
|
412
|
+
keepalive: true,
|
|
413
|
+
body: {
|
|
414
|
+
...body,
|
|
415
|
+
consent: gate.getState()
|
|
416
|
+
}
|
|
417
|
+
}).catch(()=>{});
|
|
418
|
+
}
|
|
419
|
+
function fireImpression(testId, variantId) {
|
|
420
|
+
// Dedup on "queued or sent" to avoid double-buffering; the dedup mark is
|
|
421
|
+
// committed only when the effect actually runs, and released on drop so
|
|
422
|
+
// a later grant can still fire (no permanent suppression).
|
|
423
|
+
if (impressionsSent.has(testId) || impressionsQueued.has(testId)) return;
|
|
424
|
+
impressionsQueued.add(testId);
|
|
425
|
+
const ctx = getContext();
|
|
426
|
+
const body = {
|
|
427
|
+
testId,
|
|
428
|
+
variantId,
|
|
429
|
+
visitorId: ctx.key,
|
|
430
|
+
anonymous: ctx.anonymous ?? false,
|
|
431
|
+
eventType: 'impression'
|
|
432
|
+
};
|
|
433
|
+
gate.run(()=>{
|
|
434
|
+
impressionsQueued.delete(testId);
|
|
435
|
+
if (impressionsSent.has(testId)) return;
|
|
436
|
+
impressionsSent.add(testId);
|
|
437
|
+
persistImpressions();
|
|
438
|
+
postEvent(body);
|
|
439
|
+
}, ()=>{
|
|
440
|
+
impressionsQueued.delete(testId);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Pattern A impression: report the SERVER-rendered variant by branch (the
|
|
445
|
+
* edge already chose it — no client re-bucketing). ANONYMOUS + CONSENT-FREE
|
|
446
|
+
* by design: it sends NO visitor id and is NOT consent-gated, because an
|
|
447
|
+
* aggregate variant impression count carries no identifier and no PII (the
|
|
448
|
+
* variant came from the URL). It is deduped per SESSION via sessionStorage
|
|
449
|
+
* (client-only, never sent), so the count is ~per-session without storing
|
|
450
|
+
* anything linkable. The consent-gated legs (GA4/dataLayer forwarding,
|
|
451
|
+
* unique-visitor identity) are separate. trackEvent resolves the variant id
|
|
452
|
+
* from the branch.
|
|
453
|
+
*/ function recordImpression(testId, branchId) {
|
|
454
|
+
if (typeof document === 'undefined') return; // SSR no-op
|
|
455
|
+
if (impressionsSent.has(testId)) return; // per-session dedup
|
|
456
|
+
impressionsSent.add(testId);
|
|
457
|
+
persistImpressions();
|
|
458
|
+
// Fan out through the M3a event-bus. The on-mount impression is OWNED by
|
|
459
|
+
// the consent-free A/B store (the experiment's source of truth) and is
|
|
460
|
+
// deliberately NOT server-MP-forwarded: it fires before consent resolves,
|
|
461
|
+
// so no `transport`/`consent` is stamped — which also keeps the anonymous
|
|
462
|
+
// aggregate count clear of the server's denied-consent guard. GA4 still
|
|
463
|
+
// receives the impression via the dataLayer leg (buffered, fires on
|
|
464
|
+
// grant) when that sink is enabled; server-MP forwards the post-consent
|
|
465
|
+
// goal/conversion events (see dispatchEvent). Read impression rates from
|
|
466
|
+
// the dashboard (getResults / useLiveResults), not GA4.
|
|
467
|
+
dispatchEvent({
|
|
468
|
+
name: 'impression',
|
|
469
|
+
ab: {
|
|
470
|
+
testId,
|
|
471
|
+
branchId
|
|
472
|
+
},
|
|
473
|
+
anonymous: true
|
|
474
|
+
}, sinks, gate);
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
abTest: {
|
|
478
|
+
/**
|
|
479
|
+
* Tell the CMS about the visitor's consent (Consent Mode v2) — a real
|
|
480
|
+
* decision (treated like a Consent Mode `update`). Optional: the gate
|
|
481
|
+
* also auto-reads Consent Mode commands off the dataLayer. That
|
|
482
|
+
* auto-read is best-effort (a `push`-hook fast path plus a re-scan
|
|
483
|
+
* poll); when running GTM, calling this from the CMP's Consent Mode
|
|
484
|
+
* update callback is the most reliable path.
|
|
485
|
+
*/ setConsent (consent) {
|
|
486
|
+
gate.applyUpdate(consent);
|
|
487
|
+
},
|
|
488
|
+
/** Read the current resolved consent state. */ getConsent () {
|
|
489
|
+
return gate.getState();
|
|
490
|
+
},
|
|
491
|
+
/**
|
|
492
|
+
* Report the impression for a SERVER-rendered variant (AB_FANOUT
|
|
493
|
+
* Pattern A). Call with the served branch (the `/<branchId>/` URL
|
|
494
|
+
* segment). ANONYMOUS + consent-free: sends no visitor id, not
|
|
495
|
+
* consent-gated, deduped per session via sessionStorage. Reach it from
|
|
496
|
+
* the variant route via {@link useImpression}.
|
|
497
|
+
*/ recordImpression,
|
|
498
|
+
/**
|
|
499
|
+
* React hook: fire the Pattern A impression once per (testId, branchId)
|
|
500
|
+
* on mount. Render a tiny `'use client'` beacon from the variant-coded
|
|
501
|
+
* route: `cmsClient.abTest.useImpression(testId, branchId)`.
|
|
502
|
+
*/ useImpression (testId, branchId) {
|
|
503
|
+
react.useEffect(()=>{
|
|
504
|
+
recordImpression(testId, branchId);
|
|
505
|
+
}, [
|
|
506
|
+
testId,
|
|
507
|
+
branchId
|
|
508
|
+
]);
|
|
509
|
+
},
|
|
510
|
+
/**
|
|
511
|
+
* Fire a raw client event through the M3a sink pipeline (the SAME
|
|
512
|
+
* sinks + consent gate as recordImpression — so consent state never
|
|
513
|
+
* diverges). The M3c `<TrackingRuntimeProvider>` wires this as its
|
|
514
|
+
* `dispatch`; functional blocks reach it via `useTrackedBlock().fire`.
|
|
515
|
+
* Anonymous aggregate legs are consent-free; the GA4/gtm leg is gated.
|
|
516
|
+
*/ dispatchEvent (event) {
|
|
517
|
+
// Attach GA4 stitching ids (consent-gated) so a block/funnel event
|
|
518
|
+
// can also forward to the server-MP — unless the caller already set
|
|
519
|
+
// transport. The anonymous store leg ignores it; the forward needs it.
|
|
520
|
+
// Stamp consent ALONGSIDE transport (both imply analytics granted) so
|
|
521
|
+
// the server can authorize the forward; leave both absent otherwise so
|
|
522
|
+
// the consent-free leg never trips the server's denied-consent guard.
|
|
523
|
+
const transport = event.transport ?? gaTransport();
|
|
524
|
+
dispatchEvent({
|
|
525
|
+
...event,
|
|
526
|
+
...transport ? {
|
|
527
|
+
transport,
|
|
528
|
+
consent: event.consent ?? gate.getState()
|
|
529
|
+
} : {}
|
|
530
|
+
}, sinks, gate);
|
|
531
|
+
},
|
|
532
|
+
identify (ctx) {
|
|
533
|
+
if (ctx.anonymous && !ctx.key) {
|
|
534
|
+
context = {
|
|
535
|
+
key: visitorKey(),
|
|
536
|
+
anonymous: true
|
|
537
|
+
};
|
|
538
|
+
} else {
|
|
539
|
+
context = {
|
|
540
|
+
key: ctx.key,
|
|
541
|
+
anonymous: ctx.anonymous ?? false
|
|
542
|
+
};
|
|
543
|
+
memKey = ctx.key;
|
|
544
|
+
}
|
|
545
|
+
persistContext();
|
|
546
|
+
},
|
|
547
|
+
async getVariant (testId) {
|
|
548
|
+
const cached = assignmentCache[testId];
|
|
549
|
+
if (cached) {
|
|
550
|
+
fireImpression(testId, cached.variantId);
|
|
551
|
+
return cached;
|
|
552
|
+
}
|
|
553
|
+
// Functional, visitor-independent assignment — allowed pre-consent
|
|
554
|
+
// (renders the right variant; persists no identifier server-side).
|
|
555
|
+
const result = await $fetch('/abTest/assignVariant', {
|
|
556
|
+
method: 'POST',
|
|
557
|
+
body: {
|
|
558
|
+
testId,
|
|
559
|
+
context: getContext()
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
const assignment = {
|
|
563
|
+
variantId: result.variantId,
|
|
564
|
+
branchId: result.branchId,
|
|
565
|
+
assignedAt: Date.now()
|
|
566
|
+
};
|
|
567
|
+
assignmentCache[testId] = assignment;
|
|
568
|
+
persistAssignments();
|
|
569
|
+
fireImpression(testId, result.variantId);
|
|
570
|
+
return assignment;
|
|
571
|
+
},
|
|
572
|
+
reset () {
|
|
573
|
+
context = null;
|
|
574
|
+
memKey = null;
|
|
575
|
+
hydrated = false;
|
|
576
|
+
for (const key of Object.keys(assignmentCache)){
|
|
577
|
+
delete assignmentCache[key];
|
|
578
|
+
}
|
|
579
|
+
impressionsSent.clear();
|
|
580
|
+
impressionsQueued.clear();
|
|
581
|
+
safeLocalStorageRemove(LS_CONTEXT_KEY);
|
|
582
|
+
safeLocalStorageRemove(LS_ASSIGNMENTS_KEY);
|
|
583
|
+
safeSessionStorageRemove(SS_IMPRESSIONS_KEY);
|
|
584
|
+
removeCookie(COOKIE_VID);
|
|
585
|
+
// Revoke consent in-session — stops any further fan-out.
|
|
586
|
+
gate.reset();
|
|
587
|
+
},
|
|
588
|
+
/**
|
|
589
|
+
* React hook for live dashboard results.
|
|
590
|
+
* Connects to the auto-registered realtime SSE route.
|
|
591
|
+
*/ useLiveResults (opts) {
|
|
592
|
+
const [results, setResults] = react.useState(opts.initial);
|
|
593
|
+
const [isLive, setIsLive] = react.useState(false);
|
|
594
|
+
const esRef = react.useRef(null);
|
|
595
|
+
const applyDelta = react.useCallback((delta)=>{
|
|
596
|
+
setResults((prev)=>{
|
|
597
|
+
const variants = prev.variants.map((v)=>{
|
|
598
|
+
if (v.variantId !== delta.variantId) return v;
|
|
599
|
+
const updated = {
|
|
600
|
+
...v
|
|
601
|
+
};
|
|
602
|
+
if (delta.eventType === 'impression') {
|
|
603
|
+
updated.impressions += delta.count;
|
|
604
|
+
} else if (delta.eventType === 'conversion') {
|
|
605
|
+
updated.conversions += delta.count;
|
|
606
|
+
}
|
|
607
|
+
const breakdown = {
|
|
608
|
+
...updated.eventBreakdown
|
|
609
|
+
};
|
|
610
|
+
const entry = breakdown[delta.eventType] ?? {
|
|
611
|
+
count: 0,
|
|
612
|
+
uniqueVisitors: 0,
|
|
613
|
+
distinctInteractions: 0
|
|
614
|
+
};
|
|
615
|
+
breakdown[delta.eventType] = {
|
|
616
|
+
count: entry.count + delta.count,
|
|
617
|
+
uniqueVisitors: entry.uniqueVisitors,
|
|
618
|
+
// Live deltas don't carry interaction ids; the funnel
|
|
619
|
+
// refreshes on the next getResults poll.
|
|
620
|
+
distinctInteractions: entry.distinctInteractions
|
|
621
|
+
};
|
|
622
|
+
updated.eventBreakdown = breakdown;
|
|
623
|
+
updated.conversionRate = updated.impressions > 0 ? Math.round(updated.conversions / updated.impressions * 10000) / 100 : 0;
|
|
624
|
+
return updated;
|
|
625
|
+
});
|
|
626
|
+
return {
|
|
627
|
+
...prev,
|
|
628
|
+
variants,
|
|
629
|
+
totalImpressions: variants.reduce((s, v)=>s + v.impressions, 0),
|
|
630
|
+
totalConversions: variants.reduce((s, v)=>s + v.conversions, 0)
|
|
631
|
+
};
|
|
632
|
+
});
|
|
633
|
+
}, []);
|
|
634
|
+
react.useEffect(()=>{
|
|
635
|
+
const url = `${baseURL}/abTest/realtime?channels=ab:live:${opts.testId}`;
|
|
636
|
+
let es;
|
|
637
|
+
try {
|
|
638
|
+
es = new EventSource(url);
|
|
639
|
+
} catch {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
esRef.current = es;
|
|
643
|
+
es.onopen = ()=>setIsLive(true);
|
|
644
|
+
es.onmessage = (event)=>{
|
|
645
|
+
try {
|
|
646
|
+
const delta = JSON.parse(event.data);
|
|
647
|
+
applyDelta(delta);
|
|
648
|
+
} catch {
|
|
649
|
+
// Ignore malformed messages
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
es.onerror = ()=>{
|
|
653
|
+
setIsLive(false);
|
|
654
|
+
$fetch('/abTest/getResults', {
|
|
655
|
+
method: 'GET',
|
|
656
|
+
query: {
|
|
657
|
+
testId: opts.testId
|
|
658
|
+
}
|
|
659
|
+
}).then((fresh)=>setResults(fresh)).catch(()=>{});
|
|
660
|
+
};
|
|
661
|
+
return ()=>{
|
|
662
|
+
es.close();
|
|
663
|
+
esRef.current = null;
|
|
664
|
+
setIsLive(false);
|
|
665
|
+
};
|
|
666
|
+
}, [
|
|
667
|
+
opts.testId,
|
|
668
|
+
applyDelta
|
|
669
|
+
]);
|
|
670
|
+
return {
|
|
671
|
+
results,
|
|
672
|
+
isLive
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
},
|
|
678
|
+
pathMethods: {
|
|
679
|
+
'/abTest/assignVariant': 'POST',
|
|
680
|
+
'/abTest/trackEvent': 'POST',
|
|
681
|
+
'/abTest/getResults': 'GET'
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
exports.abTestClient = abTestClient;
|