@tokenoftrust/storefront-runner 2.0.4 → 2.0.5

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 (46) hide show
  1. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  2. package/apps/storefront/node_modules/.bin/vitest +2 -2
  3. package/apps/storefront/node_modules/.vite/deps/_metadata.json +19 -19
  4. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +3 -3
  5. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  6. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  7. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  9. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  10. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  11. package/apps/storefront/src/layouts/Layout.astro +64 -6
  12. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  13. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  14. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  15. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  16. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  17. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  18. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  19. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  20. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  21. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  22. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  23. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  24. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  25. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  26. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  27. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  28. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  29. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  30. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  31. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  32. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  33. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  34. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  35. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  36. package/package.json +1 -1
  37. package/packages/public-runtime/node_modules/.bin/vitest +2 -2
  38. package/packages/public-runtime/src/chrome.ts +18 -7
  39. package/packages/public-runtime/src/index.ts +10 -0
  40. package/packages/public-runtime/src/integration/index.ts +1 -0
  41. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  42. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  43. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  44. package/scripts/tenant/validate.mjs +306 -1
  45. package/scripts/tot-dev.mjs +59 -3
  46. package/tenants/home/public/js/storefront.js +6 -87
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Ingest validation for `POST /api/tracking/interaction` (dt-collector-v1).
3
+ *
4
+ * PURE (no I/O) — a thin auth-free parse/validate shell, same split as
5
+ * `activity/ingest.ts`: this module decides accept/reject, the route
6
+ * (`pages/api/tracking/interaction.ts`) does auth/transport/persistence.
7
+ *
8
+ * DEFAULT-DENY ALLOWLIST (reuses the posture of `activity/redaction.ts` /
9
+ * `activity/ingest.ts`'s `createActivityEvent()` re-validation, not their
10
+ * code — there is no per-action catalog for a fixed-shape event):
11
+ * 1. Only keys in `INTERACTION_WIRE_ALLOWLIST` are ever read from the raw
12
+ * body. Every other key is DROPPED and COUNTED (`droppedKeys`) — never
13
+ * stored, never logged with its value.
14
+ * 2. A declared key with the WRONG TYPE is never coerced — the whole event
15
+ * is rejected with a reason naming the field, exactly like a malformed
16
+ * `activity` event is rejected rather than papered over.
17
+ *
18
+ * TENANT + STREAM ISOLATION. `tenant_id` and `stream` are NEVER read from the
19
+ * posted body (they are not even in the allowlist) — the route stamps both
20
+ * server-side on every accepted event: `tenant_id` from the authenticated
21
+ * `context.locals.tenant.tenant_id`, `stream` as the fixed literal this module
22
+ * exports (`INTERACTION_STREAM`). A client cannot claim another tenant's scope
23
+ * or another stream.
24
+ */
25
+ import { isIsoTimestamp } from "@tot/public-runtime";
26
+ import { isAllowedInteractionKey } from "./collectorAllowlist.js";
27
+
28
+ /** The single stream this collector accepts in V1. Never client-supplied —
29
+ * stamped server-side so `dt-storage-per-tenant-d1` has a stable discriminator
30
+ * column ready before a second stream (e.g. a future custom `track()` call)
31
+ * exists. */
32
+ export const INTERACTION_STREAM = "interaction" as const;
33
+
34
+ /** Interaction kinds this collector accepts. Mirrors `TotInteraction["type"]`
35
+ * (currently "click" only — V1 has no other interaction kind). */
36
+ const INTERACTION_TYPES = new Set(["click"]);
37
+
38
+ /** A validated wire event, before tenant/stream are stamped. */
39
+ export interface InteractionWireEvent {
40
+ type: "click";
41
+ el: string;
42
+ action: string | null;
43
+ placement: string | null;
44
+ scope: string | null;
45
+ index: number | null;
46
+ /** Client emit clock (ISO-8601). Informational only — never trusted for
47
+ * ordering guarantees; the store also has its own receive clock. */
48
+ ts: string;
49
+ /** Groups events emitted by one collector lifetime (roughly: one page load). */
50
+ correlationId: string;
51
+ /** Monotonic within `correlationId`, assigned by the client collector. */
52
+ seq: number;
53
+ }
54
+
55
+ /** The stored/forwarded shape: a validated wire event plus the server stamp. */
56
+ export interface StampedInteractionEvent extends InteractionWireEvent {
57
+ tenant_id: string;
58
+ stream: typeof INTERACTION_STREAM;
59
+ /** Server receive clock (ISO-8601), stamped once per ingest. */
60
+ ingested_at: string;
61
+ }
62
+
63
+ export interface InteractionRejection {
64
+ index: number;
65
+ reason:
66
+ | "not_object"
67
+ | "unknown_type"
68
+ | "el_invalid"
69
+ | "action_invalid"
70
+ | "placement_invalid"
71
+ | "scope_invalid"
72
+ | "index_invalid"
73
+ | "ts_invalid"
74
+ | "correlation_id_invalid"
75
+ | "seq_invalid";
76
+ }
77
+
78
+ export interface AcceptedInteraction {
79
+ event: StampedInteractionEvent;
80
+ /** Non-allowlisted keys present on the raw body, dropped before validation
81
+ * (fact-of, never the value) — for audit / alerting, same convention as
82
+ * `RedactionFieldHit`. */
83
+ droppedKeys: string[];
84
+ }
85
+
86
+ export interface InteractionIngestOutcome {
87
+ accepted: AcceptedInteraction[];
88
+ rejected: InteractionRejection[];
89
+ }
90
+
91
+ const MAX_CORRELATION_ID_LEN = 128;
92
+
93
+ function nullableString(v: unknown): v is string | null {
94
+ return v === null || v === undefined || typeof v === "string";
95
+ }
96
+
97
+ function asNullableString(v: unknown): string | null {
98
+ return typeof v === "string" ? v : null;
99
+ }
100
+
101
+ /**
102
+ * Validate + stamp one posted interaction under the authenticated tenant.
103
+ * Strips any non-allowlisted key first (dropped + counted, never stored),
104
+ * then validates each allowlisted field's TYPE — a wrong-typed field rejects
105
+ * the whole event rather than being coerced.
106
+ */
107
+ export function ingestInteraction(
108
+ index: number,
109
+ raw: unknown,
110
+ tenantId: string,
111
+ ingestedAt: string,
112
+ ): AcceptedInteraction | InteractionRejection {
113
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { index, reason: "not_object" };
114
+ const e = raw as Record<string, unknown>;
115
+
116
+ const droppedKeys = Object.keys(e).filter((k) => !isAllowedInteractionKey(k));
117
+
118
+ const type = e.type;
119
+ if (typeof type !== "string" || !INTERACTION_TYPES.has(type)) return { index, reason: "unknown_type" };
120
+
121
+ const el = e.el;
122
+ if (typeof el !== "string" || el.trim().length === 0) return { index, reason: "el_invalid" };
123
+
124
+ if (!nullableString(e.action)) return { index, reason: "action_invalid" };
125
+ if (!nullableString(e.placement)) return { index, reason: "placement_invalid" };
126
+ if (!nullableString(e.scope)) return { index, reason: "scope_invalid" };
127
+
128
+ const rawIndex = e.index;
129
+ let idx: number | null;
130
+ if (rawIndex === null || rawIndex === undefined) {
131
+ idx = null;
132
+ } else if (typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0) {
133
+ idx = rawIndex;
134
+ } else {
135
+ return { index, reason: "index_invalid" };
136
+ }
137
+
138
+ const ts = e.ts;
139
+ if (!isIsoTimestamp(ts)) return { index, reason: "ts_invalid" };
140
+
141
+ const correlationId = e.correlationId;
142
+ if (
143
+ typeof correlationId !== "string" ||
144
+ correlationId.trim().length === 0 ||
145
+ correlationId.length > MAX_CORRELATION_ID_LEN
146
+ ) {
147
+ return { index, reason: "correlation_id_invalid" };
148
+ }
149
+
150
+ const seq = e.seq;
151
+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 0) {
152
+ return { index, reason: "seq_invalid" };
153
+ }
154
+
155
+ const event: StampedInteractionEvent = {
156
+ type: "click",
157
+ el,
158
+ action: asNullableString(e.action),
159
+ placement: asNullableString(e.placement),
160
+ scope: asNullableString(e.scope),
161
+ index: idx,
162
+ ts,
163
+ correlationId,
164
+ seq,
165
+ // Server-stamped, never from the client.
166
+ tenant_id: tenantId,
167
+ stream: INTERACTION_STREAM,
168
+ ingested_at: ingestedAt,
169
+ };
170
+
171
+ return { event, droppedKeys };
172
+ }
173
+
174
+ /** Validate + stamp a whole posted batch under the authenticated tenant. */
175
+ export function ingestInteractions(
176
+ events: unknown[],
177
+ tenantId: string,
178
+ ingestedAt: string,
179
+ ): InteractionIngestOutcome {
180
+ const accepted: AcceptedInteraction[] = [];
181
+ const rejected: InteractionRejection[] = [];
182
+ events.forEach((raw, i) => {
183
+ const r = ingestInteraction(i, raw, tenantId, ingestedAt);
184
+ if ("event" in r) accepted.push(r);
185
+ else rejected.push(r);
186
+ });
187
+ return { accepted, rejected };
188
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Cloudflare D1 control + data plane over the REST API — how a PER-TENANT D1
3
+ * database is created and queried from inside the Worker.
4
+ *
5
+ * WHY REST AND NOT A BINDING. Worker bindings are STATIC: `wrangler.toml`
6
+ * declares a fixed set of `[[d1_databases]]` at build time, so there is no
7
+ * `env.lookupD1(name)` and no way for a request to bind a database that did not
8
+ * exist when the Worker was built. One database per tenant is therefore only
9
+ * reachable through the D1 HTTP API (`/accounts/:account/d1/database/...`),
10
+ * which addresses a database by UUID at call time. The shared bindings
11
+ * (`CATALOG_DB`, `STOREFRONT_APPS_DB`) stay exactly as they are; this module is
12
+ * additive and used ONLY for the per-tenant raw-interaction stores.
13
+ *
14
+ * CREDENTIALS. `CF_ACCOUNT_ID` + `TRACKING_D1_API_TOKEN` (a `wrangler secret`,
15
+ * scoped to D1:edit). Either one absent ⇒ `readD1Control()` returns undefined
16
+ * and every caller treats that as "sink unavailable, skip it" — never an error,
17
+ * the same posture `readAnalytics` + `activity/store.ts` already establish for
18
+ * an unbound sink. Nothing here ever throws to the caller.
19
+ *
20
+ * The query adapter satisfies the same `Queryable` seam the binding-backed
21
+ * stores use (`d1/catalog.ts`), so the insert code in `interactionStore.ts` is
22
+ * transport-agnostic and unit-testable against an in-memory sqlite.
23
+ */
24
+ import { readEnv } from "../env.js";
25
+ import type { Queryable, QueryStatement } from "../d1/catalog.js";
26
+
27
+ const CF_API_BASE = "https://api.cloudflare.com/client/v4";
28
+
29
+ /** Resolved REST credentials for the D1 API. */
30
+ export interface D1ControlConfig {
31
+ accountId: string;
32
+ apiToken: string;
33
+ /** Injected for tests; defaults to the global `fetch`. */
34
+ fetchImpl?: typeof fetch;
35
+ /** Injected for tests; defaults to the public Cloudflare API. */
36
+ apiBase?: string;
37
+ }
38
+
39
+ /**
40
+ * Read the D1 REST credentials from the runtime env. Undefined when either is
41
+ * unset — i.e. the per-tenant store is not configured in this environment
42
+ * (local `astro dev`, an un-provisioned tier). Callers SKIP, never fail.
43
+ */
44
+ export async function readD1Control(): Promise<D1ControlConfig | undefined> {
45
+ const [accountId, apiToken] = await Promise.all([
46
+ readEnv("CF_ACCOUNT_ID"),
47
+ readEnv("TRACKING_D1_API_TOKEN"),
48
+ ]);
49
+ if (!accountId || !apiToken) return undefined;
50
+ return { accountId, apiToken };
51
+ }
52
+
53
+ interface CfEnvelope<T> {
54
+ success?: boolean;
55
+ result?: T;
56
+ errors?: Array<{ code?: number; message?: string }>;
57
+ }
58
+
59
+ async function cfRequest<T>(
60
+ cfg: D1ControlConfig,
61
+ path: string,
62
+ init?: { method?: string; body?: unknown },
63
+ ): Promise<CfEnvelope<T>> {
64
+ const doFetch = cfg.fetchImpl ?? fetch;
65
+ const res = await doFetch(`${cfg.apiBase ?? CF_API_BASE}/accounts/${cfg.accountId}${path}`, {
66
+ method: init?.method ?? "GET",
67
+ headers: {
68
+ authorization: `Bearer ${cfg.apiToken}`,
69
+ "content-type": "application/json",
70
+ },
71
+ body: init?.body === undefined ? undefined : JSON.stringify(init.body),
72
+ });
73
+ const parsed = (await res.json()) as CfEnvelope<T>;
74
+ if (!res.ok || parsed.success === false) {
75
+ const detail = parsed.errors?.map((e) => e.message).filter(Boolean).join("; ") || `HTTP ${res.status}`;
76
+ throw new Error(`d1 api ${path} failed: ${detail}`);
77
+ }
78
+ return parsed;
79
+ }
80
+
81
+ interface D1DatabaseSummary {
82
+ uuid?: string;
83
+ name?: string;
84
+ }
85
+
86
+ /** The UUID of the D1 database with this exact name, or null if none exists. */
87
+ export async function findDatabaseByName(
88
+ cfg: D1ControlConfig,
89
+ name: string,
90
+ ): Promise<string | null> {
91
+ const body = await cfRequest<D1DatabaseSummary[]>(
92
+ cfg,
93
+ `/d1/database?name=${encodeURIComponent(name)}&per_page=100`,
94
+ );
95
+ // `?name=` is a SUBSTRING filter on Cloudflare's side, so match exactly here —
96
+ // otherwise `…-dt-acme` could resolve to `…-dt-acme-staging` and route one
97
+ // tenant's rows into another tenant's database.
98
+ const exact = (body.result ?? []).find((d) => d.name === name);
99
+ return exact?.uuid ?? null;
100
+ }
101
+
102
+ /** Create a D1 database and return its UUID. */
103
+ export async function createDatabase(cfg: D1ControlConfig, name: string): Promise<string> {
104
+ const body = await cfRequest<D1DatabaseSummary>(cfg, "/d1/database", {
105
+ method: "POST",
106
+ body: { name },
107
+ });
108
+ const uuid = body.result?.uuid;
109
+ if (!uuid) throw new Error(`d1 api create ${name} returned no uuid`);
110
+ return uuid;
111
+ }
112
+
113
+ /**
114
+ * Find-or-create, tolerant of a concurrent creator: two beacons for a brand-new
115
+ * tenant can race here, and the loser's `create` fails with "already exists" —
116
+ * which is a SUCCESS for our purposes, so we re-resolve by name instead of
117
+ * propagating it.
118
+ */
119
+ export async function ensureDatabase(cfg: D1ControlConfig, name: string): Promise<string> {
120
+ const existing = await findDatabaseByName(cfg, name);
121
+ if (existing) return existing;
122
+ try {
123
+ return await createDatabase(cfg, name);
124
+ } catch (err) {
125
+ const raced = await findDatabaseByName(cfg, name);
126
+ if (raced) return raced;
127
+ throw err;
128
+ }
129
+ }
130
+
131
+ interface D1QueryResult {
132
+ results?: unknown[];
133
+ }
134
+
135
+ /** POST one (or a semicolon-separated script of) statement(s) to a database. */
136
+ async function query(
137
+ cfg: D1ControlConfig,
138
+ databaseUuid: string,
139
+ sql: string,
140
+ params: readonly unknown[],
141
+ ): Promise<unknown[]> {
142
+ const body = await cfRequest<D1QueryResult[]>(cfg, `/d1/database/${databaseUuid}/query`, {
143
+ method: "POST",
144
+ body: { sql, params },
145
+ });
146
+ // One entry per statement; flatten to the rows of the LAST statement, which is
147
+ // what a single-statement `all`/`first` caller means.
148
+ const blocks = body.result ?? [];
149
+ return (blocks[blocks.length - 1]?.results ?? []) as unknown[];
150
+ }
151
+
152
+ /**
153
+ * Adapt a per-tenant database (addressed by UUID over REST) to the same
154
+ * `Queryable` seam the D1 bindings use. `batch` is emulated as a
155
+ * semicolon-joined script in ONE HTTP call — D1's `/query` runs a multi-
156
+ * statement script inside one implicit transaction, which is what the binding's
157
+ * `batch()` guarantees too.
158
+ */
159
+ export function fromD1Rest(cfg: D1ControlConfig, databaseUuid: string): Queryable {
160
+ return {
161
+ all: async <T>(sql: string, ...params: unknown[]) =>
162
+ (await query(cfg, databaseUuid, sql, params)) as T[],
163
+ first: async <T>(sql: string, ...params: unknown[]) =>
164
+ (((await query(cfg, databaseUuid, sql, params))[0] ?? null) as T | null),
165
+ run: async (sql: string, ...params: unknown[]) => {
166
+ await query(cfg, databaseUuid, sql, params);
167
+ },
168
+ exec: async (sql: string) => {
169
+ await query(cfg, databaseUuid, sql, []);
170
+ },
171
+ batch: async (statements: readonly QueryStatement[]) => {
172
+ if (statements.length === 0) return;
173
+ // The REST `params` array is positional across the whole script, so the
174
+ // placeholders of every statement bind in order.
175
+ const sql = statements.map((s) => s.sql.trim().replace(/;$/, "")).join(";\n");
176
+ const params = statements.flatMap((s) => [...(s.params ?? [])]);
177
+ await query(cfg, databaseUuid, sql, params);
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * dt-collector-v1 — the browser-side delivery destination for
3
+ * `TotInteraction` (`@tot/public-runtime`'s declarative interaction-tracking
4
+ * contract). This module OWNS batching + transport; it does not install the
5
+ * click listener and does not gate on consent/environment — those are
6
+ * `interaction-tracking.ts` and `dt-consent-environment-gate` respectively.
7
+ *
8
+ * Wiring (owned by whichever module composes the gate in front of delivery,
9
+ * e.g. `dt-consent-environment-gate`):
10
+ *
11
+ * import { onInteraction } from "@tot/public-runtime";
12
+ * import { collectInteraction } from "@/lib/tracking/interactionCollector";
13
+ * onInteraction((interaction) => {
14
+ * if (consentAndEnvironmentAllow()) collectInteraction(interaction);
15
+ * });
16
+ *
17
+ * or, where the caller controls install directly:
18
+ *
19
+ * installInteractionTracking({ deliver: collectInteraction });
20
+ *
21
+ * PAYLOAD GUARDRAIL: this module reads only the five `TotInteraction` fields
22
+ * handed to it, plus what it synthesizes itself (`ts`, `correlationId`,
23
+ * `seq`). No DOM access, no `textContent`/`href`/form reads — same prohibition
24
+ * as `interaction-tracking.ts`'s listener; see that module's guardrail test
25
+ * (`tests/interaction-tracking.test.ts`) for the pattern this module must not
26
+ * violate either.
27
+ */
28
+ import type { TotInteraction } from "@tot/public-runtime";
29
+
30
+ /** Where the browser beacon POSTs interaction batches. */
31
+ export const INTERACTION_BEACON_PATH = "/api/tracking/interaction";
32
+
33
+ /** Max events held in memory before a forced flush — bounds one beacon's size
34
+ * and the worst case if flush triggers never fire (e.g. a test/SSR context). */
35
+ export const MAX_QUEUE_SIZE = 20;
36
+
37
+ /** The wire shape POSTed to `INTERACTION_BEACON_PATH` — `TotInteraction`'s five
38
+ * fields plus this module's own synthesized correlation fields. Must stay in
39
+ * lockstep with `INTERACTION_WIRE_ALLOWLIST`
40
+ * (`apps/storefront/src/lib/tracking/collectorAllowlist.ts`). */
41
+ export interface InteractionWireEvent {
42
+ type: TotInteraction["type"];
43
+ el: string;
44
+ action: string | null;
45
+ placement: string | null;
46
+ scope: string | null;
47
+ index: number | null;
48
+ /** Client emit clock, ISO-8601. */
49
+ ts: string;
50
+ /** Groups every event from one collector lifetime (one page load). */
51
+ correlationId: string;
52
+ /** Monotonic within `correlationId`, starting at 0. */
53
+ seq: number;
54
+ }
55
+
56
+ /** Build the wire event for one interaction. Pure — no DOM, no clock side
57
+ * effects beyond the `now` parameter, so it is unit-testable without faking
58
+ * globals. */
59
+ export function toWireEvent(
60
+ interaction: TotInteraction,
61
+ correlationId: string,
62
+ seq: number,
63
+ now: () => string = () => new Date().toISOString(),
64
+ ): InteractionWireEvent {
65
+ return {
66
+ type: interaction.type,
67
+ el: interaction.el,
68
+ action: interaction.action,
69
+ placement: interaction.placement,
70
+ scope: interaction.scope,
71
+ index: interaction.index,
72
+ ts: now(),
73
+ correlationId,
74
+ seq,
75
+ };
76
+ }
77
+
78
+ /** A sink for a finished batch. The default posts one JSON beacon. */
79
+ export type InteractionBatchSender = (events: InteractionWireEvent[]) => void;
80
+
81
+ /** Default sender: one `sendBeacon` per flushed batch, falling back to a
82
+ * keepalive `fetch` where `sendBeacon` is absent (mirrors
83
+ * `analytics/webVitals.ts`'s `beaconReporter`). Never throws into the page. */
84
+ export function beaconBatchSender(endpoint: string = INTERACTION_BEACON_PATH): InteractionBatchSender {
85
+ return (events) => {
86
+ if (events.length === 0 || typeof navigator === "undefined") return;
87
+ const body = JSON.stringify({ events });
88
+ try {
89
+ if (typeof navigator.sendBeacon === "function") {
90
+ navigator.sendBeacon(endpoint, body);
91
+ } else if (typeof fetch === "function") {
92
+ void fetch(endpoint, { method: "POST", body, keepalive: true });
93
+ }
94
+ } catch {
95
+ // Never let telemetry throw into the page.
96
+ }
97
+ };
98
+ }
99
+
100
+ function randomCorrelationId(): string {
101
+ const c: Crypto | undefined = (globalThis as { crypto?: Crypto }).crypto;
102
+ if (c?.randomUUID) return c.randomUUID();
103
+ return `ic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
104
+ }
105
+
106
+ /**
107
+ * A collector instance: batches interactions in memory and flushes them to
108
+ * `send` on demand, on queue overflow, or when the caller wires
109
+ * `flush`-on-`visibilitychange`/`pagehide` (see {@link installFlushTriggers}).
110
+ * Exposed as a factory (rather than one module-level singleton) so tests can
111
+ * construct an isolated instance with a fake sender and clock.
112
+ */
113
+ export interface InteractionCollector {
114
+ /** Enqueue one interaction; synthesizes `ts`/`correlationId`/`seq`. Flushes
115
+ * immediately once the queue reaches `MAX_QUEUE_SIZE`. */
116
+ collect(interaction: TotInteraction): void;
117
+ /** Send whatever is queued right now (no-op if empty). */
118
+ flush(): void;
119
+ }
120
+
121
+ export function createInteractionCollector(
122
+ send: InteractionBatchSender = beaconBatchSender(),
123
+ correlationId: string = randomCorrelationId(),
124
+ now?: () => string,
125
+ ): InteractionCollector {
126
+ let queue: InteractionWireEvent[] = [];
127
+ let seq = 0;
128
+
129
+ const flush = (): void => {
130
+ if (queue.length === 0) return;
131
+ const batch = queue;
132
+ queue = [];
133
+ send(batch);
134
+ };
135
+
136
+ const collect = (interaction: TotInteraction): void => {
137
+ queue.push(toWireEvent(interaction, correlationId, seq, now));
138
+ seq += 1;
139
+ if (queue.length >= MAX_QUEUE_SIZE) flush();
140
+ };
141
+
142
+ return { collect, flush };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Browser wiring (thin — everything above is pure + unit-tested).
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Wire `collector.flush` to the two reliable end-of-visibility moments (same
151
+ * pair `analytics/webVitals.ts#startWebVitals` uses): the page first going
152
+ * hidden, and `pagehide` — the last point mobile Safari guarantees a beacon
153
+ * can still be sent. No-op teardown when there is no `document`/`window`
154
+ * (SSR, tests).
155
+ */
156
+ export function installFlushTriggers(collector: InteractionCollector): () => void {
157
+ if (typeof document === "undefined" || typeof window === "undefined") return () => {};
158
+ const onVisibility = (): void => {
159
+ if (document.visibilityState === "hidden") collector.flush();
160
+ };
161
+ document.addEventListener("visibilitychange", onVisibility, { capture: true });
162
+ window.addEventListener("pagehide", collector.flush, { capture: true });
163
+ return () => {
164
+ document.removeEventListener("visibilitychange", onVisibility, { capture: true });
165
+ window.removeEventListener("pagehide", collector.flush, { capture: true });
166
+ };
167
+ }
168
+
169
+ let defaultCollector: InteractionCollector | null = null;
170
+ let defaultTeardown: (() => void) | null = null;
171
+
172
+ function getDefaultCollector(): InteractionCollector {
173
+ if (!defaultCollector) {
174
+ defaultCollector = createInteractionCollector();
175
+ defaultTeardown = installFlushTriggers(defaultCollector);
176
+ }
177
+ return defaultCollector;
178
+ }
179
+
180
+ /**
181
+ * The default delivery destination: `installInteractionTracking({ deliver:
182
+ * collectInteraction })` or forward into it from an `onInteraction` subscriber
183
+ * (that is what the consent/environment gate does). Lazily creates the
184
+ * module-level collector and its flush wiring on first call, so importing
185
+ * this module has no side effects until an interaction actually arrives.
186
+ */
187
+ export function collectInteraction(interaction: TotInteraction): void {
188
+ getDefaultCollector().collect(interaction);
189
+ }
190
+
191
+ /** Force-flush the default collector (e.g. from a manual "send now" hook). */
192
+ export function flushInteractions(): void {
193
+ defaultCollector?.flush();
194
+ }
195
+
196
+ /** Test-only: drop the module-level singleton so a fresh collector/wiring is
197
+ * created on the next {@link collectInteraction} call. */
198
+ export function resetDefaultCollectorForTests(): void {
199
+ defaultTeardown?.();
200
+ defaultCollector = null;
201
+ defaultTeardown = null;
202
+ }