@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.
Files changed (83) hide show
  1. package/README.md +169 -0
  2. package/dist/ab-edge/index.cjs +214 -0
  3. package/dist/ab-edge/index.d.cts +121 -0
  4. package/dist/ab-edge/index.d.ts +121 -0
  5. package/dist/ab-edge/index.js +205 -0
  6. package/dist/bin/createcms.js +3082 -0
  7. package/dist/db.cjs +496 -0
  8. package/dist/db.d.cts +128 -0
  9. package/dist/db.d.ts +128 -0
  10. package/dist/db.js +488 -0
  11. package/dist/index.cjs +13789 -0
  12. package/dist/index.d.cts +10277 -0
  13. package/dist/index.d.ts +10277 -0
  14. package/dist/index.js +13737 -0
  15. package/dist/nanoid.cjs +50 -0
  16. package/dist/nanoid.d.cts +29 -0
  17. package/dist/nanoid.d.ts +29 -0
  18. package/dist/nanoid.js +47 -0
  19. package/dist/next/index.cjs +60 -0
  20. package/dist/next/index.d.cts +141 -0
  21. package/dist/next/index.d.ts +141 -0
  22. package/dist/next/index.js +58 -0
  23. package/dist/next/middleware.cjs +113 -0
  24. package/dist/next/middleware.d.cts +77 -0
  25. package/dist/next/middleware.d.ts +77 -0
  26. package/dist/next/middleware.js +111 -0
  27. package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
  28. package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
  29. package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
  30. package/dist/plugins/ab-test/analytics/upstash.js +343 -0
  31. package/dist/plugins/ab-test/client.cjs +686 -0
  32. package/dist/plugins/ab-test/client.d.cts +233 -0
  33. package/dist/plugins/ab-test/client.d.ts +233 -0
  34. package/dist/plugins/ab-test/client.js +684 -0
  35. package/dist/plugins/ab-test/index.cjs +3400 -0
  36. package/dist/plugins/ab-test/index.d.cts +1131 -0
  37. package/dist/plugins/ab-test/index.d.ts +1131 -0
  38. package/dist/plugins/ab-test/index.js +3367 -0
  39. package/dist/plugins/client.cjs +20 -0
  40. package/dist/plugins/client.d.cts +3 -0
  41. package/dist/plugins/client.d.ts +3 -0
  42. package/dist/plugins/client.js +3 -0
  43. package/dist/plugins/consent/client.cjs +315 -0
  44. package/dist/plugins/consent/client.d.cts +145 -0
  45. package/dist/plugins/consent/client.d.ts +145 -0
  46. package/dist/plugins/consent/client.js +313 -0
  47. package/dist/plugins/consent/index.cjs +267 -0
  48. package/dist/plugins/consent/index.d.cts +618 -0
  49. package/dist/plugins/consent/index.d.ts +618 -0
  50. package/dist/plugins/consent/index.js +258 -0
  51. package/dist/plugins/i18n/index.cjs +2177 -0
  52. package/dist/plugins/i18n/index.d.cts +562 -0
  53. package/dist/plugins/i18n/index.d.ts +562 -0
  54. package/dist/plugins/i18n/index.js +2150 -0
  55. package/dist/plugins/media-optimize/index.cjs +315 -0
  56. package/dist/plugins/media-optimize/index.d.cts +144 -0
  57. package/dist/plugins/media-optimize/index.d.ts +144 -0
  58. package/dist/plugins/media-optimize/index.js +311 -0
  59. package/dist/plugins/multi-tenant/index.cjs +210 -0
  60. package/dist/plugins/multi-tenant/index.d.cts +431 -0
  61. package/dist/plugins/multi-tenant/index.d.ts +431 -0
  62. package/dist/plugins/multi-tenant/index.js +207 -0
  63. package/dist/plugins/server.cjs +24 -0
  64. package/dist/plugins/server.d.cts +3 -0
  65. package/dist/plugins/server.d.ts +3 -0
  66. package/dist/plugins/server.js +3 -0
  67. package/dist/react/blocks.cjs +233 -0
  68. package/dist/react/blocks.d.cts +320 -0
  69. package/dist/react/blocks.d.ts +320 -0
  70. package/dist/react/blocks.js +226 -0
  71. package/dist/react/index.cjs +901 -0
  72. package/dist/react/index.d.cts +992 -0
  73. package/dist/react/index.d.ts +992 -0
  74. package/dist/react/index.js +872 -0
  75. package/dist/react/tracking.cjs +243 -0
  76. package/dist/react/tracking.d.cts +364 -0
  77. package/dist/react/tracking.d.ts +364 -0
  78. package/dist/react/tracking.js +216 -0
  79. package/dist/react/variant.cjs +59 -0
  80. package/dist/react/variant.d.cts +26 -0
  81. package/dist/react/variant.d.ts +26 -0
  82. package/dist/react/variant.js +57 -0
  83. 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;