@refraction-ui/astro 0.7.0 → 0.8.0

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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * GA4 `client-sdk` adapter — lazy gtag.js.
3
+ *
4
+ * The vendor library is NEVER a hard dependency and is NEVER bundled. On the
5
+ * first delivery the adapter:
6
+ * 1. installs the gtag stub + dataLayer,
7
+ * 2. pushes the Consent Mode default (if a bridge is configured),
8
+ * 3. lazily injects `https://www.googletagmanager.com/gtag/js?id=<id>`
9
+ * via a `<script>` tag (or an injected loader for tests/SSR),
10
+ * 4. runs `gtag('js', Date)` + `gtag('config', <id>, { send_page_view:false })`.
11
+ *
12
+ * Subsequent deliveries map each canonical envelope through the shared mapper
13
+ * and call `gtag('set', 'user_properties', …)` / `gtag('set', { user_id })` /
14
+ * `gtag('event', name, params)`.
15
+ *
16
+ * Consent Mode bridge: `init` pushes `consent: default`; `deliver` is only
17
+ * reached when the router's consent gate already allows this sink, at which
18
+ * point `consent: update` is pushed for the mapped GA4 consent types.
19
+ */
20
+
21
+ import type {
22
+ AnalyticsEvent,
23
+ AnalyticsSink,
24
+ SinkInitContext,
25
+ } from '../analytics/index.ts'
26
+ import type { GA4ClientSdkOptions, GtagFn } from './types.js'
27
+ import { mapEvent } from './mapping.js'
28
+
29
+ const DEFAULT_SRC_BASE = 'https://www.googletagmanager.com/gtag/js'
30
+
31
+ interface GtagGlobal {
32
+ dataLayer?: unknown[]
33
+ gtag?: GtagFn
34
+ document?: {
35
+ createElement(tag: string): {
36
+ async: boolean
37
+ src: string
38
+ onload: (() => void) | null
39
+ onerror: (() => void) | null
40
+ }
41
+ head?: { appendChild(node: unknown): void }
42
+ getElementsByTagName(tag: string): ArrayLike<{
43
+ parentNode?: { insertBefore(a: unknown, b: unknown): void }
44
+ }>
45
+ }
46
+ }
47
+
48
+ /** DOM `<script>` injector (browser only). */
49
+ function defaultScriptLoader(src: string): Promise<void> {
50
+ const g = globalThis as unknown as GtagGlobal
51
+ const doc = g.document
52
+ if (!doc || typeof doc.createElement !== 'function') {
53
+ return Promise.reject(
54
+ new Error('GA4 client-sdk sink: no DOM to inject gtag.js'),
55
+ )
56
+ }
57
+ return new Promise<void>((resolve, reject) => {
58
+ const el = doc.createElement('script')
59
+ el.async = true
60
+ el.src = src
61
+ el.onload = () => resolve()
62
+ el.onerror = () => reject(new Error('GA4 client-sdk: gtag.js failed'))
63
+ if (doc.head?.appendChild) {
64
+ doc.head.appendChild(el)
65
+ } else {
66
+ const first = doc.getElementsByTagName('script')[0]
67
+ first?.parentNode?.insertBefore(el, first)
68
+ }
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Create the GA4 gtag.js (`client-sdk`) sink. The vendor script is dynamically
74
+ * loaded on first delivery only — there is no static import of any GA4/gtag
75
+ * library, so `http`-mode consumers never pull this code path.
76
+ */
77
+ export function createGA4ClientSdkSink(
78
+ options: GA4ClientSdkOptions,
79
+ ): AnalyticsSink {
80
+ const {
81
+ measurementId,
82
+ consentCategories = ['analytics'],
83
+ name = 'ga4',
84
+ consentMode,
85
+ } = options
86
+
87
+ const g = globalThis as unknown as GtagGlobal
88
+
89
+ let gtag: GtagFn | undefined = options.gtag
90
+ let loaded = false
91
+ let loadPromise: Promise<void> | undefined
92
+
93
+ function ensureGtagStub(): GtagFn {
94
+ if (gtag) return gtag
95
+ // Standard gtag bootstrap: dataLayer-backed shim available synchronously.
96
+ g.dataLayer = g.dataLayer ?? []
97
+ const dl = g.dataLayer
98
+ const fn: GtagFn = (...args: unknown[]) => {
99
+ dl.push(args)
100
+ }
101
+ g.gtag = g.gtag ?? fn
102
+ gtag = g.gtag
103
+ return gtag
104
+ }
105
+
106
+ function pushConsentDefault(): void {
107
+ if (!consentMode?.default || !gtag) return
108
+ gtag('consent', 'default', { ...consentMode.default })
109
+ }
110
+
111
+ function pushConsentUpdate(): void {
112
+ if (!consentMode?.map || !gtag) return
113
+ const update: Record<string, 'granted'> = {}
114
+ // deliver() only runs when the router's consent gate already permits this
115
+ // sink (all consentCategories granted) → reflect that to GA4.
116
+ for (const cat of consentCategories) {
117
+ for (const ga4Type of consentMode.map[cat] ?? []) {
118
+ update[ga4Type] = 'granted'
119
+ }
120
+ }
121
+ if (Object.keys(update).length > 0) {
122
+ gtag('consent', 'update', update)
123
+ }
124
+ }
125
+
126
+ function ensureLoaded(): Promise<void> {
127
+ if (loaded) return Promise.resolve()
128
+ if (loadPromise) return loadPromise
129
+
130
+ ensureGtagStub()
131
+ pushConsentDefault()
132
+
133
+ // App/test supplied its own gtag → never inject a vendor script.
134
+ if (options.gtag) {
135
+ gtag!('js', new Date())
136
+ gtag!('config', measurementId, { send_page_view: false })
137
+ loaded = true
138
+ return Promise.resolve()
139
+ }
140
+
141
+ const base = options.gtagSrcBase ?? DEFAULT_SRC_BASE
142
+ const src = `${base}?id=${encodeURIComponent(measurementId)}`
143
+ const load = options.scriptLoader ?? defaultScriptLoader
144
+
145
+ loadPromise = load(src).then(() => {
146
+ gtag!('js', new Date())
147
+ gtag!('config', measurementId, { send_page_view: false })
148
+ loaded = true
149
+ })
150
+ return loadPromise
151
+ }
152
+
153
+ return {
154
+ name,
155
+ consentCategories,
156
+
157
+ init(_ctx: SinkInitContext): void {
158
+ // Install the stub + Consent Mode default eagerly so a `consent:
159
+ // default` is in dataLayer before the tag loads. The vendor script
160
+ // itself is still deferred to the first delivery.
161
+ ensureGtagStub()
162
+ pushConsentDefault()
163
+ void _ctx
164
+ },
165
+
166
+ async deliver(batch: AnalyticsEvent[]): Promise<void> {
167
+ if (batch.length === 0) return
168
+ await ensureLoaded()
169
+ const send = gtag!
170
+ pushConsentUpdate()
171
+
172
+ for (const ev of batch) {
173
+ const m = mapEvent(ev)
174
+
175
+ if (m.userId) {
176
+ send('set', { user_id: m.userId })
177
+ }
178
+ if (m.userProperties) {
179
+ const flat: Record<string, unknown> = {}
180
+ for (const [k, v] of Object.entries(m.userProperties)) {
181
+ flat[k] = v.value
182
+ }
183
+ send('set', 'user_properties', flat)
184
+ }
185
+ if (m.event) {
186
+ send('event', m.event.name, m.event.params)
187
+ }
188
+ }
189
+ },
190
+ }
191
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `createGA4Sink` — the single entry point. Dispatches on `mode`:
3
+ * - `mode: 'client-sdk'` → lazy gtag.js adapter
4
+ * - `mode: 'http'` (default) → Measurement Protocol adapter, no vendor lib
5
+ *
6
+ * Default is `http` to honour the epic's "default = protocol adapters (no
7
+ * vendor libs in the browser)" stance. GA4 is just a sink — register it via
8
+ * `createAnalytics({ sinks: [createGA4Sink(...)] })` or `analytics.addSink`.
9
+ *
10
+ * Both factories are independent modules. The `http` factory has ZERO
11
+ * references to gtag.js / DOM-script code, and the `client-sdk` factory only
12
+ * touches the vendor script lazily (first delivery). Selecting one mode never
13
+ * executes the other's load path; consumers that only ever construct an
14
+ * `http` sink never run any vendor code.
15
+ */
16
+
17
+ import type { AnalyticsSink } from '../analytics/index.ts'
18
+ import type { GA4SinkOptions } from './types.js'
19
+ import { createGA4HttpSink } from './http-sink.js'
20
+ import { createGA4ClientSdkSink } from './client-sdk-sink.js'
21
+
22
+ export function createGA4Sink(options: GA4SinkOptions): AnalyticsSink {
23
+ if (options.mode === 'client-sdk') {
24
+ return createGA4ClientSdkSink(options)
25
+ }
26
+ return createGA4HttpSink(options)
27
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * GA4 `http` adapter — Measurement Protocol (`/mp/collect`).
3
+ *
4
+ * Server-relay friendly: this path NEVER loads gtag.js or any browser
5
+ * library. It only POSTs JSON to the Measurement Protocol endpoint:
6
+ *
7
+ * POST {endpoint}/mp/collect?measurement_id={id}&api_secret={secret}
8
+ * Content-Type: application/json
9
+ * { client_id, user_id?, user_properties?, events: [{ name, params }] }
10
+ *
11
+ * GA4 Measurement Protocol constraints honoured here:
12
+ * - up to 25 events per request → batches are chunked.
13
+ * - `identify` envelopes carry no event; they still POST so user_id /
14
+ * user_properties propagate (events array may be empty for a user-props
15
+ * only ping, which GA4 accepts).
16
+ * - 2xx (incl. 204) = accepted. The MP endpoint always returns 2xx for
17
+ * well-formed requests; the `debug` endpoint returns validation messages.
18
+ */
19
+
20
+ import type {
21
+ AnalyticsEvent,
22
+ AnalyticsSink,
23
+ SinkInitContext,
24
+ } from '../analytics/index.ts'
25
+ import type { GA4HttpOptions } from './types.js'
26
+ import { mapEvent } from './mapping.js'
27
+
28
+ const DEFAULT_ENDPOINT = 'https://www.google-analytics.com'
29
+ const MAX_EVENTS_PER_REQUEST = 25
30
+
31
+ interface MPPayload {
32
+ client_id: string
33
+ user_id?: string
34
+ user_properties?: Record<string, { value: unknown }>
35
+ events: Array<{ name: string; params: Record<string, unknown> }>
36
+ }
37
+
38
+ function resolveFetch(opts: GA4HttpOptions): typeof fetch {
39
+ if (opts.fetchImpl) return opts.fetchImpl
40
+ const f = (globalThis as unknown as { fetch?: typeof fetch }).fetch
41
+ if (!f) throw new Error('GA4 http sink: no fetch implementation available')
42
+ return f
43
+ }
44
+
45
+ /**
46
+ * Group consecutive envelopes by GA4 identity (client_id + user_id) so each
47
+ * Measurement Protocol request carries a single identity, then chunk to the
48
+ * 25-event limit.
49
+ */
50
+ function buildPayloads(batch: AnalyticsEvent[]): MPPayload[] {
51
+ const payloads: MPPayload[] = []
52
+
53
+ for (const ev of batch) {
54
+ const m = mapEvent(ev)
55
+ const last = payloads[payloads.length - 1]
56
+ const sameIdentity =
57
+ last &&
58
+ last.client_id === m.clientId &&
59
+ last.user_id === m.userId &&
60
+ last.events.length < MAX_EVENTS_PER_REQUEST &&
61
+ // user_properties must not silently differ within one request
62
+ JSON.stringify(last.user_properties) ===
63
+ JSON.stringify(m.userProperties)
64
+
65
+ const target: MPPayload = sameIdentity
66
+ ? last
67
+ : (() => {
68
+ const p: MPPayload = { client_id: m.clientId, events: [] }
69
+ if (m.userId) p.user_id = m.userId
70
+ if (m.userProperties) p.user_properties = m.userProperties
71
+ payloads.push(p)
72
+ return p
73
+ })()
74
+
75
+ if (m.event) {
76
+ target.events.push(m.event)
77
+ }
78
+ }
79
+
80
+ // Drop empty payloads that carry neither an event nor user identity
81
+ // signal (nothing to send to GA4).
82
+ return payloads.filter(
83
+ (p) =>
84
+ p.events.length > 0 ||
85
+ p.user_id !== undefined ||
86
+ p.user_properties !== undefined,
87
+ )
88
+ }
89
+
90
+ /**
91
+ * Create the GA4 Measurement-Protocol (`http`) sink.
92
+ *
93
+ * No vendor library is loaded on this path under any circumstance.
94
+ */
95
+ export function createGA4HttpSink(options: GA4HttpOptions): AnalyticsSink {
96
+ const {
97
+ measurementId,
98
+ apiSecret,
99
+ consentCategories = ['analytics'],
100
+ name = 'ga4',
101
+ debug = false,
102
+ } = options
103
+ const base = (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '')
104
+ const path = debug ? '/debug/mp/collect' : '/mp/collect'
105
+ const url =
106
+ `${base}${path}?measurement_id=${encodeURIComponent(measurementId)}` +
107
+ `&api_secret=${encodeURIComponent(apiSecret)}`
108
+
109
+ return {
110
+ name,
111
+ consentCategories,
112
+
113
+ init(_ctx: SinkInitContext): void {
114
+ // No-op: the Measurement Protocol is stateless and credential-driven.
115
+ // Explicitly NO vendor script / SDK is loaded here.
116
+ void _ctx
117
+ },
118
+
119
+ async deliver(batch: AnalyticsEvent[]): Promise<void> {
120
+ if (batch.length === 0) return
121
+ const payloads = buildPayloads(batch)
122
+ if (payloads.length === 0) return
123
+ const doFetch = resolveFetch(options)
124
+
125
+ for (const payload of payloads) {
126
+ try {
127
+ await doFetch(url, {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify(payload),
131
+ keepalive: true,
132
+ })
133
+ // GA4 MP returns 2xx (204) for well-formed requests and never
134
+ // asks for client-side retries; transient failures are dropped.
135
+ } catch {
136
+ // Network failure — GA4 MP is fire-and-forget; drop silently.
137
+ }
138
+ }
139
+ },
140
+ }
141
+ }
@@ -0,0 +1,20 @@
1
+ // Unified factory (dispatches on `mode`).
2
+ export { createGA4Sink } from './ga4-sink.js'
3
+
4
+ // Direct mode factories (for explicit selection / tree-shaking).
5
+ export { createGA4HttpSink } from './http-sink.js'
6
+ export { createGA4ClientSdkSink } from './client-sdk-sink.js'
7
+
8
+ // Pure mapping (canonical envelope → GA4) — reusable / testable in isolation.
9
+ export { mapEvent, ga4EventName, toGa4Name } from './mapping.js'
10
+ export type { GA4Event, GA4Mapped } from './mapping.js'
11
+
12
+ // Option contracts.
13
+ export type {
14
+ GA4SinkOptions,
15
+ GA4HttpOptions,
16
+ GA4ClientSdkOptions,
17
+ GA4ConsentBridge,
18
+ ConsentState,
19
+ GtagFn,
20
+ } from './types.js'
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Canonical envelope → GA4 mapping.
3
+ *
4
+ * One mapper, two consumers: the `client-sdk` adapter feeds the result into
5
+ * `gtag('event', name, params)` / `gtag('set', ...)`, and the `http` adapter
6
+ * serialises it into a Measurement Protocol `/mp/collect` payload. Keeping the
7
+ * mapping in one place guarantees the two modes stay behaviourally identical.
8
+ *
9
+ * Identity / param mapping (issue #216, epic #213):
10
+ * anonymousId → client_id (GA4 device/browser id)
11
+ * userId → user_id (GA4 User-ID, cross-device)
12
+ * properties → event params (track/page/screen/group payload)
13
+ * identify → user_properties (traits become GA4 user properties)
14
+ * sessionId → session_id param (so GA4 sessionisation can align)
15
+ *
16
+ * GA4 event-name normalisation: GA4 recommends snake_case event names and
17
+ * forbids spaces. `page` → `page_view`, `screen` → `screen_view` (GA4
18
+ * Enhanced-Measurement parity); everything else is lower_snake_cased.
19
+ */
20
+
21
+ import type { AnalyticsEvent, AnalyticsProperties } from '../analytics/index.ts'
22
+
23
+ /** A GA4 event ready for gtag.js or the Measurement Protocol. */
24
+ export interface GA4Event {
25
+ /** GA4 event name (snake_case, no spaces). */
26
+ name: string
27
+ /** Event params (GA4 caps these; we pass them through verbatim). */
28
+ params: Record<string, unknown>
29
+ }
30
+
31
+ /** The fully-mapped result for a single canonical envelope. */
32
+ export interface GA4Mapped {
33
+ /** GA4 client_id (from anonymousId). Always present. */
34
+ clientId: string
35
+ /** GA4 user_id (from userId). Present only after identify. */
36
+ userId?: string
37
+ /**
38
+ * GA4 user_properties (from identify/group traits). Each value is wrapped
39
+ * in `{ value }` as the Measurement Protocol requires; the gtag adapter
40
+ * unwraps when it calls `gtag('set', 'user_properties', ...)`.
41
+ */
42
+ userProperties?: Record<string, { value: unknown }>
43
+ /**
44
+ * The GA4 event to send. `identify` calls carry no event (they only set
45
+ * user_id / user_properties), so this is optional.
46
+ */
47
+ event?: GA4Event
48
+ }
49
+
50
+ const GA4_RESERVED_PREFIXES = ['google_', 'ga_', 'firebase_']
51
+
52
+ /** Lower_snake_case an arbitrary event/trait name for GA4. */
53
+ export function toGa4Name(name: string): string {
54
+ const snake = name
55
+ .trim()
56
+ .replace(/['"]/g, '')
57
+ .replace(/[^a-zA-Z0-9]+/g, '_')
58
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
59
+ .replace(/_+/g, '_')
60
+ .replace(/^_+|_+$/g, '')
61
+ .toLowerCase()
62
+ // GA4 names must start with a letter and avoid reserved prefixes.
63
+ const safe = /^[a-z]/.test(snake) ? snake : `e_${snake}`
64
+ return GA4_RESERVED_PREFIXES.some((p) => safe.startsWith(p))
65
+ ? `x_${safe}`
66
+ : safe
67
+ }
68
+
69
+ /** Map a Segment call type + name to its GA4 event name. */
70
+ export function ga4EventName(ev: AnalyticsEvent): string | undefined {
71
+ switch (ev.type) {
72
+ case 'identify':
73
+ // identify only sets identity/user_properties — no event is emitted.
74
+ return undefined
75
+ case 'page':
76
+ return 'page_view'
77
+ case 'screen':
78
+ return 'screen_view'
79
+ case 'group':
80
+ return 'group'
81
+ case 'alias':
82
+ return 'alias'
83
+ case 'track':
84
+ return ev.event ? toGa4Name(ev.event) : 'track'
85
+ default:
86
+ return undefined
87
+ }
88
+ }
89
+
90
+ /** Wrap traits as GA4 user_properties (`{ name: { value } }`). */
91
+ function toUserProperties(
92
+ traits: AnalyticsProperties | undefined,
93
+ ): Record<string, { value: unknown }> | undefined {
94
+ if (!traits) return undefined
95
+ const out: Record<string, { value: unknown }> = {}
96
+ let any = false
97
+ for (const [k, v] of Object.entries(traits)) {
98
+ if (v === undefined) continue
99
+ out[toGa4Name(k)] = { value: v }
100
+ any = true
101
+ }
102
+ return any ? out : undefined
103
+ }
104
+
105
+ /** Build the GA4 event params from a canonical envelope. */
106
+ function toParams(ev: AnalyticsEvent): Record<string, unknown> {
107
+ const params: Record<string, unknown> = {}
108
+
109
+ // Pass through the canonical payload as GA4 params.
110
+ for (const [k, v] of Object.entries(ev.properties ?? {})) {
111
+ if (v === undefined) continue
112
+ params[toGa4Name(k)] = v
113
+ }
114
+
115
+ // Align GA4 sessionisation with our analytics session.
116
+ params.session_id = ev.sessionId
117
+ // Stable client-supplied de-dupe / debugging aid.
118
+ params.engagement_time_msec = params.engagement_time_msec ?? 1
119
+
120
+ // page/screen context → GA4 page_* params (Enhanced-Measurement parity).
121
+ const page = ev.context.page
122
+ if (ev.type === 'page' || ev.type === 'screen') {
123
+ if (ev.event) {
124
+ if (ev.type === 'screen') params.screen_name = ev.event
125
+ else params.page_title = params.page_title ?? ev.event
126
+ }
127
+ if (page?.url && params.page_location === undefined) {
128
+ params.page_location = page.url
129
+ }
130
+ if (page?.path && params.page_path === undefined) {
131
+ params.page_path = page.path
132
+ }
133
+ if (page?.referrer && params.page_referrer === undefined) {
134
+ params.page_referrer = page.referrer
135
+ }
136
+ if (page?.title && params.page_title === undefined) {
137
+ params.page_title = page.title
138
+ }
139
+ }
140
+
141
+ if (ev.type === 'group' && ev.groupId) {
142
+ params.group_id = ev.groupId
143
+ }
144
+ if (ev.type === 'alias') {
145
+ if (ev.userId) params.user_id = ev.userId
146
+ if (ev.previousId) params.previous_id = ev.previousId
147
+ }
148
+
149
+ return params
150
+ }
151
+
152
+ /**
153
+ * Map one canonical envelope to its GA4 representation. Pure — no transport,
154
+ * no vendor lib; both the gtag and Measurement-Protocol adapters consume this.
155
+ */
156
+ export function mapEvent(ev: AnalyticsEvent): GA4Mapped {
157
+ const name = ga4EventName(ev)
158
+ const mapped: GA4Mapped = {
159
+ clientId: ev.anonymousId,
160
+ }
161
+ if (ev.userId) mapped.userId = ev.userId
162
+
163
+ if (ev.type === 'identify' || ev.type === 'group') {
164
+ const up = toUserProperties(ev.traits)
165
+ if (up) mapped.userProperties = up
166
+ }
167
+
168
+ if (name) {
169
+ mapped.event = { name, params: toParams(ev) }
170
+ }
171
+ return mapped
172
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @refraction-ui/analytics-sink-ga4 — option contracts.
3
+ *
4
+ * GA4 is "just a sink" in the epic's model (no privileged engine). This
5
+ * adapter implements the `AnalyticsSink` SPI from `@refraction-ui/analytics`
6
+ * in two interchangeable modes:
7
+ *
8
+ * - `client-sdk` — lazy-loads gtag.js in the browser (no hard vendor dep;
9
+ * the script is injected on first delivery only). Bridges Consent Mode.
10
+ * - `http` — GA4 Measurement Protocol (`/mp/collect`). No browser
11
+ * library is ever loaded — server-relay friendly.
12
+ *
13
+ * Default = `http` (protocol adapter, no vendor lib in the browser), matching
14
+ * the epic's "default = protocol adapters" stance.
15
+ */
16
+
17
+ /** Minimal gtag.js function signature (we never import the real types). */
18
+ export type GtagFn = (...args: unknown[]) => void
19
+
20
+ /** GA4 Consent Mode signal values. */
21
+ export type ConsentState = 'granted' | 'denied'
22
+
23
+ /**
24
+ * GA4 Consent Mode bridge — maps our consent categories to the gtag
25
+ * `consent` command. Only used in `client-sdk` mode.
26
+ */
27
+ export interface GA4ConsentBridge {
28
+ /**
29
+ * Default consent state pushed via `gtag('consent', 'default', …)` before
30
+ * the GA4 tag loads. Keys are GA4 consent types
31
+ * (`analytics_storage`, `ad_storage`, `ad_user_data`,
32
+ * `ad_personalization`, `functionality_storage`, …).
33
+ */
34
+ default?: Record<string, ConsentState>
35
+ /**
36
+ * Maps a refraction consent category → the GA4 consent types it controls.
37
+ * When the router reports the sink may deliver (category granted) the
38
+ * adapter pushes `gtag('consent', 'update', { <types>: 'granted' })`.
39
+ * Example: `{ analytics: ['analytics_storage'] }`.
40
+ */
41
+ map?: Record<string, string[]>
42
+ }
43
+
44
+ interface GA4CommonOptions {
45
+ /** GA4 Measurement ID, e.g. `G-XXXXXXXXXX`. */
46
+ measurementId: string
47
+ /**
48
+ * Consent categories this sink requires. The router will not deliver until
49
+ * all are granted. Default: `['analytics']`.
50
+ */
51
+ consentCategories?: string[]
52
+ /** Stable sink name. Default `'ga4'`. */
53
+ name?: string
54
+ }
55
+
56
+ /** `client-sdk` mode — runs gtag.js in the browser. */
57
+ export interface GA4ClientSdkOptions extends GA4CommonOptions {
58
+ mode: 'client-sdk'
59
+ /**
60
+ * Inject the GA4 Consent Mode bridge. The default state (if any) is pushed
61
+ * before the tag loads; category grants drive `consent: update`.
62
+ */
63
+ consentMode?: GA4ConsentBridge
64
+ /**
65
+ * Inject an existing gtag function (tests / apps that manage the tag
66
+ * themselves). When provided, the script loader is NOT used and **no**
67
+ * vendor script is injected.
68
+ */
69
+ gtag?: GtagFn
70
+ /**
71
+ * Inject the script loader (tests / SSR-safe apps). Receives the gtag.js
72
+ * src URL; must resolve once the script has executed. Defaults to a DOM
73
+ * `<script>` injector (browser only).
74
+ */
75
+ scriptLoader?: (src: string) => Promise<void>
76
+ /** Override the gtag.js base URL (testing). */
77
+ gtagSrcBase?: string
78
+ }
79
+
80
+ /** `http` mode — GA4 Measurement Protocol. No browser library. */
81
+ export interface GA4HttpOptions extends GA4CommonOptions {
82
+ mode?: 'http'
83
+ /** GA4 Measurement Protocol API secret (server-side credential). */
84
+ apiSecret: string
85
+ /** Override the `/mp/collect` base URL (testing / EU endpoint). */
86
+ endpoint?: string
87
+ /** Use the Measurement Protocol `/debug/mp/collect` validation endpoint. */
88
+ debug?: boolean
89
+ /** Injected fetch (defaults to global fetch). Never loads a vendor lib. */
90
+ fetchImpl?: typeof fetch
91
+ }
92
+
93
+ /** Discriminated union of the two modes. */
94
+ export type GA4SinkOptions = GA4ClientSdkOptions | GA4HttpOptions