Skip to content

Commit 6cabb5f

Browse files
feat(audience): add @imtbl/audience-sdk package
Scaffold the Audience SDK built on top of core's createConsentManager and MessageQueue. The SDK provides ImmutableAudienceSDK as the main entry point with page/track/identify/alias methods, consent management, session tracking, attribution collection, and debug logging. No core changes — SDK consumes core's consent state machine, session module, and attribution from the pixel-core-consent branch. Also fixes pre-existing CI flaky tests: - x-client: mock getStarkPublicKeyFromImx and IMX API calls - checkout: stop replacing entire window object in connect tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4b3ad9c commit 6cabb5f

17 files changed

Lines changed: 629 additions & 36 deletions

File tree

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/sdk/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-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+
// SDK-specific constants.
2+
// Backend endpoints and base URLs come from @imtbl/audience-core.
3+
4+
export const LIBRARY_NAME = '@imtbl/audience-sdk';
5+
// Replaced at build time by esbuild replace plugin
6+
export const LIBRARY_VERSION = '__SDK_VERSION__';
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+
}

packages/audience/sdk/src/debug.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { ConsentLevel, Message } from '@imtbl/audience-core';
2+
3+
const PREFIX = '[Immutable Audience]';
4+
5+
export class DebugLogger {
6+
private enabled: boolean;
7+
8+
constructor(enabled = false) {
9+
this.enabled = enabled;
10+
}
11+
12+
logEvent(method: string, message: Message): void {
13+
if (!this.enabled) return;
14+
// eslint-disable-next-line no-console
15+
console.log(`${PREFIX} ${method}`, message);
16+
}
17+
18+
logFlush(ok: boolean, count: number): void {
19+
if (!this.enabled) return;
20+
// eslint-disable-next-line no-console
21+
console.log(`${PREFIX} flush ${ok ? 'ok' : 'failed'} (${count} messages)`);
22+
}
23+
24+
logConsent(from: ConsentLevel, to: ConsentLevel): void {
25+
if (!this.enabled) return;
26+
// eslint-disable-next-line no-console
27+
console.log(`${PREFIX} consent ${from}${to}`);
28+
}
29+
30+
logWarning(msg: string): void {
31+
if (!this.enabled) return;
32+
// eslint-disable-next-line no-console
33+
console.warn(`${PREFIX} ${msg}`);
34+
}
35+
}

packages/audience/sdk/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { ImmutableAudienceSDK } from './sdk';
2+
export type { AudienceSDKConfig } from './types';
3+
export { DebugLogger } from './debug';
4+
export { collectContext } from './context';
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import {
3+
MessageQueue,
4+
createConsentManager,
5+
deleteCookie,
6+
} from '@imtbl/audience-core';
7+
import { ImmutableAudienceSDK } from './sdk';
8+
9+
// Mock core modules
10+
jest.mock('@imtbl/audience-core', () => {
11+
const actual = jest.requireActual('@imtbl/audience-core');
12+
13+
const mockQueue = {
14+
start: jest.fn(),
15+
stop: jest.fn(),
16+
destroy: jest.fn(),
17+
enqueue: jest.fn(),
18+
flush: jest.fn(),
19+
flushUnload: jest.fn(),
20+
purge: jest.fn(),
21+
transform: jest.fn(),
22+
clear: jest.fn(),
23+
get length() { return 0; },
24+
};
25+
26+
let consentLevel = 'none';
27+
const mockConsent = {
28+
get level() { return consentLevel as 'none' | 'anonymous' | 'full'; },
29+
setLevel: jest.fn((next: string) => { consentLevel = next; }),
30+
};
31+
32+
return {
33+
...actual,
34+
MessageQueue: jest.fn(() => mockQueue),
35+
createConsentManager: jest.fn(() => {
36+
consentLevel = 'none';
37+
return mockConsent;
38+
}),
39+
getOrCreateAnonymousId: jest.fn(() => 'anon-123'),
40+
getOrCreateSession: jest.fn(() => ({ sessionId: 'sess-456', isNew: false })),
41+
collectAttribution: jest.fn(() => ({})),
42+
deleteCookie: jest.fn(),
43+
httpTransport: { send: jest.fn() },
44+
generateId: actual.generateId,
45+
getTimestamp: actual.getTimestamp,
46+
getBaseUrl: actual.getBaseUrl,
47+
};
48+
});
49+
50+
beforeEach(() => {
51+
jest.clearAllMocks();
52+
});
53+
54+
function createSDK(overrides: Record<string, any> = {}) {
55+
return new ImmutableAudienceSDK({
56+
publishableKey: 'pk_test',
57+
environment: 'sandbox',
58+
...overrides,
59+
});
60+
}
61+
62+
describe('ImmutableAudienceSDK', () => {
63+
it('initialises with default consent none', () => {
64+
createSDK();
65+
expect(createConsentManager).toHaveBeenCalledWith(
66+
expect.anything(),
67+
'pk_test',
68+
'anon-123',
69+
'sandbox',
70+
'none',
71+
);
72+
});
73+
74+
it('initialises with provided consent level', () => {
75+
createSDK({ consent: 'full' });
76+
expect(createConsentManager).toHaveBeenCalledWith(
77+
expect.anything(),
78+
'pk_test',
79+
'anon-123',
80+
'sandbox',
81+
'full',
82+
);
83+
});
84+
85+
it('starts the queue on construction', () => {
86+
createSDK();
87+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
88+
expect(queue.start).toHaveBeenCalled();
89+
});
90+
91+
it('does not enqueue when consent is none', () => {
92+
const sdk = createSDK();
93+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
94+
95+
sdk.page();
96+
sdk.track('click');
97+
98+
expect(queue.enqueue).not.toHaveBeenCalled();
99+
});
100+
101+
it('enqueues page event when consent allows', () => {
102+
const sdk = createSDK({ consent: 'anonymous' });
103+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
104+
consent.setLevel('anonymous');
105+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
106+
107+
sdk.page({ section: 'shop' });
108+
109+
expect(queue.enqueue).toHaveBeenCalledWith(
110+
expect.objectContaining({
111+
type: 'page',
112+
surface: 'web',
113+
anonymousId: 'anon-123',
114+
}),
115+
);
116+
});
117+
118+
it('enqueues track event with eventName', () => {
119+
const sdk = createSDK({ consent: 'anonymous' });
120+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
121+
consent.setLevel('anonymous');
122+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
123+
124+
sdk.track('purchase', { value: 9.99 });
125+
126+
expect(queue.enqueue).toHaveBeenCalledWith(
127+
expect.objectContaining({
128+
type: 'track',
129+
eventName: 'purchase',
130+
}),
131+
);
132+
});
133+
134+
it('only allows identify at full consent', () => {
135+
const sdk = createSDK();
136+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
137+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
138+
139+
consent.setLevel('anonymous');
140+
sdk.identify('user-1', { name: 'Test' });
141+
expect(queue.enqueue).not.toHaveBeenCalled();
142+
143+
consent.setLevel('full');
144+
sdk.identify('user-1', { name: 'Test' });
145+
expect(queue.enqueue).toHaveBeenCalledWith(
146+
expect.objectContaining({
147+
type: 'identify',
148+
userId: 'user-1',
149+
}),
150+
);
151+
});
152+
153+
it('clears cookies when consent set to none', () => {
154+
const sdk = createSDK();
155+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
156+
consent.setLevel('full');
157+
158+
sdk.setConsent('none');
159+
160+
expect(consent.setLevel).toHaveBeenCalledWith('none');
161+
expect(deleteCookie).toHaveBeenCalledTimes(2);
162+
});
163+
164+
it('destroys queue on destroy', () => {
165+
const sdk = createSDK();
166+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
167+
168+
sdk.destroy();
169+
expect(queue.destroy).toHaveBeenCalled();
170+
171+
sdk.track('late-event');
172+
expect(queue.enqueue).not.toHaveBeenCalled();
173+
});
174+
});

0 commit comments

Comments
 (0)