@refraction-ui/astro 0.5.0 → 0.6.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.
Files changed (38) hide show
  1. package/dist/analytics/analytics-manager.ts +361 -0
  2. package/dist/analytics/consent.ts +40 -0
  3. package/dist/analytics/console-sink.ts +37 -0
  4. package/dist/analytics/http-sink.ts +213 -0
  5. package/dist/analytics/identity.ts +64 -0
  6. package/dist/analytics/index.ts +60 -0
  7. package/dist/analytics/mock-sink.ts +62 -0
  8. package/dist/analytics/noop.ts +48 -0
  9. package/dist/analytics/redaction.ts +93 -0
  10. package/dist/analytics/session.ts +162 -0
  11. package/dist/analytics/storage.ts +119 -0
  12. package/dist/analytics/types.ts +273 -0
  13. package/dist/analytics/uuid.ts +63 -0
  14. package/dist/astro-analytics/AnalyticsScript.astro +130 -0
  15. package/dist/astro-analytics/index.ts +49 -0
  16. package/dist/astro-logger/TelemetryScript.astro +108 -0
  17. package/dist/astro-logger/index.ts +48 -0
  18. package/dist/astro-logger/library-error-capture.ts +121 -0
  19. package/dist/astro-logger/telemetry-middleware.ts +92 -0
  20. package/dist/astro-waveform/Waveform.astro +8 -2
  21. package/dist/index.ts +2 -0
  22. package/dist/logger/console-sink.ts +64 -0
  23. package/dist/logger/faro-engine.ts +130 -0
  24. package/dist/logger/index.ts +39 -0
  25. package/dist/logger/mock-sink.ts +38 -0
  26. package/dist/logger/noop.ts +52 -0
  27. package/dist/logger/presets.ts +51 -0
  28. package/dist/logger/redact.ts +33 -0
  29. package/dist/logger/telemetry-manager.ts +229 -0
  30. package/dist/logger/types.ts +121 -0
  31. package/dist/shared/dev-feedback.ts +154 -0
  32. package/dist/shared/index.ts +25 -0
  33. package/dist/shared/library-origin-error.ts +240 -0
  34. package/dist/voice-pill/voice-pill.styles.ts +14 -13
  35. package/dist/voice-pill/voice-pill.ts +3 -2
  36. package/dist/waveform/waveform-renderer.ts +13 -6
  37. package/dist/waveform/waveform.ts +15 -2
  38. package/package.json +4 -2
@@ -0,0 +1,361 @@
1
+ import type {
2
+ Analytics,
3
+ AnalyticsConfig,
4
+ AnalyticsContext,
5
+ AnalyticsEvent,
6
+ AnalyticsEventType,
7
+ AnalyticsSink,
8
+ CallOptions,
9
+ SinkDeliverContext,
10
+ } from './types.js'
11
+ import { SCHEMA_VERSION } from './types.js'
12
+ import { uuidv4 } from './uuid.js'
13
+ import { createSession, campaignFingerprint } from './session.js'
14
+ import { createIdentity } from './identity.js'
15
+ import { createConsent } from './consent.js'
16
+ import { createRedactor } from './redaction.js'
17
+ import { createHttpSink } from './http-sink.js'
18
+ import { createConsoleSink } from './console-sink.js'
19
+ import { createNoopAnalytics } from './noop.js'
20
+
21
+ const LIBRARY = {
22
+ name: '@refraction-ui/analytics',
23
+ version: '0.1.0',
24
+ }
25
+
26
+ /** Read the current page block from the DOM, if any (browser-only). */
27
+ function readPage(): AnalyticsContext['page'] | undefined {
28
+ const g = globalThis as unknown as {
29
+ location?: { pathname?: string; href?: string; search?: string }
30
+ document?: { title?: string; referrer?: string }
31
+ }
32
+ if (!g.location && !g.document) return undefined
33
+ return {
34
+ path: g.location?.pathname,
35
+ url: g.location?.href,
36
+ search: g.location?.search,
37
+ title: g.document?.title,
38
+ referrer: g.document?.referrer,
39
+ }
40
+ }
41
+
42
+ /**
43
+ * createAnalytics — the neutral Segment-spec collector/router.
44
+ *
45
+ * Mirrors `createAI`: a single factory returns the entire public surface and
46
+ * fans the canonical envelope out to registered sinks. There is NO privileged
47
+ * engine — the built-in HTTP sink and every vendor adapter are equal sinks.
48
+ *
49
+ * Presets:
50
+ * dev — synchronous delivery + a console sink (see exactly what ships).
51
+ * prod — batching + sampling + beacon flush on pagehide/visibilitychange.
52
+ *
53
+ * When `enabled: false`, a tree-shakeable noop is returned and none of the
54
+ * live collector / sink code is reachable.
55
+ */
56
+ export function createAnalytics(config: AnalyticsConfig): Analytics {
57
+ if (config.enabled === false) {
58
+ return createNoopAnalytics()
59
+ }
60
+
61
+ const { app, env } = config
62
+ const preset =
63
+ config.preset ?? (env === 'production' ? 'prod' : 'dev')
64
+ const sampleRate = config.sampleRate ?? 1
65
+ const batchSize = config.batchSize ?? 20
66
+ const flushIntervalMs = config.flushIntervalMs ?? 10_000
67
+
68
+ const session = createSession(config.session)
69
+ const identity = createIdentity(config.identity)
70
+ const consent = createConsent(config.consent)
71
+ const redactor = createRedactor(config.redactKeys)
72
+
73
+ // --- Sink registry ------------------------------------------------------
74
+ const sinks = new Map<string, AnalyticsSink>()
75
+ const sinkOrder: string[] = []
76
+ const initialized = new Set<string>()
77
+
78
+ function registerSink(sink: AnalyticsSink): void {
79
+ if (!sinks.has(sink.name)) sinkOrder.push(sink.name)
80
+ sinks.set(sink.name, sink)
81
+ }
82
+
83
+ // Auto HTTP sink when an endpoint is supplied.
84
+ if (config.endpoint) {
85
+ registerSink(
86
+ createHttpSink({
87
+ endpoint: config.endpoint,
88
+ writeKey: config.writeKey ?? '',
89
+ }),
90
+ )
91
+ }
92
+ // dev preset → add a console sink for visibility.
93
+ if (preset === 'dev') {
94
+ registerSink(createConsoleSink())
95
+ }
96
+ // Explicit sinks (override same-named built-ins).
97
+ for (const s of config.sinks ?? []) registerSink(s)
98
+
99
+ /**
100
+ * Initialise a sink at most once. Returns a promise only when the sink's
101
+ * own `init` is async — synchronous sinks stay on the synchronous path so
102
+ * the dev preset delivers without a microtask hop.
103
+ */
104
+ function ensureInit(sink: AnalyticsSink): void | Promise<void> {
105
+ if (initialized.has(sink.name)) return
106
+ initialized.add(sink.name)
107
+ if (sink.init) {
108
+ return sink.init({ app, env, endpoint: config.endpoint })
109
+ }
110
+ }
111
+
112
+ // --- Batch buffer -------------------------------------------------------
113
+ const buffer: AnalyticsEvent[] = []
114
+ let timer: ReturnType<typeof setInterval> | undefined
115
+
116
+ function startTimer(): void {
117
+ if (preset !== 'prod' || timer) return
118
+ timer = setInterval(() => {
119
+ void flush(false)
120
+ }, flushIntervalMs)
121
+ // Do not keep a Node process alive purely for the flush timer.
122
+ ;(timer as unknown as { unref?: () => void }).unref?.()
123
+ }
124
+
125
+ /**
126
+ * Fan a batch out to every consented sink. Stays fully synchronous when all
127
+ * sinks are synchronous (dev preset visibility); returns a promise only if a
128
+ * sink's init/deliver is async.
129
+ */
130
+ function deliverToSinks(
131
+ batch: AnalyticsEvent[],
132
+ unload: boolean,
133
+ ): void | Promise<void> {
134
+ if (batch.length === 0) return
135
+ const ctx: SinkDeliverContext = { unload }
136
+ const pending: Array<Promise<unknown>> = []
137
+ for (const name of sinkOrder) {
138
+ const sink = sinks.get(name)
139
+ if (!sink) continue
140
+ if (!consent.allows(sink.consentCategories)) continue
141
+ const inited = ensureInit(sink)
142
+ if (inited && typeof (inited as Promise<void>).then === 'function') {
143
+ pending.push(
144
+ (inited as Promise<void>).then(() => sink.deliver(batch, ctx)),
145
+ )
146
+ } else {
147
+ const r = sink.deliver(batch, ctx)
148
+ if (r && typeof (r as Promise<void>).then === 'function') {
149
+ pending.push(r as Promise<void>)
150
+ }
151
+ }
152
+ }
153
+ if (pending.length) return Promise.all(pending).then(() => undefined)
154
+ }
155
+
156
+ async function flush(unload = false): Promise<void> {
157
+ const batch = buffer.splice(0, buffer.length)
158
+ await deliverToSinks(batch, unload)
159
+ for (const name of sinkOrder) {
160
+ const sink = sinks.get(name)
161
+ if (sink?.flush && consent.allows(sink.consentCategories)) {
162
+ await sink.flush()
163
+ }
164
+ }
165
+ }
166
+
167
+ // --- Unload flush (prod) ------------------------------------------------
168
+ function bindUnload(): void {
169
+ if (preset !== 'prod') return
170
+ const g = globalThis as unknown as {
171
+ addEventListener?: (t: string, h: () => void) => void
172
+ document?: { visibilityState?: string }
173
+ }
174
+ if (typeof g.addEventListener !== 'function') return
175
+ const onUnload = (): void => {
176
+ void deliverToSinks(buffer.splice(0, buffer.length), true)
177
+ }
178
+ g.addEventListener('pagehide', onUnload)
179
+ g.addEventListener('visibilitychange', () => {
180
+ if (g.document?.visibilityState === 'hidden') onUnload()
181
+ })
182
+ }
183
+
184
+ startTimer()
185
+ bindUnload()
186
+
187
+ // --- Envelope construction ---------------------------------------------
188
+ function buildContext(
189
+ extra?: Partial<AnalyticsContext>,
190
+ childCtx?: Partial<AnalyticsContext>,
191
+ ): AnalyticsContext {
192
+ const page = readPage()
193
+ return {
194
+ app,
195
+ env,
196
+ ...(page ? { page } : {}),
197
+ ...childCtx,
198
+ ...extra,
199
+ library: LIBRARY,
200
+ }
201
+ }
202
+
203
+ function sampled(): boolean {
204
+ if (sampleRate >= 1) return true
205
+ if (sampleRate <= 0) return false
206
+ return Math.random() < sampleRate
207
+ }
208
+
209
+ function enqueue(ev: AnalyticsEvent): void {
210
+ if (preset === 'dev') {
211
+ // Synchronous, unbatched — immediate visibility.
212
+ void deliverToSinks([ev], false)
213
+ return
214
+ }
215
+ buffer.push(ev)
216
+ if (buffer.length >= batchSize) {
217
+ void flush(false)
218
+ }
219
+ }
220
+
221
+ function emit(
222
+ type: AnalyticsEventType,
223
+ fields: Partial<AnalyticsEvent>,
224
+ childCtx: Partial<AnalyticsContext> | undefined,
225
+ opts?: CallOptions,
226
+ ): void {
227
+ if (!sampled()) return
228
+
229
+ const page = readPage()
230
+ const campaign = campaignFingerprint(page?.search)
231
+ const sessionId = session.touch(campaign)
232
+ const sessionProps = session.props()
233
+
234
+ const ev: AnalyticsEvent = {
235
+ type,
236
+ messageId: uuidv4(),
237
+ anonymousId: identity.anonymousId(),
238
+ userId: identity.userId(),
239
+ sessionId,
240
+ context: buildContext(opts?.context, childCtx),
241
+ timestamp: opts?.timestamp ?? new Date().toISOString(),
242
+ schemaVersion: SCHEMA_VERSION,
243
+ ...fields,
244
+ }
245
+
246
+ // Merge session-scoped props beneath call props.
247
+ if (sessionProps && (ev.properties || type === 'track' || type === 'page' || type === 'screen')) {
248
+ ev.properties = { ...sessionProps, ...(ev.properties ?? {}) }
249
+ }
250
+
251
+ enqueue(ev)
252
+ }
253
+
254
+ function makeApi(childCtx?: Partial<AnalyticsContext>): Analytics {
255
+ const api: Analytics = {
256
+ track(event, properties, opts) {
257
+ emit(
258
+ 'track',
259
+ { event, properties: redactor.redact(properties) },
260
+ childCtx,
261
+ opts,
262
+ )
263
+ },
264
+
265
+ identify(userId, traits, opts) {
266
+ identity.setUserId(userId)
267
+ emit('identify', { traits: redactor.redact(traits) }, childCtx, opts)
268
+ },
269
+
270
+ page(name, properties, opts) {
271
+ emit(
272
+ 'page',
273
+ { event: name, properties: redactor.redact(properties) },
274
+ childCtx,
275
+ opts,
276
+ )
277
+ },
278
+
279
+ screen(name, properties, opts) {
280
+ emit(
281
+ 'screen',
282
+ { event: name, properties: redactor.redact(properties) },
283
+ childCtx,
284
+ opts,
285
+ )
286
+ },
287
+
288
+ group(groupId, traits, opts) {
289
+ emit(
290
+ 'group',
291
+ { groupId, traits: redactor.redact(traits) },
292
+ childCtx,
293
+ opts,
294
+ )
295
+ },
296
+
297
+ alias(userId, previousId, opts) {
298
+ const stitch = identity.alias(userId, previousId)
299
+ emit(
300
+ 'alias',
301
+ { userId: stitch.userId, previousId: stitch.previousId },
302
+ childCtx,
303
+ opts,
304
+ )
305
+ },
306
+
307
+ session: {
308
+ id: () => session.id(),
309
+ start: () => session.start(),
310
+ end: () => session.end(),
311
+ set: (props) => session.set(props),
312
+ },
313
+
314
+ consent: {
315
+ grant: (...c) => consent.grant(...c),
316
+ revoke: (...c) => consent.revoke(...c),
317
+ granted: () => consent.granted(),
318
+ isGranted: (c) => consent.isGranted(c),
319
+ },
320
+
321
+ anonymousId: () => identity.anonymousId(),
322
+ userId: () => identity.userId(),
323
+
324
+ with(extra: Partial<AnalyticsContext>): Analytics {
325
+ return makeApi({ ...childCtx, ...extra })
326
+ },
327
+
328
+ addSink(sink: AnalyticsSink): void {
329
+ registerSink(sink)
330
+ },
331
+
332
+ removeSink(name: string): void {
333
+ if (sinks.has(name)) {
334
+ sinks.delete(name)
335
+ const i = sinkOrder.indexOf(name)
336
+ if (i !== -1) sinkOrder.splice(i, 1)
337
+ initialized.delete(name)
338
+ }
339
+ },
340
+
341
+ get sinks(): string[] {
342
+ return [...sinkOrder]
343
+ },
344
+
345
+ async flush(): Promise<void> {
346
+ await flush(false)
347
+ },
348
+
349
+ reset(): void {
350
+ identity.reset()
351
+ session.end()
352
+ },
353
+
354
+ enabled: true,
355
+ }
356
+
357
+ return api
358
+ }
359
+
360
+ return makeApi()
361
+ }
@@ -0,0 +1,40 @@
1
+ import type { ConsentAPI, ConsentConfig } from './types.js'
2
+
3
+ /**
4
+ * Consent gate.
5
+ *
6
+ * Holds the set of granted consent categories. The router asks the gate, per
7
+ * sink, whether *all* of that sink's required categories are granted before
8
+ * delivering. A sink with no declared categories is always allowed (it is the
9
+ * sink author's responsibility to declare what it needs).
10
+ */
11
+ export function createConsent(config?: ConsentConfig): ConsentAPI & {
12
+ /** True when every required category is granted (empty list ⇒ allowed). */
13
+ allows(required?: string[]): boolean
14
+ /** True when at least one sink could receive (used by strict mode). */
15
+ strict: boolean
16
+ } {
17
+ const granted = new Set<string>(config?.granted ?? [])
18
+
19
+ return {
20
+ strict: config?.strict ?? false,
21
+ grant(...categories: string[]): void {
22
+ for (const c of categories) granted.add(c)
23
+ },
24
+ revoke(...categories: string[]): void {
25
+ for (const c of categories) granted.delete(c)
26
+ },
27
+ granted(): string[] {
28
+ return [...granted]
29
+ },
30
+ isGranted(category: string): boolean {
31
+ return granted.has(category)
32
+ },
33
+ allows(required?: string[]): boolean {
34
+ if (!required || required.length === 0) return true
35
+ return required.every((c) => granted.has(c))
36
+ },
37
+ }
38
+ }
39
+
40
+ export type Consent = ReturnType<typeof createConsent>
@@ -0,0 +1,37 @@
1
+ import type { AnalyticsEvent, AnalyticsSink } from './types.js'
2
+
3
+ export interface ConsoleSinkOptions {
4
+ /** Injected logger (defaults to globalThis.console). */
5
+ logger?: Pick<Console, 'log' | 'groupCollapsed' | 'groupEnd'>
6
+ /** Consent categories this sink requires. Default: none (always allowed). */
7
+ consentCategories?: string[]
8
+ }
9
+
10
+ /**
11
+ * Built-in `console` sink — the dev preset's default. Prints each canonical
12
+ * envelope so engineers can see exactly what would ship over the wire.
13
+ */
14
+ export function createConsoleSink(
15
+ options: ConsoleSinkOptions = {},
16
+ ): AnalyticsSink {
17
+ const logger =
18
+ options.logger ??
19
+ (globalThis as unknown as { console: Console }).console
20
+
21
+ return {
22
+ name: 'console',
23
+ consentCategories: options.consentCategories,
24
+ deliver(batch: AnalyticsEvent[]): void {
25
+ for (const ev of batch) {
26
+ const label = `[analytics] ${ev.type}${ev.event ? ` ${ev.event}` : ''}`
27
+ if (typeof logger.groupCollapsed === 'function') {
28
+ logger.groupCollapsed(label)
29
+ logger.log(ev)
30
+ logger.groupEnd?.()
31
+ } else {
32
+ logger.log(label, ev)
33
+ }
34
+ }
35
+ },
36
+ }
37
+ }
@@ -0,0 +1,213 @@
1
+ import type {
2
+ AnalyticsEvent,
3
+ AnalyticsSink,
4
+ HttpSinkOptions,
5
+ SinkDeliverContext,
6
+ } from './types.js'
7
+ import { SCHEMA_VERSION } from './types.js'
8
+ import { uuidv4 } from './uuid.js'
9
+
10
+ /**
11
+ * Built-in `http` sink — Segment HTTP Tracking API wire contract.
12
+ *
13
+ * Wire contract (adopt, do not invent — RudderStack/Jitsu/Segment conform):
14
+ *
15
+ * POST {endpoint}/v{schemaVersion}/batch
16
+ * Content-Type: application/json
17
+ * Authorization: Basic base64(writeKey:) (note the trailing colon)
18
+ * Body: { batch: AnalyticsEvent[], sentAt, batchId }
19
+ *
20
+ * Each event carries `messageId` (idempotency — backends MUST dedupe),
21
+ * `anonymousId`, `userId?`, `sessionId`, `type`, `event?`,
22
+ * `properties`/`traits`, `context`, `timestamp`, `schemaVersion`.
23
+ *
24
+ * Response handling (accept-and-queue semantics):
25
+ * 200 accepted (the backend has only queued it, not processed it)
26
+ * 400 malformed → DROP, never retry
27
+ * 401 bad write key → DROP, never retry
28
+ * 413 payload too big → DROP (we also pre-split under the size caps)
29
+ * 429 / 5xx transient → exponential backoff retry
30
+ *
31
+ * Clock skew: the backend corrects with
32
+ * corrected = timestamp + (receivedAt − sentAt)
33
+ * so we always stamp an honest client `sentAt`.
34
+ *
35
+ * sendBeacon caveat: the unload path (`pagehide`/`visibilitychange`) uses
36
+ * `navigator.sendBeacon`, which cannot set an `Authorization` header. On that
37
+ * path we fall back to `?writeKey=` in the query string with a
38
+ * `text/plain` body (the wire contract requires the backend to accept this).
39
+ */
40
+
41
+ const NO_RETRY = new Set([400, 401, 413])
42
+
43
+ function base64(input: string): string {
44
+ const g = globalThis as unknown as {
45
+ btoa?: (s: string) => string
46
+ Buffer?: { from(s: string, enc: string): { toString(enc: string): string } }
47
+ }
48
+ if (typeof g.btoa === 'function') {
49
+ return g.btoa(input)
50
+ }
51
+ if (g.Buffer) {
52
+ return g.Buffer.from(input, 'utf-8').toString('base64')
53
+ }
54
+ throw new Error('No base64 implementation available (btoa/Buffer)')
55
+ }
56
+
57
+ function byteLength(s: string): number {
58
+ const g = globalThis as unknown as {
59
+ TextEncoder?: new () => { encode(s: string): { length: number } }
60
+ }
61
+ if (g.TextEncoder) return new g.TextEncoder().encode(s).length
62
+ // Conservative fallback (UTF-8 worst case is 4 bytes/char; use 3 as typical).
63
+ return unescape(encodeURIComponent(s)).length
64
+ }
65
+
66
+ const sleep = (ms: number) =>
67
+ new Promise<void>((r) => setTimeout(r, ms))
68
+
69
+ /**
70
+ * Split a batch so neither the per-event nor per-batch byte caps are
71
+ * exceeded. Over-sized single events are dropped (they can never be sent).
72
+ */
73
+ function splitBatch(
74
+ batch: AnalyticsEvent[],
75
+ maxBatchBytes: number,
76
+ maxEventBytes: number,
77
+ ): { batches: AnalyticsEvent[][]; dropped: AnalyticsEvent[] } {
78
+ const batches: AnalyticsEvent[][] = []
79
+ const dropped: AnalyticsEvent[] = []
80
+ let current: AnalyticsEvent[] = []
81
+ let currentBytes = 2 // "[]"
82
+
83
+ for (const ev of batch) {
84
+ const evBytes = byteLength(JSON.stringify(ev))
85
+ if (evBytes > maxEventBytes) {
86
+ dropped.push(ev)
87
+ continue
88
+ }
89
+ if (current.length && currentBytes + evBytes + 1 > maxBatchBytes) {
90
+ batches.push(current)
91
+ current = []
92
+ currentBytes = 2
93
+ }
94
+ current.push(ev)
95
+ currentBytes += evBytes + 1
96
+ }
97
+ if (current.length) batches.push(current)
98
+ return { batches, dropped }
99
+ }
100
+
101
+ /** Create the built-in Segment-spec HTTP sink. */
102
+ export function createHttpSink(options: HttpSinkOptions): AnalyticsSink {
103
+ const {
104
+ endpoint,
105
+ writeKey,
106
+ maxRetries = 3,
107
+ backoffBaseMs = 500,
108
+ consentCategories = ['analytics'],
109
+ maxBatchBytes = 500_000,
110
+ maxEventBytes = 32_000,
111
+ } = options
112
+
113
+ const base = endpoint.replace(/\/+$/, '')
114
+ const url = `${base}/v${SCHEMA_VERSION}/batch`
115
+ const authHeader = `Basic ${base64(`${writeKey}:`)}`
116
+
117
+ const resolveFetch = (): typeof fetch => {
118
+ if (options.fetchImpl) return options.fetchImpl
119
+ const f = (globalThis as unknown as { fetch?: typeof fetch }).fetch
120
+ if (!f) throw new Error('No fetch implementation available')
121
+ return f
122
+ }
123
+
124
+ const resolveBeacon = ():
125
+ | ((u: string, body: string) => boolean)
126
+ | undefined => {
127
+ if (options.beaconImpl) return options.beaconImpl
128
+ const nav = (globalThis as unknown as {
129
+ navigator?: { sendBeacon?: (u: string, data: BodyInit) => boolean }
130
+ }).navigator
131
+ if (nav && typeof nav.sendBeacon === 'function') {
132
+ return (u, body) => nav.sendBeacon!(u, body)
133
+ }
134
+ return undefined
135
+ }
136
+
137
+ function envelope(batch: AnalyticsEvent[]) {
138
+ return {
139
+ batch,
140
+ sentAt: new Date().toISOString(),
141
+ batchId: uuidv4(),
142
+ }
143
+ }
144
+
145
+ /** Beacon path: writeKey via query string, text/plain body. */
146
+ function sendViaBeacon(batch: AnalyticsEvent[]): boolean {
147
+ const beacon = resolveBeacon()
148
+ if (!beacon) return false
149
+ const beaconUrl = `${url}?writeKey=${encodeURIComponent(writeKey)}`
150
+ return beacon(beaconUrl, JSON.stringify(envelope(batch)))
151
+ }
152
+
153
+ /** Standard path: fetch + Authorization header, with backoff retries. */
154
+ async function sendViaFetch(batch: AnalyticsEvent[]): Promise<void> {
155
+ const doFetch = resolveFetch()
156
+ const body = JSON.stringify(envelope(batch))
157
+
158
+ for (let attempt = 0; ; attempt++) {
159
+ let status: number
160
+ try {
161
+ const res = await doFetch(url, {
162
+ method: 'POST',
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ Authorization: authHeader,
166
+ },
167
+ body,
168
+ keepalive: true,
169
+ })
170
+ status = res.status
171
+ } catch {
172
+ // Network error — treat as transient.
173
+ status = 0
174
+ }
175
+
176
+ if (status >= 200 && status < 300) return // accepted-and-queued
177
+ if (NO_RETRY.has(status)) return // 400/401/413 — drop, never retry
178
+
179
+ // 429 / 5xx / network (0) → backoff retry.
180
+ if (attempt >= maxRetries) return
181
+ const delay = backoffBaseMs * 2 ** attempt
182
+ await sleep(delay)
183
+ }
184
+ }
185
+
186
+ return {
187
+ name: 'http',
188
+ consentCategories,
189
+
190
+ async deliver(
191
+ batch: AnalyticsEvent[],
192
+ ctx: SinkDeliverContext,
193
+ ): Promise<void> {
194
+ if (batch.length === 0) return
195
+ const { batches, dropped } = splitBatch(
196
+ batch,
197
+ maxBatchBytes,
198
+ maxEventBytes,
199
+ )
200
+ void dropped // oversized events are unsendable by contract — silently dropped
201
+
202
+ for (const part of batches) {
203
+ if (ctx.unload) {
204
+ // Unload path: try beacon first; fall back to keepalive fetch.
205
+ if (sendViaBeacon(part)) continue
206
+ void sendViaFetch(part)
207
+ } else {
208
+ await sendViaFetch(part)
209
+ }
210
+ }
211
+ },
212
+ }
213
+ }