Skip to content

Commit 312c6c2

Browse files
feat(audience): add ImmutableWebSDK with consent, attribution, and session lifecycle
Adds @imtbl/audience-web-sdk at packages/audience/web/ — the explicit, typed tracking surface for game studios on web. Architecture (core → sdk → web): - ConsentManager + DebugLogger imported from @imtbl/audience-sdk - WebSDKConfig extends AudienceSDKConfig (adds consentSource) - Web provides fetch-based ConsentTransport and cookie-clearing callbacks - Deleted sdk/context.ts wrapper — calls core collectContext directly API: init, track, page, identify, alias, setConsent, reset, flush, shutdown Design: - Attribution stored in wire format (snake_case) from parse to send. TRACKED_PARAMS constant is the single source of truth. - Message factory (baseMessage + enqueue) eliminates repeated boilerplate. - Event names (SESSION_START, SESSION_END) are named constants. - All public interfaces have JSDoc on every field. - identify() type guard rejects null and arrays, truncates once. - Helpers: isTrackingDisabled(), effectiveUserId(), startSession(), renewSession() — each method reads as intent, not mechanics. - Multi-instance warning on double init without prior shutdown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3a66130 commit 312c6c2

21 files changed

Lines changed: 1750 additions & 216 deletions

packages/audience/sdk/src/context.test.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

packages/audience/sdk/src/context.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

packages/audience/sdk/src/debug.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Message } from '@imtbl/audience-core';
2-
import { DebugLogger } from './debug';
2+
import { DebugLogger, LOG_PREFIX } from './debug';
33

44
describe('DebugLogger', () => {
55
let logSpy: jest.SpyInstance;
@@ -22,7 +22,7 @@ describe('DebugLogger', () => {
2222
anonymousId: 'anon-1',
2323
surface: 'web',
2424
context: { library: 'test', libraryVersion: '0.0.0' },
25-
event: 'click',
25+
eventName: 'click',
2626
properties: {},
2727
};
2828

@@ -42,7 +42,7 @@ describe('DebugLogger', () => {
4242
logger.logEvent('track', stubMessage);
4343

4444
expect(logSpy).toHaveBeenCalledWith(
45-
'[Immutable Audience] track',
45+
`${LOG_PREFIX} track`,
4646
stubMessage,
4747
);
4848
});
@@ -52,12 +52,12 @@ describe('DebugLogger', () => {
5252

5353
logger.logFlush(true, 5);
5454
expect(logSpy).toHaveBeenCalledWith(
55-
'[Immutable Audience] flush ok (5 messages)',
55+
`${LOG_PREFIX} flush ok (5 messages)`,
5656
);
5757

5858
logger.logFlush(false, 3);
5959
expect(logSpy).toHaveBeenCalledWith(
60-
'[Immutable Audience] flush failed (3 messages)',
60+
`${LOG_PREFIX} flush failed (3 messages)`,
6161
);
6262
});
6363

@@ -66,7 +66,7 @@ describe('DebugLogger', () => {
6666
logger.logConsent('none', 'full');
6767

6868
expect(logSpy).toHaveBeenCalledWith(
69-
'[Immutable Audience] consent none full',
69+
`${LOG_PREFIX} consent none \u2192 full`,
7070
);
7171
});
7272

@@ -75,7 +75,7 @@ describe('DebugLogger', () => {
7575
logger.logWarning('something went wrong');
7676

7777
expect(warnSpy).toHaveBeenCalledWith(
78-
'[Immutable Audience] something went wrong',
78+
`${LOG_PREFIX} something went wrong`,
7979
);
8080
});
8181
});

packages/audience/sdk/src/debug.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ConsentLevel, Message } from '@imtbl/audience-core';
22

3-
const PREFIX = '[Immutable Audience]';
3+
export const LOG_PREFIX = '[audience-sdk]';
44

55
export class DebugLogger {
66
private enabled: boolean;
@@ -12,24 +12,24 @@ export class DebugLogger {
1212
logEvent(method: string, message: Message): void {
1313
if (!this.enabled) return;
1414
// eslint-disable-next-line no-console
15-
console.log(`${PREFIX} ${method}`, message);
15+
console.log(`${LOG_PREFIX} ${method}`, message);
1616
}
1717

1818
logFlush(ok: boolean, count: number): void {
1919
if (!this.enabled) return;
2020
// eslint-disable-next-line no-console
21-
console.log(`${PREFIX} flush ${ok ? 'ok' : 'failed'} (${count} messages)`);
21+
console.log(`${LOG_PREFIX} flush ${ok ? 'ok' : 'failed'} (${count} messages)`);
2222
}
2323

2424
logConsent(from: ConsentLevel, to: ConsentLevel): void {
2525
if (!this.enabled) return;
2626
// eslint-disable-next-line no-console
27-
console.log(`${PREFIX} consent ${from}${to}`);
27+
console.log(`${LOG_PREFIX} consent ${from}${to}`);
2828
}
2929

3030
logWarning(msg: string): void {
3131
if (!this.enabled) return;
3232
// eslint-disable-next-line no-console
33-
console.warn(`${PREFIX} ${msg}`);
33+
console.warn(`${LOG_PREFIX} ${msg}`);
3434
}
3535
}
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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
'^@imtbl/audience-sdk$': '<rootDir>/../sdk/src/index.ts',
13+
},
14+
};
15+
16+
export default config;

packages/audience/web/package.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
"@imtbl/audience-sdk": "workspace:*"
10+
},
11+
"devDependencies": {
12+
"@swc/core": "^1.4.2",
13+
"@swc/jest": "^0.2.37",
14+
"@types/jest": "^29.5.12",
15+
"@types/node": "^22.10.7",
16+
"eslint": "^8.56.0",
17+
"jest": "^29.7.0",
18+
"jest-environment-jsdom": "^29.4.3",
19+
"ts-jest": "^29.1.0",
20+
"tsup": "^8.3.0",
21+
"typescript": "^5.6.2"
22+
},
23+
"engines": {
24+
"node": ">=20.11.0"
25+
},
26+
"exports": {
27+
"development": {
28+
"types": "./src/index.ts",
29+
"browser": "./dist/browser/index.js",
30+
"require": "./dist/node/index.cjs",
31+
"default": "./dist/node/index.js"
32+
},
33+
"default": {
34+
"types": "./dist/types/index.d.ts",
35+
"browser": "./dist/browser/index.js",
36+
"require": "./dist/node/index.cjs",
37+
"default": "./dist/node/index.js"
38+
}
39+
},
40+
"files": ["dist"],
41+
"homepage": "https://github.com/immutable/ts-immutable-sdk#readme",
42+
"main": "dist/node/index.cjs",
43+
"module": "dist/node/index.js",
44+
"browser": "dist/browser/index.js",
45+
"publishConfig": {
46+
"access": "public"
47+
},
48+
"repository": "immutable/ts-immutable-sdk.git",
49+
"scripts": {
50+
"build": "pnpm transpile && pnpm typegen",
51+
"transpile": "tsup src/index.ts --config ../../../tsup.config.js",
52+
"transpile:cdn": "tsup --config tsup.cdn.js",
53+
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
54+
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
55+
"test": "jest --passWithNoTests",
56+
"test:watch": "jest --watch",
57+
"demo": "pnpm build && npx serve -l 3456 --cors ..",
58+
"typecheck": "tsc --customConditions development --noEmit --jsx preserve"
59+
},
60+
"type": "module",
61+
"types": "./dist/types/index.d.ts"
62+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { parseAttribution } from './attribution';
2+
3+
const originalLocation = window.location;
4+
5+
beforeEach(() => {
6+
sessionStorage.clear();
7+
});
8+
9+
afterEach(() => {
10+
Object.defineProperty(window, 'location', {
11+
value: originalLocation,
12+
writable: true,
13+
configurable: true,
14+
});
15+
Object.defineProperty(document, 'referrer', {
16+
value: '',
17+
configurable: true,
18+
});
19+
});
20+
21+
describe('parseAttribution', () => {
22+
it('parses UTM params from the URL', () => {
23+
Object.defineProperty(window, 'location', {
24+
value: {
25+
search: '?utm_source=youtube&utm_medium=influencer&utm_campaign=launch',
26+
href: 'https://studio.com/shop?utm_source=youtube&utm_medium=influencer&utm_campaign=launch',
27+
},
28+
writable: true,
29+
configurable: true,
30+
});
31+
32+
const ctx = parseAttribution();
33+
expect(ctx.utm_source).toBe('youtube');
34+
expect(ctx.utm_medium).toBe('influencer');
35+
expect(ctx.utm_campaign).toBe('launch');
36+
expect(ctx.landing_page).toBe(window.location.href);
37+
});
38+
39+
it('parses click IDs from the URL', () => {
40+
Object.defineProperty(window, 'location', {
41+
value: {
42+
search: '?gclid=abc&fbclid=def&ttclid=ghi&msclkid=jkl&dclid=mno&li_fat_id=pqr',
43+
href: 'https://studio.com/?gclid=abc&fbclid=def&ttclid=ghi&msclkid=jkl&dclid=mno&li_fat_id=pqr',
44+
},
45+
writable: true,
46+
configurable: true,
47+
});
48+
49+
const ctx = parseAttribution();
50+
expect(ctx.gclid).toBe('abc');
51+
expect(ctx.fbclid).toBe('def');
52+
expect(ctx.ttclid).toBe('ghi');
53+
expect(ctx.msclkid).toBe('jkl');
54+
expect(ctx.dclid).toBe('mno');
55+
expect(ctx.li_fat_id).toBe('pqr');
56+
});
57+
58+
it('parses ref param as referral_code', () => {
59+
Object.defineProperty(window, 'location', {
60+
value: {
61+
search: '?ref=creator_handle',
62+
href: 'https://studio.com/?ref=creator_handle',
63+
},
64+
writable: true,
65+
configurable: true,
66+
});
67+
68+
const ctx = parseAttribution();
69+
expect(ctx.referral_code).toBe('creator_handle');
70+
});
71+
72+
it('returns cached attribution on subsequent calls within session', () => {
73+
Object.defineProperty(window, 'location', {
74+
value: {
75+
search: '?utm_source=first',
76+
href: 'https://studio.com/?utm_source=first',
77+
},
78+
writable: true,
79+
configurable: true,
80+
});
81+
82+
const first = parseAttribution();
83+
expect(first.utm_source).toBe('first');
84+
85+
// Change URL (simulating SPA navigation)
86+
Object.defineProperty(window, 'location', {
87+
value: {
88+
search: '',
89+
href: 'https://studio.com/shop',
90+
},
91+
writable: true,
92+
configurable: true,
93+
});
94+
95+
const second = parseAttribution();
96+
expect(second.utm_source).toBe('first'); // Still the original
97+
});
98+
99+
it('captures document.referrer', () => {
100+
Object.defineProperty(document, 'referrer', {
101+
value: 'https://google.com/search?q=immutable',
102+
configurable: true,
103+
});
104+
Object.defineProperty(window, 'location', {
105+
value: {
106+
search: '',
107+
href: 'https://studio.com/',
108+
},
109+
writable: true,
110+
configurable: true,
111+
});
112+
113+
const ctx = parseAttribution();
114+
expect(ctx.referrer).toBe('https://google.com/search?q=immutable');
115+
});
116+
117+
it('only includes params that are present', () => {
118+
Object.defineProperty(window, 'location', {
119+
value: {
120+
search: '',
121+
href: 'https://studio.com/',
122+
},
123+
writable: true,
124+
configurable: true,
125+
});
126+
127+
const ctx = parseAttribution();
128+
expect(ctx.utm_source).toBeUndefined();
129+
expect(ctx.gclid).toBeUndefined();
130+
expect(ctx.landing_page).toBe('https://studio.com/');
131+
});
132+
});

0 commit comments

Comments
 (0)