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