Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/audience/web/src/attribution.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const props: Record<string, string> = {};
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;
}
24 changes: 24 additions & 0 deletions packages/audience/web/src/cdn.ts
Original file line number Diff line number Diff line change
@@ -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';
6 changes: 6 additions & 0 deletions packages/audience/web/src/config.ts
Original file line number Diff line number Diff line change
@@ -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__';
141 changes: 141 additions & 0 deletions packages/audience/web/src/consent.ts
Original file line number Diff line number Diff line change
@@ -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<ConsentStatus | undefined> {
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<void> {
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
}
}
}
23 changes: 23 additions & 0 deletions packages/audience/web/src/context.ts
Original file line number Diff line number Diff line change
@@ -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;
}
84 changes: 84 additions & 0 deletions packages/audience/web/src/cookie.ts
Original file line number Diff line number Diff line change
@@ -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);
}
24 changes: 24 additions & 0 deletions packages/audience/web/src/debug.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
Loading
Loading