Skip to content

Commit 7ecb14c

Browse files
feat(audience): scaffold @imtbl/audience-web-sdk with consent and cookies
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bad21ca commit 7ecb14c

16 files changed

Lines changed: 463 additions & 0 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = {
2+
extends: ['../../../.eslintrc'],
3+
parserOptions: {
4+
project: './tsconfig.eslint.json',
5+
tsconfigRootDir: __dirname,
6+
},
7+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Config } from 'jest';
2+
3+
const config: Config = {
4+
roots: ['<rootDir>/src'],
5+
moduleDirectories: ['node_modules', 'src'],
6+
testEnvironment: 'jsdom',
7+
transform: {
8+
'^.+\\.(t|j)sx?$': '@swc/jest',
9+
},
10+
moduleNameMapper: {
11+
'^@imtbl/audience-core$': '<rootDir>/../core/src/index.ts',
12+
},
13+
};
14+
15+
export default config;

packages/audience/web/package.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"name": "@imtbl/audience-web-sdk",
3+
"description": "Immutable Audience Web SDK — consent-aware event tracking and identity management",
4+
"version": "0.0.0",
5+
"author": "Immutable",
6+
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
7+
"dependencies": {
8+
"@imtbl/audience-core": "workspace:*"
9+
},
10+
"devDependencies": {
11+
"@swc/core": "^1.4.2",
12+
"@swc/jest": "^0.2.37",
13+
"@types/jest": "^29.5.12",
14+
"@types/node": "^22.10.7",
15+
"eslint": "^8.56.0",
16+
"jest": "^29.7.0",
17+
"jest-environment-jsdom": "^29.4.3",
18+
"ts-jest": "^29.1.0",
19+
"tsup": "^8.3.0",
20+
"typescript": "^5.6.2"
21+
},
22+
"engines": {
23+
"node": ">=20.11.0"
24+
},
25+
"exports": {
26+
"development": {
27+
"types": "./src/index.ts",
28+
"browser": "./dist/browser/index.js",
29+
"require": "./dist/node/index.cjs",
30+
"default": "./dist/node/index.js"
31+
},
32+
"default": {
33+
"types": "./dist/types/index.d.ts",
34+
"browser": "./dist/browser/index.js",
35+
"require": "./dist/node/index.cjs",
36+
"default": "./dist/node/index.js"
37+
}
38+
},
39+
"files": ["dist"],
40+
"homepage": "https://github.com/immutable/ts-immutable-sdk#readme",
41+
"main": "dist/node/index.cjs",
42+
"module": "dist/node/index.js",
43+
"browser": "dist/browser/index.js",
44+
"publishConfig": {
45+
"access": "public"
46+
},
47+
"repository": "immutable/ts-immutable-sdk.git",
48+
"scripts": {
49+
"build": "pnpm transpile && pnpm typegen",
50+
"transpile": "tsup src/index.ts --config ../../../tsup.config.js",
51+
"transpile:cdn": "tsup --config tsup.cdn.js",
52+
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
53+
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
54+
"test": "jest --passWithNoTests",
55+
"test:watch": "jest --watch",
56+
"demo": "pnpm build && npx serve -l 3456 --cors ..",
57+
"typecheck": "tsc --customConditions development --noEmit --jsx preserve"
58+
},
59+
"type": "module",
60+
"types": "./dist/types/index.d.ts"
61+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Web SDK-specific constants.
2+
// Backend endpoints and base URLs come from @imtbl/audience-core.
3+
4+
export const LIBRARY_NAME = '@imtbl/audience-web-sdk';
5+
// Replaced at build time by esbuild replace plugin
6+
export const LIBRARY_VERSION = '__SDK_VERSION__';
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { ConsentManager } from './consent';
2+
3+
// Mock fetch for server sync
4+
const mockFetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
5+
global.fetch = mockFetch;
6+
7+
beforeEach(() => {
8+
jest.clearAllMocks();
9+
});
10+
11+
describe('ConsentManager', () => {
12+
it('initialises with the provided consent level', () => {
13+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
14+
expect(manager.getLevel()).toBe('anonymous');
15+
});
16+
17+
it('calls onPurgeQueue when downgrading to none', () => {
18+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
19+
const onPurge = jest.fn();
20+
const onClear = jest.fn();
21+
22+
manager.setLevel('none', 'anon-123', {
23+
onPurgeQueue: onPurge,
24+
onClearCookies: onClear,
25+
});
26+
27+
expect(onPurge).toHaveBeenCalled();
28+
expect(onClear).toHaveBeenCalled();
29+
expect(manager.getLevel()).toBe('none');
30+
});
31+
32+
it('calls onStripIdentity when downgrading from full to anonymous', () => {
33+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
34+
const onStrip = jest.fn();
35+
36+
manager.setLevel('anonymous', 'anon-123', { onStripIdentity: onStrip });
37+
38+
expect(onStrip).toHaveBeenCalled();
39+
expect(manager.getLevel()).toBe('anonymous');
40+
});
41+
42+
it('syncs consent to server via PUT on setLevel', () => {
43+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
44+
manager.setLevel('full', 'anon-123');
45+
46+
expect(mockFetch).toHaveBeenCalledWith(
47+
'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
48+
expect.objectContaining({
49+
method: 'PUT',
50+
body: JSON.stringify({
51+
anonymousId: 'anon-123',
52+
status: 'full',
53+
source: 'TestSDK',
54+
}),
55+
}),
56+
);
57+
});
58+
59+
it('fetches server consent status', async () => {
60+
mockFetch.mockResolvedValueOnce({
61+
ok: true,
62+
json: async () => ({ status: 'anonymous' }),
63+
});
64+
65+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
66+
const status = await manager.fetchServerConsent('anon-123');
67+
68+
expect(status).toBe('anonymous');
69+
expect(mockFetch).toHaveBeenCalledWith(
70+
'https://api.sandbox.immutable.com/v1/audience/tracking-consent?anonymousId=anon-123',
71+
expect.objectContaining({
72+
headers: { 'x-immutable-publishable-key': 'pk_test' },
73+
}),
74+
);
75+
});
76+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type {
2+
ConsentLevel,
3+
ConsentStatus,
4+
Environment,
5+
} from '@imtbl/audience-core';
6+
import {
7+
CONSENT_PATH,
8+
COOKIE_NAME,
9+
SESSION_COOKIE,
10+
getBaseUrl,
11+
deleteCookie,
12+
truncateSource,
13+
} from '@imtbl/audience-core';
14+
15+
export interface ConsentCallbacks {
16+
onPurgeQueue?: () => void;
17+
onStripIdentity?: () => void;
18+
onClearCookies?: () => void;
19+
}
20+
21+
export class ConsentManager {
22+
private level: ConsentLevel;
23+
24+
private readonly baseUrl: string;
25+
26+
private readonly publishableKey: string;
27+
28+
private readonly source: string;
29+
30+
private readonly cookieDomain?: string;
31+
32+
constructor(
33+
environment: Environment,
34+
publishableKey: string,
35+
initialConsent: ConsentLevel,
36+
rawSource: string,
37+
cookieDomain?: string,
38+
) {
39+
this.baseUrl = getBaseUrl(environment);
40+
this.publishableKey = publishableKey;
41+
this.source = truncateSource(rawSource);
42+
this.cookieDomain = cookieDomain;
43+
this.level = initialConsent;
44+
}
45+
46+
getLevel(): ConsentLevel {
47+
return this.level;
48+
}
49+
50+
setLevel(
51+
level: ConsentLevel,
52+
anonymousId: string,
53+
callbacks?: ConsentCallbacks,
54+
): void {
55+
const { level: previous } = this;
56+
this.level = level;
57+
58+
// Downgrade: full/anonymous -> none — purge everything
59+
if (level === 'none') {
60+
callbacks?.onPurgeQueue?.();
61+
callbacks?.onClearCookies?.();
62+
} else if (level === 'anonymous' && previous === 'full') {
63+
// Downgrade: full -> anonymous — strip PII, keep anonymous events
64+
callbacks?.onStripIdentity?.();
65+
}
66+
67+
// Sync to server (fire-and-forget)
68+
this.syncToServer(anonymousId, level);
69+
}
70+
71+
/** Fetch server-side consent status for reconciliation. */
72+
async fetchServerConsent(anonymousId: string): Promise<ConsentStatus | undefined> {
73+
try {
74+
const url = `${this.baseUrl}${CONSENT_PATH}?anonymousId=${encodeURIComponent(anonymousId)}`;
75+
const res = await fetch(url, {
76+
headers: { 'x-immutable-publishable-key': this.publishableKey },
77+
});
78+
if (!res.ok) return undefined;
79+
const body = (await res.json()) as { status: ConsentStatus };
80+
return body.status;
81+
} catch {
82+
return undefined;
83+
}
84+
}
85+
86+
clearCookies(): void {
87+
deleteCookie(COOKIE_NAME, this.cookieDomain);
88+
deleteCookie(SESSION_COOKIE, this.cookieDomain);
89+
}
90+
91+
private async syncToServer(anonymousId: string, status: ConsentLevel): Promise<void> {
92+
try {
93+
await fetch(`${this.baseUrl}${CONSENT_PATH}`, {
94+
method: 'PUT',
95+
headers: {
96+
'Content-Type': 'application/json',
97+
'x-immutable-publishable-key': this.publishableKey,
98+
},
99+
body: JSON.stringify({ anonymousId, status, source: this.source }),
100+
});
101+
} catch {
102+
// Fire-and-forget — consent sync failure shouldn't break the SDK
103+
}
104+
}
105+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { EventContext } from '@imtbl/audience-core';
2+
import { collectContext as coreCollectContext } from '@imtbl/audience-core';
3+
import { LIBRARY_NAME, LIBRARY_VERSION } from './config';
4+
5+
export function collectContext(): EventContext {
6+
return coreCollectContext(LIBRARY_NAME, LIBRARY_VERSION);
7+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
getOrCreateSessionId,
3+
getSessionId,
4+
touchSession,
5+
} from './cookie';
6+
7+
beforeEach(() => {
8+
document.cookie.split(';').forEach((c) => {
9+
document.cookie = `${c.trim().split('=')[0]}=; max-age=0; path=/`;
10+
});
11+
});
12+
13+
describe('getOrCreateSessionId', () => {
14+
it('creates a new session ID and reports isNew', () => {
15+
const result = getOrCreateSessionId();
16+
expect(result.sessionId).toBeDefined();
17+
expect(typeof result.sessionId).toBe('string');
18+
expect(result.isNew).toBe(true);
19+
});
20+
21+
it('returns existing session and isNew=false', () => {
22+
const first = getOrCreateSessionId();
23+
const second = getOrCreateSessionId();
24+
expect(second.sessionId).toBe(first.sessionId);
25+
expect(second.isNew).toBe(false);
26+
});
27+
});
28+
29+
describe('getSessionId', () => {
30+
it('returns undefined when no session exists', () => {
31+
expect(getSessionId()).toBeUndefined();
32+
});
33+
34+
it('returns session ID after creation', () => {
35+
const { sessionId } = getOrCreateSessionId();
36+
expect(getSessionId()).toBe(sessionId);
37+
});
38+
});
39+
40+
describe('touchSession', () => {
41+
it('does nothing if no session exists', () => {
42+
touchSession();
43+
expect(getSessionId()).toBeUndefined();
44+
});
45+
46+
it('preserves existing session cookie', () => {
47+
const { sessionId } = getOrCreateSessionId();
48+
touchSession();
49+
expect(getOrCreateSessionId().sessionId).toBe(sessionId);
50+
});
51+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {
2+
SESSION_COOKIE,
3+
getCookie,
4+
setCookie,
5+
generateId,
6+
} from '@imtbl/audience-core';
7+
8+
const SESSION_MAX_AGE = 30 * 60; // 30 minutes (rolling)
9+
10+
// --- Session ID (_imtbl_sid cookie, 30min rolling) ---
11+
12+
export interface SessionResult {
13+
sessionId: string;
14+
isNew: boolean;
15+
}
16+
17+
export function getOrCreateSessionId(domain?: string): SessionResult {
18+
const existing = getCookie(SESSION_COOKIE);
19+
const isNew = !existing;
20+
const sid = existing ?? generateId();
21+
setCookie(SESSION_COOKIE, sid, SESSION_MAX_AGE, domain);
22+
return { sessionId: sid, isNew };
23+
}
24+
25+
export function getSessionId(): string | undefined {
26+
return getCookie(SESSION_COOKIE);
27+
}
28+
29+
export function touchSession(domain?: string): void {
30+
const sid = getCookie(SESSION_COOKIE);
31+
if (sid) {
32+
setCookie(SESSION_COOKIE, sid, SESSION_MAX_AGE, domain);
33+
}
34+
}

packages/audience/web/src/debug.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/* eslint-disable no-console, class-methods-use-this */
2+
import type { Message, ConsentLevel } from '@imtbl/audience-core';
3+
4+
const PREFIX = '[Immutable Audience]';
5+
6+
export class DebugLogger {
7+
logEvent(method: string, message: Message): void {
8+
console.log(`${PREFIX} ${method}()`, message);
9+
}
10+
11+
logFlush(ok: boolean, count: number): void {
12+
console.log(
13+
`${PREFIX} flush: ${ok ? 'success' : 'failed'}, ${count} message${count !== 1 ? 's' : ''}`,
14+
);
15+
}
16+
17+
logConsent(from: ConsentLevel, to: ConsentLevel): void {
18+
console.log(`${PREFIX} consent: ${from} \u2192 ${to}`);
19+
}
20+
21+
logWarning(msg: string): void {
22+
console.warn(`${PREFIX} ${msg}`);
23+
}
24+
}

0 commit comments

Comments
 (0)