/** * Storefront session replay, confined to the modal. * * Three constraints shape everything here. * * **Bundle.** This widget builds as a single IIFE (see vite.config.ts), so there * is no code-splitting to lean on: importing posthog-js would put ~180KB of * recorder into the bundle, and the plugin's whole promise is that it is a thin * connector that keeps the merchant's store fast. So the SDK is fetched as a * script tag from our own ingest proxy, on demand, and the bundle grows by this * file alone. * * **Consent.** Nothing here runs before the merchant's CMP grants analytics AND * the shopper has opened the modal. Loading the SDK is itself a request to our * proxy, so "load it early and start later" would already be a request a * non-consenting shopper never agreed to. WordPress has no platform consent * API, so the answer comes from the bootstrap's CMP reader — the only code that * knows what the merchant's banner said. * * **Scope.** A replay of a WooCommerce product page would capture the theme's * newsletter field, whatever chat plugin the merchant runs, and every other * script on the page. Recording starts when the modal mounts and stops when it * closes — but that bounds it in time, not in space, because rrweb's first act * is a full-document snapshot. So everything outside our own overlay is blocked * outright (see BLOCK_SELECTOR), every input is masked, and every image is * blocked even inside the modal: the shopper's photo and their try-on result * are the entire reason this feature is sensitive. */ import { sanitizeBrowserEvent } from './browserSanitize'; export interface ReplayConfig { /** Browser project key. Empty means replay is off; nothing is requested. */ key: string; /** Ingest proxy origin. Never posthog.com directly. */ host: string; /** Server-side switch, so replay can be turned off without a plugin release. */ enabled: boolean; environment: string; store: string; } /** * The only values posthog-js recognises for `person_profiles`. * * It does not validate this option: an unrecognised literal is ignored and the * SDK falls back to its default, so a typo — or a plausible-looking `never` — * reads in review as a privacy guarantee while meaning nothing at all. Typing * the option against this list makes that a compile error instead. */ export const SUPPORTED_PERSON_PROFILES = ['always', 'identified_only'] as const; export type PersonProfiles = (typeof SUPPORTED_PERSON_PROFILES)[number]; /** * No person profile for an anonymous shopper. * * `identified_only` is how posthog-js spells that: a profile appears only once * `identify()` is called, and the storefront never calls it. */ const PERSON_PROFILES: PersonProfiles = 'identified_only'; interface PostHogLike { init: (key: string, options: Record) => void; startSessionRecording?: () => void; stopSessionRecording?: () => void; register?: (properties: Record) => void; opt_out_capturing?: () => void; opt_in_capturing?: () => void; __loaded?: boolean; } /** posthog-js's library object, whose `init` can mint a NAMED instance. */ interface PostHogLibrary extends PostHogLike { init: (key: string, options: Record, name?: string) => PostHogLike | undefined; [instanceName: string]: unknown; } /** * The name of the instance Aisthetix owns, and the reason it exists. * * A WooCommerce merchant running their own PostHog plugin is ordinary, not * exotic. Their `window.posthog` is configured for THEIR project: their key, * their proxy, no masking, no block selector. Reusing it — which the loader * used to do whenever `__loaded` was set — would start a recorder on their * configuration, so our `before_send`, our memory persistence, our masks and * the modal `blockSelector` would never be installed. That is not a degraded * recording; it is a full-page recorder owned by another script, and it defeats * both the URL sanitation and the entire confinement guarantee at once. * * posthog-js supports naming an instance: `init(key, options, name)` creates or * returns one entirely separate from the default. We only ever touch ours. */ export const AISTHETIX_INSTANCE = 'aisthetixReplay'; /** The library object, if any script has loaded it. */ function posthogLibrary(): PostHogLibrary | undefined { return (window as unknown as { posthog?: PostHogLibrary }).posthog; } /** * OUR instance, held explicitly. * * A module reference rather than rediscovering `window.posthog` on every stop * and opt-in: the global can be replaced by a later plugin, and calling * `opt_out_capturing()` on whatever happens to be there would silently switch * off the merchant's own analytics. */ let instance: PostHogLike | null = null; let loading: Promise | null = null; let recording = false; /** Whether a withdrawal has told the SDK to stop sending anything at all. */ let optedOut = false; /** The class the widget puts on anything that must never be recorded. */ export const NO_CAPTURE_CLASS = 'ph-no-capture'; /** * The class on the one element a replay is allowed to contain. * * Exported and consumed by Modal.tsx rather than written out twice: the * recording's entire scope is "this element and its children", so a rename that * only reached one of the two files would silently widen it to the whole page. */ export const MODAL_OVERLAY_CLASS = 'aisthetix-modal-overlay'; /** * Whether the element a recording is confined to is actually in the DOM. * * The precondition for recording, stated as the thing it actually is. A React * ref set during render is not that: it still holds the previous render's value * inside the click handler that opened the modal, so a check written against it * answers "closed" at exactly the moment the shopper opened it — and for an * already-consented shopper, nothing ever asks again. * * Asking the DOM is also the stronger question. rrweb's first act is a * full-document snapshot; taken before the overlay mounts, every node matches * the block selector and the recording contains nothing at all, with no error * anywhere to notice. */ export function modalIsMounted(): boolean { try { return document.querySelector( `.${ MODAL_OVERLAY_CLASS }` ) !== null; } catch { // No document (SSR, a sandboxed context): not mounted, so not recording. return false; } } /** * What the recorder must refuse to look at. * * Starting the recorder when the modal opens bounds the recording in TIME, and * that is all it does: rrweb's first act is a full-document snapshot, so the * theme's newsletter field, the merchant's chat plugin and every other script * on the page would all be in frame one. * * So scope is stated positively: every direct child of `body` is blocked EXCEPT * our own overlay. That is an allowlist of one, and a WordPress plugin that * appends its own node to the body — which is how they all arrive — is blocked * by default rather than needing to be predicted here. * * Images, canvases and videos stay blocked on top of that, because they are * blocked INSIDE the modal too: the shopper's photo, the processing preview, * the result and the comparison are all ``. */ export const BLOCK_SELECTOR = [ `body > *:not(.${MODAL_OVERLAY_CLASS})`, 'img', 'canvas', 'video', `.${NO_CAPTURE_CLASS}`, ].join(', '); /** * Load the recorder from our own proxy, once. * * @param config - Where to load from and under which key * @returns The SDK, or null when it could not be loaded */ function loadRecorder(config: ReplayConfig): Promise { if (loading) return loading; loading = new Promise((resolve) => { try { // Already loaded by the merchant — by their own PostHog plugin, or by // ours on an earlier open. Either way the LIBRARY is here and only our // INSTANCE is missing, so there is nothing to fetch. const present = posthogLibrary(); if (present?.__loaded) { resolve(initAisthetixInstance(present, config)); return; } const script = document.createElement('script'); script.src = `${config.host.replace(/\/$/, '')}/static/array.js`; script.async = true; // WP Rocket, LiteSpeed and Perfmatters rewrite and defer third-party // scripts. This one is already deferred by construction — it is injected // on modal open — and must be left alone, exactly like the bootstrap. script.setAttribute('data-no-optimize', '1'); script.setAttribute('data-cfasync', 'false'); script.onload = () => { const library = posthogLibrary(); if (!library) { resolve(null); return; } resolve(initAisthetixInstance(library, config)); }; script.onerror = () => resolve(null); document.head.appendChild(script); } catch { resolve(null); } }); return loading; } /** * Create (or retrieve) the instance Aisthetix records through. * * Never the default one. See AISTHETIX_INSTANCE for why that distinction is the * whole privacy guarantee rather than a tidiness preference. * * @param library - posthog-js, however it got here * @param config - Where to send and under which key * @returns Our instance, or null when it could not be created */ function initAisthetixInstance(library: PostHogLibrary, config: ReplayConfig): PostHogLike | null { try { const created = library.init(config.key, { api_host: config.host, // Memory only: no cookies, no localStorage. A shopper's device is // left exactly as it was found, and there is no identifier to // outlive the session. persistence: 'memory', autocapture: false, capture_pageview: false, capture_pageleave: false, // The only hook that sees every outgoing event, $snapshot included. // capture_pageview: false suppresses the automatic pageview EVENT; // it does not stop posthog-js attaching $current_url, $referrer and // their "initial" copies to everything else — and none of that // browser traffic passes through the brain's sanitizer. See // browserSanitize.ts. before_send: sanitizeBrowserEvent, disable_surveys: true, // Recording starts explicitly, when the modal mounts — never at init. disable_session_recording: true, // No person profile for an anonymous shopper. See PERSON_PROFILES. person_profiles: PERSON_PROFILES, session_recording: { maskAllInputs: true, maskTextSelector: '[data-ph-mask]', // Everything outside our own modal, plus every image inside it. // See BLOCK_SELECTOR — this is the whole confinement guarantee. blockSelector: BLOCK_SELECTOR, collectFonts: false, recordCrossOriginIframes: false, }, }, // The name is the whole point. Without it this is `init` on the DEFAULT // instance, which on a merchant storefront may be theirs. AISTHETIX_INSTANCE ); // `init` returns the named instance; the property lookup is the documented // fallback for builds that return void. const named = created ?? (library[AISTHETIX_INSTANCE] as PostHogLike | undefined); if (!named) return null; named.register?.({ environment: config.environment, store: config.store, surface: 'storefront', platform: 'woocommerce', }); instance = named; return named; } catch { return null; } } /** Whether replay is configured to run at all. */ export function replayConfigured(config: ReplayConfig | null | undefined): boolean { return Boolean(config?.enabled && config.key && config.host); } /** * Begin recording, loading the SDK first if needed. * * Both gates are re-checked after the load resolves, because loading takes time * and a shopper can close the modal or withdraw consent while it is in flight. * * @param config - Replay configuration from the widget config * @param stillAllowed - Re-checked after the async load: consent and modal state */ export async function startReplay( config: ReplayConfig | null | undefined, stillAllowed: () => boolean ): Promise { if (!replayConfigured(config) || !config) return; if (!stillAllowed()) return; if (recording) return; const sdk = await loadRecorder(config); if (!sdk) return; // The gate that matters: by now the shopper may have closed the modal or // withdrawn consent, and starting anyway would record neither of their wishes. if (!stillAllowed()) return; try { // Undo a previous withdrawal. `opt_out_capturing` is sticky for the page, // so without this a shopper who declined and then accepted again would get // a recorder that reports itself as running and sends nothing. if (optedOut) { sdk.opt_in_capturing?.(); optedOut = false; } sdk.startSessionRecording?.(); recording = true; } catch { recording = false; } } /** * React to the shopper changing their mind, whichever way it goes. * * The withdrawal half was the only half that existed, and it made the grant * half impossible to notice: a shopper who opened the modal before the CMP * banner was answered and then accepted was never recorded, because the only * code that could have started the recorder ran on modal open and had already * decided. * * @param granted - Whether product analytics are now allowed * @param config - Replay configuration from the widget config * @param stillAllowed - Re-checked after the async load: consent and modal state */ export async function applyConsentDecision( granted: boolean, config: ReplayConfig | null | undefined, stillAllowed: () => boolean ): Promise { if (!granted) { revokeReplay(); return; } await startReplay(config, stillAllowed); } /** * Stop recording. * * Called on modal close, on unmount, and on consent withdrawal. Safe to call * when nothing is recording — which is most of the time, and is why it must be. */ export function stopReplay(): void { if (!recording) return; try { instance?.stopSessionRecording?.(); } catch { // Nothing to do; the flag below still stops us claiming we are recording. } recording = false; } /** * Stop recording and stop sending. * * The withdrawal path: `opt_out_capturing` tells the SDK to stop sending * anything at all, not just to pause the recorder. It is sticky, which is the * point — and also why `startReplay` has to undo it explicitly if the same * shopper accepts again on the same page. */ export function revokeReplay(): void { stopReplay(); try { instance?.opt_out_capturing?.(); optedOut = true; } catch { // Best-effort; recording has already stopped. } } /** Whether a recording is currently running. Exported for tests. */ export function isRecording(): boolean { return recording; } /** Reset module state. Tests only. */ export function resetReplayForTests(): void { loading = null; instance = null; recording = false; optedOut = false; }