@refraction-ui/astro 0.5.1 → 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.
@@ -0,0 +1,64 @@
1
+ import type { AnalyticsStorage, IdentityConfig } from './types.js'
2
+ import { resolveStorage } from './storage.js'
3
+ import { uuidv4, isUuidV4 } from './uuid.js'
4
+
5
+ const DEFAULT_KEY = 'rfx:analytics:anon'
6
+
7
+ /**
8
+ * Identity engine.
9
+ *
10
+ * - `anonymousId`: persistent, non-PII, resettable UUIDv4 stored cross-tab.
11
+ * - `userId`: opaque, app-supplied; never persisted by the library (the app
12
+ * owns the user record) — kept only in memory for the active page.
13
+ * - `alias`: records a previous→current stitch for the wire envelope.
14
+ */
15
+ export function createIdentity(config?: IdentityConfig) {
16
+ const storage: AnalyticsStorage = resolveStorage(config?.storage)
17
+ const key = config?.storageKey ?? DEFAULT_KEY
18
+
19
+ let userId: string | undefined
20
+
21
+ function loadOrMintAnon(): string {
22
+ const existing = storage.get(key)
23
+ if (isUuidV4(existing)) return existing as string
24
+ const fresh = uuidv4()
25
+ storage.set(key, fresh)
26
+ return fresh
27
+ }
28
+
29
+ let anonymousId = loadOrMintAnon()
30
+
31
+ return {
32
+ anonymousId(): string {
33
+ return anonymousId
34
+ },
35
+ userId(): string | undefined {
36
+ return userId
37
+ },
38
+ /** identify(): bind an opaque app user id (no validation, no persistence). */
39
+ setUserId(id: string): void {
40
+ userId = id
41
+ },
42
+ /**
43
+ * alias(): returns the stitch pair for the envelope. `previousId`
44
+ * defaults to the current user or anonymous id.
45
+ */
46
+ alias(nextUserId: string, previousId?: string): { userId: string; previousId: string } {
47
+ const prev = previousId ?? userId ?? anonymousId
48
+ userId = nextUserId
49
+ return { userId: nextUserId, previousId: prev }
50
+ },
51
+ /**
52
+ * reset(): privacy-safe logout. Drops the user binding and mints a brand
53
+ * new anonymousId so the next visitor is not stitched to the old one.
54
+ */
55
+ reset(): string {
56
+ userId = undefined
57
+ anonymousId = uuidv4()
58
+ storage.set(key, anonymousId)
59
+ return anonymousId
60
+ },
61
+ }
62
+ }
63
+
64
+ export type Identity = ReturnType<typeof createIdentity>
@@ -0,0 +1,60 @@
1
+ // Types
2
+ export type {
3
+ Analytics,
4
+ AnalyticsConfig,
5
+ AnalyticsEvent,
6
+ AnalyticsEventType,
7
+ AnalyticsProperties,
8
+ AnalyticsContext,
9
+ AnalyticsSink,
10
+ AnalyticsStorage,
11
+ SinkInitContext,
12
+ SinkDeliverContext,
13
+ SessionConfig,
14
+ SessionAPI,
15
+ IdentityConfig,
16
+ ConsentConfig,
17
+ ConsentAPI,
18
+ HttpSinkOptions,
19
+ CallOptions,
20
+ } from './types.js'
21
+ export { SCHEMA_VERSION } from './types.js'
22
+
23
+ // Manager
24
+ export { createAnalytics } from './analytics-manager.js'
25
+
26
+ // Built-in sinks
27
+ export { createHttpSink } from './http-sink.js'
28
+ export { createConsoleSink } from './console-sink.js'
29
+ export type { ConsoleSinkOptions } from './console-sink.js'
30
+
31
+ // Mock sink (for testing)
32
+ export { createMockSink } from './mock-sink.js'
33
+ export type { MockSink, CreateMockSinkOptions } from './mock-sink.js'
34
+
35
+ // Engines (advanced / standalone use)
36
+ export {
37
+ createSession,
38
+ campaignFingerprint,
39
+ DEFAULT_SESSION_TIMEOUT_MS,
40
+ } from './session.js'
41
+ export { createIdentity } from './identity.js'
42
+ export { createConsent } from './consent.js'
43
+ export {
44
+ createRedactor,
45
+ PII_DENY_LIST,
46
+ PII_EXACT_KEYS,
47
+ REDACTED,
48
+ } from './redaction.js'
49
+ export type { Redactor } from './redaction.js'
50
+
51
+ // Storage adapters
52
+ export {
53
+ createMemoryStorage,
54
+ createLocalStorageAdapter,
55
+ createCookieAdapter,
56
+ resolveStorage,
57
+ } from './storage.js'
58
+
59
+ // Utilities
60
+ export { uuidv4, isUuidV4, UUID_V4_RE } from './uuid.js'
@@ -0,0 +1,62 @@
1
+ import type {
2
+ AnalyticsEvent,
3
+ AnalyticsSink,
4
+ SinkDeliverContext,
5
+ SinkInitContext,
6
+ } from './types.js'
7
+
8
+ /** A mock sink that records every call — used for testing the router. */
9
+ export interface MockSink extends AnalyticsSink {
10
+ /** All events received across all deliver() calls (flattened). */
11
+ events: AnalyticsEvent[]
12
+ /** Each deliver() call's batch + context. */
13
+ deliveries: Array<{ batch: AnalyticsEvent[]; ctx: SinkDeliverContext }>
14
+ /** init() call contexts. */
15
+ initCalls: SinkInitContext[]
16
+ /** flush() invocation count. */
17
+ flushCalls: number
18
+ /** shutdown() invocation count. */
19
+ shutdownCalls: number
20
+ }
21
+
22
+ export interface CreateMockSinkOptions {
23
+ name?: string
24
+ consentCategories?: string[]
25
+ }
26
+
27
+ /**
28
+ * createMockSink — an `AnalyticsSink` that captures everything for assertion.
29
+ * Mirrors the testing ergonomics of `@refraction-ui/ai`'s mock providers.
30
+ */
31
+ export function createMockSink(
32
+ options: CreateMockSinkOptions = {},
33
+ ): MockSink {
34
+ const sink: MockSink = {
35
+ name: options.name ?? 'mock',
36
+ consentCategories: options.consentCategories,
37
+ events: [],
38
+ deliveries: [],
39
+ initCalls: [],
40
+ flushCalls: 0,
41
+ shutdownCalls: 0,
42
+
43
+ init(ctx: SinkInitContext): void {
44
+ sink.initCalls.push(ctx)
45
+ },
46
+
47
+ deliver(batch: AnalyticsEvent[], ctx: SinkDeliverContext): void {
48
+ sink.deliveries.push({ batch, ctx })
49
+ for (const ev of batch) sink.events.push(ev)
50
+ },
51
+
52
+ flush(): void {
53
+ sink.flushCalls++
54
+ },
55
+
56
+ shutdown(): void {
57
+ sink.shutdownCalls++
58
+ },
59
+ }
60
+
61
+ return sink
62
+ }
@@ -0,0 +1,48 @@
1
+ import type { Analytics } from './types.js'
2
+
3
+ /**
4
+ * The noop collector returned when `enabled: false`.
5
+ *
6
+ * Every method is an empty function so calls are free and the surrounding
7
+ * vendor/sink code can be tree-shaken out of a production bundle when
8
+ * analytics is compiled off. Kept in its own module so bundlers can drop the
9
+ * live collector entirely on the `enabled:false` path.
10
+ */
11
+ export function createNoopAnalytics(): Analytics {
12
+ const sessionId = '00000000-0000-4000-8000-000000000000'
13
+ const noop = (): void => {}
14
+
15
+ const api: Analytics = {
16
+ track: noop,
17
+ identify: noop,
18
+ page: noop,
19
+ screen: noop,
20
+ group: noop,
21
+ alias: noop,
22
+ session: {
23
+ id: () => sessionId,
24
+ start: () => sessionId,
25
+ end: noop,
26
+ set: noop,
27
+ },
28
+ consent: {
29
+ grant: noop,
30
+ revoke: noop,
31
+ granted: () => [],
32
+ isGranted: () => false,
33
+ },
34
+ anonymousId: () => sessionId,
35
+ userId: () => undefined,
36
+ with: () => api,
37
+ addSink: noop,
38
+ removeSink: noop,
39
+ get sinks() {
40
+ return []
41
+ },
42
+ flush: async () => {},
43
+ reset: noop,
44
+ enabled: false,
45
+ }
46
+
47
+ return api
48
+ }
@@ -0,0 +1,93 @@
1
+ import type { AnalyticsProperties } from './types.js'
2
+
3
+ /**
4
+ * PII redaction.
5
+ *
6
+ * A built-in deny-list of well-known PII key names (email/phone/name and
7
+ * common variants) plus any caller-supplied `redactKeys`. Matching is
8
+ * case-insensitive and substring-based so `userEmail`, `email_address`,
9
+ * `phoneNumber`, `fullName`, etc. are all caught. Redaction recurses into
10
+ * nested objects and arrays so PII cannot hide one level down.
11
+ */
12
+
13
+ /**
14
+ * Built-in PII deny-list (substring, case-insensitive, separator-insensitive).
15
+ * These match anywhere in a key, so `userEmail`, `email_address`,
16
+ * `phoneNumber`, `firstName`, etc. are all caught.
17
+ */
18
+ export const PII_DENY_LIST: readonly string[] = [
19
+ 'email',
20
+ 'phone',
21
+ 'mobile',
22
+ 'firstname',
23
+ 'lastname',
24
+ 'fullname',
25
+ 'givenname',
26
+ 'surname',
27
+ 'password',
28
+ 'passwd',
29
+ 'ssn',
30
+ 'creditcard',
31
+ 'cardnumber',
32
+ 'cvv',
33
+ 'dob',
34
+ 'dateofbirth',
35
+ 'address',
36
+ ]
37
+
38
+ /**
39
+ * Keys that are PII only as an *exact* (normalised) match. `name` belongs
40
+ * here so genuine name fields redact while `username`, `eventName`,
41
+ * `fileName`, `firstName`-style compounds (handled by the deny-list) do not
42
+ * over-redact every key that merely contains "name".
43
+ */
44
+ export const PII_EXACT_KEYS: readonly string[] = ['name']
45
+
46
+ /** Replacement token written in place of a redacted value. */
47
+ export const REDACTED = '[REDACTED]'
48
+
49
+ function normalize(key: string): string {
50
+ return key.toLowerCase().replace(/[_\-\s]/g, '')
51
+ }
52
+
53
+ /**
54
+ * Build a key matcher from the deny-list + extra keys. `extra` entries match
55
+ * exactly (case-insensitive, normalised); deny-list entries match as
56
+ * substrings.
57
+ */
58
+ export function createRedactor(extraKeys: string[] = []) {
59
+ const exact = new Set([
60
+ ...extraKeys.map(normalize),
61
+ ...PII_EXACT_KEYS.map(normalize),
62
+ ])
63
+ const deny = PII_DENY_LIST.map(normalize)
64
+
65
+ const shouldRedact = (key: string): boolean => {
66
+ const n = normalize(key)
67
+ if (exact.has(n)) return true
68
+ return deny.some((d) => n.includes(d))
69
+ }
70
+
71
+ const walk = (value: unknown): unknown => {
72
+ if (Array.isArray(value)) return value.map(walk)
73
+ if (value && typeof value === 'object') {
74
+ const out: Record<string, unknown> = {}
75
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
76
+ out[k] = shouldRedact(k) ? REDACTED : walk(v)
77
+ }
78
+ return out
79
+ }
80
+ return value
81
+ }
82
+
83
+ return {
84
+ shouldRedact,
85
+ /** Redact a properties/traits bag (returns a new object). */
86
+ redact(props?: AnalyticsProperties): AnalyticsProperties | undefined {
87
+ if (!props) return props
88
+ return walk(props) as AnalyticsProperties
89
+ },
90
+ }
91
+ }
92
+
93
+ export type Redactor = ReturnType<typeof createRedactor>
@@ -0,0 +1,162 @@
1
+ import type {
2
+ AnalyticsProperties,
3
+ AnalyticsStorage,
4
+ SessionConfig,
5
+ } from './types.js'
6
+ import { resolveStorage } from './storage.js'
7
+ import { uuidv4 } from './uuid.js'
8
+
9
+ /** GA4 parity: 30 minutes of inactivity ends a session. */
10
+ export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000
11
+
12
+ const DEFAULT_KEY = 'rfx:analytics:session'
13
+
14
+ interface PersistedSession {
15
+ id: string
16
+ /** Last activity epoch ms. */
17
+ lastActivity: number
18
+ /** Campaign fingerprint at session start (for campaign-reset). */
19
+ campaign?: string
20
+ /** Session-scoped properties (set via session.set). */
21
+ props?: AnalyticsProperties
22
+ }
23
+
24
+ /** Recognised campaign params (UTM + common click ids). GA4 parity. */
25
+ const CAMPAIGN_PARAMS = [
26
+ 'utm_source',
27
+ 'utm_medium',
28
+ 'utm_campaign',
29
+ 'utm_term',
30
+ 'utm_content',
31
+ 'gclid',
32
+ 'fbclid',
33
+ 'msclkid',
34
+ ]
35
+
36
+ /**
37
+ * Derive a stable campaign fingerprint from a URL's query string. A change in
38
+ * this fingerprint (a *new* campaign, not its absence) forces a new session,
39
+ * matching GA4's "campaign change resets the session" behaviour.
40
+ */
41
+ export function campaignFingerprint(search?: string): string | undefined {
42
+ if (!search) return undefined
43
+ let qs = search
44
+ const q = qs.indexOf('?')
45
+ if (q !== -1) qs = qs.slice(q + 1)
46
+ let params: URLSearchParams
47
+ try {
48
+ params = new URLSearchParams(qs)
49
+ } catch {
50
+ return undefined
51
+ }
52
+ const pairs: string[] = []
53
+ for (const p of CAMPAIGN_PARAMS) {
54
+ const v = params.get(p)
55
+ if (v) pairs.push(`${p}=${v}`)
56
+ }
57
+ return pairs.length ? pairs.join('&') : undefined
58
+ }
59
+
60
+ /**
61
+ * Session engine.
62
+ *
63
+ * A session is a span of continuous activity. It ends after `timeoutMs` of
64
+ * inactivity (GA4 parity, default 30 min) or when a *new* campaign is
65
+ * detected. State is persisted cross-tab so multiple tabs share one session.
66
+ */
67
+ export function createSession(
68
+ config?: SessionConfig,
69
+ now: () => number = () => Date.now(),
70
+ ) {
71
+ const storage: AnalyticsStorage = resolveStorage(config?.storage)
72
+ const key = config?.storageKey ?? DEFAULT_KEY
73
+ const timeoutMs = config?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS
74
+ const resetOnCampaign = config?.resetOnCampaign ?? true
75
+
76
+ function read(): PersistedSession | null {
77
+ const raw = storage.get(key)
78
+ if (!raw) return null
79
+ try {
80
+ const parsed = JSON.parse(raw) as PersistedSession
81
+ if (parsed && typeof parsed.id === 'string') return parsed
82
+ } catch {
83
+ /* corrupt — treat as none */
84
+ }
85
+ return null
86
+ }
87
+
88
+ function write(s: PersistedSession): void {
89
+ storage.set(key, JSON.stringify(s))
90
+ }
91
+
92
+ function mint(campaign?: string): PersistedSession {
93
+ const s: PersistedSession = {
94
+ id: uuidv4(),
95
+ lastActivity: now(),
96
+ campaign,
97
+ }
98
+ write(s)
99
+ return s
100
+ }
101
+
102
+ /**
103
+ * Return the live session, creating/rotating it as required by the
104
+ * inactivity timeout and (optionally) a campaign change.
105
+ */
106
+ function ensure(campaign?: string): PersistedSession {
107
+ const existing = read()
108
+ const t = now()
109
+
110
+ if (!existing) return mint(campaign)
111
+
112
+ // Inactivity timeout.
113
+ if (t - existing.lastActivity > timeoutMs) {
114
+ return mint(campaign)
115
+ }
116
+
117
+ // Campaign reset — only when a *new* campaign appears.
118
+ if (
119
+ resetOnCampaign &&
120
+ campaign !== undefined &&
121
+ existing.campaign !== campaign
122
+ ) {
123
+ return mint(campaign)
124
+ }
125
+
126
+ return existing
127
+ }
128
+
129
+ return {
130
+ /** Get the current session id, rotating if expired. */
131
+ id(campaign?: string): string {
132
+ return ensure(campaign).id
133
+ },
134
+ /** Force a brand-new session. */
135
+ start(campaign?: string): string {
136
+ return mint(campaign).id
137
+ },
138
+ /** End the current session (next id() mints a fresh one). */
139
+ end(): void {
140
+ storage.remove(key)
141
+ },
142
+ /** Touch activity so the inactivity window slides forward. */
143
+ touch(campaign?: string): string {
144
+ const s = ensure(campaign)
145
+ s.lastActivity = now()
146
+ write(s)
147
+ return s.id
148
+ },
149
+ /** Attach/merge session-scoped properties. */
150
+ set(props: AnalyticsProperties): void {
151
+ const s = ensure()
152
+ s.props = { ...(s.props ?? {}), ...props }
153
+ write(s)
154
+ },
155
+ /** Read session-scoped properties (undefined when none). */
156
+ props(): AnalyticsProperties | undefined {
157
+ return read()?.props
158
+ },
159
+ }
160
+ }
161
+
162
+ export type Session = ReturnType<typeof createSession>
@@ -0,0 +1,119 @@
1
+ import type { AnalyticsStorage } from './types.js'
2
+
3
+ /**
4
+ * Storage adapters.
5
+ *
6
+ * Order of preference for the cross-tab default: localStorage → cookie →
7
+ * in-memory. The package is environment-agnostic; consumers can inject any
8
+ * `AnalyticsStorage` (e.g. an RN AsyncStorage shim) via config.
9
+ */
10
+
11
+ /** Volatile per-process store (SSR / Node / no-DOM fallback). */
12
+ export function createMemoryStorage(): AnalyticsStorage {
13
+ const map = new Map<string, string>()
14
+ return {
15
+ get: (k) => (map.has(k) ? (map.get(k) as string) : null),
16
+ set: (k, v) => {
17
+ map.set(k, v)
18
+ },
19
+ remove: (k) => {
20
+ map.delete(k)
21
+ },
22
+ }
23
+ }
24
+
25
+ /** `window.localStorage` adapter (cross-tab via the storage event). */
26
+ export function createLocalStorageAdapter(
27
+ ls: Storage,
28
+ ): AnalyticsStorage {
29
+ return {
30
+ get: (k) => {
31
+ try {
32
+ return ls.getItem(k)
33
+ } catch {
34
+ return null
35
+ }
36
+ },
37
+ set: (k, v) => {
38
+ try {
39
+ ls.setItem(k, v)
40
+ } catch {
41
+ /* quota / disabled — degrade silently */
42
+ }
43
+ },
44
+ remove: (k) => {
45
+ try {
46
+ ls.removeItem(k)
47
+ } catch {
48
+ /* ignore */
49
+ }
50
+ },
51
+ }
52
+ }
53
+
54
+ interface CookieDoc {
55
+ cookie: string
56
+ }
57
+
58
+ /** `document.cookie` adapter (cross-tab + cross-subdomain capable). */
59
+ export function createCookieAdapter(
60
+ doc: CookieDoc,
61
+ maxAgeSeconds = 60 * 60 * 24 * 365,
62
+ ): AnalyticsStorage {
63
+ const read = (k: string): string | null => {
64
+ const target = encodeURIComponent(k) + '='
65
+ const parts = doc.cookie ? doc.cookie.split(';') : []
66
+ for (const part of parts) {
67
+ const c = part.trim()
68
+ if (c.startsWith(target)) {
69
+ return decodeURIComponent(c.slice(target.length))
70
+ }
71
+ }
72
+ return null
73
+ }
74
+ return {
75
+ get: read,
76
+ set: (k, v) => {
77
+ doc.cookie = `${encodeURIComponent(k)}=${encodeURIComponent(
78
+ v,
79
+ )}; path=/; max-age=${maxAgeSeconds}; SameSite=Lax`
80
+ },
81
+ remove: (k) => {
82
+ doc.cookie = `${encodeURIComponent(k)}=; path=/; max-age=0; SameSite=Lax`
83
+ },
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Resolve the default storage for the current environment. A caller-supplied
89
+ * storage always wins. Browser → localStorage (falls back to cookie if
90
+ * localStorage throws), otherwise in-memory.
91
+ */
92
+ export function resolveStorage(
93
+ override?: AnalyticsStorage,
94
+ ): AnalyticsStorage {
95
+ if (override) return override
96
+
97
+ const g = globalThis as unknown as {
98
+ localStorage?: Storage
99
+ document?: CookieDoc
100
+ }
101
+
102
+ if (g.localStorage) {
103
+ try {
104
+ // Probe — Safari private mode throws on setItem.
105
+ const probe = '__rfx_a_probe__'
106
+ g.localStorage.setItem(probe, '1')
107
+ g.localStorage.removeItem(probe)
108
+ return createLocalStorageAdapter(g.localStorage)
109
+ } catch {
110
+ /* fall through to cookie */
111
+ }
112
+ }
113
+
114
+ if (g.document && typeof g.document.cookie === 'string') {
115
+ return createCookieAdapter(g.document)
116
+ }
117
+
118
+ return createMemoryStorage()
119
+ }