@refraction-ui/react 0.5.0 → 0.7.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,448 @@
1
+ /**
2
+ * @refraction-ui/analytics — type contracts.
3
+ *
4
+ * The library is a neutral Segment-spec collector/router. The app instruments
5
+ * once via the canonical API; the router fans the canonical envelope out to
6
+ * N pluggable sinks. There is NO privileged engine — every vendor is a sink.
7
+ */
8
+ /** Canonical Segment event types. */
9
+ type AnalyticsEventType = 'track' | 'identify' | 'page' | 'screen' | 'group' | 'alias';
10
+ /** Schema version stamped onto every envelope and the wire contract path. */
11
+ declare const SCHEMA_VERSION = 1;
12
+ /** Arbitrary, JSON-serialisable bag of values. */
13
+ type AnalyticsProperties = Record<string, unknown>;
14
+ /**
15
+ * The context block attached to every event. `library` identifies the
16
+ * collector; `app`/`env` identify the product instance.
17
+ */
18
+ interface AnalyticsContext {
19
+ app: string;
20
+ env: string;
21
+ page?: {
22
+ path?: string;
23
+ url?: string;
24
+ referrer?: string;
25
+ title?: string;
26
+ search?: string;
27
+ };
28
+ library: {
29
+ name: string;
30
+ version: string;
31
+ };
32
+ /** Free-form additions contributed by `with(context)` children. */
33
+ [key: string]: unknown;
34
+ }
35
+ /**
36
+ * The canonical Segment envelope. Every sink receives events in exactly this
37
+ * shape; the built-in HTTP sink ships it verbatim over the wire contract.
38
+ */
39
+ interface AnalyticsEvent {
40
+ /** Segment call type. */
41
+ type: AnalyticsEventType;
42
+ /** Event name (track/screen) or page name (page). */
43
+ event?: string;
44
+ /** Idempotency key — backends MUST dedupe on this. */
45
+ messageId: string;
46
+ /** Persistent, non-PII, resettable client id. */
47
+ anonymousId: string;
48
+ /** Opaque app-supplied id (set after identify). */
49
+ userId?: string;
50
+ /** Group id (group calls). */
51
+ groupId?: string;
52
+ /** Previous id (alias calls). */
53
+ previousId?: string;
54
+ /** Analytics session id (UUIDv4, minted at session start). */
55
+ sessionId: string;
56
+ /** track/page/screen/group payload. */
57
+ properties?: AnalyticsProperties;
58
+ /** identify/group trait payload. */
59
+ traits?: AnalyticsProperties;
60
+ /** Collector + product context. */
61
+ context: AnalyticsContext;
62
+ /** ISO-8601 client timestamp. */
63
+ timestamp: string;
64
+ /** Envelope schema version. */
65
+ schemaVersion: number;
66
+ }
67
+ /**
68
+ * Sink SPI — implemented by adapter packages (GA4, Azure, PostHog) and by the
69
+ * built-in HTTP sink. A sink declares the consent categories it requires; the
70
+ * router will not deliver to a sink whose categories are not all granted.
71
+ */
72
+ interface AnalyticsSink {
73
+ /** Stable sink identifier. */
74
+ name: string;
75
+ /** Consent categories this sink requires (e.g. ['analytics']). */
76
+ consentCategories?: string[];
77
+ /** Called once before the first delivery. */
78
+ init?(ctx: SinkInitContext): void | Promise<void>;
79
+ /** Deliver a batch of canonical events. */
80
+ deliver(batch: AnalyticsEvent[], ctx: SinkDeliverContext): void | Promise<void>;
81
+ /** Flush any buffered state (best-effort). */
82
+ flush?(): void | Promise<void>;
83
+ /** Release resources on reset()/shutdown. */
84
+ shutdown?(): void | Promise<void>;
85
+ }
86
+ /** Context handed to `sink.init`. */
87
+ interface SinkInitContext {
88
+ app: string;
89
+ env: string;
90
+ endpoint?: string;
91
+ }
92
+ /** Context handed to every `sink.deliver`. */
93
+ interface SinkDeliverContext {
94
+ /** True on the unload path — sinks should prefer sendBeacon. */
95
+ unload: boolean;
96
+ }
97
+ /** Cross-tab persistence SPI. Defaults pick localStorage/cookie/memory. */
98
+ interface AnalyticsStorage {
99
+ get(key: string): string | null;
100
+ set(key: string, value: string): void;
101
+ remove(key: string): void;
102
+ }
103
+ /** Session engine configuration. */
104
+ interface SessionConfig {
105
+ /** Inactivity timeout in ms before a new session is minted. GA4 = 30min. */
106
+ timeoutMs?: number;
107
+ /** Reset the session when a campaign/utm change is detected. */
108
+ resetOnCampaign?: boolean;
109
+ /** Persistence backend; defaults to localStorage→cookie→memory. */
110
+ storage?: AnalyticsStorage;
111
+ /** Storage key namespace. */
112
+ storageKey?: string;
113
+ }
114
+ /** Identity engine configuration. */
115
+ interface IdentityConfig {
116
+ /** Persistence backend; defaults to localStorage→cookie→memory. */
117
+ storage?: AnalyticsStorage;
118
+ /** Storage key namespace. */
119
+ storageKey?: string;
120
+ }
121
+ /** Consent gate configuration. */
122
+ interface ConsentConfig {
123
+ /** Categories granted at boot. */
124
+ granted?: string[];
125
+ /**
126
+ * If true, an event is dropped entirely when NO sink can receive it.
127
+ * If false (default), events are still buffered until consent is granted.
128
+ */
129
+ strict?: boolean;
130
+ }
131
+ /** Consent gate runtime API. */
132
+ interface ConsentAPI {
133
+ grant(...categories: string[]): void;
134
+ revoke(...categories: string[]): void;
135
+ granted(): string[];
136
+ isGranted(category: string): boolean;
137
+ }
138
+ /** Session runtime API. */
139
+ interface SessionAPI {
140
+ /** Current session id (mints one lazily if none active). */
141
+ id(): string;
142
+ /** Force-start a new session, returning the new id. */
143
+ start(): string;
144
+ /** End the current session. */
145
+ end(): void;
146
+ /** Attach arbitrary session-scoped properties. */
147
+ set(props: AnalyticsProperties): void;
148
+ }
149
+ /** Built-in HTTP sink options (Segment HTTP Tracking API wire contract). */
150
+ interface HttpSinkOptions {
151
+ /** Base endpoint; events POST to `{endpoint}/v{schemaVersion}/batch`. */
152
+ endpoint: string;
153
+ /** Write key — sent as `Authorization: Basic base64(writeKey:)`. */
154
+ writeKey: string;
155
+ /** Max retries for 429/5xx (exponential backoff). Default 3. */
156
+ maxRetries?: number;
157
+ /** Base backoff delay in ms. Default 500. */
158
+ backoffBaseMs?: number;
159
+ /** Injected fetch (defaults to global fetch). */
160
+ fetchImpl?: typeof fetch;
161
+ /** Injected sendBeacon (defaults to navigator.sendBeacon). */
162
+ beaconImpl?: (url: string, body: string) => boolean;
163
+ /** Consent categories this sink requires. Default ['analytics']. */
164
+ consentCategories?: string[];
165
+ /** Soft per-batch byte cap (Segment ≈ 500KB). Default 500_000. */
166
+ maxBatchBytes?: number;
167
+ /** Soft per-event byte cap (Segment ≈ 32KB). Default 32_000. */
168
+ maxEventBytes?: number;
169
+ }
170
+ /** `createAnalytics` configuration. */
171
+ interface AnalyticsConfig {
172
+ /** Product/app identifier (stamped into context.app). */
173
+ app: string;
174
+ /** Environment, e.g. 'development' | 'production'. Drives presets. */
175
+ env: string;
176
+ /** When set, a built-in HTTP sink is auto-registered to this endpoint. */
177
+ endpoint?: string;
178
+ /** Write key for the auto-registered HTTP sink. */
179
+ writeKey?: string;
180
+ /** Kill switch — when false, a tree-shakeable noop is returned. Default true. */
181
+ enabled?: boolean;
182
+ /** 0..1 sampling rate applied per top-level call. Default 1. */
183
+ sampleRate?: number;
184
+ /** Extra property/trait keys to redact (in addition to the PII deny-list). */
185
+ redactKeys?: string[];
186
+ /** Explicit sinks (in addition to / instead of the auto HTTP sink). */
187
+ sinks?: AnalyticsSink[];
188
+ /** Session engine config. */
189
+ session?: SessionConfig;
190
+ /** Identity engine config. */
191
+ identity?: IdentityConfig;
192
+ /** Consent gate config. */
193
+ consent?: ConsentConfig;
194
+ /**
195
+ * Force a preset. Defaults: env==='production' → 'prod', else 'dev'.
196
+ * dev = sync + console; prod = batch + sample + beacon flush on unload.
197
+ */
198
+ preset?: 'dev' | 'prod';
199
+ /** Batch size before an automatic flush (prod). Default 20. */
200
+ batchSize?: number;
201
+ /** Auto-flush interval in ms (prod). Default 10_000. */
202
+ flushIntervalMs?: number;
203
+ }
204
+ /** Options accepted by every top-level call. */
205
+ interface CallOptions {
206
+ /** Per-call timestamp override (ISO-8601). */
207
+ timestamp?: string;
208
+ /** Per-call context overrides (shallow-merged). */
209
+ context?: Partial<AnalyticsContext>;
210
+ }
211
+ /** The public analytics surface returned by `createAnalytics`. */
212
+ interface Analytics {
213
+ track(event: string, properties?: AnalyticsProperties, opts?: CallOptions): void;
214
+ identify(userId: string, traits?: AnalyticsProperties, opts?: CallOptions): void;
215
+ page(name?: string, properties?: AnalyticsProperties, opts?: CallOptions): void;
216
+ screen(name?: string, properties?: AnalyticsProperties, opts?: CallOptions): void;
217
+ group(groupId: string, traits?: AnalyticsProperties, opts?: CallOptions): void;
218
+ alias(userId: string, previousId?: string, opts?: CallOptions): void;
219
+ /** Analytics-session API (NOT replay). */
220
+ session: SessionAPI;
221
+ /** Consent gate API. */
222
+ consent: ConsentAPI;
223
+ /** Persistent, non-PII anonymous id. */
224
+ anonymousId(): string;
225
+ /** Current opaque user id (if identified). */
226
+ userId(): string | undefined;
227
+ /** Derive a child that merges extra context into every event. */
228
+ with(context: Partial<AnalyticsContext>): Analytics;
229
+ /** Register an additional sink at runtime. */
230
+ addSink(sink: AnalyticsSink): void;
231
+ /** Remove a sink by name. */
232
+ removeSink(name: string): void;
233
+ /** Registered sink names. */
234
+ readonly sinks: string[];
235
+ /** Flush all sinks (and the internal batch). */
236
+ flush(): Promise<void>;
237
+ /**
238
+ * Reset identity + session (privacy-safe logout). Mints a fresh
239
+ * anonymousId and ends the session.
240
+ */
241
+ reset(): void;
242
+ /** Whether this is a live collector (false for the noop). */
243
+ readonly enabled: boolean;
244
+ }
245
+
246
+ /**
247
+ * createAnalytics — the neutral Segment-spec collector/router.
248
+ *
249
+ * Mirrors `createAI`: a single factory returns the entire public surface and
250
+ * fans the canonical envelope out to registered sinks. There is NO privileged
251
+ * engine — the built-in HTTP sink and every vendor adapter are equal sinks.
252
+ *
253
+ * Presets:
254
+ * dev — synchronous delivery + a console sink (see exactly what ships).
255
+ * prod — batching + sampling + beacon flush on pagehide/visibilitychange.
256
+ *
257
+ * When `enabled: false`, a tree-shakeable noop is returned and none of the
258
+ * live collector / sink code is reachable.
259
+ */
260
+ declare function createAnalytics(config: AnalyticsConfig): Analytics;
261
+
262
+ /** Create the built-in Segment-spec HTTP sink. */
263
+ declare function createHttpSink(options: HttpSinkOptions): AnalyticsSink;
264
+
265
+ interface ConsoleSinkOptions {
266
+ /** Injected logger (defaults to globalThis.console). */
267
+ logger?: Pick<Console, 'log' | 'groupCollapsed' | 'groupEnd'>;
268
+ /** Consent categories this sink requires. Default: none (always allowed). */
269
+ consentCategories?: string[];
270
+ }
271
+ /**
272
+ * Built-in `console` sink — the dev preset's default. Prints each canonical
273
+ * envelope so engineers can see exactly what would ship over the wire.
274
+ */
275
+ declare function createConsoleSink(options?: ConsoleSinkOptions): AnalyticsSink;
276
+
277
+ /** A mock sink that records every call — used for testing the router. */
278
+ interface MockSink extends AnalyticsSink {
279
+ /** All events received across all deliver() calls (flattened). */
280
+ events: AnalyticsEvent[];
281
+ /** Each deliver() call's batch + context. */
282
+ deliveries: Array<{
283
+ batch: AnalyticsEvent[];
284
+ ctx: SinkDeliverContext;
285
+ }>;
286
+ /** init() call contexts. */
287
+ initCalls: SinkInitContext[];
288
+ /** flush() invocation count. */
289
+ flushCalls: number;
290
+ /** shutdown() invocation count. */
291
+ shutdownCalls: number;
292
+ }
293
+ interface CreateMockSinkOptions {
294
+ name?: string;
295
+ consentCategories?: string[];
296
+ }
297
+ /**
298
+ * createMockSink — an `AnalyticsSink` that captures everything for assertion.
299
+ * Mirrors the testing ergonomics of `@refraction-ui/ai`'s mock providers.
300
+ */
301
+ declare function createMockSink(options?: CreateMockSinkOptions): MockSink;
302
+
303
+ /** GA4 parity: 30 minutes of inactivity ends a session. */
304
+ declare const DEFAULT_SESSION_TIMEOUT_MS: number;
305
+ /**
306
+ * Derive a stable campaign fingerprint from a URL's query string. A change in
307
+ * this fingerprint (a *new* campaign, not its absence) forces a new session,
308
+ * matching GA4's "campaign change resets the session" behaviour.
309
+ */
310
+ declare function campaignFingerprint(search?: string): string | undefined;
311
+ /**
312
+ * Session engine.
313
+ *
314
+ * A session is a span of continuous activity. It ends after `timeoutMs` of
315
+ * inactivity (GA4 parity, default 30 min) or when a *new* campaign is
316
+ * detected. State is persisted cross-tab so multiple tabs share one session.
317
+ */
318
+ declare function createSession(config?: SessionConfig, now?: () => number): {
319
+ /** Get the current session id, rotating if expired. */
320
+ id(campaign?: string): string;
321
+ /** Force a brand-new session. */
322
+ start(campaign?: string): string;
323
+ /** End the current session (next id() mints a fresh one). */
324
+ end(): void;
325
+ /** Touch activity so the inactivity window slides forward. */
326
+ touch(campaign?: string): string;
327
+ /** Attach/merge session-scoped properties. */
328
+ set(props: AnalyticsProperties): void;
329
+ /** Read session-scoped properties (undefined when none). */
330
+ props(): AnalyticsProperties | undefined;
331
+ };
332
+
333
+ /**
334
+ * Identity engine.
335
+ *
336
+ * - `anonymousId`: persistent, non-PII, resettable UUIDv4 stored cross-tab.
337
+ * - `userId`: opaque, app-supplied; never persisted by the library (the app
338
+ * owns the user record) — kept only in memory for the active page.
339
+ * - `alias`: records a previous→current stitch for the wire envelope.
340
+ */
341
+ declare function createIdentity(config?: IdentityConfig): {
342
+ anonymousId(): string;
343
+ userId(): string | undefined;
344
+ /** identify(): bind an opaque app user id (no validation, no persistence). */
345
+ setUserId(id: string): void;
346
+ /**
347
+ * alias(): returns the stitch pair for the envelope. `previousId`
348
+ * defaults to the current user or anonymous id.
349
+ */
350
+ alias(nextUserId: string, previousId?: string): {
351
+ userId: string;
352
+ previousId: string;
353
+ };
354
+ /**
355
+ * reset(): privacy-safe logout. Drops the user binding and mints a brand
356
+ * new anonymousId so the next visitor is not stitched to the old one.
357
+ */
358
+ reset(): string;
359
+ };
360
+
361
+ /**
362
+ * Consent gate.
363
+ *
364
+ * Holds the set of granted consent categories. The router asks the gate, per
365
+ * sink, whether *all* of that sink's required categories are granted before
366
+ * delivering. A sink with no declared categories is always allowed (it is the
367
+ * sink author's responsibility to declare what it needs).
368
+ */
369
+ declare function createConsent(config?: ConsentConfig): ConsentAPI & {
370
+ /** True when every required category is granted (empty list ⇒ allowed). */
371
+ allows(required?: string[]): boolean;
372
+ /** True when at least one sink could receive (used by strict mode). */
373
+ strict: boolean;
374
+ };
375
+
376
+ /**
377
+ * PII redaction.
378
+ *
379
+ * A built-in deny-list of well-known PII key names (email/phone/name and
380
+ * common variants) plus any caller-supplied `redactKeys`. Matching is
381
+ * case-insensitive and substring-based so `userEmail`, `email_address`,
382
+ * `phoneNumber`, `fullName`, etc. are all caught. Redaction recurses into
383
+ * nested objects and arrays so PII cannot hide one level down.
384
+ */
385
+ /**
386
+ * Built-in PII deny-list (substring, case-insensitive, separator-insensitive).
387
+ * These match anywhere in a key, so `userEmail`, `email_address`,
388
+ * `phoneNumber`, `firstName`, etc. are all caught.
389
+ */
390
+ declare const PII_DENY_LIST: readonly string[];
391
+ /**
392
+ * Keys that are PII only as an *exact* (normalised) match. `name` belongs
393
+ * here so genuine name fields redact while `username`, `eventName`,
394
+ * `fileName`, `firstName`-style compounds (handled by the deny-list) do not
395
+ * over-redact every key that merely contains "name".
396
+ */
397
+ declare const PII_EXACT_KEYS: readonly string[];
398
+ /** Replacement token written in place of a redacted value. */
399
+ declare const REDACTED = "[REDACTED]";
400
+ /**
401
+ * Build a key matcher from the deny-list + extra keys. `extra` entries match
402
+ * exactly (case-insensitive, normalised); deny-list entries match as
403
+ * substrings.
404
+ */
405
+ declare function createRedactor(extraKeys?: string[]): {
406
+ shouldRedact: (key: string) => boolean;
407
+ /** Redact a properties/traits bag (returns a new object). */
408
+ redact(props?: AnalyticsProperties): AnalyticsProperties | undefined;
409
+ };
410
+ type Redactor = ReturnType<typeof createRedactor>;
411
+
412
+ /**
413
+ * Storage adapters.
414
+ *
415
+ * Order of preference for the cross-tab default: localStorage → cookie →
416
+ * in-memory. The package is environment-agnostic; consumers can inject any
417
+ * `AnalyticsStorage` (e.g. an RN AsyncStorage shim) via config.
418
+ */
419
+ /** Volatile per-process store (SSR / Node / no-DOM fallback). */
420
+ declare function createMemoryStorage(): AnalyticsStorage;
421
+ /** `window.localStorage` adapter (cross-tab via the storage event). */
422
+ declare function createLocalStorageAdapter(ls: Storage): AnalyticsStorage;
423
+ interface CookieDoc {
424
+ cookie: string;
425
+ }
426
+ /** `document.cookie` adapter (cross-tab + cross-subdomain capable). */
427
+ declare function createCookieAdapter(doc: CookieDoc, maxAgeSeconds?: number): AnalyticsStorage;
428
+ /**
429
+ * Resolve the default storage for the current environment. A caller-supplied
430
+ * storage always wins. Browser → localStorage (falls back to cookie if
431
+ * localStorage throws), otherwise in-memory.
432
+ */
433
+ declare function resolveStorage(override?: AnalyticsStorage): AnalyticsStorage;
434
+
435
+ /**
436
+ * uuidv4 — RFC 4122 version 4 UUID.
437
+ *
438
+ * Prefers `crypto.randomUUID` / `crypto.getRandomValues` when available
439
+ * (browser, modern Node) and degrades to `Math.random` only as a last
440
+ * resort. No external dependency by design (this is the neutral router).
441
+ */
442
+ declare function uuidv4(): string;
443
+ /** RFC 4122 v4 shape matcher (case-insensitive). */
444
+ declare const UUID_V4_RE: RegExp;
445
+ /** True when `value` is a well-formed v4 UUID. */
446
+ declare function isUuidV4(value: unknown): value is string;
447
+
448
+ export { type Analytics, type AnalyticsConfig, type AnalyticsContext, type AnalyticsEvent, type AnalyticsEventType, type AnalyticsProperties, type AnalyticsSink, type AnalyticsStorage, type CallOptions, type ConsentAPI, type ConsentConfig, type ConsoleSinkOptions, type CreateMockSinkOptions, DEFAULT_SESSION_TIMEOUT_MS, type HttpSinkOptions, type IdentityConfig, type MockSink, PII_DENY_LIST, PII_EXACT_KEYS, REDACTED, type Redactor, SCHEMA_VERSION, type SessionAPI, type SessionConfig, type SinkDeliverContext, type SinkInitContext, UUID_V4_RE, campaignFingerprint, createAnalytics, createConsent, createConsoleSink, createCookieAdapter, createHttpSink, createIdentity, createLocalStorageAdapter, createMemoryStorage, createMockSink, createRedactor, createSession, isUuidV4, resolveStorage, uuidv4 };