From 358f4ea59413e3788c061f94d70abadd9c67b47d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 5 Apr 2026 12:10:51 +1000 Subject: [PATCH] feat(audience): add web SDK with consent, page tracking, and attribution Implement @imtbl/audience-web-sdk importing types, config, and events from @imtbl/audience-core (contract-only). All runtime code is web-specific: - ImmutableWebSDK class: init, track, page, identify, alias, consent - ConsentManager: three-tier (none/anonymous/full), server sync, DNT/GPC - MessageQueue: localStorage persistence, unload flush (keepalive), stale filtering, consent-aware purge/transform - PageTracker: SPA route detection (pushState/replaceState/popstate) - Attribution: UTM params, click IDs, referrer, landing page - Transport: fetch with keepalive for page-unload resilience - Cookie: domain-aware, session (30min rolling), consent (1yr) - Validation: timestamp range, alias dedup, max-length truncation - CDN bundle: IIFE exposing window.ImmutableWebSDK Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/web/src/attribution.ts | 83 +++++ packages/audience/web/src/cdn.ts | 24 ++ packages/audience/web/src/config.ts | 6 + packages/audience/web/src/consent.ts | 141 ++++++++ packages/audience/web/src/context.ts | 23 ++ packages/audience/web/src/cookie.ts | 84 +++++ packages/audience/web/src/debug.ts | 24 ++ packages/audience/web/src/index.ts | 16 + packages/audience/web/src/page.ts | 84 +++++ packages/audience/web/src/queue.ts | 136 ++++++++ packages/audience/web/src/sdk.ts | 405 +++++++++++++++++++++++ packages/audience/web/src/storage.ts | 39 +++ packages/audience/web/src/transport.ts | 30 ++ packages/audience/web/src/types.ts | 14 + packages/audience/web/src/utils.ts | 15 + packages/audience/web/src/validation.ts | 41 +++ packages/audience/web/tsup.cdn.js | 32 ++ 17 files changed, 1197 insertions(+) create mode 100644 packages/audience/web/src/attribution.ts create mode 100644 packages/audience/web/src/cdn.ts create mode 100644 packages/audience/web/src/config.ts create mode 100644 packages/audience/web/src/consent.ts create mode 100644 packages/audience/web/src/context.ts create mode 100644 packages/audience/web/src/cookie.ts create mode 100644 packages/audience/web/src/debug.ts create mode 100644 packages/audience/web/src/index.ts create mode 100644 packages/audience/web/src/page.ts create mode 100644 packages/audience/web/src/queue.ts create mode 100644 packages/audience/web/src/sdk.ts create mode 100644 packages/audience/web/src/storage.ts create mode 100644 packages/audience/web/src/transport.ts create mode 100644 packages/audience/web/src/types.ts create mode 100644 packages/audience/web/src/utils.ts create mode 100644 packages/audience/web/src/validation.ts create mode 100644 packages/audience/web/tsup.cdn.js diff --git a/packages/audience/web/src/attribution.ts b/packages/audience/web/src/attribution.ts new file mode 100644 index 0000000000..3155d4359e --- /dev/null +++ b/packages/audience/web/src/attribution.ts @@ -0,0 +1,83 @@ +import { isBrowser } from './utils'; + +export interface AttributionContext { + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + utmContent?: string; + utmTerm?: string; + gclid?: string; + fbclid?: string; + ttclid?: string; + msclkid?: string; + referrer?: string; + landingPage?: string; +} + +const SESSION_KEY = '__imtbl_attribution'; + +function getSessionAttribution(): AttributionContext | undefined { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + return raw ? JSON.parse(raw) : undefined; + } catch { + return undefined; + } +} + +function persistSessionAttribution(ctx: AttributionContext): void { + try { + sessionStorage.setItem(SESSION_KEY, JSON.stringify(ctx)); + } catch { + // sessionStorage unavailable — attribution won't persist across SPA navigations + } +} + +/** + * Parse attribution signals from the current URL. + * Captured once per session and persisted in sessionStorage so SPA + * route changes don't lose the original UTM params. + */ +export function parseAttribution(): AttributionContext { + if (!isBrowser()) return {}; + + // Return cached attribution for this session if it exists + const cached = getSessionAttribution(); + if (cached) return cached; + + const params = new URLSearchParams(window.location.search); + + const ctx: AttributionContext = { + utmSource: params.get('utm_source') ?? undefined, + utmMedium: params.get('utm_medium') ?? undefined, + utmCampaign: params.get('utm_campaign') ?? undefined, + utmContent: params.get('utm_content') ?? undefined, + utmTerm: params.get('utm_term') ?? undefined, + gclid: params.get('gclid') ?? undefined, + fbclid: params.get('fbclid') ?? undefined, + ttclid: params.get('ttclid') ?? undefined, + msclkid: params.get('msclkid') ?? undefined, + referrer: document.referrer || undefined, + landingPage: window.location.href, + }; + + persistSessionAttribution(ctx); + return ctx; +} + +/** Convert attribution context to flat properties for the first PageMessage. */ +export function attributionToProperties(ctx: AttributionContext): Record { + const props: Record = {}; + if (ctx.utmSource) props.utm_source = ctx.utmSource; + if (ctx.utmMedium) props.utm_medium = ctx.utmMedium; + if (ctx.utmCampaign) props.utm_campaign = ctx.utmCampaign; + if (ctx.utmContent) props.utm_content = ctx.utmContent; + if (ctx.utmTerm) props.utm_term = ctx.utmTerm; + if (ctx.gclid) props.gclid = ctx.gclid; + if (ctx.fbclid) props.fbclid = ctx.fbclid; + if (ctx.ttclid) props.ttclid = ctx.ttclid; + if (ctx.msclkid) props.msclkid = ctx.msclkid; + if (ctx.referrer) props.referrer = ctx.referrer; + if (ctx.landingPage) props.landing_page = ctx.landingPage; + return props; +} diff --git a/packages/audience/web/src/cdn.ts b/packages/audience/web/src/cdn.ts new file mode 100644 index 0000000000..4ffb402300 --- /dev/null +++ b/packages/audience/web/src/cdn.ts @@ -0,0 +1,24 @@ +/** + * Audience web SDK CDN entry point — self-contained IIFE bundle. + * Assigns exports to window globals for script-tag usage: + * window.ImmutableWebSDK + * window.AudienceEvent + * window.IdentityProvider + */ +import { ImmutableWebSDK, AudienceEvent, IdentityProvider } from './index'; + +if (typeof window !== 'undefined') { + (window as any).ImmutableWebSDK = ImmutableWebSDK; + (window as any).AudienceEvent = AudienceEvent; + (window as any).IdentityProvider = IdentityProvider; +} + +export { ImmutableWebSDK, AudienceEvent, IdentityProvider }; +export type { + EventParamMap, + Identity, + WebSDKConfig, + ConsentLevel, + UserTraits, + Environment, +} from './index'; diff --git a/packages/audience/web/src/config.ts b/packages/audience/web/src/config.ts new file mode 100644 index 0000000000..e7d5dd191e --- /dev/null +++ b/packages/audience/web/src/config.ts @@ -0,0 +1,6 @@ +// Web SDK-specific constants. +// Backend endpoints and base URLs come from @imtbl/audience-core. + +export const LIBRARY_NAME = '@imtbl/audience-web-sdk'; +// Replaced at build time by esbuild replace plugin +export const LIBRARY_VERSION = '__SDK_VERSION__'; diff --git a/packages/audience/web/src/consent.ts b/packages/audience/web/src/consent.ts new file mode 100644 index 0000000000..0b2568ba0b --- /dev/null +++ b/packages/audience/web/src/consent.ts @@ -0,0 +1,141 @@ +import type { ConsentLevel, ConsentStatus, Environment } from '@imtbl/audience-core'; +import { CONSENT_PATH, getBaseUrl } from '@imtbl/audience-core'; +import { + getConsentCookie, + setConsentCookie, + deleteCookie, + ANON_ID_COOKIE, + SESSION_COOKIE, +} from './cookie'; +import { isBrowser } from './utils'; +import { truncateSource } from './validation'; + +/** + * Check if the browser signals a Do Not Track or Global Privacy Control + * preference. When either is active, consent should be capped at 'none'. + */ +export function detectPrivacySignal(): boolean { + if (!isBrowser()) return false; + const nav = navigator as any; + // DNT: '1' means tracking opt-out + if (nav.doNotTrack === '1' || (window as any).doNotTrack === '1') return true; + // GPC: globalPrivacyControl is a boolean + if (nav.globalPrivacyControl === true) return true; + return false; +} + +export interface ConsentCallbacks { + onPurgeQueue?: () => void; + onStripIdentity?: () => void; + onClearCookies?: () => void; +} + +export class ConsentManager { + private level: ConsentLevel; + + private readonly baseUrl: string; + + private readonly publishableKey: string; + + private readonly source: string; + + private readonly cookieDomain?: string; + + constructor( + environment: Environment, + publishableKey: string, + initialConsent: ConsentLevel, + rawSource: string, + cookieDomain?: string, + ) { + this.baseUrl = getBaseUrl(environment); + this.publishableKey = publishableKey; + this.source = truncateSource(rawSource); + this.cookieDomain = cookieDomain; + + // DNT / GPC: auto-downgrade to 'none' if browser signals tracking opt-out + if (detectPrivacySignal()) { + this.level = 'none'; + this.persistLocal(); + return; + } + + // Honour existing consent cookie if set (shared with pixel) + const persisted = getConsentCookie() as ConsentLevel | undefined; + if (persisted && ['none', 'anonymous', 'full'].includes(persisted)) { + this.level = persisted; + } else { + this.level = initialConsent; + } + this.persistLocal(); + } + + getLevel(): ConsentLevel { + return this.level; + } + + setLevel( + level: ConsentLevel, + anonymousId: string, + callbacks?: ConsentCallbacks, + ): void { + // DNT / GPC active: refuse to upgrade consent + if (level !== 'none' && detectPrivacySignal()) return; + + const { level: previous } = this; + this.level = level; + this.persistLocal(); + + // Downgrade: full/anonymous -> none — purge everything + if (level === 'none') { + callbacks?.onPurgeQueue?.(); + callbacks?.onClearCookies?.(); + } else if (level === 'anonymous' && previous === 'full') { + // Downgrade: full -> anonymous — strip PII, keep anonymous events + callbacks?.onStripIdentity?.(); + } + + // Sync to server (fire-and-forget) + this.syncToServer(anonymousId, level); + } + + /** Fetch server-side consent status for reconciliation. */ + async fetchServerConsent(anonymousId: string): Promise { + try { + const url = `${this.baseUrl}${CONSENT_PATH}?anonymousId=${encodeURIComponent(anonymousId)}`; + const res = await fetch(url, { + headers: { 'x-immutable-publishable-key': this.publishableKey }, + }); + if (!res.ok) return undefined; + const body = (await res.json()) as { status: ConsentStatus }; + return body.status; + } catch { + return undefined; + } + } + + clearCookies(): void { + deleteCookie(ANON_ID_COOKIE, this.cookieDomain); + deleteCookie(SESSION_COOKIE, this.cookieDomain); + // Keep consent cookie — remembers the "none" choice + } + + private persistLocal(): void { + setConsentCookie(this.level, this.cookieDomain); + } + + private async syncToServer(anonymousId: string, status: ConsentLevel): Promise { + try { + await fetch(`${this.baseUrl}${CONSENT_PATH}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-immutable-publishable-key': this.publishableKey, + }, + body: JSON.stringify({ anonymousId, status, source: this.source }), + }); + } catch { + // Fire-and-forget — consent sync failure shouldn't break the SDK + } + } +} diff --git a/packages/audience/web/src/context.ts b/packages/audience/web/src/context.ts new file mode 100644 index 0000000000..fa82ec3076 --- /dev/null +++ b/packages/audience/web/src/context.ts @@ -0,0 +1,23 @@ +import type { EventContext } from '@imtbl/audience-core'; +import { isBrowser } from './utils'; +import { LIBRARY_NAME, LIBRARY_VERSION } from './config'; + +export function collectContext(): EventContext { + const ctx: EventContext = { + library: LIBRARY_NAME, + libraryVersion: LIBRARY_VERSION, + }; + + if (!isBrowser()) return ctx; + + ctx.userAgent = navigator.userAgent; + ctx.locale = navigator.language; + ctx.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + ctx.screen = `${window.screen.width}x${window.screen.height}`; + ctx.pageUrl = window.location.href; + ctx.pagePath = window.location.pathname; + ctx.pageReferrer = document.referrer; + ctx.pageTitle = document.title; + + return ctx; +} diff --git a/packages/audience/web/src/cookie.ts b/packages/audience/web/src/cookie.ts new file mode 100644 index 0000000000..b8442b0245 --- /dev/null +++ b/packages/audience/web/src/cookie.ts @@ -0,0 +1,84 @@ +import { + ANON_ID_COOKIE, + SESSION_COOKIE, + CONSENT_COOKIE, +} from '@imtbl/audience-core'; +import { generateId, isBrowser } from './utils'; + +const ANON_ID_MAX_AGE = 2 * 365 * 24 * 60 * 60; // 2 years +const SESSION_MAX_AGE = 30 * 60; // 30 minutes (rolling) +const CONSENT_MAX_AGE = 365 * 24 * 60 * 60; // 1 year + +// Re-export cookie names for convenience +export { ANON_ID_COOKIE, SESSION_COOKIE, CONSENT_COOKIE }; + +// --- Helpers --- + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// --- Low-level cookie operations --- + +export function getCookie(name: string): string | undefined { + if (!isBrowser()) return undefined; + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${escapeRegExp(name)}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : undefined; +} + +export function setCookie( + name: string, + value: string, + maxAge: number, + domain?: string, +): void { + if (!isBrowser()) return; + let cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${maxAge};SameSite=Lax`; + if (domain) cookie += `;domain=${domain}`; + if (window.location.protocol === 'https:') cookie += ';Secure'; + document.cookie = cookie; +} + +export function deleteCookie(name: string, domain?: string): void { + setCookie(name, '', 0, domain); +} + +// --- Anonymous ID (shared with pixel via imtbl_anon_id cookie, 2yr) --- + +export function getOrCreateAnonymousId(domain?: string): string { + let id = getCookie(ANON_ID_COOKIE); + if (!id) { + id = generateId(); + setCookie(ANON_ID_COOKIE, id, ANON_ID_MAX_AGE, domain); + } + return id; +} + +// --- Session ID (_imtbl_sid cookie, 30min rolling) --- + +export function getOrCreateSessionId(domain?: string): string { + let sid = getCookie(SESSION_COOKIE); + if (!sid) { + sid = generateId(); + } + // Always reset rolling expiry + setCookie(SESSION_COOKIE, sid, SESSION_MAX_AGE, domain); + return sid; +} + +export function touchSession(domain?: string): void { + const sid = getCookie(SESSION_COOKIE); + if (sid) { + setCookie(SESSION_COOKIE, sid, SESSION_MAX_AGE, domain); + } +} + +// --- Consent cookie (_imtbl_consent, 1yr) --- + +export function getConsentCookie(): string | undefined { + return getCookie(CONSENT_COOKIE); +} + +export function setConsentCookie(level: string, domain?: string): void { + setCookie(CONSENT_COOKIE, level, CONSENT_MAX_AGE, domain); +} diff --git a/packages/audience/web/src/debug.ts b/packages/audience/web/src/debug.ts new file mode 100644 index 0000000000..b697cb6349 --- /dev/null +++ b/packages/audience/web/src/debug.ts @@ -0,0 +1,24 @@ +/* eslint-disable no-console, class-methods-use-this */ +import type { Message, ConsentLevel } from '@imtbl/audience-core'; + +const PREFIX = '[Immutable Audience]'; + +export class DebugLogger { + logEvent(method: string, message: Message): void { + console.log(`${PREFIX} ${method}()`, message); + } + + logFlush(ok: boolean, count: number): void { + console.log( + `${PREFIX} flush: ${ok ? 'success' : 'failed'}, ${count} message${count !== 1 ? 's' : ''}`, + ); + } + + logConsent(from: ConsentLevel, to: ConsentLevel): void { + console.log(`${PREFIX} consent: ${from} \u2192 ${to}`); + } + + logWarning(msg: string): void { + console.warn(`${PREFIX} ${msg}`); + } +} diff --git a/packages/audience/web/src/index.ts b/packages/audience/web/src/index.ts new file mode 100644 index 0000000000..9c1639d7e7 --- /dev/null +++ b/packages/audience/web/src/index.ts @@ -0,0 +1,16 @@ +export { ImmutableWebSDK } from './sdk'; +export type { WebSDKConfig } from './types'; + +// Re-export the shared contract from core +export { + AudienceEvent, + IdentityProvider, +} from '@imtbl/audience-core'; + +export type { + Environment, + ConsentLevel, + UserTraits, + EventParamMap, + Identity, +} from '@imtbl/audience-core'; diff --git a/packages/audience/web/src/page.ts b/packages/audience/web/src/page.ts new file mode 100644 index 0000000000..54865a677d --- /dev/null +++ b/packages/audience/web/src/page.ts @@ -0,0 +1,84 @@ +import { isBrowser } from './utils'; + +const DEDUP_THRESHOLD_MS = 100; + +/** + * Page view tracker with SPA route detection. + * + * When installed, patches history.pushState / replaceState and listens + * for popstate events to detect client-side navigation. Deduplicates + * rapid-fire calls for the same URL (some routers fire multiple events). + */ +export class PageTracker { + private lastPageUrl: string | null = null; + + private lastPageTime = 0; + + private installed = false; + + private originalPushState?: typeof window.history.pushState; + + private originalReplaceState?: typeof window.history.replaceState; + + private popstateHandler?: () => void; + + constructor( + private readonly onPage: (properties?: Record) => void, + ) {} + + /** Install SPA route-change listeners (popstate + history patching). */ + installSPAListeners(): void { + if (!isBrowser() || this.installed) return; + this.installed = true; + + // Back/forward navigation + this.popstateHandler = () => this.handleRouteChange(); + window.addEventListener('popstate', this.popstateHandler); + + // Patch pushState + this.originalPushState = window.history.pushState.bind(window.history); + window.history.pushState = (...args: Parameters) => { + this.originalPushState!(...args); + this.handleRouteChange(); + }; + + // Patch replaceState + this.originalReplaceState = window.history.replaceState.bind(window.history); + window.history.replaceState = (...args: Parameters) => { + this.originalReplaceState!(...args); + this.handleRouteChange(); + }; + } + + /** Restore original history methods and remove listeners. */ + teardown(): void { + if (!this.installed) return; + + if (this.popstateHandler) { + window.removeEventListener('popstate', this.popstateHandler); + } + if (this.originalPushState) { + window.history.pushState = this.originalPushState; + } + if (this.originalReplaceState) { + window.history.replaceState = this.originalReplaceState; + } + this.installed = false; + } + + private handleRouteChange(): void { + if (!isBrowser()) return; + + const now = Date.now(); + const url = window.location.href; + + // Dedup: suppress if same URL within threshold + if (url === this.lastPageUrl && now - this.lastPageTime < DEDUP_THRESHOLD_MS) { + return; + } + + this.lastPageUrl = url; + this.lastPageTime = now; + this.onPage(); + } +} diff --git a/packages/audience/web/src/queue.ts b/packages/audience/web/src/queue.ts new file mode 100644 index 0000000000..7959b8c123 --- /dev/null +++ b/packages/audience/web/src/queue.ts @@ -0,0 +1,136 @@ +import type { Message, BatchPayload } from '@imtbl/audience-core'; +import { sendMessages } from './transport'; +import { isTimestampValid } from './validation'; +import * as storage from './storage'; +import { isBrowser } from './utils'; + +const QUEUE_KEY = 'queue'; +const MAX_BATCH_SIZE = 100; // Backend limit + +export class MessageQueue { + private messages: Message[]; + + private timer: ReturnType | null = null; + + private flushing = false; + + private unloadBound = false; + + private visibilityHandler?: () => void; + + private pagehideHandler?: () => void; + + constructor( + private readonly endpointUrl: string, + private readonly publishableKey: string, + private readonly flushIntervalMs: number, + private readonly flushSize: number, + private readonly onFlush?: (ok: boolean, count: number) => void, + ) { + // Restore persisted messages, filtering out stale ones (>30 days old) + const restored = storage.getItem(QUEUE_KEY) ?? []; + this.messages = restored.filter((m) => isTimestampValid(m.eventTimestamp)); + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => this.flush(), this.flushIntervalMs); + this.registerUnload(); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.removeUnload(); + } + + enqueue(message: Message): void { + this.messages.push(message); + this.persist(); + + if (this.messages.length >= this.flushSize) { + this.flush(); + } + } + + async flush(): Promise { + if (this.flushing || this.messages.length === 0) return; + + this.flushing = true; + try { + const batch = this.messages.slice(0, MAX_BATCH_SIZE); + const payload: BatchPayload = { messages: batch }; + const ok = await sendMessages(this.endpointUrl, this.publishableKey, payload); + + if (ok) { + this.messages = this.messages.slice(batch.length); + this.persist(); + } + this.onFlush?.(ok, batch.length); + } finally { + this.flushing = false; + } + } + + /** Fire-and-forget flush for page unload — uses fetch with keepalive. */ + flushUnload(): void { + if (this.messages.length === 0) return; + + const batch = this.messages.slice(0, MAX_BATCH_SIZE); + const payload: BatchPayload = { messages: batch }; + // Fire-and-forget: keepalive lets the request survive navigation + sendMessages(this.endpointUrl, this.publishableKey, payload, true); + this.messages = this.messages.slice(batch.length); + this.persist(); + } + + clear(): void { + this.messages = []; + storage.removeItem(QUEUE_KEY); + } + + /** Remove all messages matching a predicate. */ + purge(predicate: (msg: Message) => boolean): void { + this.messages = this.messages.filter((m) => !predicate(m)); + this.persist(); + } + + /** Transform messages in place (e.g., strip userId on consent downgrade). */ + transform(fn: (msg: Message) => Message): void { + this.messages = this.messages.map(fn); + this.persist(); + } + + get length(): number { + return this.messages.length; + } + + private persist(): void { + storage.setItem(QUEUE_KEY, this.messages); + } + + private registerUnload(): void { + if (!isBrowser() || this.unloadBound) return; + this.unloadBound = true; + + this.pagehideHandler = () => this.flushUnload(); + this.visibilityHandler = () => { + if (document.visibilityState === 'hidden') this.flushUnload(); + }; + document.addEventListener('visibilitychange', this.visibilityHandler); + window.addEventListener('pagehide', this.pagehideHandler); + } + + private removeUnload(): void { + if (!this.unloadBound) return; + if (this.visibilityHandler) { + document.removeEventListener('visibilitychange', this.visibilityHandler); + } + if (this.pagehideHandler) { + window.removeEventListener('pagehide', this.pagehideHandler); + } + this.unloadBound = false; + } +} diff --git a/packages/audience/web/src/sdk.ts b/packages/audience/web/src/sdk.ts new file mode 100644 index 0000000000..57aeead81a --- /dev/null +++ b/packages/audience/web/src/sdk.ts @@ -0,0 +1,405 @@ +import type { + ConsentLevel, + Message, + TrackMessage, + PageMessage, + IdentifyMessage, + AliasMessage, + UserTraits, + IdentityType, + EventParamMap, + Identity, +} from '@imtbl/audience-core'; +import { + INGEST_PATH, + DEFAULT_FLUSH_INTERVAL_MS, + DEFAULT_FLUSH_SIZE, + AudienceEvent, + IdentityProvider, + getBaseUrl, +} from '@imtbl/audience-core'; +import type { WebSDKConfig } from './types'; +import { collectContext } from './context'; +import { MessageQueue } from './queue'; +import { ConsentManager } from './consent'; +import { PageTracker } from './page'; +import { + parseAttribution, + attributionToProperties, + type AttributionContext, +} from './attribution'; +import { + getOrCreateAnonymousId, + getOrCreateSessionId, + touchSession, + getCookie, + deleteCookie, + ANON_ID_COOKIE, + SESSION_COOKIE, +} from './cookie'; +import { generateId, getTimestamp } from './utils'; +import { isAliasValid, truncate } from './validation'; +import { DebugLogger } from './debug'; + +const DEFAULT_CONSENT_SOURCE = 'WebSDK'; + +export class ImmutableWebSDK { + private readonly queue: MessageQueue; + + private readonly consent: ConsentManager; + + private readonly pageTracker: PageTracker; + + private readonly attribution: AttributionContext; + + private readonly debug?: DebugLogger; + + private readonly cookieDomain?: string; + + private anonymousId: string; + + private userId: string | undefined; + + private isFirstPage = true; + + private consentManuallySet = false; + + private readonly trackPageViewsEnabled: boolean; + + private constructor(config: WebSDKConfig) { + const { + cookieDomain, + environment, + publishableKey, + } = config; + const consentLevel = config.consent ?? 'none'; + const consentSource = config.consentSource ?? DEFAULT_CONSENT_SOURCE; + const flushInterval = config.flushInterval ?? DEFAULT_FLUSH_INTERVAL_MS; + const flushSize = config.flushSize ?? DEFAULT_FLUSH_SIZE; + + this.cookieDomain = cookieDomain; + this.trackPageViewsEnabled = config.trackPageViews ?? false; + + // Debug logger — tree-shaken in prod when debug: false + if (config.debug) { + this.debug = new DebugLogger(); + } + + // Consent manager — reads existing _imtbl_consent cookie, honours config + this.consent = new ConsentManager( + environment, + publishableKey, + consentLevel, + consentSource, + cookieDomain, + ); + + // Anonymous ID — only write cookie if consent allows + const effectiveConsent = this.consent.getLevel(); + if (effectiveConsent !== 'none') { + this.anonymousId = getOrCreateAnonymousId(cookieDomain); + getOrCreateSessionId(cookieDomain); + } else { + // At none: read existing cookie if present, otherwise generate ephemeral ID + this.anonymousId = getCookie(ANON_ID_COOKIE) ?? generateId(); + } + + // Message queue + const endpointUrl = `${getBaseUrl(environment)}${INGEST_PATH}`; + this.queue = new MessageQueue( + endpointUrl, + publishableKey, + flushInterval, + flushSize, + config.debug ? (ok, count) => this.debug?.logFlush(ok, count) : undefined, + ); + + // Attribution context — captured once per session + this.attribution = parseAttribution(); + + // Page tracker + this.pageTracker = new PageTracker((props) => this.page(props)); + + // Start queue if consent allows + if (effectiveConsent !== 'none') { + this.queue.start(); + } + + // Auto page tracking (opt-in, default false) + if (config.trackPageViews) { + if (effectiveConsent !== 'none') { + this.page(); + } + this.pageTracker.installSPAListeners(); + } + + // Reconcile local consent with server (non-blocking) + this.reconcileServerConsent(); + } + + static init(config: WebSDKConfig): ImmutableWebSDK { + return new ImmutableWebSDK(config); + } + + /** + * Fetch server-side consent and reconcile with local state. + * If the server has a consent record but the local cookie was cleared, + * restore the server's consent level locally. Non-blocking, fire-and-forget. + */ + private reconcileServerConsent(): void { + this.consent.fetchServerConsent(this.anonymousId).then((serverStatus) => { + if (!serverStatus || serverStatus === 'not_set') return; + // Skip if studio has already called setConsent() — their intent wins + if (this.consentManuallySet) return; + + const local = this.consent.getLevel(); + if (local === 'none' && serverStatus !== 'none') { + this.setConsent(serverStatus as ConsentLevel); + this.consentManuallySet = false; // reset — this was automatic, not manual + this.debug?.logWarning( + `Restored consent from server: ${serverStatus} (local cookie was cleared)`, + ); + } + }).catch(() => { + // Network failure — continue with local consent + }); + } + + // --- Page tracking --- + + page(properties?: Record): void { + if (this.consent.getLevel() === 'none') return; + touchSession(this.cookieDomain); + + const mergedProps: Record = { ...properties }; + + // Attach attribution context to the first page view in the session + if (this.isFirstPage) { + Object.assign(mergedProps, attributionToProperties(this.attribution)); + this.isFirstPage = false; + } + + const message: PageMessage = { + type: 'page', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web', + context: collectContext(), + properties: Object.keys(mergedProps).length > 0 ? mergedProps : undefined, + userId: this.consent.getLevel() === 'full' ? this.userId : undefined, + }; + + this.queue.enqueue(message); + this.debug?.logEvent('page', message); + } + + // --- Event tracking --- + + /** Track a typed event with compile-time parameter validation. */ + track(event: E, properties: EventParamMap[E]): void; + + /** Track a custom event with arbitrary name and properties. */ + track(event: string, properties?: Record): void; + + track(event: string, properties?: Record): void { + if (this.consent.getLevel() === 'none') return; + touchSession(this.cookieDomain); + + const message: TrackMessage = { + type: 'track', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web', + context: collectContext(), + eventName: truncate(event), + properties: properties as Record | undefined, + userId: this.consent.getLevel() === 'full' ? this.userId : undefined, + }; + + this.queue.enqueue(message); + this.debug?.logEvent('track', message); + } + + // --- Identity --- + + /** + * Associate all future events with a known user identity. + * Requires full consent — at anonymous/none this is a no-op. + */ + identify(uid: string, provider: IdentityProvider, traits?: UserTraits): void; + + /** + * Attach traits to the current anonymous identity (no userId). + * For the external-ID-first scenario: a player links Steam before + * creating an account. Requires full consent. + */ + identify(traits: UserTraits): void; + + identify( + uidOrTraits: string | UserTraits, + provider?: IdentityProvider, + traits?: UserTraits, + ): void { + if (this.consent.getLevel() !== 'full') { + this.debug?.logWarning( + 'identify() requires full consent — call ignored. Set consent to "full" first.', + ); + return; + } + touchSession(this.cookieDomain); + + // Overload: identify(traits) — anonymous identify, no userId + if (typeof uidOrTraits === 'object') { + const message: IdentifyMessage = { + type: 'identify', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web', + context: collectContext(), + traits: uidOrTraits as Record, + }; + this.queue.enqueue(message); + this.debug?.logEvent('identify', message); + return; + } + + // Overload: identify(uid, provider, traits?) — known user + this.userId = truncate(uidOrTraits); + + const message: IdentifyMessage = { + type: 'identify', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web', + context: collectContext(), + userId: truncate(uidOrTraits), + identityType: provider as IdentityType, + traits: traits as Record | undefined, + }; + + this.queue.enqueue(message); + this.debug?.logEvent('identify', message); + } + + /** + * Link two identities as belonging to the same player. + * + * Requires full consent (alias carries identity-bearing fields). + * Fires an AliasMessage with fromId/fromType/toId/toType per the + * backend schema. Does not change the current userId. + */ + alias(from: Identity, to: Identity): void { + if (this.consent.getLevel() !== 'full') { + this.debug?.logWarning( + 'alias() requires full consent — call ignored. Set consent to "full" first.', + ); + return; + } + if (!isAliasValid(from.uid, from.provider, to.uid, to.provider)) { + this.debug?.logWarning('alias() from and to are identical — call ignored.'); + return; + } + touchSession(this.cookieDomain); + + const message: AliasMessage = { + type: 'alias', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web', + context: collectContext(), + fromId: truncate(from.uid), + fromType: from.provider as IdentityType, + toId: truncate(to.uid), + toType: to.provider as IdentityType, + }; + + this.queue.enqueue(message); + this.debug?.logEvent('alias', message); + } + + // --- Consent --- + + /** + * Update consent level. Triggers queue purge on downgrade and + * syncs the new status to the backend via PUT /v1/audience/tracking-consent. + */ + setConsent(level: ConsentLevel): void { + const previous = this.consent.getLevel(); + if (level === previous) return; + + this.consentManuallySet = true; + this.debug?.logConsent(previous, level); + + this.consent.setLevel(level, this.anonymousId, { + onPurgeQueue: () => { + this.queue.stop(); + this.queue.clear(); + }, + onStripIdentity: () => { + this.userId = undefined; + // Remove identify and alias messages (both carry PII), strip userId from remaining + this.queue.purge((m) => m.type === 'identify' || m.type === 'alias'); + this.queue.transform((m) => { + if ('userId' in m && m.userId) { + const cleaned = { ...m }; + delete (cleaned as Record).userId; + return cleaned as Message; + } + return m; + }); + }, + onClearCookies: () => { + this.consent.clearCookies(); + }, + }); + + // Upgrading from none — create identity cookies and start queue + if (previous === 'none' && level !== 'none') { + this.anonymousId = getOrCreateAnonymousId(this.cookieDomain); + getOrCreateSessionId(this.cookieDomain); + this.queue.start(); + + // Fire the initial page view that was skipped at init due to none consent + if (this.trackPageViewsEnabled) { + this.page(); + } + } + } + + // --- Lifecycle --- + + /** + * Reset identity. Clears userId and generates a new anonymousId. + * Use on logout to prevent cross-user data contamination. + */ + reset(): void { + this.userId = undefined; + deleteCookie(ANON_ID_COOKIE, this.cookieDomain); + deleteCookie(SESSION_COOKIE, this.cookieDomain); + // Only write new cookies if consent allows (mirrors init behaviour) + if (this.consent.getLevel() !== 'none') { + this.anonymousId = getOrCreateAnonymousId(this.cookieDomain); + getOrCreateSessionId(this.cookieDomain); + } else { + this.anonymousId = generateId(); + } + this.isFirstPage = true; + } + + /** Flush all queued messages immediately. */ + async flush(): Promise { + await this.queue.flush(); + } + + /** Flush remaining events and stop the SDK. */ + shutdown(): void { + this.queue.flushUnload(); + this.queue.stop(); + this.pageTracker.teardown(); + } +} diff --git a/packages/audience/web/src/storage.ts b/packages/audience/web/src/storage.ts new file mode 100644 index 0000000000..6f8451b45f --- /dev/null +++ b/packages/audience/web/src/storage.ts @@ -0,0 +1,39 @@ +import { isBrowser } from './utils'; + +const PREFIX = '__imtbl_audience_'; + +function hasLocalStorage(): boolean { + try { + return isBrowser() && !!window.localStorage; + } catch { + return false; + } +} + +export function getItem(key: string): T | undefined { + if (!hasLocalStorage()) return undefined; + try { + const raw = window.localStorage.getItem(`${PREFIX}${key}`); + return raw ? (JSON.parse(raw) as T) : undefined; + } catch { + return undefined; + } +} + +export function setItem(key: string, value: unknown): void { + if (!hasLocalStorage()) return; + try { + window.localStorage.setItem(`${PREFIX}${key}`, JSON.stringify(value)); + } catch { + // Storage full — silently degrade + } +} + +export function removeItem(key: string): void { + if (!hasLocalStorage()) return; + try { + window.localStorage.removeItem(`${PREFIX}${key}`); + } catch { + // Ignore + } +} diff --git a/packages/audience/web/src/transport.ts b/packages/audience/web/src/transport.ts new file mode 100644 index 0000000000..489fbb3529 --- /dev/null +++ b/packages/audience/web/src/transport.ts @@ -0,0 +1,30 @@ +import type { BatchPayload } from '@imtbl/audience-core'; + +/** + * Send a batch of messages to the backend. + * + * Uses fetch with optional keepalive flag for page-unload resilience. + * sendBeacon is NOT used because the backend requires the + * x-immutable-publishable-key header, which sendBeacon cannot set. + */ +export async function sendMessages( + url: string, + publishableKey: string, + payload: BatchPayload, + keepalive = false, +): Promise { + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-immutable-publishable-key': publishableKey, + }, + body: JSON.stringify(payload), + keepalive, + }); + return response.ok; + } catch { + return false; + } +} diff --git a/packages/audience/web/src/types.ts b/packages/audience/web/src/types.ts new file mode 100644 index 0000000000..b7b6480d7d --- /dev/null +++ b/packages/audience/web/src/types.ts @@ -0,0 +1,14 @@ +import type { Environment, ConsentLevel } from '@imtbl/audience-core'; + +/** Configuration for the Immutable Web SDK. */ +export interface WebSDKConfig { + publishableKey: string; + environment: Environment; + consent?: ConsentLevel; + consentSource?: string; + trackPageViews?: boolean; + debug?: boolean; + cookieDomain?: string; + flushInterval?: number; + flushSize?: number; +} diff --git a/packages/audience/web/src/utils.ts b/packages/audience/web/src/utils.ts new file mode 100644 index 0000000000..65f6dd6267 --- /dev/null +++ b/packages/audience/web/src/utils.ts @@ -0,0 +1,15 @@ +export const isBrowser = (): boolean => typeof window !== 'undefined' && typeof document !== 'undefined'; + +export const generateId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // Fallback: UUID v4 shape using Math.random (backend requires uuid format) + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.trunc(Math.random() * 16); + const v = c === 'x' ? r : (r % 4) + 8; + return v.toString(16); + }); +}; + +export const getTimestamp = (): string => new Date().toISOString(); diff --git a/packages/audience/web/src/validation.ts b/packages/audience/web/src/validation.ts new file mode 100644 index 0000000000..ee90fe2c4b --- /dev/null +++ b/packages/audience/web/src/validation.ts @@ -0,0 +1,41 @@ +const MAX_FUTURE_MS = 24 * 60 * 60 * 1000; // 24 hours +const MAX_PAST_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +// Backend maxLength constraints from OAS +const MAX_STRING_LENGTH = 256; // anonymousId, eventName, userId, fromId, toId +const MAX_SOURCE_LENGTH = 128; // consent source + +/** + * Validate that an event timestamp is within the backend's accepted range: + * no more than 24 hours in the future, no more than 30 days in the past. + */ +export function isTimestampValid(eventTimestamp: string): boolean { + const ts = new Date(eventTimestamp).getTime(); + if (Number.isNaN(ts)) return false; + const now = Date.now(); + return ts <= now + MAX_FUTURE_MS && ts >= now - MAX_PAST_MS; +} + +/** + * Validate that alias from and to are not the same identity. + */ +export function isAliasValid( + fromUid: string, + fromProvider: string, + toUid: string, + toProvider: string, +): boolean { + return fromUid !== toUid || fromProvider !== toProvider; +} + +/** + * Truncate a string to the backend's max length for the given field. + * Returns the original string if within limits. + */ +export function truncate(value: string, maxLength = MAX_STRING_LENGTH): string { + return value.length > maxLength ? value.slice(0, maxLength) : value; +} + +export function truncateSource(value: string): string { + return truncate(value, MAX_SOURCE_LENGTH); +} diff --git a/packages/audience/web/tsup.cdn.js b/packages/audience/web/tsup.cdn.js new file mode 100644 index 0000000000..7fa631a863 --- /dev/null +++ b/packages/audience/web/tsup.cdn.js @@ -0,0 +1,32 @@ +// @ts-check +import { defineConfig } from 'tsup'; +import { replace } from 'esbuild-plugin-replace'; +import pkg from './package.json' assert { type: 'json' }; + +/** + * Audience web SDK CDN bundle — self-contained IIFE exposing window.ImmutableWebSDK. + * All dependencies (including @imtbl/audience-core) are inlined. + * + * Output: dist/cdn/imtbl-web.js + * Usage: + * + */ +export default defineConfig({ + entry: { 'imtbl-web': 'src/cdn.ts' }, + outDir: 'dist/cdn', + format: 'iife', + platform: 'browser', + target: 'es2020', + minify: true, + bundle: true, + treeshake: true, + noExternal: [/.*/], + esbuildPlugins: [ + replace({ + '__SDK_VERSION__': pkg.version === '0.0.0' ? '0.1.0' : pkg.version, + }), + ], +});