@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.
- package/dist/analytics/analytics-manager.ts +361 -0
- package/dist/analytics/consent.ts +40 -0
- package/dist/analytics/console-sink.ts +37 -0
- package/dist/analytics/http-sink.ts +213 -0
- package/dist/analytics/identity.ts +64 -0
- package/dist/analytics/index.ts +60 -0
- package/dist/analytics/mock-sink.ts +62 -0
- package/dist/analytics/noop.ts +48 -0
- package/dist/analytics/redaction.ts +93 -0
- package/dist/analytics/session.ts +162 -0
- package/dist/analytics/storage.ts +119 -0
- package/dist/analytics/types.ts +273 -0
- package/dist/analytics/uuid.ts +63 -0
- package/dist/astro-analytics/AnalyticsScript.astro +130 -0
- package/dist/astro-analytics/index.ts +49 -0
- package/dist/astro-logger/TelemetryScript.astro +108 -0
- package/dist/astro-logger/index.ts +48 -0
- package/dist/astro-logger/library-error-capture.ts +121 -0
- package/dist/astro-logger/telemetry-middleware.ts +92 -0
- package/dist/index.ts +2 -0
- package/dist/logger/console-sink.ts +64 -0
- package/dist/logger/faro-engine.ts +130 -0
- package/dist/logger/index.ts +39 -0
- package/dist/logger/mock-sink.ts +38 -0
- package/dist/logger/noop.ts +52 -0
- package/dist/logger/presets.ts +51 -0
- package/dist/logger/redact.ts +33 -0
- package/dist/logger/telemetry-manager.ts +229 -0
- package/dist/logger/types.ts +121 -0
- package/dist/shared/dev-feedback.ts +154 -0
- package/dist/shared/index.ts +25 -0
- package/dist/shared/library-origin-error.ts +240 -0
- package/package.json +4 -2
|
@@ -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
|
+
}
|