diff --git a/packages/audience/core/src/config.ts b/packages/audience/core/src/config.ts index 0ef355ce17..df6dd0f552 100644 --- a/packages/audience/core/src/config.ts +++ b/packages/audience/core/src/config.ts @@ -13,6 +13,7 @@ export const FLUSH_INTERVAL_MS = 5_000; export const FLUSH_SIZE = 20; export const COOKIE_NAME = 'imtbl_anon_id'; +export const SESSION_COOKIE = '_imtbl_sid'; export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment]; diff --git a/packages/audience/core/src/context.test.ts b/packages/audience/core/src/context.test.ts index 54fe7d0649..8e3b18c298 100644 --- a/packages/audience/core/src/context.test.ts +++ b/packages/audience/core/src/context.test.ts @@ -1,12 +1,18 @@ import { collectContext } from './context'; describe('collectContext', () => { - it('includes library name and version', () => { + it('defaults to @imtbl/audience library name', () => { const ctx = collectContext(); expect(ctx.library).toBe('@imtbl/audience'); expect(ctx.libraryVersion).toBeDefined(); }); + it('accepts custom library name and version', () => { + const ctx = collectContext('@imtbl/audience-web-sdk', '1.0.0'); + expect(ctx.library).toBe('@imtbl/audience-web-sdk'); + expect(ctx.libraryVersion).toBe('1.0.0'); + }); + it('collects browser signals in jsdom', () => { const ctx = collectContext(); expect(ctx.userAgent).toBeDefined(); diff --git a/packages/audience/core/src/context.ts b/packages/audience/core/src/context.ts index 826f97fed8..4c449b5a75 100644 --- a/packages/audience/core/src/context.ts +++ b/packages/audience/core/src/context.ts @@ -4,10 +4,20 @@ import { isBrowser } from './utils'; // WARNING: DO NOT CHANGE THE STRING BELOW. IT GETS REPLACED AT BUILD TIME. const SDK_VERSION = '__SDK_VERSION__'; -export function collectContext(): EventContext { +/** + * Collect browser context for event payloads. + * + * Callers may pass their own library name and version when multiple surfaces + * (web SDK, pixel, Unity, Unreal) share this function and each must identify + * itself. Defaults to '@imtbl/audience' with the build-time SDK version. + */ +export function collectContext( + library = '@imtbl/audience', + version = SDK_VERSION, +): EventContext { const context: EventContext = { - library: '@imtbl/audience', - libraryVersion: SDK_VERSION, + library, + libraryVersion: version, }; if (!isBrowser()) return context; diff --git a/packages/audience/core/src/cookie.ts b/packages/audience/core/src/cookie.ts index 3f0057545c..7ccd9f5f98 100644 --- a/packages/audience/core/src/cookie.ts +++ b/packages/audience/core/src/cookie.ts @@ -1,16 +1,22 @@ import { COOKIE_NAME, COOKIE_MAX_AGE_SECONDS } from './config'; import { isBrowser, generateId } from './utils'; -function getCookie(name: string): string | undefined { +export function getCookie(name: string): string | undefined { if (!isBrowser()) return undefined; const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : undefined; } -function setCookie(name: string, value: string, maxAge: number): void { +export function setCookie(name: string, value: string, maxAge: number, domain?: string): void { if (!isBrowser()) return; const secure = window.location.protocol === 'https:' ? '; Secure' : ''; - document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`; + const domainAttr = domain ? `; domain=${domain}` : ''; + document.cookie = `${name}=${encodeURIComponent(value)}` + + `; path=/; max-age=${maxAge}; SameSite=Lax${domainAttr}${secure}`; +} + +export function deleteCookie(name: string, domain?: string): void { + setCookie(name, '', 0, domain); } /** @@ -18,12 +24,12 @@ function setCookie(name: string, value: string, maxAge: number): void { * Both the web SDK and pixel read/write the same cookie so identity stitching * works across surfaces on the same domain. */ -export function getOrCreateAnonymousId(): string { +export function getOrCreateAnonymousId(domain?: string): string { const existing = getCookie(COOKIE_NAME); if (existing) return existing; const id = generateId(); - setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS); + setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS, domain); return id; } diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 7cd23d9245..0ae370d0d8 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -11,9 +11,17 @@ export type { AliasMessage, Message, BatchPayload, + ConsentLevel, + ConsentStatus, } from './types'; -export { getOrCreateAnonymousId, getAnonymousId } from './cookie'; +export { + getOrCreateAnonymousId, + getAnonymousId, + getCookie, + setCookie, + deleteCookie, +} from './cookie'; export * as storage from './storage'; export { @@ -23,6 +31,7 @@ export { FLUSH_INTERVAL_MS, FLUSH_SIZE, COOKIE_NAME, + SESSION_COOKIE, } from './config'; export { generateId, getTimestamp, isBrowser } from './utils'; @@ -31,3 +40,9 @@ export type { Transport } from './transport'; export { httpTransport, httpSend } from './transport'; export { MessageQueue } from './queue'; export { collectContext } from './context'; +export { + isTimestampValid, + isAliasValid, + truncate, + truncateSource, +} from './validation'; diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index bf9abbe462..f5bbd624c3 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -15,9 +15,16 @@ function makeMessage(id: string): Message { }; } +interface QueueOpts { + flushIntervalMs?: number; + flushSize?: number; + onFlush?: (ok: boolean, count: number) => void; + staleFilter?: (msg: Message) => boolean; +} + function createQueue( transport: Transport, - opts: { flushIntervalMs?: number; flushSize?: number } = {}, + opts: QueueOpts = {}, ) { return new MessageQueue( transport, @@ -25,6 +32,7 @@ function createQueue( 'pk_imx_test', opts.flushIntervalMs ?? 5_000, opts.flushSize ?? 20, + { onFlush: opts.onFlush, staleFilter: opts.staleFilter }, ); } @@ -110,6 +118,17 @@ describe('MessageQueue', () => { expect(queue.length).toBe(1); }); + it('filters stale messages on restore', () => { + storage.setItem('queue', [makeMessage('stale'), makeMessage('fresh')]); + + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }, { + staleFilter: (m) => m.messageId === 'fresh', + }); + + expect(queue.length).toBe(1); + }); + it('does not flush concurrently', async () => { let resolveFirst: () => void; const firstCall = new Promise((r) => { resolveFirst = () => r(true); }); @@ -144,7 +163,6 @@ describe('MessageQueue', () => { it('handles messages enqueued during flush', async () => { let queue: ReturnType; const send = jest.fn().mockImplementation(async () => { - // Simulate a message arriving during the network request queue.enqueue(makeMessage('late')); return true; }); @@ -154,28 +172,52 @@ describe('MessageQueue', () => { await queue.flush(); - // The original message was sent, but the late one should remain expect(queue.length).toBe(1); }); -}); -describe('page-unload flush', () => { - let sendBeaconSpy: jest.SpyInstance; + it('calls onFlush callback', async () => { + const onFlush = jest.fn(); + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }, { onFlush }); - beforeEach(() => { - sendBeaconSpy = jest.fn().mockReturnValue(true); - Object.defineProperty(navigator, 'sendBeacon', { - value: sendBeaconSpy, - writable: true, - configurable: true, - }); + queue.enqueue(makeMessage('1')); + await queue.flush(); + + expect(onFlush).toHaveBeenCalledWith(true, 1); }); - afterEach(() => { - sendBeaconSpy.mockRestore?.(); + it('purges messages matching a predicate', () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + + queue.enqueue(makeMessage('1')); + queue.enqueue({ ...makeMessage('2'), type: 'identify' } as any); + queue.enqueue(makeMessage('3')); + + queue.purge((m) => m.type === 'identify'); + expect(queue.length).toBe(2); }); - it('flushes via sendBeacon on visibilitychange to hidden', () => { + it('transforms messages in place', async () => { + const send = jest.fn().mockResolvedValue(true); + const queue = createQueue({ send }); + + queue.enqueue({ ...makeMessage('1'), userId: 'should-strip' } as any); + + queue.transform((m) => { + const cleaned = { ...m }; + delete (cleaned as any).userId; + return cleaned; + }); + + await queue.flush(); + const msg = send.mock.calls[0][2].messages[0]; + expect((msg as any).userId).toBeUndefined(); + }); +}); + +describe('page-unload flush (keepalive)', () => { + it('flushes via keepalive fetch on visibilitychange to hidden', () => { const send = jest.fn().mockResolvedValue(true); const queue = createQueue({ send }); queue.start(); @@ -189,10 +231,11 @@ describe('page-unload flush', () => { }); document.dispatchEvent(new Event('visibilitychange')); - expect(sendBeaconSpy).toHaveBeenCalledTimes(1); - expect(sendBeaconSpy).toHaveBeenCalledWith( + expect(send).toHaveBeenCalledWith( 'https://api.immutable.com/v1/audience/messages', - expect.any(Blob), + 'pk_imx_test', + expect.objectContaining({ messages: expect.any(Array) }), + { keepalive: true }, ); expect(queue.length).toBe(0); @@ -204,7 +247,7 @@ describe('page-unload flush', () => { }); }); - it('flushes via sendBeacon on pagehide', () => { + it('flushes via keepalive fetch on pagehide', () => { const send = jest.fn().mockResolvedValue(true); const queue = createQueue({ send }); queue.start(); @@ -212,20 +255,25 @@ describe('page-unload flush', () => { queue.enqueue(makeMessage('1')); window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith( + 'https://api.immutable.com/v1/audience/messages', + 'pk_imx_test', + expect.objectContaining({ messages: expect.any(Array) }), + { keepalive: true }, + ); expect(queue.length).toBe(0); queue.stop(); }); - it('does not fire beacon when queue is empty', () => { + it('does not fire unload flush when queue is empty', () => { const send = jest.fn().mockResolvedValue(true); const queue = createQueue({ send }); queue.start(); window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); queue.stop(); }); @@ -239,7 +287,7 @@ describe('page-unload flush', () => { queue.enqueue(makeMessage('1')); window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); }); it('destroy stops the queue and flushes remaining messages', () => { @@ -251,52 +299,21 @@ describe('page-unload flush', () => { queue.enqueue(makeMessage('2')); queue.destroy(); - expect(sendBeaconSpy).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ messages: expect.any(Array) }), + { keepalive: true }, + ); expect(queue.length).toBe(0); // Listeners removed — no double flush queue.enqueue(makeMessage('3')); window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).toHaveBeenCalledTimes(1); - }); - - it('falls back to async flush if sendBeacon returns false', async () => { - sendBeaconSpy.mockReturnValue(false); - const send = jest.fn().mockResolvedValue(true); - const queue = createQueue({ send }); - queue.start(); - - queue.enqueue(makeMessage('1')); - window.dispatchEvent(new Event('pagehide')); - - // sendBeacon failed, so async flush should have been triggered - await Promise.resolve(); - expect(send).toHaveBeenCalledTimes(1); - - queue.stop(); - }); - - it('falls back to async flush if sendBeacon is unavailable', async () => { - Object.defineProperty(navigator, 'sendBeacon', { - value: undefined, - writable: true, - configurable: true, - }); - - const send = jest.fn().mockResolvedValue(true); - const queue = createQueue({ send }); - queue.start(); - - queue.enqueue(makeMessage('1')); - window.dispatchEvent(new Event('pagehide')); - - await Promise.resolve(); expect(send).toHaveBeenCalledTimes(1); - - queue.stop(); }); - it('skips beacon if an async flush is already in flight', async () => { + it('skips unload flush if an async flush is already in flight', async () => { let resolveFlush: () => void; const flushPromise = new Promise((r) => { resolveFlush = () => r(true); }); const send = jest.fn().mockReturnValueOnce(flushPromise); @@ -308,9 +325,10 @@ describe('page-unload flush', () => { // Start an async flush (sets flushing = true) const pending = queue.flush(); - // pagehide fires while async flush is in flight — beacon should be skipped + // pagehide fires while async flush is in flight — unload flush should be skipped window.dispatchEvent(new Event('pagehide')); - expect(sendBeaconSpy).not.toHaveBeenCalled(); + // Only 1 call (the async flush), no keepalive call + expect(send).toHaveBeenCalledTimes(1); resolveFlush!(); await pending; diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index e165aef83c..c8c0508fe2 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -4,6 +4,19 @@ import * as storage from './storage'; import { isBrowser } from './utils'; const STORAGE_KEY = 'queue'; +const MAX_BATCH_SIZE = 100; // Backend maxItems limit per OAS + +export interface MessageQueueOptions { + onFlush?: (ok: boolean, count: number) => void; + staleFilter?: (msg: Message) => boolean; + /** + * Override the localStorage key prefix (default: '__imtbl_audience_'). + * Use when multiple SDK surfaces run on the same page to prevent + * queue collision — e.g. web SDK uses '__imtbl_web_' so its queued + * messages don't interfere with the shared SDK's queue. + */ + storagePrefix?: string; +} /** * Batched message queue with localStorage durability. @@ -14,11 +27,13 @@ const STORAGE_KEY = 'queue'; * * localStorage is used as a write-through cache so messages survive * page navigations. On construction, any previously-persisted messages - * are restored into memory. + * are restored into memory (optionally filtered by `staleFilter`). * - * When started, the queue also listens for page-unload events - * (`visibilitychange` and `pagehide`) and flushes via `sendBeacon` - * to ensure events are not lost when the user navigates away. + * When started, the queue listens for page-unload events + * (`visibilitychange` and `pagehide`) and flushes via `fetch` with + * `keepalive: true` to ensure events are not lost when the user + * navigates away. sendBeacon is NOT used because the backend requires + * the `x-immutable-publishable-key` header which sendBeacon cannot set. */ export class MessageQueue { private messages: Message[]; @@ -27,15 +42,15 @@ export class MessageQueue { private flushing = false; - private readonly onVisibilityChange = (): void => { - if (document.visibilityState === 'hidden') { - this.flushBeacon(); - } - }; + private unloadBound = false; - private readonly onPageHide = (): void => { - this.flushBeacon(); - }; + private visibilityHandler?: () => void; + + private pagehideHandler?: () => void; + + private readonly onFlush?: (ok: boolean, count: number) => void; + + private readonly storagePrefix?: string; constructor( private readonly transport: Transport, @@ -43,35 +58,35 @@ export class MessageQueue { private readonly publishableKey: string, private readonly flushIntervalMs: number, private readonly flushSize: number, + options?: MessageQueueOptions, ) { - this.messages = (storage.getItem(STORAGE_KEY) as Message[] | undefined) ?? []; + this.onFlush = options?.onFlush; + this.storagePrefix = options?.storagePrefix; + + const restored = (storage.getItem(STORAGE_KEY, this.storagePrefix) as Message[] | undefined) ?? []; + this.messages = options?.staleFilter + ? restored.filter(options.staleFilter) + : restored; } start(): void { if (this.timer) return; this.timer = setInterval(() => this.flush(), this.flushIntervalMs); - - if (isBrowser()) { - document.addEventListener('visibilitychange', this.onVisibilityChange); - window.addEventListener('pagehide', this.onPageHide); - } + this.registerUnload(); } stop(): void { - if (!this.timer) return; - clearInterval(this.timer); - this.timer = null; - - if (isBrowser()) { - document.removeEventListener('visibilitychange', this.onVisibilityChange); - window.removeEventListener('pagehide', this.onPageHide); + if (this.timer) { + clearInterval(this.timer); + this.timer = null; } + this.removeUnload(); } - /** Stops the queue, flushes remaining messages via beacon, and removes listeners. */ + /** Stops the queue, flushes remaining messages via keepalive fetch, and removes listeners. */ destroy(): void { this.stop(); - this.flushBeacon(); + this.flushUnload(); } enqueue(message: Message): void { @@ -83,61 +98,96 @@ export class MessageQueue { } } - /** Guard prevents concurrent flushes from racing on the same batch. */ + /** + * Send queued messages to the backend and wait for the response. + * On success, sent messages are removed from the queue. On failure, + * messages stay queued and retry on the next flush cycle. + * Use this for normal operation. For page-unload scenarios, use + * flushUnload() instead — it's fire-and-forget and survives navigation. + */ async flush(): Promise { if (this.flushing || this.messages.length === 0) return; this.flushing = true; try { - const batch = [...this.messages]; + const batch = this.messages.slice(0, MAX_BATCH_SIZE); const payload: BatchPayload = { messages: batch }; const ok = await this.transport.send(this.endpointUrl, this.publishableKey, payload); if (ok) { - // Slice rather than clear — new messages may have been enqueued during the request. 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 scenarios. + * + * Uses `fetch` with `keepalive: true` so the request survives page + * navigation. Unlike `flush()`, this is synchronous and does not wait + * for the response — use it only in `visibilitychange`/`pagehide` + * handlers or in `shutdown()`. + */ + flushUnload(): void { + if (this.flushing || this.messages.length === 0) return; + + const batch = this.messages.slice(0, MAX_BATCH_SIZE); + const payload: BatchPayload = { messages: batch }; + + this.transport.send(this.endpointUrl, this.publishableKey, payload, { keepalive: true }); + this.messages = this.messages.slice(batch.length); + this.persist(); + } + + /** 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; } clear(): void { this.messages = []; - storage.removeItem(STORAGE_KEY); + storage.removeItem(STORAGE_KEY, this.storagePrefix); } - /** - * Synchronous flush using sendBeacon for page-unload scenarios. - * sendBeacon is fire-and-forget and survives page navigation. - * Falls back to the normal async flush if sendBeacon is unavailable. - */ - private flushBeacon(): void { - if (this.flushing || this.messages.length === 0) return; + private registerUnload(): void { + if (!isBrowser() || this.unloadBound) return; + this.unloadBound = true; - const payload: BatchPayload = { messages: [...this.messages] }; - const body = JSON.stringify(payload); + this.pagehideHandler = () => this.flushUnload(); + this.visibilityHandler = () => { + if (document.visibilityState === 'hidden') this.flushUnload(); + }; + document.addEventListener('visibilitychange', this.visibilityHandler); + window.addEventListener('pagehide', this.pagehideHandler); + } - if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { - const blob = new Blob([body], { type: 'application/json' }); - const sent = navigator.sendBeacon(this.endpointUrl, blob); - if (sent) { - this.messages = []; - this.persist(); - return; - } + private removeUnload(): void { + if (!this.unloadBound) return; + if (this.visibilityHandler) { + document.removeEventListener('visibilitychange', this.visibilityHandler); } - - // Fallback: trigger async flush (best-effort, may not complete before unload) - this.flush(); + if (this.pagehideHandler) { + window.removeEventListener('pagehide', this.pagehideHandler); + } + this.unloadBound = false; } private persist(): void { - storage.setItem(STORAGE_KEY, this.messages); + storage.setItem(STORAGE_KEY, this.messages, this.storagePrefix); } } diff --git a/packages/audience/core/src/storage.ts b/packages/audience/core/src/storage.ts index 8c2150074a..1b76f04f3e 100644 --- a/packages/audience/core/src/storage.ts +++ b/packages/audience/core/src/storage.ts @@ -1,6 +1,6 @@ import { isBrowser } from './utils'; -const PREFIX = '__imtbl_audience_'; +const DEFAULT_PREFIX = '__imtbl_audience_'; function hasLocalStorage(): boolean { try { @@ -10,29 +10,29 @@ function hasLocalStorage(): boolean { } } -export function getItem(key: string): unknown | undefined { +export function getItem(key: string, prefix = DEFAULT_PREFIX): unknown | undefined { if (!hasLocalStorage()) return undefined; try { - const raw = localStorage.getItem(`${PREFIX}${key}`); + const raw = localStorage.getItem(`${prefix}${key}`); return raw ? JSON.parse(raw) : undefined; } catch { return undefined; } } -export function setItem(key: string, value: unknown): void { +export function setItem(key: string, value: unknown, prefix = DEFAULT_PREFIX): void { if (!hasLocalStorage()) return; try { - localStorage.setItem(`${PREFIX}${key}`, JSON.stringify(value)); + localStorage.setItem(`${prefix}${key}`, JSON.stringify(value)); } catch { // Storage full or unavailable. } } -export function removeItem(key: string): void { +export function removeItem(key: string, prefix = DEFAULT_PREFIX): void { if (!hasLocalStorage()) return; try { - localStorage.removeItem(`${PREFIX}${key}`); + localStorage.removeItem(`${prefix}${key}`); } catch { // Ignore. } diff --git a/packages/audience/core/src/transport.test.ts b/packages/audience/core/src/transport.test.ts index c64d80caeb..22e9fd6324 100644 --- a/packages/audience/core/src/transport.test.ts +++ b/packages/audience/core/src/transport.test.ts @@ -36,9 +36,21 @@ describe('httpSend', () => { 'x-immutable-publishable-key': 'pk_imx_test', }, body: JSON.stringify(payload), + keepalive: undefined, }); }); + it('passes keepalive option when specified', async () => { + const mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + await httpSend('https://example.com', 'pk', payload, { keepalive: true }); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com', expect.objectContaining({ + keepalive: true, + })); + }); + it('returns true on success', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true }); expect(await httpSend('https://example.com', 'pk', payload)).toBe(true); diff --git a/packages/audience/core/src/transport.ts b/packages/audience/core/src/transport.ts index a352e9e4c1..65a99d9609 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -1,14 +1,19 @@ import { track, trackError } from '@imtbl/metrics'; import type { BatchPayload } from './types'; +export interface TransportOptions { + keepalive?: boolean; +} + export interface Transport { - send(url: string, publishableKey: string, payload: BatchPayload): Promise; + send(url: string, publishableKey: string, payload: BatchPayload, options?: TransportOptions): Promise; } export async function httpSend( url: string, publishableKey: string, payload: BatchPayload, + options?: TransportOptions, ): Promise { try { const response = await fetch(url, { @@ -18,6 +23,7 @@ export async function httpSend( 'x-immutable-publishable-key': publishableKey, }, body: JSON.stringify(payload), + keepalive: options?.keepalive, }); if (!response.ok) { diff --git a/packages/audience/core/src/types.ts b/packages/audience/core/src/types.ts index 277ed533da..6bc7e26b7c 100644 --- a/packages/audience/core/src/types.ts +++ b/packages/audience/core/src/types.ts @@ -77,3 +77,23 @@ export type Message = export interface BatchPayload { messages: Message[]; } + +/** + * The consent level a studio sets via setConsent(). + * + * - `'none'` — No tracking. SDK does nothing. + * - `'anonymous'` — Track activity but not who the user is. + * - `'full'` — Track everything including user identity. + */ +export type ConsentLevel = 'none' | 'anonymous' | 'full'; + +/** + * The consent status the backend stores and returns. + * Includes `'not_set'` for users who haven't been asked yet. + * + * - `'not_set'` — No consent decision recorded yet. + * - `'none'` — User declined tracking. + * - `'anonymous'` — User accepted anonymous tracking. + * - `'full'` — User accepted full tracking. + */ +export type ConsentStatus = 'not_set' | 'none' | 'anonymous' | 'full'; diff --git a/packages/audience/core/src/utils.ts b/packages/audience/core/src/utils.ts index 701b9bf61c..2951eb4695 100644 --- a/packages/audience/core/src/utils.ts +++ b/packages/audience/core/src/utils.ts @@ -4,7 +4,11 @@ export const generateId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } - return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + 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/core/src/validation.test.ts b/packages/audience/core/src/validation.test.ts new file mode 100644 index 0000000000..9564acdaad --- /dev/null +++ b/packages/audience/core/src/validation.test.ts @@ -0,0 +1,76 @@ +import { + isTimestampValid, + isAliasValid, + truncate, + truncateSource, +} from './validation'; + +describe('isTimestampValid', () => { + it('accepts a current timestamp', () => { + expect(isTimestampValid(new Date().toISOString())).toBe(true); + }); + + it('accepts a timestamp 23 hours in the future', () => { + const future = new Date(Date.now() + 23 * 60 * 60 * 1000).toISOString(); + expect(isTimestampValid(future)).toBe(true); + }); + + it('rejects a timestamp 25 hours in the future', () => { + const future = new Date(Date.now() + 25 * 60 * 60 * 1000).toISOString(); + expect(isTimestampValid(future)).toBe(false); + }); + + it('accepts a timestamp 29 days in the past', () => { + const past = new Date(Date.now() - 29 * 24 * 60 * 60 * 1000).toISOString(); + expect(isTimestampValid(past)).toBe(true); + }); + + it('rejects a timestamp 31 days in the past', () => { + const past = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(); + expect(isTimestampValid(past)).toBe(false); + }); + + it('rejects an invalid date string', () => { + expect(isTimestampValid('not-a-date')).toBe(false); + }); +}); + +describe('isAliasValid', () => { + it('returns true when from and to differ', () => { + expect(isAliasValid('steam_123', 'steam', 'user@example.com', 'email')).toBe(true); + }); + + it('returns true when same ID but different type', () => { + expect(isAliasValid('123', 'steam', '123', 'email')).toBe(true); + }); + + it('returns false when from and to are identical', () => { + expect(isAliasValid('user@example.com', 'email', 'user@example.com', 'email')).toBe(false); + }); +}); + +describe('truncate', () => { + it('returns the original string when within the limit', () => { + expect(truncate('hello', 256)).toBe('hello'); + }); + + it('truncates to the default max length of 256', () => { + const long = 'x'.repeat(300); + expect(truncate(long)).toHaveLength(256); + }); + + it('truncates to a custom max length', () => { + expect(truncate('hello world', 5)).toBe('hello'); + }); +}); + +describe('truncateSource', () => { + it('truncates to the consent source max length of 128', () => { + const long = 'x'.repeat(200); + expect(truncateSource(long)).toHaveLength(128); + }); + + it('returns the original when within 128 chars', () => { + expect(truncateSource('CookieBannerV2')).toBe('CookieBannerV2'); + }); +}); diff --git a/packages/audience/core/src/validation.ts b/packages/audience/core/src/validation.ts new file mode 100644 index 0000000000..3f85d67e99 --- /dev/null +++ b/packages/audience/core/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( + fromId: string, + fromType: string, + toId: string, + toType: string, +): boolean { + return fromId !== toId || fromType !== toType; +} + +/** + * 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/.eslintrc.cjs b/packages/audience/web/.eslintrc.cjs new file mode 100644 index 0000000000..b48069b5a7 --- /dev/null +++ b/packages/audience/web/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../../.eslintrc'], + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/audience/web/README.md b/packages/audience/web/README.md new file mode 100644 index 0000000000..53aadeceee --- /dev/null +++ b/packages/audience/web/README.md @@ -0,0 +1,154 @@ +# @imtbl/audience-web-sdk + +Consent-aware event tracking and identity management for web surfaces. Part of the Immutable Audience platform. + +## Install + +```bash +npm install @imtbl/audience-web-sdk +``` + +## Quick Start + +```typescript +import { ImmutableWebSDK } from '@imtbl/audience-web-sdk'; + +const sdk = ImmutableWebSDK.init({ + publishableKey: 'pk_imtbl_...', + environment: 'production', + consent: 'anonymous', +}); + +sdk.track('purchase', { currency: 'USD', value: 9.99, itemId: 'sword_01' }); +sdk.page(); +``` + +## Initialisation + +```typescript +const sdk = ImmutableWebSDK.init({ + publishableKey: 'pk_imtbl_...', // Required — from Immutable Hub + environment: 'production', // 'dev' | 'sandbox' | 'production' + consent: 'none', // 'none' | 'anonymous' | 'full' (default: 'none') + consentSource: 'CookieBannerV2', // Identifies the consent source (default: 'WebSDK') + debug: false, // Log all events to console (default: false) + cookieDomain: '.studio.com', // Cross-subdomain cookie sharing (optional) + flushInterval: 5000, // Queue flush interval in ms (default: 5000) + flushSize: 20, // Queue flush size threshold (default: 20) +}); +``` + +## Consent + +The SDK defaults to `none` — no events are collected until consent is explicitly set. + +```typescript +sdk.setConsent('anonymous'); // Anonymous tracking (no PII) +sdk.setConsent('full'); // Full tracking (PII via identify) +sdk.setConsent('none'); // Stop tracking, purge queue, clear cookies +``` + +Consent is: +- **Set at init** via the `consent` config option +- **Synced server-side** via `PUT /v1/audience/tracking-consent` +- **Reconciled on init** via `GET /v1/audience/tracking-consent` (restores consent if config is stale) + +| Level | Behaviour | +|-------|-----------| +| `none` | SDK is inert. No events collected. Queue purged on downgrade. | +| `anonymous` | Events collected with anonymous ID only. `identify()` calls are discarded. | +| `full` | Full collection. `identify()` sends. `userId` included on events. | + +**On downgrade to `none`:** queue purged, `imtbl_anon_id` and `_imtbl_sid` cookies cleared. +**On downgrade from `full` to `anonymous`:** identify messages purged, `userId` stripped from queued events. + +## Auto-Tracked Events + +The SDK automatically fires these events. Studios do not call them. + +| Event | When | Properties | +|-------|------|------------| +| `session_start` | SDK init with no active session cookie | `sessionId` | +| `session_end` | `shutdown()` called | `sessionId`, `duration` (seconds) | + +## Event Tracking + +```typescript +sdk.track('sign_up', { method: 'google' }); +sdk.track('purchase', { currency: 'USD', value: 9.99 }); +sdk.track('wishlist_add', { gameId: 'game_123', source: 'landing_page' }); +sdk.track('beta_key_redeemed', { source: 'influencer' }); +``` + +## Page Tracking + +Call `sdk.page()` on route changes. Attribution context (UTMs, click IDs, referrer, landing page) is automatically attached to the first page view. + +```typescript +sdk.page(); +sdk.page({ section: 'shop', category: 'weapons' }); +``` + +## Identity + +```typescript +// Identify a known user (requires full consent) +sdk.identify('user@example.com', 'email'); +sdk.identify('76561198012345', 'steam'); +sdk.identify('passport_sub_abc', 'passport', { + email: 'user@example.com', + name: 'Player One', +}); + +// Identify with traits only (anonymous, no userId) +sdk.identify({ source: 'steam', steamId: '76561198012345' }); + +// Link two identities (same player, different providers) +sdk.alias( + { uid: '76561198012345', provider: 'steam' }, + { uid: 'user@example.com', provider: 'email' }, +); + +// Reset on logout (new anonymous ID, clears userId) +sdk.reset(); +``` + +## Queue & Lifecycle + +```typescript +await sdk.flush(); // Force flush all queued events +sdk.shutdown(); // Flush remaining events, stop the SDK +``` + +Events are batched and flushed every 5 seconds or when 20 messages accumulate. On page unload (`visibilitychange` / `pagehide`), remaining events are flushed via `fetch` with `keepalive: true`. + +## CDN Usage + +For sites without a bundler: + +```html + + +``` + +## Cookies + +All cookies are first-party, `SameSite=Lax`, `Secure` on HTTPS, and shared with the pixel: + +| Cookie | Lifetime | Purpose | +|--------|----------|---------| +| `imtbl_anon_id` | 2 years | Anonymous device ID | +| `_imtbl_sid` | 30 min (rolling) | Session continuity | + +## Wire Format + +Events are sent to `POST /v1/audience/messages` with the `x-immutable-publishable-key` header. All messages include `surface: 'web'` and follow the backend OpenAPI spec. diff --git a/packages/audience/web/demo/index.html b/packages/audience/web/demo/index.html new file mode 100644 index 0000000000..4de21fca75 --- /dev/null +++ b/packages/audience/web/demo/index.html @@ -0,0 +1,350 @@ + + + + + + Audience Web SDK — Demo + + + + + + +

Audience Web SDK — Demo

+

End-to-end testing against dev/sandbox backend. Open DevTools Network tab to see requests.

+ +
+ Environment: + Consent: + Anonymous ID: + User ID: none +
+ + +
+

1. Initialise

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+

2. Consent

+
+ + + + +
+ +
+ + +
+

3. Page Tracking

+
+ + +
+
+ + +
+

4. Track Events

+
+ + + + + + + +
+
+
+ + +
+ +
+
+ + +
+

5. Identity

+
+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+

6. Queue

+
+ +
+
+ + +
+

Event Log

+
+ +
+ + + + + diff --git a/packages/audience/web/jest.config.ts b/packages/audience/web/jest.config.ts new file mode 100644 index 0000000000..323ad11b09 --- /dev/null +++ b/packages/audience/web/jest.config.ts @@ -0,0 +1,15 @@ +import type { Config } from 'jest'; + +const config: Config = { + roots: ['/src'], + moduleDirectories: ['node_modules', 'src'], + testEnvironment: 'jsdom', + transform: { + '^.+\\.(t|j)sx?$': '@swc/jest', + }, + moduleNameMapper: { + '^@imtbl/audience-core$': '/../core/src/index.ts', + }, +}; + +export default config; diff --git a/packages/audience/web/package.json b/packages/audience/web/package.json new file mode 100644 index 0000000000..fed68fda14 --- /dev/null +++ b/packages/audience/web/package.json @@ -0,0 +1,61 @@ +{ + "name": "@imtbl/audience-web-sdk", + "description": "Immutable Audience Web SDK — consent-aware event tracking and identity management", + "version": "0.0.0", + "author": "Immutable", + "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", + "dependencies": { + "@imtbl/audience-core": "workspace:*" + }, + "devDependencies": { + "@swc/core": "^1.4.2", + "@swc/jest": "^0.2.37", + "@types/jest": "^29.5.12", + "@types/node": "^22.10.7", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.4.3", + "ts-jest": "^29.1.0", + "tsup": "^8.3.0", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=20.11.0" + }, + "exports": { + "development": { + "types": "./src/index.ts", + "browser": "./dist/browser/index.js", + "require": "./dist/node/index.cjs", + "default": "./dist/node/index.js" + }, + "default": { + "types": "./dist/types/index.d.ts", + "browser": "./dist/browser/index.js", + "require": "./dist/node/index.cjs", + "default": "./dist/node/index.js" + } + }, + "files": ["dist"], + "homepage": "https://github.com/immutable/ts-immutable-sdk#readme", + "main": "dist/node/index.cjs", + "module": "dist/node/index.js", + "browser": "dist/browser/index.js", + "publishConfig": { + "access": "public" + }, + "repository": "immutable/ts-immutable-sdk.git", + "scripts": { + "build": "pnpm transpile && pnpm transpile:cdn && pnpm typegen", + "transpile": "tsup src/index.ts --config ../../../tsup.config.js", + "transpile:cdn": "tsup --config tsup.cdn.js", + "typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types", + "lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0", + "test": "jest --passWithNoTests", + "test:watch": "jest --watch", + "demo": "pnpm build && npx serve -l 3456 --cors ..", + "typecheck": "tsc --customConditions development --noEmit --jsx preserve" + }, + "type": "module", + "types": "./dist/types/index.d.ts" +} diff --git a/packages/audience/web/src/attribution.test.ts b/packages/audience/web/src/attribution.test.ts new file mode 100644 index 0000000000..3d6062adce --- /dev/null +++ b/packages/audience/web/src/attribution.test.ts @@ -0,0 +1,124 @@ +import { parseAttribution, attributionToProperties } from './attribution'; + +beforeEach(() => { + sessionStorage.clear(); +}); + +describe('parseAttribution', () => { + it('parses UTM params from the URL', () => { + Object.defineProperty(window, 'location', { + value: { + search: '?utm_source=youtube&utm_medium=influencer&utm_campaign=launch', + href: 'https://studio.com/shop?utm_source=youtube&utm_medium=influencer&utm_campaign=launch', + }, + writable: true, + }); + + const ctx = parseAttribution(); + expect(ctx.utmSource).toBe('youtube'); + expect(ctx.utmMedium).toBe('influencer'); + expect(ctx.utmCampaign).toBe('launch'); + expect(ctx.landingPage).toBe(window.location.href); + }); + + it('parses click IDs from the URL', () => { + Object.defineProperty(window, 'location', { + value: { + search: '?gclid=abc&fbclid=def&ttclid=ghi&msclkid=jkl&dclid=mno&li_fat_id=pqr', + href: 'https://studio.com/?gclid=abc&fbclid=def&ttclid=ghi&msclkid=jkl&dclid=mno&li_fat_id=pqr', + }, + writable: true, + }); + + const ctx = parseAttribution(); + expect(ctx.gclid).toBe('abc'); + expect(ctx.fbclid).toBe('def'); + expect(ctx.ttclid).toBe('ghi'); + expect(ctx.msclkid).toBe('jkl'); + expect(ctx.dclid).toBe('mno'); + expect(ctx.li_fat_id).toBe('pqr'); + }); + + it('parses ref param as referralCode', () => { + Object.defineProperty(window, 'location', { + value: { + search: '?ref=creator_handle', + href: 'https://studio.com/?ref=creator_handle', + }, + writable: true, + }); + + const ctx = parseAttribution(); + expect(ctx.referralCode).toBe('creator_handle'); + }); + + it('returns cached attribution on subsequent calls within session', () => { + Object.defineProperty(window, 'location', { + value: { + search: '?utm_source=first', + href: 'https://studio.com/?utm_source=first', + }, + writable: true, + }); + + const first = parseAttribution(); + expect(first.utmSource).toBe('first'); + + // Change URL (simulating SPA navigation) + Object.defineProperty(window, 'location', { + value: { + search: '', + href: 'https://studio.com/shop', + }, + writable: true, + }); + + const second = parseAttribution(); + expect(second.utmSource).toBe('first'); // Still the original + }); + + it('returns empty context with no params', () => { + Object.defineProperty(window, 'location', { + value: { + search: '', + href: 'https://studio.com/', + }, + writable: true, + }); + + const ctx = parseAttribution(); + expect(ctx.utmSource).toBeUndefined(); + expect(ctx.gclid).toBeUndefined(); + expect(ctx.landingPage).toBe('https://studio.com/'); + }); +}); + +describe('attributionToProperties', () => { + it('converts attribution context to flat properties', () => { + const props = attributionToProperties({ + utmSource: 'youtube', + utmMedium: 'influencer', + gclid: 'abc', + dclid: 'mno', + li_fat_id: 'pqr', + referralCode: 'ref123', + landingPage: 'https://studio.com/', + }); + + expect(props).toEqual({ + utm_source: 'youtube', + utm_medium: 'influencer', + gclid: 'abc', + dclid: 'mno', + li_fat_id: 'pqr', + referral_code: 'ref123', + landing_page: 'https://studio.com/', + }); + }); + + it('omits undefined fields', () => { + const props = attributionToProperties({ utmSource: 'youtube' }); + expect(props).toEqual({ utm_source: 'youtube' }); + expect(props).not.toHaveProperty('utm_medium'); + }); +}); diff --git a/packages/audience/web/src/attribution.ts b/packages/audience/web/src/attribution.ts new file mode 100644 index 0000000000..a26a63a66a --- /dev/null +++ b/packages/audience/web/src/attribution.ts @@ -0,0 +1,92 @@ +import { isBrowser } from '@imtbl/audience-core'; + +export interface AttributionContext { + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + utmContent?: string; + utmTerm?: string; + gclid?: string; + fbclid?: string; + ttclid?: string; + msclkid?: string; + dclid?: string; + li_fat_id?: string; + referrer?: string; + referralCode?: 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, + dclid: params.get('dclid') ?? undefined, + li_fat_id: params.get('li_fat_id') ?? undefined, + referrer: document.referrer || undefined, + referralCode: params.get('ref') ?? 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.dclid) props.dclid = ctx.dclid; + if (ctx.li_fat_id) props.li_fat_id = ctx.li_fat_id; + if (ctx.referrer) props.referrer = ctx.referrer; + if (ctx.referralCode) props.referral_code = ctx.referralCode; + 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..69ca415cf9 --- /dev/null +++ b/packages/audience/web/src/cdn.ts @@ -0,0 +1,17 @@ +/** + * Audience web SDK CDN entry point — self-contained IIFE bundle. + * Assigns ImmutableWebSDK to window for script-tag usage. + */ +import { ImmutableWebSDK } from './index'; + +if (typeof window !== 'undefined') { + (window as any).ImmutableWebSDK = ImmutableWebSDK; +} + +export { ImmutableWebSDK }; +export type { + 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.test.ts b/packages/audience/web/src/consent.test.ts new file mode 100644 index 0000000000..b9c9c5f1c6 --- /dev/null +++ b/packages/audience/web/src/consent.test.ts @@ -0,0 +1,76 @@ +import { ConsentManager } from './consent'; + +// Mock fetch for server sync +const mockFetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); +global.fetch = mockFetch; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('ConsentManager', () => { + it('initialises with the provided consent level', () => { + const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK'); + expect(manager.getLevel()).toBe('anonymous'); + }); + + it('calls onPurgeQueue when downgrading to none', () => { + const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK'); + const onPurge = jest.fn(); + const onClear = jest.fn(); + + manager.setLevel('none', 'anon-123', { + onPurgeQueue: onPurge, + onClearCookies: onClear, + }); + + expect(onPurge).toHaveBeenCalled(); + expect(onClear).toHaveBeenCalled(); + expect(manager.getLevel()).toBe('none'); + }); + + it('calls onStripIdentity when downgrading from full to anonymous', () => { + const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK'); + const onStrip = jest.fn(); + + manager.setLevel('anonymous', 'anon-123', { onStripIdentity: onStrip }); + + expect(onStrip).toHaveBeenCalled(); + expect(manager.getLevel()).toBe('anonymous'); + }); + + it('syncs consent to server via PUT on setLevel', () => { + const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK'); + manager.setLevel('full', 'anon-123'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.sandbox.immutable.com/v1/audience/tracking-consent', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + anonymousId: 'anon-123', + status: 'full', + source: 'TestSDK', + }), + }), + ); + }); + + it('fetches server consent status', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 'anonymous' }), + }); + + const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK'); + const status = await manager.fetchServerConsent('anon-123'); + + expect(status).toBe('anonymous'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.sandbox.immutable.com/v1/audience/tracking-consent?anonymousId=anon-123', + expect.objectContaining({ + headers: { 'x-immutable-publishable-key': 'pk_test' }, + }), + ); + }); +}); diff --git a/packages/audience/web/src/consent.ts b/packages/audience/web/src/consent.ts new file mode 100644 index 0000000000..c7dd33d613 --- /dev/null +++ b/packages/audience/web/src/consent.ts @@ -0,0 +1,114 @@ +import type { + ConsentLevel, + ConsentStatus, + Environment, +} from '@imtbl/audience-core'; +import { + CONSENT_PATH, + COOKIE_NAME, + SESSION_COOKIE, + getBaseUrl, + isBrowser, + deleteCookie, + truncateSource, +} from '@imtbl/audience-core'; + +/** + * Check if the browser signals a Do Not Track or Global Privacy Control + * preference. Studios can call this before deciding what to pass to setConsent(). + */ +export function detectPrivacySignal(): boolean { + if (!isBrowser()) return false; + const nav = navigator as any; + if (nav.doNotTrack === '1' || (window as any).doNotTrack === '1') return true; + 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; + this.level = initialConsent; + } + + getLevel(): ConsentLevel { + return this.level; + } + + setLevel( + level: ConsentLevel, + anonymousId: string, + callbacks?: ConsentCallbacks, + ): void { + const { level: previous } = this; + this.level = level; + + if (level === 'none') { + callbacks?.onPurgeQueue?.(); + callbacks?.onClearCookies?.(); + } else if (level === 'anonymous' && previous === 'full') { + callbacks?.onStripIdentity?.(); + } + + this.syncToServer(anonymousId, level); + } + + 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(COOKIE_NAME, this.cookieDomain); + deleteCookie(SESSION_COOKIE, 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 + } + } +} diff --git a/packages/audience/web/src/context.ts b/packages/audience/web/src/context.ts new file mode 100644 index 0000000000..7fec3e8ef6 --- /dev/null +++ b/packages/audience/web/src/context.ts @@ -0,0 +1,7 @@ +import type { EventContext } from '@imtbl/audience-core'; +import { collectContext as coreCollectContext } from '@imtbl/audience-core'; +import { LIBRARY_NAME, LIBRARY_VERSION } from './config'; + +export function collectContext(): EventContext { + return coreCollectContext(LIBRARY_NAME, LIBRARY_VERSION); +} diff --git a/packages/audience/web/src/cookie.test.ts b/packages/audience/web/src/cookie.test.ts new file mode 100644 index 0000000000..da0b1a7304 --- /dev/null +++ b/packages/audience/web/src/cookie.test.ts @@ -0,0 +1,51 @@ +import { + getOrCreateSessionId, + getSessionId, + touchSession, +} from './cookie'; + +beforeEach(() => { + document.cookie.split(';').forEach((c) => { + document.cookie = `${c.trim().split('=')[0]}=; max-age=0; path=/`; + }); +}); + +describe('getOrCreateSessionId', () => { + it('creates a new session ID and reports isNew', () => { + const result = getOrCreateSessionId(); + expect(result.sessionId).toBeDefined(); + expect(typeof result.sessionId).toBe('string'); + expect(result.isNew).toBe(true); + }); + + it('returns existing session and isNew=false', () => { + const first = getOrCreateSessionId(); + const second = getOrCreateSessionId(); + expect(second.sessionId).toBe(first.sessionId); + expect(second.isNew).toBe(false); + }); +}); + +describe('getSessionId', () => { + it('returns undefined when no session exists', () => { + expect(getSessionId()).toBeUndefined(); + }); + + it('returns session ID after creation', () => { + const { sessionId } = getOrCreateSessionId(); + expect(getSessionId()).toBe(sessionId); + }); +}); + +describe('touchSession', () => { + it('does nothing if no session exists', () => { + touchSession(); + expect(getSessionId()).toBeUndefined(); + }); + + it('preserves existing session cookie', () => { + const { sessionId } = getOrCreateSessionId(); + touchSession(); + expect(getOrCreateSessionId().sessionId).toBe(sessionId); + }); +}); diff --git a/packages/audience/web/src/cookie.ts b/packages/audience/web/src/cookie.ts new file mode 100644 index 0000000000..1181409632 --- /dev/null +++ b/packages/audience/web/src/cookie.ts @@ -0,0 +1,34 @@ +import { + SESSION_COOKIE, + getCookie, + setCookie, + generateId, +} from '@imtbl/audience-core'; + +const SESSION_MAX_AGE = 30 * 60; // 30 minutes (rolling) + +// --- Session ID (_imtbl_sid cookie, 30min rolling) --- + +export interface SessionResult { + sessionId: string; + isNew: boolean; +} + +export function getOrCreateSessionId(domain?: string): SessionResult { + const existing = getCookie(SESSION_COOKIE); + const isNew = !existing; + const sid = existing ?? generateId(); + setCookie(SESSION_COOKIE, sid, SESSION_MAX_AGE, domain); + return { sessionId: sid, isNew }; +} + +export function getSessionId(): string | undefined { + return getCookie(SESSION_COOKIE); +} + +export function touchSession(domain?: string): void { + const sid = getCookie(SESSION_COOKIE); + if (sid) { + setCookie(SESSION_COOKIE, sid, SESSION_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..27c22328e4 --- /dev/null +++ b/packages/audience/web/src/index.ts @@ -0,0 +1,3 @@ +export { ImmutableWebSDK } from './sdk'; +export type { WebSDKConfig } from './types'; +export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core'; diff --git a/packages/audience/web/src/sdk.test.ts b/packages/audience/web/src/sdk.test.ts new file mode 100644 index 0000000000..959d37d07e --- /dev/null +++ b/packages/audience/web/src/sdk.test.ts @@ -0,0 +1,364 @@ +import { ImmutableWebSDK } from './sdk'; + +function createSDK(overrides: Record = {}) { + return ImmutableWebSDK.init({ + publishableKey: 'pk_imtbl_test', + environment: 'sandbox', + consent: 'full', + ...overrides, + }); +} + +const fetchCalls: { url: string; init: RequestInit }[] = []; + +const mockFetch = jest.fn().mockImplementation( + async (url: string, init?: RequestInit) => { + fetchCalls.push({ url: url as string, init: init ?? {} }); + return { ok: true, json: async () => ({}) }; + }, +); +global.fetch = mockFetch; + +function sentMessages(): any[] { + return fetchCalls + .filter((c) => c.url.includes('/v1/audience/messages')) + .flatMap((c) => { + try { + return JSON.parse(c.init.body as string).messages; + } catch { + return []; + } + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + fetchCalls.length = 0; + jest.useFakeTimers(); + document.cookie.split(';').forEach((c) => { + document.cookie = `${c.trim().split('=')[0]}=;max-age=0;path=/`; + }); + localStorage.clear(); + sessionStorage.clear(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('ImmutableWebSDK', () => { + describe('init', () => { + it('creates an SDK instance via static init()', () => { + const sdk = createSDK(); + expect(sdk).toBeInstanceOf(ImmutableWebSDK); + sdk.shutdown(); + }); + + it('creates anonymous ID cookie when consent allows', () => { + const sdk = createSDK({ consent: 'anonymous' }); + expect(document.cookie).toContain('imtbl_anon_id='); + sdk.shutdown(); + }); + + it('does not create identity cookies at none consent', () => { + const sdk = createSDK({ consent: 'none' }); + expect(document.cookie).not.toContain('imtbl_anon_id='); + sdk.shutdown(); + }); + }); + + describe('track', () => { + it('enqueues an event and flushes', async () => { + const sdk = createSDK(); + + sdk.track('purchase', { + currency: 'USD', + value: 9.99, + itemId: 'sword_01', + }); + + await sdk.flush(); + + const msgs = sentMessages(); + const msg = msgs.find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + + expect(msg).toBeDefined(); + expect(msg.properties).toEqual({ + currency: 'USD', + value: 9.99, + itemId: 'sword_01', + }); + expect(msg.surface).toBe('web'); + expect(msg.context.library).toBe('@imtbl/audience-web-sdk'); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + expect(sentMessages()).toHaveLength(0); + sdk.shutdown(); + }); + + it('excludes userId at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.track('sign_in', { method: 'passport' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'sign_in', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + + sdk.shutdown(); + }); + }); + + describe('page', () => { + it('enqueues a page message', async () => { + const sdk = createSDK(); + + sdk.page({ section: 'shop' }); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'page'); + expect(msg).toBeDefined(); + expect(msg.properties).toMatchObject({ section: 'shop' }); + + sdk.shutdown(); + }); + + it('attaches attribution to the first page view', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=youtube', + href: 'https://studio.com/?utm_source=youtube', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK(); + sdk.page(); + sdk.page(); + await sdk.flush(); + + const pages = sentMessages().filter( + (m: any) => m.type === 'page', + ); + expect(pages[0].properties).toHaveProperty( + 'utm_source', + 'youtube', + ); + if (pages[1]) { + expect(pages[1].properties?.utm_source).toBeUndefined(); + } + + sdk.shutdown(); + }); + }); + + describe('identify', () => { + it('sends an identify message at full consent', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify('user@example.com', 'email', { + name: 'Player One', + }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'identify', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBe('user@example.com'); + expect(msg.identityType).toBe('email'); + expect(msg.traits).toEqual({ name: 'Player One' }); + + sdk.shutdown(); + }); + + it('is a no-op at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.identify('user@example.com', 'email'); + await sdk.flush(); + + const ids = sentMessages().filter( + (m: any) => m.type === 'identify', + ); + expect(ids).toHaveLength(0); + sdk.shutdown(); + }); + + it('sends anonymous identify with traits only', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify({ + source: 'steam', + steamId: '76561198012345', + }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'identify', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + expect(msg.traits).toEqual({ + source: 'steam', + steamId: '76561198012345', + }); + + sdk.shutdown(); + }); + + it('includes userId on subsequent track calls', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify('player42', 'steam'); + sdk.track('purchase', { currency: 'USD', value: 9.99 }); + await sdk.flush(); + + const trackMsg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + expect(trackMsg).toBeDefined(); + expect(trackMsg.userId).toBe('player42'); + + sdk.shutdown(); + }); + }); + + describe('alias', () => { + it('sends alias with fromId/fromType/toId/toType', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.alias( + { uid: '76561198012345', provider: 'steam' }, + { uid: 'user@example.com', provider: 'email' }, + ); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'alias', + ); + expect(msg).toBeDefined(); + expect(msg.fromId).toBe('76561198012345'); + expect(msg.fromType).toBe('steam'); + expect(msg.toId).toBe('user@example.com'); + expect(msg.toType).toBe('email'); + + sdk.shutdown(); + }); + }); + + describe('setConsent', () => { + it('starts queue when upgrading from none', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + expect(sentMessages()).toHaveLength(0); + + sdk.setConsent('anonymous'); + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + const tracks = sentMessages().filter( + (m: any) => m.type === 'track', + ); + expect(tracks.length).toBeGreaterThan(0); + + sdk.shutdown(); + }); + + it('purges identify/alias, strips userId on downgrade', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify('user@example.com', 'email'); + sdk.alias( + { uid: '76561198012345', provider: 'steam' }, + { uid: 'user@example.com', provider: 'email' }, + ); + sdk.track('purchase', { currency: 'USD', value: 9.99 }); + + sdk.setConsent('anonymous'); + await sdk.flush(); + + const msgs = sentMessages(); + expect( + msgs.every((m: any) => m.type !== 'identify'), + ).toBe(true); + expect( + msgs.every((m: any) => m.type !== 'alias'), + ).toBe(true); + const trackMsg = msgs.find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + expect(trackMsg).toBeDefined(); + expect(trackMsg.userId).toBeUndefined(); + + sdk.shutdown(); + }); + + it('alias requires full consent, not anonymous', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.alias( + { uid: '76561198012345', provider: 'steam' }, + { uid: 'user@example.com', provider: 'email' }, + ); + await sdk.flush(); + + const aliases = sentMessages().filter( + (m: any) => m.type === 'alias', + ); + expect(aliases).toHaveLength(0); + sdk.shutdown(); + }); + }); + + describe('reset', () => { + it('clears userId and generates new anonymousId', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.track('sign_in', { method: 'passport' }); + await sdk.flush(); + const originalAnonId = sentMessages().find( + (m: any) => m.type === 'track', + )?.anonymousId; + fetchCalls.length = 0; + + sdk.identify('user@example.com', 'email'); + await sdk.flush(); + fetchCalls.length = 0; + + sdk.reset(); + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + expect(msg.anonymousId).toBeDefined(); + expect(msg.anonymousId).not.toBe(originalAnonId); + + sdk.shutdown(); + }); + }); +}); diff --git a/packages/audience/web/src/sdk.ts b/packages/audience/web/src/sdk.ts new file mode 100644 index 0000000000..772886662a --- /dev/null +++ b/packages/audience/web/src/sdk.ts @@ -0,0 +1,413 @@ +import type { + ConsentLevel, + Message, + TrackMessage, + PageMessage, + IdentifyMessage, + AliasMessage, + UserTraits, +} from '@imtbl/audience-core'; +import { + INGEST_PATH, + FLUSH_INTERVAL_MS, + FLUSH_SIZE, + COOKIE_NAME, + SESSION_COOKIE, + MessageQueue, + httpTransport, + getBaseUrl, + getOrCreateAnonymousId, + getCookie, + deleteCookie, + generateId, + getTimestamp, + isAliasValid, + isTimestampValid, + truncate, +} from '@imtbl/audience-core'; +import type { WebSDKConfig } from './types'; +import { collectContext } from './context'; +import { ConsentManager } from './consent'; +import { + parseAttribution, + attributionToProperties, + type AttributionContext, +} from './attribution'; +import { + getOrCreateSessionId, + touchSession, +} from './cookie'; +import { DebugLogger } from './debug'; + +const DEFAULT_CONSENT_SOURCE = 'WebSDK'; + +export class ImmutableWebSDK { + private readonly queue: MessageQueue; + + private readonly consent: ConsentManager; + + private readonly attribution: AttributionContext; + + private readonly debug?: DebugLogger; + + private readonly cookieDomain?: string; + + private anonymousId: string; + + private sessionId: string | undefined; + + private sessionStartTime: number | undefined; + + private userId: string | undefined; + + private isFirstPage = true; + + private consentManuallySet = false; + + 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 ?? FLUSH_INTERVAL_MS; + const flushSize = config.flushSize ?? FLUSH_SIZE; + + this.cookieDomain = cookieDomain; + + if (config.debug) { + this.debug = new DebugLogger(); + } + + this.consent = new ConsentManager( + environment, + publishableKey, + consentLevel, + consentSource, + cookieDomain, + ); + + const effectiveConsent = this.consent.getLevel(); + let isNewSession = false; + if (effectiveConsent !== 'none') { + this.anonymousId = getOrCreateAnonymousId(cookieDomain); + const session = getOrCreateSessionId(cookieDomain); + this.sessionId = session.sessionId; + this.sessionStartTime = Date.now(); + isNewSession = session.isNew; + } else { + this.anonymousId = getCookie(COOKIE_NAME) ?? generateId(); + } + + const endpointUrl = `${getBaseUrl(environment)}${INGEST_PATH}`; + this.queue = new MessageQueue( + httpTransport, + endpointUrl, + publishableKey, + flushInterval, + flushSize, + { + onFlush: config.debug + ? (ok, count) => this.debug?.logFlush(ok, count) + : undefined, + staleFilter: (m) => isTimestampValid(m.eventTimestamp), + storagePrefix: '__imtbl_web_', + }, + ); + + this.attribution = parseAttribution(); + + if (effectiveConsent !== 'none') { + this.queue.start(); + + if (isNewSession) { + this.trackSessionStart(); + } + } + + this.reconcileServerConsent(); + } + + static init(config: WebSDKConfig): ImmutableWebSDK { + return new ImmutableWebSDK(config); + } + + private reconcileServerConsent(): void { + this.consent.fetchServerConsent(this.anonymousId).then((serverStatus) => { + if (!serverStatus || serverStatus === 'not_set') return; + if (this.consentManuallySet) return; + + const local = this.consent.getLevel(); + if (local === 'none' && serverStatus !== 'none') { + this.setConsent(serverStatus as ConsentLevel); + this.consentManuallySet = false; + this.debug?.logWarning( + `Restored consent from server: ${serverStatus}`, + ); + } + }).catch(() => { + // Network failure — continue with local consent + }); + } + + // --- Session lifecycle --- + + private trackSessionStart(): void { + if (!this.sessionId) return; + const message: TrackMessage = { + type: 'track', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web', + context: collectContext(), + eventName: 'session_start', + properties: { sessionId: this.sessionId }, + }; + this.queue.enqueue(message); + this.debug?.logEvent('track(session_start)', message); + } + + private trackSessionEnd(): void { + if (!this.sessionId) return; + const properties: Record = { + sessionId: this.sessionId, + }; + if (this.sessionStartTime) { + properties.duration = Math.round( + (Date.now() - this.sessionStartTime) / 1000, + ); + } + const message: TrackMessage = { + type: 'track', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web', + context: collectContext(), + eventName: 'session_end', + properties, + }; + this.queue.enqueue(message); + this.debug?.logEvent('track(session_end)', message); + } + + // --- Page tracking (manual — studios call sdk.page() on route changes) --- + + page(properties?: Record): void { + if (this.consent.getLevel() === 'none') return; + touchSession(this.cookieDomain); + + const mergedProps: Record = { ...properties }; + + 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(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, + userId: this.consent.getLevel() === 'full' + ? this.userId + : undefined, + }; + + this.queue.enqueue(message); + this.debug?.logEvent('track', message); + } + + // --- Identity --- + + identify(uid: string, provider: string, traits?: UserTraits): void; + + identify(traits: UserTraits): void; + + identify( + uidOrTraits: string | UserTraits, + provider?: string, + traits?: UserTraits, + ): void { + if (this.consent.getLevel() !== 'full') { + this.debug?.logWarning( + 'identify() requires full consent — call ignored.', + ); + return; + } + touchSession(this.cookieDomain); + + if (typeof uidOrTraits === 'object') { + const message: IdentifyMessage = { + type: 'identify', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web', + context: collectContext(), + traits: uidOrTraits, + }; + this.queue.enqueue(message); + this.debug?.logEvent('identify', message); + return; + } + + 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, + traits, + }; + + this.queue.enqueue(message); + this.debug?.logEvent('identify', message); + } + + alias( + from: { uid: string; provider: string }, + to: { uid: string; provider: string }, + ): void { + if (this.consent.getLevel() !== 'full') { + this.debug?.logWarning( + 'alias() requires full consent — call ignored.', + ); + 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, + toId: truncate(to.uid), + toType: to.provider, + }; + + this.queue.enqueue(message); + this.debug?.logEvent('alias', message); + } + + // --- 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; + 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(); + }, + }); + + if (previous === 'none' && level !== 'none') { + this.anonymousId = getOrCreateAnonymousId(this.cookieDomain); + const session = getOrCreateSessionId(this.cookieDomain); + this.sessionId = session.sessionId; + this.sessionStartTime = Date.now(); + this.queue.start(); + + if (session.isNew) { + this.trackSessionStart(); + } + } + } + + // --- Lifecycle --- + + reset(): void { + this.userId = undefined; + deleteCookie(COOKIE_NAME, this.cookieDomain); + deleteCookie(SESSION_COOKIE, this.cookieDomain); + if (this.consent.getLevel() !== 'none') { + this.anonymousId = getOrCreateAnonymousId(this.cookieDomain); + const session = getOrCreateSessionId(this.cookieDomain); + this.sessionId = session.sessionId; + this.sessionStartTime = Date.now(); + } else { + this.anonymousId = generateId(); + this.sessionId = undefined; + this.sessionStartTime = undefined; + } + this.isFirstPage = true; + } + + async flush(): Promise { + await this.queue.flush(); + } + + shutdown(): void { + this.trackSessionEnd(); + this.queue.destroy(); + } +} diff --git a/packages/audience/web/src/types.ts b/packages/audience/web/src/types.ts new file mode 100644 index 0000000000..702a474ee5 --- /dev/null +++ b/packages/audience/web/src/types.ts @@ -0,0 +1,13 @@ +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; + debug?: boolean; + cookieDomain?: string; + flushInterval?: number; + flushSize?: number; +} diff --git a/packages/audience/web/tsconfig.eslint.json b/packages/audience/web/tsconfig.eslint.json new file mode 100644 index 0000000000..7a70f2c77d --- /dev/null +++ b/packages/audience/web/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": [] +} diff --git a/packages/audience/web/tsconfig.json b/packages/audience/web/tsconfig.json new file mode 100644 index 0000000000..f5b8d5a351 --- /dev/null +++ b/packages/audience/web/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDirs": ["src"], + "customConditions": ["development"] + }, + "include": ["src"], + "exclude": ["dist", "jest.config.ts", "node_modules", "src/**/*.test.ts"] +} 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, + }), + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64014f7b3e..2705afffab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1031,6 +1031,43 @@ importers: specifier: ^5.6.2 version: 5.6.2 + packages/audience/web: + dependencies: + '@imtbl/audience-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@swc/core': + specifier: ^1.4.2 + version: 1.15.3(@swc/helpers@0.5.15) + '@swc/jest': + specifier: ^0.2.37 + version: 0.2.37(@swc/core@1.15.3(@swc/helpers@0.5.15)) + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^22.10.7 + version: 22.19.7 + eslint: + specifier: ^8.56.0 + version: 8.57.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) + jest-environment-jsdom: + specifier: ^29.4.3 + version: 29.6.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) + ts-jest: + specifier: ^29.1.0 + version: 29.2.5(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(esbuild@0.23.1)(jest@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2))(typescript@5.6.2) + tsup: + specifier: ^8.3.0 + version: 8.3.0(@swc/core@1.15.3(@swc/helpers@0.5.15))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) + typescript: + specifier: ^5.6.2 + version: 5.6.2 + packages/auth: dependencies: '@imtbl/generated-clients': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8043dcd6aa..3683c47ff8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,6 +25,7 @@ packages: - "packages/checkout/widgets-lib" - "packages/blockchain-data/sdk" - "packages/audience/core" + - "packages/audience/web" - "packages/game-bridge" - "packages/webhook/sdk" - "packages/minting-backend/sdk"