@refraction-ui/astro 0.6.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,160 @@
1
+ import type {
2
+ AnalyticsEvent,
3
+ AnalyticsSink,
4
+ SinkInitContext,
5
+ } from '../analytics/index.ts'
6
+ import { distinctId } from './mapping.js'
7
+
8
+ /**
9
+ * PostHog `client-sdk` sink — OPTIONAL, opt-in mode.
10
+ *
11
+ * For client-exclusive PostHog features (autocapture, feature flags,
12
+ * surveys, web experiments) that the protocol API alone cannot drive.
13
+ * `posthog-js` is loaded **lazily via dynamic `import()`** the first time the
14
+ * sink delivers, so a consumer who only uses the default `http` sink never
15
+ * pays the browser-library cost and `posthog-js` stays a fully optional peer.
16
+ *
17
+ * Session replay is deliberately NOT touched here — it lives in the separate
18
+ * `@refraction-ui/analytics-sink-posthog/replay` module so it is never on the
19
+ * event path and tree-shakes out unless explicitly enabled.
20
+ */
21
+
22
+ /** Minimal structural type for the bits of `posthog-js` we use. */
23
+ interface PostHogJs {
24
+ init(apiKey: string, options: Record<string, unknown>): void
25
+ capture(event: string, properties?: Record<string, unknown>): void
26
+ identify(distinctId: string, set?: Record<string, unknown>): void
27
+ alias(alias: string, original?: string): void
28
+ group(
29
+ groupType: string,
30
+ groupKey: string,
31
+ properties?: Record<string, unknown>,
32
+ ): void
33
+ reset(): void
34
+ }
35
+
36
+ export interface PostHogClientSdkSinkOptions {
37
+ /** PostHog project API key. */
38
+ apiKey: string
39
+ /** PostHog API host. Default `https://us.i.posthog.com`. */
40
+ host?: string
41
+ /** Sink name. Default `posthog`. */
42
+ name?: string
43
+ /** Consent categories this sink requires. Default `['analytics']`. */
44
+ consentCategories?: string[]
45
+ /**
46
+ * Extra `posthog-js` init options. `autocapture`, `capture_pageview`, and
47
+ * `session_recording` default to OFF — the canonical router owns the event
48
+ * path; replay is opt-in via the separate module.
49
+ */
50
+ posthogOptions?: Record<string, unknown>
51
+ /**
52
+ * Injected loader (testing / custom bundling). Defaults to a lazy
53
+ * `import('posthog-js')`.
54
+ */
55
+ loadPostHog?: () => Promise<{ default: PostHogJs } | PostHogJs>
56
+ }
57
+
58
+ async function defaultLoad(): Promise<PostHogJs> {
59
+ // Dynamic import keeps `posthog-js` out of the graph until first delivery.
60
+ const mod = (await import('posthog-js')) as unknown as
61
+ | { default: PostHogJs }
62
+ | PostHogJs
63
+ return 'default' in mod ? mod.default : mod
64
+ }
65
+
66
+ /**
67
+ * Create the OPTIONAL PostHog `client-sdk`-mode sink. `posthog-js` is
68
+ * dynamically imported on first use, never at module load.
69
+ */
70
+ export function createPostHogClientSdkSink(
71
+ options: PostHogClientSdkSinkOptions,
72
+ ): AnalyticsSink {
73
+ const {
74
+ apiKey,
75
+ host = 'https://us.i.posthog.com',
76
+ name = 'posthog',
77
+ consentCategories = ['analytics'],
78
+ posthogOptions = {},
79
+ } = options
80
+
81
+ let ph: PostHogJs | undefined
82
+ let loading: Promise<PostHogJs> | undefined
83
+
84
+ async function ensure(): Promise<PostHogJs> {
85
+ if (ph) return ph
86
+ if (!loading) {
87
+ const load = options.loadPostHog
88
+ ? async () => {
89
+ const m = await options.loadPostHog!()
90
+ return ('default' in m ? m.default : m) as PostHogJs
91
+ }
92
+ : defaultLoad
93
+ loading = load().then((instance) => {
94
+ instance.init(apiKey, {
95
+ api_host: host,
96
+ // The canonical router owns the event path — disable PostHog's
97
+ // own auto-capture and pageview, and never start replay here.
98
+ autocapture: false,
99
+ capture_pageview: false,
100
+ disable_session_recording: true,
101
+ ...posthogOptions,
102
+ })
103
+ ph = instance
104
+ return instance
105
+ })
106
+ }
107
+ return loading
108
+ }
109
+
110
+ return {
111
+ name,
112
+ consentCategories,
113
+
114
+ async init(_ctx: SinkInitContext): Promise<void> {
115
+ // Eagerly warm the SDK so the first event is not delayed. Still lazy
116
+ // relative to module load — only runs once the sink is registered.
117
+ await ensure()
118
+ },
119
+
120
+ async deliver(batch: AnalyticsEvent[]): Promise<void> {
121
+ if (batch.length === 0) return
122
+ const client = await ensure()
123
+ for (const ev of batch) {
124
+ const did = distinctId(ev)
125
+ switch (ev.type) {
126
+ case 'identify':
127
+ client.identify(did, { ...(ev.traits ?? {}) })
128
+ break
129
+ case 'alias':
130
+ if (ev.previousId) client.alias(ev.previousId, did)
131
+ break
132
+ case 'group':
133
+ client.group(
134
+ (ev.properties?.groupType as string) ?? 'company',
135
+ ev.groupId ?? '',
136
+ { ...(ev.traits ?? {}) },
137
+ )
138
+ break
139
+ case 'page':
140
+ client.capture('$pageview', { ...(ev.properties ?? {}) })
141
+ break
142
+ case 'screen':
143
+ client.capture('$screen', {
144
+ $screen_name: ev.event,
145
+ ...(ev.properties ?? {}),
146
+ })
147
+ break
148
+ case 'track':
149
+ default:
150
+ client.capture(ev.event ?? 'track', { ...(ev.properties ?? {}) })
151
+ break
152
+ }
153
+ }
154
+ },
155
+
156
+ shutdown(): void {
157
+ ph?.reset()
158
+ },
159
+ }
160
+ }
@@ -0,0 +1,214 @@
1
+ import type {
2
+ AnalyticsEvent,
3
+ AnalyticsSink,
4
+ SinkDeliverContext,
5
+ } from '../analytics/index.ts'
6
+ import { toPostHogBatch } from './mapping.js'
7
+
8
+ /**
9
+ * PostHog `http` sink — the DEFAULT mode.
10
+ *
11
+ * Talks to PostHog's ingestion API directly over `fetch`; it never loads
12
+ * `posthog-js` or any browser library, so it is server-relay friendly and
13
+ * ad-blocker-proof when fronted by your own endpoint.
14
+ *
15
+ * single event → POST {host}/capture/ { api_key, event, ... }
16
+ * batch → POST {host}/batch/ { api_key, batch: [...] }
17
+ *
18
+ * PostHog accept-and-queue semantics mirror the canonical wire contract:
19
+ * 2xx accepted (queued, not processed) → done
20
+ * 400 malformed → DROP, never retry
21
+ * 401 / 403 bad project key → DROP, never retry
22
+ * 413 payload too big → DROP (we also pre-split under the size caps)
23
+ * 429 / 5xx transient → exponential backoff retry
24
+ * network — → treated as transient → retry
25
+ *
26
+ * No `Authorization` header is used: PostHog authenticates with the public
27
+ * project API key carried in the JSON body, so the `sendBeacon` unload path
28
+ * works without any header juggling.
29
+ */
30
+
31
+ const NO_RETRY = new Set([400, 401, 403, 413])
32
+
33
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
34
+
35
+ function byteLength(s: string): number {
36
+ const g = globalThis as unknown as {
37
+ TextEncoder?: new () => { encode(s: string): { length: number } }
38
+ }
39
+ if (g.TextEncoder) return new g.TextEncoder().encode(s).length
40
+ return unescape(encodeURIComponent(s)).length
41
+ }
42
+
43
+ export interface PostHogHttpSinkOptions {
44
+ /** PostHog project API key (the public, write-only key). */
45
+ apiKey: string
46
+ /**
47
+ * Ingestion host. Default `https://us.i.posthog.com`. Use
48
+ * `https://eu.i.posthog.com`, a self-hosted host, or — recommended for
49
+ * production — your own reverse-proxy/relay path.
50
+ */
51
+ host?: string
52
+ /** Sink name. Default `posthog`. */
53
+ name?: string
54
+ /** Consent categories this sink requires. Default `['analytics']`. */
55
+ consentCategories?: string[]
56
+ /** Max retries for 429/5xx (exponential backoff). Default 3. */
57
+ maxRetries?: number
58
+ /** Base backoff delay in ms. Default 500. */
59
+ backoffBaseMs?: number
60
+ /** Injected fetch (defaults to global fetch). */
61
+ fetchImpl?: typeof fetch
62
+ /** Injected sendBeacon (defaults to navigator.sendBeacon). */
63
+ beaconImpl?: (url: string, body: string) => boolean
64
+ /** Soft per-batch byte cap. PostHog limit ≈ 20MB; default 1MB. */
65
+ maxBatchBytes?: number
66
+ /** Soft per-event byte cap. Default 32KB. */
67
+ maxEventBytes?: number
68
+ }
69
+
70
+ type PostHogBatchItem = ReturnType<typeof toPostHogBatch>[number]
71
+
72
+ /** Split so neither per-event nor per-batch byte caps are exceeded. */
73
+ function splitBatch(
74
+ items: PostHogBatchItem[],
75
+ maxBatchBytes: number,
76
+ maxEventBytes: number,
77
+ ): PostHogBatchItem[][] {
78
+ const batches: PostHogBatchItem[][] = []
79
+ let current: PostHogBatchItem[] = []
80
+ let currentBytes = 2
81
+
82
+ for (const item of items) {
83
+ const itemBytes = byteLength(JSON.stringify(item))
84
+ // Oversized single events can never be sent — drop them.
85
+ if (itemBytes > maxEventBytes) continue
86
+ if (current.length && currentBytes + itemBytes + 1 > maxBatchBytes) {
87
+ batches.push(current)
88
+ current = []
89
+ currentBytes = 2
90
+ }
91
+ current.push(item)
92
+ currentBytes += itemBytes + 1
93
+ }
94
+ if (current.length) batches.push(current)
95
+ return batches
96
+ }
97
+
98
+ /**
99
+ * Create the PostHog `http`-mode sink (default). Pure protocol adapter —
100
+ * no `posthog-js`, safe in Node and the browser.
101
+ */
102
+ export function createPostHogHttpSink(
103
+ options: PostHogHttpSinkOptions,
104
+ ): AnalyticsSink {
105
+ const {
106
+ apiKey,
107
+ host = 'https://us.i.posthog.com',
108
+ name = 'posthog',
109
+ consentCategories = ['analytics'],
110
+ maxRetries = 3,
111
+ backoffBaseMs = 500,
112
+ maxBatchBytes = 1_000_000,
113
+ maxEventBytes = 32_000,
114
+ } = options
115
+
116
+ const base = host.replace(/\/+$/, '')
117
+ const captureUrl = `${base}/capture/`
118
+ const batchUrl = `${base}/batch/`
119
+
120
+ const resolveFetch = (): typeof fetch => {
121
+ if (options.fetchImpl) return options.fetchImpl
122
+ const f = (globalThis as unknown as { fetch?: typeof fetch }).fetch
123
+ if (!f) throw new Error('No fetch implementation available')
124
+ return f
125
+ }
126
+
127
+ const resolveBeacon = ():
128
+ | ((u: string, body: string) => boolean)
129
+ | undefined => {
130
+ if (options.beaconImpl) return options.beaconImpl
131
+ const nav = (
132
+ globalThis as unknown as {
133
+ navigator?: { sendBeacon?: (u: string, data: BodyInit) => boolean }
134
+ }
135
+ ).navigator
136
+ if (nav && typeof nav.sendBeacon === 'function') {
137
+ return (u, body) => nav.sendBeacon!(u, body)
138
+ }
139
+ return undefined
140
+ }
141
+
142
+ function payload(part: PostHogBatchItem[]): {
143
+ url: string
144
+ body: string
145
+ } {
146
+ if (part.length === 1) {
147
+ return {
148
+ url: captureUrl,
149
+ body: JSON.stringify({ api_key: apiKey, ...part[0] }),
150
+ }
151
+ }
152
+ return {
153
+ url: batchUrl,
154
+ body: JSON.stringify({ api_key: apiKey, batch: part }),
155
+ }
156
+ }
157
+
158
+ async function sendViaFetch(part: PostHogBatchItem[]): Promise<void> {
159
+ const doFetch = resolveFetch()
160
+ const { url, body } = payload(part)
161
+
162
+ for (let attempt = 0; ; attempt++) {
163
+ let status: number
164
+ try {
165
+ const res = await doFetch(url, {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body,
169
+ keepalive: true,
170
+ })
171
+ status = res.status
172
+ } catch {
173
+ status = 0 // network error → transient
174
+ }
175
+
176
+ if (status >= 200 && status < 300) return
177
+ if (NO_RETRY.has(status)) return
178
+
179
+ if (attempt >= maxRetries) return
180
+ await sleep(backoffBaseMs * 2 ** attempt)
181
+ }
182
+ }
183
+
184
+ function sendViaBeacon(part: PostHogBatchItem[]): boolean {
185
+ const beacon = resolveBeacon()
186
+ if (!beacon) return false
187
+ const { url, body } = payload(part)
188
+ return beacon(url, body)
189
+ }
190
+
191
+ return {
192
+ name,
193
+ consentCategories,
194
+
195
+ async deliver(
196
+ batch: AnalyticsEvent[],
197
+ ctx: SinkDeliverContext,
198
+ ): Promise<void> {
199
+ if (batch.length === 0) return
200
+ const items = toPostHogBatch(batch)
201
+ const parts = splitBatch(items, maxBatchBytes, maxEventBytes)
202
+
203
+ for (const part of parts) {
204
+ if (part.length === 0) continue
205
+ if (ctx.unload) {
206
+ if (sendViaBeacon(part)) continue
207
+ void sendViaFetch(part)
208
+ } else {
209
+ await sendViaFetch(part)
210
+ }
211
+ }
212
+ },
213
+ }
214
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @refraction-ui/analytics-sink-posthog
3
+ *
4
+ * A PostHog `AnalyticsSink` for `@refraction-ui/analytics`. PostHog is *just
5
+ * a sink* — register it via `config.sinks` or `analytics.addSink(...)`.
6
+ *
7
+ * - `http` mode (DEFAULT): pure protocol adapter against PostHog's
8
+ * `/capture` + `/batch` API. No `posthog-js`. Server-relay friendly.
9
+ * - `client-sdk` mode (OPTIONAL): lazily dynamic-imports `posthog-js`.
10
+ *
11
+ * Session replay is intentionally NOT exported here. Import the separate
12
+ * `@refraction-ui/analytics-sink-posthog/replay` module to opt in — it is
13
+ * never on the event path and tree-shakes away when unused.
14
+ */
15
+
16
+ export { createPostHogSink } from './sink.js'
17
+ export type { PostHogSinkMode, PostHogSinkOptions } from './sink.js'
18
+
19
+ export { createPostHogHttpSink } from './http-sink.js'
20
+ export type { PostHogHttpSinkOptions } from './http-sink.js'
21
+
22
+ export { createPostHogClientSdkSink } from './client-sdk-sink.js'
23
+ export type { PostHogClientSdkSinkOptions } from './client-sdk-sink.js'
24
+
25
+ export { toPostHogEvent, toPostHogBatch, distinctId } from './mapping.js'
26
+ export type { PostHogEvent } from './mapping.js'
@@ -0,0 +1,155 @@
1
+ import type { AnalyticsEvent } from '../analytics/index.ts'
2
+
3
+ /**
4
+ * Canonical envelope → PostHog event mapping.
5
+ *
6
+ * This module is pure (no I/O, no transport) so the http sink and the
7
+ * client-sdk sink share exactly one mapping definition and it can be unit
8
+ * tested in isolation against a mock transport.
9
+ *
10
+ * PostHog identity model:
11
+ * - `distinct_id` is the single id PostHog buckets a person under.
12
+ * - Before `identify`, the anonymous visitor is keyed by `anonymousId`.
13
+ * - `identify` upgrades the person to the opaque app `userId`, sending
14
+ * `$set` traits and an `$anon_distinct_id` so PostHog stitches the
15
+ * pre-identify anonymous history onto the identified person.
16
+ * - `alias` emits PostHog's `$create_alias` linking `previousId` (alias)
17
+ * to the canonical `userId`/`anonymousId` (distinct_id).
18
+ * - `group` emits a `$groupidentify` event with `$group_set` traits.
19
+ *
20
+ * See https://posthog.com/docs/api/capture for the event shape.
21
+ */
22
+
23
+ /** A single PostHog capture event (the `/capture` and `/batch` item shape). */
24
+ export interface PostHogEvent {
25
+ /** PostHog event name (Segment names map to `$pageview`/`$screen`/etc.). */
26
+ event: string
27
+ /** The person bucket id. */
28
+ distinct_id: string
29
+ /** Event + person/group properties. */
30
+ properties: Record<string, unknown>
31
+ /** ISO-8601 client timestamp (PostHog corrects for skew server-side). */
32
+ timestamp: string
33
+ /** Idempotency key — PostHog dedupes on this. Mirrors `messageId`. */
34
+ uuid: string
35
+ }
36
+
37
+ /** Resolve the PostHog `distinct_id` for an envelope. */
38
+ export function distinctId(ev: AnalyticsEvent): string {
39
+ return ev.userId ?? ev.anonymousId
40
+ }
41
+
42
+ /**
43
+ * Properties that PostHog reads off `context` for every event so its UI
44
+ * shows app/library/page/session metadata without the consumer wiring it.
45
+ */
46
+ function contextProperties(ev: AnalyticsEvent): Record<string, unknown> {
47
+ const ctx = ev.context
48
+ const props: Record<string, unknown> = {
49
+ $lib: ctx.library?.name,
50
+ $lib_version: ctx.library?.version,
51
+ app: ctx.app,
52
+ env: ctx.env,
53
+ $session_id: ev.sessionId,
54
+ // Keep the canonical anonymous id addressable in PostHog too.
55
+ anonymousId: ev.anonymousId,
56
+ }
57
+ const page = ctx.page
58
+ if (page) {
59
+ if (page.url !== undefined) props.$current_url = page.url
60
+ if (page.path !== undefined) props.$pathname = page.path
61
+ if (page.referrer !== undefined) props.$referrer = page.referrer
62
+ if (page.title !== undefined) props.title = page.title
63
+ if (page.search !== undefined) props.$search = page.search
64
+ }
65
+ return props
66
+ }
67
+
68
+ /**
69
+ * Map one canonical envelope to one PostHog event.
70
+ *
71
+ * `track` → the event name verbatim, `properties` passed through.
72
+ * `page` → `$pageview` (PostHog's built-in pageview).
73
+ * `screen` → `$screen` with `$screen_name`.
74
+ * `identify` → `$identify` with `$set` traits + `$anon_distinct_id` stitch.
75
+ * `group` → `$groupidentify` with `$group_set` traits.
76
+ * `alias` → `$create_alias` linking `previousId` → distinct id.
77
+ */
78
+ export function toPostHogEvent(ev: AnalyticsEvent): PostHogEvent {
79
+ const base = {
80
+ distinct_id: distinctId(ev),
81
+ timestamp: ev.timestamp,
82
+ uuid: ev.messageId,
83
+ }
84
+ const props = contextProperties(ev)
85
+
86
+ switch (ev.type) {
87
+ case 'identify': {
88
+ return {
89
+ ...base,
90
+ event: '$identify',
91
+ properties: {
92
+ ...props,
93
+ $set: { ...(ev.traits ?? {}) },
94
+ // Stitch the pre-identify anonymous history onto this person.
95
+ $anon_distinct_id: ev.anonymousId,
96
+ },
97
+ }
98
+ }
99
+ case 'group': {
100
+ const groupType = (ev.properties?.groupType as string) ?? 'company'
101
+ return {
102
+ ...base,
103
+ event: '$groupidentify',
104
+ properties: {
105
+ ...props,
106
+ $group_type: groupType,
107
+ $group_key: ev.groupId,
108
+ $group_set: { ...(ev.traits ?? {}) },
109
+ },
110
+ }
111
+ }
112
+ case 'alias': {
113
+ return {
114
+ ...base,
115
+ event: '$create_alias',
116
+ properties: {
117
+ ...props,
118
+ // PostHog links `alias` to the current `distinct_id`.
119
+ alias: ev.previousId,
120
+ },
121
+ }
122
+ }
123
+ case 'page': {
124
+ return {
125
+ ...base,
126
+ event: '$pageview',
127
+ properties: { ...props, ...(ev.properties ?? {}) },
128
+ }
129
+ }
130
+ case 'screen': {
131
+ return {
132
+ ...base,
133
+ event: '$screen',
134
+ properties: {
135
+ ...props,
136
+ $screen_name: ev.event,
137
+ ...(ev.properties ?? {}),
138
+ },
139
+ }
140
+ }
141
+ case 'track':
142
+ default: {
143
+ return {
144
+ ...base,
145
+ event: ev.event ?? 'track',
146
+ properties: { ...props, ...(ev.properties ?? {}) },
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ /** Map a batch of canonical envelopes to PostHog events. */
153
+ export function toPostHogBatch(batch: AnalyticsEvent[]): PostHogEvent[] {
154
+ return batch.map(toPostHogEvent)
155
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @refraction-ui/analytics-sink-posthog/replay
3
+ *
4
+ * OPTIONAL, lazy session-replay (rrweb, via `posthog-js`).
5
+ *
6
+ * Hard guarantees this module exists to enforce:
7
+ *
8
+ * 1. **Off by default.** Nothing in the main `@refraction-ui/analytics-sink-posthog`
9
+ * entry imports this file, so it (and `posthog-js`, and rrweb) are fully
10
+ * tree-shaken out of any bundle that does not explicitly import it.
11
+ * 2. **Never on the event path.** Replay is not an `AnalyticsSink`. It does
12
+ * not see, transform, or block canonical envelopes. `track`/`identify`/…
13
+ * keep flowing through the sink even if replay never starts or fails.
14
+ * 3. **Privacy/consent gated.** `startSessionReplay` refuses to start unless
15
+ * a consent predicate returns true, and re-checks it; `stop()` tears the
16
+ * recorder down. Masking defaults are maximally private.
17
+ * 4. **Lazy.** `posthog-js` is `import()`-ed only when `startSessionReplay`
18
+ * is actually called.
19
+ *
20
+ * This is a thin controller around `posthog-js`'s built-in rrweb session
21
+ * recording — we do not bundle rrweb ourselves.
22
+ */
23
+
24
+ /** Minimal structural view of the `posthog-js` replay surface. */
25
+ interface PostHogReplay {
26
+ init(apiKey: string, options: Record<string, unknown>): void
27
+ startSessionRecording(): void
28
+ stopSessionRecording(): void
29
+ sessionRecordingStarted?(): boolean
30
+ }
31
+
32
+ export interface SessionReplayOptions {
33
+ /** PostHog project API key. */
34
+ apiKey: string
35
+ /** PostHog API host. Default `https://us.i.posthog.com`. */
36
+ host?: string
37
+ /**
38
+ * Consent predicate. Replay starts ONLY when this returns `true`, and is
39
+ * re-checked by `enforceConsent()`. There is no default-allow: if omitted,
40
+ * replay is treated as NOT consented and will not start.
41
+ */
42
+ hasConsent?: () => boolean
43
+ /**
44
+ * rrweb masking. Defaults are maximally private: all text + all inputs
45
+ * masked. Override deliberately and document the privacy review.
46
+ */
47
+ maskAllText?: boolean
48
+ maskAllInputs?: boolean
49
+ /** Extra `posthog-js` session_recording options (advanced). */
50
+ recordingOptions?: Record<string, unknown>
51
+ /**
52
+ * Injected loader (testing / custom bundling). Defaults to a lazy
53
+ * `import('posthog-js')`.
54
+ */
55
+ loadPostHog?: () => Promise<{ default: PostHogReplay } | PostHogReplay>
56
+ }
57
+
58
+ /** Handle returned by {@link startSessionReplay}. */
59
+ export interface SessionReplayHandle {
60
+ /** True if the rrweb recorder is currently running. */
61
+ readonly recording: boolean
62
+ /**
63
+ * Re-evaluate the consent predicate. If consent was revoked, recording is
64
+ * stopped. Call this from your consent-change handler.
65
+ */
66
+ enforceConsent(): void
67
+ /** Stop recording and release the recorder. Idempotent. */
68
+ stop(): void
69
+ }
70
+
71
+ async function defaultLoad(): Promise<PostHogReplay> {
72
+ const mod = (await import('posthog-js')) as unknown as
73
+ | { default: PostHogReplay }
74
+ | PostHogReplay
75
+ return 'default' in mod ? mod.default : mod
76
+ }
77
+
78
+ /**
79
+ * Start PostHog/rrweb session replay. Resolves to a handle, or to a
80
+ * non-recording handle if consent is not granted (it never throws on the
81
+ * consent path — replay simply does not start).
82
+ *
83
+ * `posthog-js` is dynamically imported here and nowhere else.
84
+ */
85
+ export async function startSessionReplay(
86
+ options: SessionReplayOptions,
87
+ ): Promise<SessionReplayHandle> {
88
+ const {
89
+ apiKey,
90
+ host = 'https://us.i.posthog.com',
91
+ hasConsent,
92
+ maskAllText = true,
93
+ maskAllInputs = true,
94
+ recordingOptions = {},
95
+ } = options
96
+
97
+ const consented = (): boolean =>
98
+ typeof hasConsent === 'function' ? hasConsent() === true : false
99
+
100
+ // Single-slot holder so the closure can observe the lazily-loaded
101
+ // instance without an outer `let` rebind.
102
+ const slot: { ph?: PostHogReplay } = {}
103
+ let recording = false
104
+
105
+ const stop = (): void => {
106
+ if (slot.ph && recording) {
107
+ slot.ph.stopSessionRecording()
108
+ }
109
+ recording = false
110
+ }
111
+
112
+ const handle: SessionReplayHandle = {
113
+ get recording() {
114
+ return recording
115
+ },
116
+ enforceConsent(): void {
117
+ if (!consented()) stop()
118
+ },
119
+ stop,
120
+ }
121
+
122
+ // Privacy gate: do not even load posthog-js if consent is absent.
123
+ if (!consented()) return handle
124
+
125
+ const load = options.loadPostHog
126
+ ? async () => {
127
+ const m = await options.loadPostHog!()
128
+ return ('default' in m ? m.default : m) as PostHogReplay
129
+ }
130
+ : defaultLoad
131
+
132
+ slot.ph = await load()
133
+ slot.ph.init(apiKey, {
134
+ api_host: host,
135
+ autocapture: false,
136
+ capture_pageview: false,
137
+ // Start with replay disabled, then opt in explicitly below.
138
+ disable_session_recording: true,
139
+ session_recording: {
140
+ maskAllInputs,
141
+ maskTextSelector: maskAllText ? '*' : undefined,
142
+ ...recordingOptions,
143
+ },
144
+ })
145
+
146
+ // Re-check consent after the (async) load in case it was revoked meanwhile.
147
+ if (!consented()) return handle
148
+
149
+ slot.ph.startSessionRecording()
150
+ recording = slot.ph.sessionRecordingStarted?.() ?? true
151
+ return handle
152
+ }