@qoretechnologies/reqraft 0.10.56 → 0.10.57-pr.127.ge03fd3a

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 (115) hide show
  1. package/.claude/CLAUDE.md +1 -0
  2. package/README.md +20 -0
  3. package/design/TRACKING.md +210 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/tracking/browser/attach.d.ts +8 -0
  9. package/dist/tracking/browser/attach.d.ts.map +1 -0
  10. package/dist/tracking/browser/attach.js +182 -0
  11. package/dist/tracking/browser/attach.js.map +1 -0
  12. package/dist/tracking/browser/env.d.ts +14 -0
  13. package/dist/tracking/browser/env.d.ts.map +1 -0
  14. package/dist/tracking/browser/env.js +150 -0
  15. package/dist/tracking/browser/env.js.map +1 -0
  16. package/dist/tracking/core/consent.d.ts +21 -0
  17. package/dist/tracking/core/consent.d.ts.map +1 -0
  18. package/dist/tracking/core/consent.js +39 -0
  19. package/dist/tracking/core/consent.js.map +1 -0
  20. package/dist/tracking/core/engagement.d.ts +20 -0
  21. package/dist/tracking/core/engagement.d.ts.map +1 -0
  22. package/dist/tracking/core/engagement.js +52 -0
  23. package/dist/tracking/core/engagement.js.map +1 -0
  24. package/dist/tracking/core/experiments.d.ts +109 -0
  25. package/dist/tracking/core/experiments.d.ts.map +1 -0
  26. package/dist/tracking/core/experiments.js +294 -0
  27. package/dist/tracking/core/experiments.js.map +1 -0
  28. package/dist/tracking/core/hash.d.ts +16 -0
  29. package/dist/tracking/core/hash.d.ts.map +1 -0
  30. package/dist/tracking/core/hash.js +31 -0
  31. package/dist/tracking/core/hash.js.map +1 -0
  32. package/dist/tracking/core/ids.d.ts +6 -0
  33. package/dist/tracking/core/ids.d.ts.map +1 -0
  34. package/dist/tracking/core/ids.js +31 -0
  35. package/dist/tracking/core/ids.js.map +1 -0
  36. package/dist/tracking/core/observable.d.ts +12 -0
  37. package/dist/tracking/core/observable.d.ts.map +1 -0
  38. package/dist/tracking/core/observable.js +32 -0
  39. package/dist/tracking/core/observable.js.map +1 -0
  40. package/dist/tracking/core/queue.d.ts +47 -0
  41. package/dist/tracking/core/queue.d.ts.map +1 -0
  42. package/dist/tracking/core/queue.js +205 -0
  43. package/dist/tracking/core/queue.js.map +1 -0
  44. package/dist/tracking/core/sections.d.ts +54 -0
  45. package/dist/tracking/core/sections.d.ts.map +1 -0
  46. package/dist/tracking/core/sections.js +156 -0
  47. package/dist/tracking/core/sections.js.map +1 -0
  48. package/dist/tracking/core/storage.d.ts +15 -0
  49. package/dist/tracking/core/storage.d.ts.map +1 -0
  50. package/dist/tracking/core/storage.js +72 -0
  51. package/dist/tracking/core/storage.js.map +1 -0
  52. package/dist/tracking/core/tracker.d.ts +122 -0
  53. package/dist/tracking/core/tracker.d.ts.map +1 -0
  54. package/dist/tracking/core/tracker.js +390 -0
  55. package/dist/tracking/core/tracker.js.map +1 -0
  56. package/dist/tracking/core/types.d.ts +130 -0
  57. package/dist/tracking/core/types.d.ts.map +1 -0
  58. package/dist/tracking/core/types.js +69 -0
  59. package/dist/tracking/core/types.js.map +1 -0
  60. package/dist/tracking/core/utm.d.ts +6 -0
  61. package/dist/tracking/core/utm.d.ts.map +1 -0
  62. package/dist/tracking/core/utm.js +30 -0
  63. package/dist/tracking/core/utm.js.map +1 -0
  64. package/dist/tracking/index.d.ts +46 -0
  65. package/dist/tracking/index.d.ts.map +1 -0
  66. package/dist/tracking/index.js +91 -0
  67. package/dist/tracking/index.js.map +1 -0
  68. package/dist/tracking/markup.d.ts +12 -0
  69. package/dist/tracking/markup.d.ts.map +1 -0
  70. package/dist/tracking/markup.js +19 -0
  71. package/dist/tracking/markup.js.map +1 -0
  72. package/dist/tracking/react/Experiment.d.ts +23 -0
  73. package/dist/tracking/react/Experiment.d.ts.map +1 -0
  74. package/dist/tracking/react/Experiment.js +28 -0
  75. package/dist/tracking/react/Experiment.js.map +1 -0
  76. package/dist/tracking/react/TrackingProvider.d.ts +26 -0
  77. package/dist/tracking/react/TrackingProvider.d.ts.map +1 -0
  78. package/dist/tracking/react/TrackingProvider.js +45 -0
  79. package/dist/tracking/react/TrackingProvider.js.map +1 -0
  80. package/dist/tracking/react/hooks.d.ts +39 -0
  81. package/dist/tracking/react/hooks.d.ts.map +1 -0
  82. package/dist/tracking/react/hooks.js +96 -0
  83. package/dist/tracking/react/hooks.js.map +1 -0
  84. package/dist/tracking/react/index.d.ts +7 -0
  85. package/dist/tracking/react/index.d.ts.map +1 -0
  86. package/dist/tracking/react/index.js +17 -0
  87. package/dist/tracking/react/index.js.map +1 -0
  88. package/dist/tracking/testing.d.ts +40 -0
  89. package/dist/tracking/testing.d.ts.map +1 -0
  90. package/dist/tracking/testing.js +140 -0
  91. package/dist/tracking/testing.js.map +1 -0
  92. package/package.json +1 -1
  93. package/src/index.tsx +3 -0
  94. package/src/tracking/browser/attach.ts +186 -0
  95. package/src/tracking/browser/env.ts +78 -0
  96. package/src/tracking/core/consent.ts +37 -0
  97. package/src/tracking/core/engagement.ts +47 -0
  98. package/src/tracking/core/experiments.ts +258 -0
  99. package/src/tracking/core/hash.ts +26 -0
  100. package/src/tracking/core/ids.ts +25 -0
  101. package/src/tracking/core/observable.ts +23 -0
  102. package/src/tracking/core/queue.ts +131 -0
  103. package/src/tracking/core/sections.ts +166 -0
  104. package/src/tracking/core/storage.ts +69 -0
  105. package/src/tracking/core/tracker.ts +417 -0
  106. package/src/tracking/core/types.ts +162 -0
  107. package/src/tracking/core/utm.ts +23 -0
  108. package/src/tracking/index.ts +72 -0
  109. package/src/tracking/markup.ts +17 -0
  110. package/src/tracking/react/Experiment.stories.tsx +174 -0
  111. package/src/tracking/react/Experiment.tsx +44 -0
  112. package/src/tracking/react/TrackingProvider.tsx +56 -0
  113. package/src/tracking/react/hooks.ts +106 -0
  114. package/src/tracking/react/index.ts +13 -0
  115. package/src/tracking/testing.ts +108 -0
@@ -0,0 +1,258 @@
1
+ import { hashV2 } from './hash';
2
+ import { Observable } from './observable';
3
+ import { readJson, writeJson, type IStorage } from './storage';
4
+ import { KEYS, type IExperimentDefinition, type IExperimentVariant, type TStorageKeys } from './types';
5
+
6
+ /**
7
+ * Client-side assignment (§5 of the contract). No round trip: the running
8
+ * experiments are fetched once (public fields only) and every visitor is bucketed
9
+ * locally from their `vid`, the same way every time, on any page.
10
+ */
11
+
12
+ export type TAssignmentReason =
13
+ /** Bucketed into a variant: an exposure is logged when the trigger is seen. */
14
+ | 'assigned'
15
+ /** Outside the experiment's traffic share: control, no exposure. */
16
+ | 'not-enrolled'
17
+ /** Targeting (path, device, language) does not match: control, no exposure. */
18
+ | 'not-targeted'
19
+ /** No consent (no vid) or tracking disabled: control, no exposure. */
20
+ | 'no-consent'
21
+ /** Definitions not loaded (yet, or the endpoint is missing) or experiment not running: control. */
22
+ | 'not-running'
23
+ /** A preview link (`?qa_variant=`) or the in-app version switch: that variant, never logged. */
24
+ | 'preview'
25
+ /** The experiment ended: everybody sees the winner, nothing is logged. */
26
+ | 'concluded';
27
+
28
+ export interface IAssignment {
29
+ key: string;
30
+ variant: string;
31
+ /** Whether this visitor counts for the experiment (exposure is logged). */
32
+ enrolled: boolean;
33
+ reason: TAssignmentReason;
34
+ /** The section whose being seen logs the exposure; null → on render. */
35
+ trigger: string | null;
36
+ }
37
+
38
+ export interface IAssignContext {
39
+ path: string;
40
+ device?: 'desktop' | 'tablet' | 'mobile';
41
+ lang?: string;
42
+ }
43
+
44
+ export const controlOf = (variants: IExperimentVariant[], fallback = 'a'): string =>
45
+ (variants.find((v) => v.control) ?? variants[0])?.key ?? fallback;
46
+
47
+ /**
48
+ * GrowthBook's bucket ranges: variant i owns `[c_i, c_i + traffic * w_i)` where
49
+ * `c_i` is the sum of the weights before it. The enrolled share equals `traffic`,
50
+ * and raising `traffic` later never moves an enrolled visitor to another variant.
51
+ * Weights that do not add up to 1 are normalised.
52
+ */
53
+ export const bucketRanges = (weights: number[], traffic: number): [number, number][] => {
54
+ const cover = Math.min(1, Math.max(0, Number.isFinite(traffic) ? traffic : 0));
55
+ const clean = weights.map((w) => (Number.isFinite(w) && w > 0 ? w : 0));
56
+ const sum = clean.reduce((a, b) => a + b, 0);
57
+ const norm = sum > 0 ? clean.map((w) => w / sum) : clean.map(() => 1 / clean.length);
58
+ let cumulative = 0;
59
+ return norm.map((w) => {
60
+ const start = cumulative;
61
+ cumulative += w;
62
+ return [start, start + cover * w];
63
+ });
64
+ };
65
+
66
+ /** `/` matches `/` only; `/blog/*` matches `/blog/` and everything under it. */
67
+ export const pathMatches = (patterns: string[] | null | undefined, path: string): boolean => {
68
+ if (!patterns || !patterns.length) return true;
69
+ const clean = path.split(/[?#]/)[0] || '/';
70
+ return patterns.some((p) => (p.endsWith('*') ? clean.startsWith(p.slice(0, -1)) : clean === p || clean === `${p}/`));
71
+ };
72
+
73
+ const targeted = (def: IExperimentDefinition, ctx: IAssignContext): boolean => {
74
+ const t = def.targeting;
75
+ if (!t) return true;
76
+ if (!pathMatches(t.paths, ctx.path)) return false;
77
+ if (t.device && ctx.device && t.device !== ctx.device) return false;
78
+ if (t.lang && ctx.lang && !ctx.lang.toLowerCase().startsWith(t.lang.toLowerCase())) return false;
79
+ return true;
80
+ };
81
+
82
+ /** Pure assignment of one visitor to one experiment. */
83
+ export const assign = (def: IExperimentDefinition, vid: string | null, ctx: IAssignContext): IAssignment => {
84
+ const control = controlOf(def.variants);
85
+ const trigger = def.trigger?.section ?? null;
86
+ const base = { key: def.key, variant: control, enrolled: false, trigger };
87
+ if (def.status === 'concluded') return { ...base, variant: def.winner || control, reason: 'concluded' };
88
+ if (!vid) return { ...base, reason: 'no-consent' };
89
+ if (!def.variants.length) return { ...base, reason: 'not-running' };
90
+ if (!targeted(def, ctx)) return { ...base, reason: 'not-targeted' };
91
+ const n = hashV2(def.key, vid);
92
+ const ranges = bucketRanges(
93
+ def.variants.map((v) => v.weight),
94
+ def.traffic,
95
+ );
96
+ const i = ranges.findIndex(([from, to]) => n >= from && n < to);
97
+ if (i < 0) return { ...base, reason: 'not-enrolled' };
98
+ return { ...base, variant: def.variants[i].key, enrolled: true, reason: 'assigned' };
99
+ };
100
+
101
+ /** The default preview query parameter. */
102
+ export const DEFAULT_PREVIEW_PARAM = 'qa_variant';
103
+
104
+ /**
105
+ * `?qa_variant=signup-cta:b,other:a` → `{ 'signup-cta': 'b', other: 'a' }`.
106
+ * `?qa_variant=clear` (or `off`) → `{}` and clears the saved preview.
107
+ * Returns null when the parameter is absent. `param` names the query parameter.
108
+ */
109
+ export const parsePreview = (search: string, param: string = DEFAULT_PREVIEW_PARAM): Record<string, string> | null => {
110
+ let raw: string | null = null;
111
+ try {
112
+ raw = new URLSearchParams(search).get(param);
113
+ } catch {
114
+ return null;
115
+ }
116
+ if (raw == null) return null;
117
+ const out: Record<string, string> = {};
118
+ if (raw === 'clear' || raw === 'off') return out;
119
+ raw.split(',').forEach((pair) => {
120
+ const [k, v] = pair.split(':').map((s) => s?.trim());
121
+ if (k && v && /^[\w.-]{1,80}$/.test(k) && /^[\w.-]{1,40}$/.test(v)) out[k] = v;
122
+ });
123
+ return out;
124
+ };
125
+
126
+ export type TExperimentsStatus = 'idle' | 'loading' | 'ready' | 'failed';
127
+
128
+ interface ICache {
129
+ at: number;
130
+ property: string;
131
+ experiments: IExperimentDefinition[];
132
+ }
133
+
134
+ /** Cached definitions are used for a flicker-free first render for up to a day, then refreshed. */
135
+ const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
136
+ const FETCH_TIMEOUT_MS = 4000;
137
+
138
+ /** A usable entry, normalised (a concluded entry gets `variants: []`); null for junk. */
139
+ export const normaliseDefinition = (x: unknown): IExperimentDefinition | null => {
140
+ const d = x as IExperimentDefinition;
141
+ if (!d || typeof d.key !== 'string') return null;
142
+ if (d.status === 'concluded') return typeof d.winner === 'string' && d.winner ? { ...d, variants: d.variants ?? [] } : null;
143
+ if (d.status && d.status !== 'running') return null;
144
+ const ok = Array.isArray(d.variants) && d.variants.every((v) => v && typeof v.key === 'string' && typeof v.weight === 'number');
145
+ return ok ? d : null;
146
+ };
147
+
148
+ export interface IExperimentsDeps {
149
+ property: string;
150
+ endpoint: string;
151
+ local: IStorage;
152
+ session: IStorage;
153
+ now: () => number;
154
+ fetchJson: (url: string, timeoutMs: number) => Promise<unknown>;
155
+ /** Storage keys (default prefix `qa.`). */
156
+ keys?: TStorageKeys;
157
+ /** The preview query parameter (default `qa_variant`). */
158
+ previewParam?: string;
159
+ }
160
+
161
+ /** Holds the running experiments and the preview overrides. */
162
+ export class ExperimentStore {
163
+ readonly status = new Observable<TExperimentsStatus>('idle');
164
+ /**
165
+ * An in-app version switch, per experiment (`{ key: variant }`), in this browser's
166
+ * localStorage (an editor lets its user see each version). A preview: never logged.
167
+ * Rendered variants follow it live.
168
+ */
169
+ readonly editorOverrides: Observable<Record<string, string>>;
170
+ /**
171
+ * Bumped by `refresh()` (an editor, after it accepted a winner or removed a version): mounted
172
+ * experiments decide again, so an accepted test shows its winner at once. Visitors never
173
+ * trigger it, so their variant stays fixed for the page view.
174
+ */
175
+ readonly revision = new Observable<number>(0);
176
+ private definitions = new Map<string, IExperimentDefinition>();
177
+ private preview: Record<string, string>;
178
+ private readonly keys: TStorageKeys;
179
+
180
+ constructor(private readonly d: IExperimentsDeps) {
181
+ this.keys = d.keys ?? KEYS;
182
+ this.preview = readJson<Record<string, string>>(d.session, this.keys.preview) ?? {};
183
+ this.editorOverrides = new Observable<Record<string, string>>(readJson<Record<string, string>>(d.local, this.keys.editorVariants) ?? {});
184
+ const cache = readJson<ICache>(d.local, this.keys.experimentsCache);
185
+ if (cache && cache.property === d.property && d.now() - cache.at < CACHE_MAX_AGE_MS && Array.isArray(cache.experiments)) {
186
+ this.setDefinitions(cache.experiments);
187
+ this.status.set('ready');
188
+ }
189
+ }
190
+
191
+ /** Reads the preview parameter (`?qa_variant=`) from the URL; the preview sticks for the browser session. */
192
+ applyPreviewFrom(search: string) {
193
+ const parsed = parsePreview(search, this.d.previewParam ?? DEFAULT_PREVIEW_PARAM);
194
+ if (!parsed) return;
195
+ this.preview = Object.keys(parsed).length ? { ...this.preview, ...parsed } : {};
196
+ if (Object.keys(this.preview).length) writeJson(this.d.session, this.keys.preview, this.preview);
197
+ else this.d.session.remove(this.keys.preview);
198
+ }
199
+
200
+ /** The in-app switch first, then a preview link. */
201
+ previewOf = (key: string): string | undefined => this.editorOverrides.get()[key] ?? this.preview[key];
202
+
203
+ editorOverrideOf = (key: string): string | undefined => this.editorOverrides.get()[key];
204
+
205
+ /** The in-app version switch for one experiment; `null` goes back to the visitor's own variant. */
206
+ setEditorOverride(key: string, variant: string | null) {
207
+ const next = { ...this.editorOverrides.get() };
208
+ if (variant) next[key] = variant;
209
+ else delete next[key];
210
+ if (Object.keys(next).length) writeJson(this.d.local, this.keys.editorVariants, next);
211
+ else this.d.local.remove(this.keys.editorVariants);
212
+ this.editorOverrides.set(next);
213
+ }
214
+
215
+ /** Re-reads the switches (another window of the same app changed them). */
216
+ reloadEditorOverrides() {
217
+ const stored = readJson<Record<string, string>>(this.d.local, this.keys.editorVariants) ?? {};
218
+ if (JSON.stringify(stored) !== JSON.stringify(this.editorOverrides.get())) this.editorOverrides.set(stored);
219
+ }
220
+
221
+ /** Loads the definitions again and makes mounted experiments decide again (for an editor, never for visitors). */
222
+ async refresh(): Promise<void> {
223
+ await this.load(true);
224
+ this.revision.set(this.revision.get() + 1);
225
+ }
226
+
227
+ private setDefinitions(list: unknown[]) {
228
+ this.definitions = new Map(
229
+ list.map(normaliseDefinition).filter((d): d is IExperimentDefinition => !!d).map((d) => [d.key, d]),
230
+ );
231
+ }
232
+
233
+ get(key: string) {
234
+ return this.definitions.get(key);
235
+ }
236
+
237
+ /** Fetches the running experiments; a missing endpoint leaves everyone on the control. */
238
+ async load(force = false): Promise<void> {
239
+ if (this.status.get() === 'loading' && !force) return;
240
+ const hadCache = this.status.get() === 'ready';
241
+ if (!hadCache) this.status.set('loading');
242
+ try {
243
+ const sep = this.d.endpoint.includes('?') ? '&' : '?';
244
+ const body = await this.d.fetchJson(`${this.d.endpoint}${sep}property=${encodeURIComponent(this.d.property)}`, FETCH_TIMEOUT_MS);
245
+ const list = Array.isArray(body) ? body : ((body as { experiments?: unknown[] } | null)?.experiments ?? null);
246
+ if (!Array.isArray(list)) throw new Error('unexpected experiments payload');
247
+ this.setDefinitions(list);
248
+ writeJson(this.d.local, this.keys.experimentsCache, {
249
+ at: this.d.now(),
250
+ property: this.d.property,
251
+ experiments: Array.from(this.definitions.values()),
252
+ } satisfies ICache);
253
+ this.status.set('ready');
254
+ } catch {
255
+ if (!hadCache) this.status.set('failed');
256
+ }
257
+ }
258
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Deterministic bucketing: GrowthBook's hash v2 (MIT), so assignments match what
3
+ * GrowthBook-compatible tooling (and the server, if it ever recomputes) gets.
4
+ *
5
+ * n = (fnv1a32(String(fnv1a32(seed + value))) % 10000) / 10000
6
+ *
7
+ * The inner hash is turned into its *decimal string* before it is hashed again —
8
+ * that is GrowthBook's definition (`hashFnv32a(hashFnv32a(seed + value) + "")`).
9
+ * Characters are hashed as UTF-16 code units (`charCodeAt`), like GrowthBook; for
10
+ * the ASCII ids and keys we use that equals FNV-1a over the bytes.
11
+ */
12
+
13
+ /** FNV-1a, 32 bit. `fnv1a32('a') === 0xe40c292c` (the reference test vector). */
14
+ export const fnv1a32 = (input: string): number => {
15
+ let h = 0x811c9dc5;
16
+ for (let i = 0; i < input.length; i++) {
17
+ h ^= input.charCodeAt(i);
18
+ // h *= 16777619 (the FNV prime) in 32-bit arithmetic, as shifts and adds.
19
+ h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
20
+ }
21
+ return h >>> 0;
22
+ };
23
+
24
+ /** A number in [0, 1) with 4 decimals; the same inputs always give the same number. */
25
+ export const hashV2 = (seed: string, value: string): number =>
26
+ (fnv1a32(String(fnv1a32(seed + value))) % 10000) / 10000;
@@ -0,0 +1,25 @@
1
+ /** Random ids from the platform CSPRNG (never Math.random: ids must not collide or be guessable). */
2
+
3
+ const toBase64Url = (bytes: Uint8Array): string => {
4
+ let bin = '';
5
+ bytes.forEach((b) => (bin += String.fromCharCode(b)));
6
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
7
+ };
8
+
9
+ /** `bytes` random bytes as base64url: 16 bytes = 128 bits = 22 characters. */
10
+ export const randomId = (bytes = 16): string => {
11
+ const buf = new Uint8Array(bytes);
12
+ globalThis.crypto.getRandomValues(buf);
13
+ return toBase64Url(buf);
14
+ };
15
+
16
+ /** Event id (a UUID v4). */
17
+ export const eventId = (): string => {
18
+ if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
19
+ const b = new Uint8Array(16);
20
+ globalThis.crypto.getRandomValues(b);
21
+ b[6] = (b[6] & 0x0f) | 0x40;
22
+ b[8] = (b[8] & 0x3f) | 0x80;
23
+ const h = Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('');
24
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
25
+ };
@@ -0,0 +1,23 @@
1
+ type TListener = () => void;
2
+
3
+ /** A tiny observable value (what React's `useSyncExternalStore` wants). */
4
+ export class Observable<T> {
5
+ private listeners = new Set<TListener>();
6
+ constructor(private value: T) {}
7
+ get = (): T => this.value;
8
+ set(next: T) {
9
+ if (Object.is(next, this.value)) return;
10
+ this.value = next;
11
+ this.listeners.forEach((l) => {
12
+ try {
13
+ l();
14
+ } catch {
15
+ /* a listener never breaks the tracker */
16
+ }
17
+ });
18
+ }
19
+ subscribe = (listener: TListener) => {
20
+ this.listeners.add(listener);
21
+ return () => void this.listeners.delete(listener);
22
+ };
23
+ }
@@ -0,0 +1,131 @@
1
+ import type { IBatch, ITrackedEvent } from './types';
2
+
3
+ /**
4
+ * How a batch leaves the page. `beacon` is used when the page is going away
5
+ * (`pagehide`, tab hidden): the browser delivers it after the page is gone.
6
+ * Resolves to `true` when the batch was handed over, `false` to keep it for later
7
+ * (network error), and never rejects.
8
+ */
9
+ export interface ITransport {
10
+ send(body: string, mode: 'fetch' | 'beacon'): Promise<boolean>;
11
+ }
12
+
13
+ /** Collector limits (§4): at most 100 events and 64 KB per batch. We stay under both. */
14
+ export const MAX_EVENTS_PER_BATCH = 100;
15
+ export const MAX_BATCH_BYTES = 60 * 1024;
16
+ /** Events kept while the collector is unreachable; the oldest are dropped past this. */
17
+ export const MAX_BACKLOG = 500;
18
+
19
+ export interface IQueueOptions {
20
+ flushIntervalMs: number;
21
+ maxQueued: number;
22
+ transport: ITransport;
23
+ /** The envelope for a batch; null → nothing may be sent (no consent) and the queue is dropped. */
24
+ envelope: () => Omit<IBatch, 'events' | 'sent_at'> | null;
25
+ now: () => number;
26
+ /** Timers are injected so tests can drive them. */
27
+ setTimer: (fn: () => void, ms: number) => unknown;
28
+ clearTimer: (handle: unknown) => void;
29
+ debug?: (batch: IBatch) => void;
30
+ }
31
+
32
+ const size = (s: string) => (typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(s).length : s.length * 2);
33
+
34
+ /**
35
+ * Batches events: sends every `flushIntervalMs`, as soon as `maxQueued` are waiting,
36
+ * and immediately (as a beacon) when the page is going away.
37
+ */
38
+ export class EventQueue {
39
+ private events: ITrackedEvent[] = [];
40
+ private timer: unknown = null;
41
+ private sending = false;
42
+
43
+ constructor(private readonly o: IQueueOptions) {}
44
+
45
+ get length() {
46
+ return this.events.length;
47
+ }
48
+
49
+ push(event: ITrackedEvent) {
50
+ this.events.push(event);
51
+ if (this.events.length > MAX_BACKLOG) this.events.splice(0, this.events.length - MAX_BACKLOG);
52
+ if (this.events.length >= this.o.maxQueued) void this.flush('fetch');
53
+ else this.schedule();
54
+ }
55
+
56
+ /** Forget everything unsent (consent withdrawn). */
57
+ clear() {
58
+ this.events = [];
59
+ if (this.timer != null) this.o.clearTimer(this.timer);
60
+ this.timer = null;
61
+ }
62
+
63
+ private schedule() {
64
+ if (this.timer != null) return;
65
+ this.timer = this.o.setTimer(() => {
66
+ this.timer = null;
67
+ void this.flush('fetch');
68
+ }, this.o.flushIntervalMs);
69
+ }
70
+
71
+ /** Splits `events` into batches that fit the collector's limits. */
72
+ static chunk(events: ITrackedEvent[], envelope: Omit<IBatch, 'events' | 'sent_at'>, sentAt: string): IBatch[] {
73
+ const batches: IBatch[] = [];
74
+ let current: ITrackedEvent[] = [];
75
+ const bodyOf = (evts: ITrackedEvent[]) => JSON.stringify({ ...envelope, sent_at: sentAt, events: evts });
76
+ for (const e of events) {
77
+ const next = [...current, e];
78
+ if (current.length && (next.length > MAX_EVENTS_PER_BATCH || size(bodyOf(next)) > MAX_BATCH_BYTES)) {
79
+ batches.push({ ...envelope, sent_at: sentAt, events: current });
80
+ current = [e];
81
+ } else {
82
+ current = next;
83
+ }
84
+ }
85
+ if (current.length) batches.push({ ...envelope, sent_at: sentAt, events: current });
86
+ // A single event larger than the limit is dropped rather than rejected forever.
87
+ return batches.filter((b) => b.events.length > 1 || size(JSON.stringify(b)) <= MAX_BATCH_BYTES);
88
+ }
89
+
90
+ async flush(mode: 'fetch' | 'beacon' = 'fetch'): Promise<void> {
91
+ if (this.timer != null) {
92
+ this.o.clearTimer(this.timer);
93
+ this.timer = null;
94
+ }
95
+ if (!this.events.length) return;
96
+ // A fetch in flight owns the queue; a beacon (page going away) sends what is left now.
97
+ if (this.sending && mode === 'fetch') {
98
+ this.schedule();
99
+ return;
100
+ }
101
+ const envelope = this.o.envelope();
102
+ if (!envelope) {
103
+ this.events = [];
104
+ return;
105
+ }
106
+ const pending = this.events;
107
+ this.events = [];
108
+ const batches = EventQueue.chunk(pending, envelope, new Date(this.o.now()).toISOString());
109
+ this.sending = true;
110
+ const failed: ITrackedEvent[] = [];
111
+ try {
112
+ for (const batch of batches) {
113
+ this.o.debug?.(batch);
114
+ let ok = false;
115
+ try {
116
+ ok = await this.o.transport.send(JSON.stringify(batch), mode);
117
+ } catch {
118
+ ok = false;
119
+ }
120
+ if (!ok) failed.push(...batch.events);
121
+ }
122
+ } finally {
123
+ this.sending = false;
124
+ }
125
+ if (failed.length) {
126
+ // Kept for the next flush, which the next event schedules (no retry loop against a
127
+ // collector that is down); event ids make a double delivery harmless.
128
+ this.events = [...failed, ...this.events].slice(-MAX_BACKLOG);
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Attention per section, as plain arithmetic over time (no DOM): the browser layer
3
+ * feeds it visibility changes from an IntersectionObserver and tab visibility, and
4
+ * asks it for the page summary.
5
+ *
6
+ * - `visible_ms`: time any part of the section was on screen while the tab was visible.
7
+ * - `seen`: the section was at least 50% visible for at least 1 s in one stretch — the
8
+ * attention definition the contract uses everywhere (§4). "50% visible" is 50% of
9
+ * the section *or* 50% of the viewport covered by it: a section taller than two
10
+ * screens can never show half of itself, and it would otherwise never count.
11
+ * - `first_seen_ms`: when that stretch began, measured from the start of the page view.
12
+ */
13
+
14
+ export const SEEN_RATIO = 0.5;
15
+ export const SEEN_MS = 1000;
16
+
17
+ export interface ISectionSummary {
18
+ id: string;
19
+ visible_ms: number;
20
+ seen: boolean;
21
+ first_seen_ms: number | null;
22
+ }
23
+
24
+ interface ISectionState {
25
+ id: string;
26
+ /** Effective visibility 0..1 (see above). */
27
+ ratio: number;
28
+ visibleMs: number;
29
+ visibleSince: number | null;
30
+ halfSince: number | null;
31
+ seenAt: number | null;
32
+ firstSeenMs: number | null;
33
+ /** Reported in the page summary (false for synthetic experiment triggers). */
34
+ report: boolean;
35
+ }
36
+
37
+ export class SectionClock {
38
+ private sections = new Map<string, ISectionState>();
39
+ private tabVisible = true;
40
+ private seenListeners = new Set<(id: string) => void>();
41
+
42
+ constructor(private start: number) {}
43
+
44
+ /** A new page view: everything starts from zero at `now`. */
45
+ reset(now: number) {
46
+ this.start = now;
47
+ const ids = Array.from(this.sections.values()).map((s) => [s.id, s.report] as const);
48
+ this.sections.clear();
49
+ ids.forEach(([id, report]) => this.ensure(id, report));
50
+ }
51
+
52
+ onSeen(listener: (id: string) => void) {
53
+ this.seenListeners.add(listener);
54
+ return () => void this.seenListeners.delete(listener);
55
+ }
56
+
57
+ isSeen = (id: string) => this.sections.get(id)?.seenAt != null;
58
+
59
+ private ensure(id: string, report = true): ISectionState {
60
+ let s = this.sections.get(id);
61
+ if (!s) {
62
+ s = { id, ratio: 0, visibleMs: 0, visibleSince: null, halfSince: null, seenAt: null, firstSeenMs: null, report };
63
+ this.sections.set(id, s);
64
+ }
65
+ return s;
66
+ }
67
+
68
+ /** Registers a section so it shows up in the summary even if it is never on screen. */
69
+ register(id: string, report = true) {
70
+ this.ensure(id, report).report ||= report;
71
+ }
72
+
73
+ /** The section's effective visibility changed. */
74
+ update(id: string, ratio: number, now: number) {
75
+ const s = this.ensure(id);
76
+ if (this.tabVisible) {
77
+ const wasVisible = s.ratio > 0;
78
+ const isVisible = ratio > 0;
79
+ if (wasVisible && !isVisible && s.visibleSince != null) {
80
+ s.visibleMs += Math.max(0, now - s.visibleSince);
81
+ s.visibleSince = null;
82
+ }
83
+ if (!wasVisible && isVisible) s.visibleSince = now;
84
+ // A stretch at >= 50% continues through ratio changes above the line.
85
+ const wasHalf = s.ratio >= SEEN_RATIO;
86
+ const isHalf = ratio >= SEEN_RATIO;
87
+ if (wasHalf && !isHalf) {
88
+ this.tickOne(s, now);
89
+ s.halfSince = null;
90
+ }
91
+ if (!wasHalf && isHalf) s.halfSince = now;
92
+ }
93
+ s.ratio = ratio;
94
+ }
95
+
96
+ /** The tab became visible or hidden: time stops counting while hidden. */
97
+ setTabVisible(visible: boolean, now: number) {
98
+ if (visible === this.tabVisible) return;
99
+ this.sections.forEach((s) => this.settle(s, now));
100
+ this.tabVisible = visible;
101
+ this.sections.forEach((s) => this.arm(s, now));
102
+ }
103
+
104
+ /** Promotes stretches that have lasted long enough to `seen`. Call ~every second. */
105
+ tick(now: number) {
106
+ this.sections.forEach((s) => this.tickOne(s, now));
107
+ }
108
+
109
+ /** Closes the open stretches up to `now` (visible time, seen). */
110
+ private settle(s: ISectionState, now: number) {
111
+ if (s.visibleSince != null) s.visibleMs += Math.max(0, now - s.visibleSince);
112
+ s.visibleSince = null;
113
+ this.tickOne(s, now);
114
+ s.halfSince = null;
115
+ }
116
+
117
+ private tickOne(s: ISectionState, now: number) {
118
+ if (s.seenAt == null && s.halfSince != null && now - s.halfSince >= SEEN_MS) {
119
+ s.seenAt = now;
120
+ s.firstSeenMs = Math.max(0, s.halfSince - this.start);
121
+ this.seenListeners.forEach((l) => l(s.id));
122
+ }
123
+ }
124
+
125
+ /** Opens stretches for the current state. */
126
+ private arm(s: ISectionState, now: number) {
127
+ if (!this.tabVisible) return;
128
+ if (s.ratio > 0) s.visibleSince = now;
129
+ if (s.ratio >= SEEN_RATIO) s.halfSince = now;
130
+ }
131
+
132
+ /** The section most on screen now (ties: the lower one on the page, i.e. the later registered). */
133
+ current(): string | null {
134
+ let best: ISectionState | null = null;
135
+ for (const s of Array.from(this.sections.values())) {
136
+ if (s.report && s.ratio > 0 && (!best || s.ratio >= best.ratio)) best = s;
137
+ }
138
+ return best?.id ?? null;
139
+ }
140
+
141
+ summary(now: number): ISectionSummary[] {
142
+ this.tick(now);
143
+ return Array.from(this.sections.values())
144
+ .filter((s) => s.report)
145
+ .map((s) => ({
146
+ id: s.id,
147
+ visible_ms: Math.round(s.visibleMs + (s.visibleSince != null ? Math.max(0, now - s.visibleSince) : 0)),
148
+ seen: s.seenAt != null,
149
+ first_seen_ms: s.firstSeenMs,
150
+ }));
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Effective visibility from an IntersectionObserver entry: the larger of the share
156
+ * of the section on screen and the share of the viewport the section fills.
157
+ */
158
+ export const effectiveRatio = (
159
+ intersectionRatio: number,
160
+ intersectionHeight: number,
161
+ viewportHeight: number,
162
+ ): number => {
163
+ if (intersectionRatio <= 0) return 0;
164
+ const cover = viewportHeight > 0 ? intersectionHeight / viewportHeight : 0;
165
+ return Math.min(1, Math.max(intersectionRatio, cover));
166
+ };