Skip to content

Commit d8ac882

Browse files
feat(audience): add CDN bundle, demo page, and README
- CDN entry point (cdn.ts) exposes ImmutableAudienceSDK on window - tsup CDN config builds self-contained IIFE with inlined core - Demo page for end-to-end testing against dev/sandbox backend - README with install, API reference, consent, cookies, CDN usage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 470f455 commit d8ac882

5 files changed

Lines changed: 418 additions & 0 deletions

File tree

packages/audience/sdk/README.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# @imtbl/audience-sdk
2+
3+
Consent-aware event tracking and identity management for web surfaces. Part of the Immutable Audience platform.
4+
5+
## Install
6+
7+
```bash
8+
npm install @imtbl/audience-sdk
9+
```
10+
11+
## Quick Start
12+
13+
```typescript
14+
import { ImmutableAudienceSDK } from '@imtbl/audience-sdk';
15+
16+
const sdk = new ImmutableAudienceSDK({
17+
publishableKey: 'pk_imtbl_...',
18+
environment: 'production',
19+
consent: 'anonymous',
20+
});
21+
22+
sdk.track('purchase', { currency: 'USD', value: 9.99, itemId: 'sword_01' });
23+
sdk.page();
24+
```
25+
26+
## Initialisation
27+
28+
```typescript
29+
const sdk = new ImmutableAudienceSDK({
30+
publishableKey: 'pk_imtbl_...', // Required — from Immutable Hub
31+
environment: 'production', // 'dev' | 'sandbox' | 'production'
32+
consent: 'none', // 'none' | 'anonymous' | 'full' (default: 'none')
33+
debug: false, // Log all events to console (default: false)
34+
cookieDomain: '.studio.com', // Cross-subdomain cookie sharing (optional)
35+
flushInterval: 5000, // Queue flush interval in ms (default: 5000)
36+
flushSize: 20, // Queue flush size threshold (default: 20)
37+
});
38+
```
39+
40+
## Consent
41+
42+
The SDK defaults to `none` — no events are collected until consent is explicitly set.
43+
44+
```typescript
45+
sdk.setConsent('anonymous'); // Anonymous tracking (no PII)
46+
sdk.setConsent('full'); // Full tracking (PII via identify)
47+
sdk.setConsent('none'); // Stop tracking, purge queue, clear cookies
48+
```
49+
50+
| Level | Behaviour |
51+
|-------|-----------|
52+
| `none` | SDK is inert. No events collected. Queue purged on downgrade. |
53+
| `anonymous` | Events collected with anonymous ID only. `identify()` calls are discarded. |
54+
| `full` | Full collection. `identify()` sends. `userId` included on events. |
55+
56+
**On downgrade to `none`:** queue purged, `imtbl_anon_id` and `_imtbl_sid` cookies cleared.
57+
**On downgrade from `full` to `anonymous`:** `userId` stripped from queued events.
58+
59+
## Auto-Tracked Events
60+
61+
| Event | When | Properties |
62+
|-------|------|------------|
63+
| `session_start` | New session detected (no active session cookie) | `sessionId` |
64+
| `session_end` | Page hidden or unloaded | `sessionId`, `duration` (seconds) |
65+
66+
## Event Tracking
67+
68+
```typescript
69+
sdk.track('sign_up', { method: 'google' });
70+
sdk.track('purchase', { currency: 'USD', value: 9.99 });
71+
```
72+
73+
## Page Tracking
74+
75+
Call `sdk.page()` on route changes. Attribution context (UTMs, click IDs, referrer, landing page) is automatically attached.
76+
77+
```typescript
78+
sdk.page();
79+
sdk.page({ section: 'shop', category: 'weapons' });
80+
```
81+
82+
## Identity
83+
84+
```typescript
85+
// Identify a known user (requires full consent)
86+
sdk.identify('user-123', { email: 'user@example.com', name: 'Player One' });
87+
88+
// Link two identities
89+
sdk.alias('steam-id-123', 'email-user@example.com', 'steam', 'email');
90+
91+
// Clear userId on logout
92+
sdk.reset();
93+
```
94+
95+
## Lifecycle
96+
97+
```typescript
98+
sdk.destroy(); // Stop queue, fire session_end, clean up listeners
99+
```
100+
101+
Events are batched and flushed every 5 seconds or when 20 messages accumulate. On page unload, remaining events are flushed via `fetch` with `keepalive: true`.
102+
103+
## CDN Usage
104+
105+
```html
106+
<script src="https://cdn.immutable.com/audience-sdk/v1/imtbl-audience.js"></script>
107+
<script>
108+
var sdk = new window.ImmutableAudienceSDK({
109+
publishableKey: 'pk_imtbl_...',
110+
environment: 'production',
111+
consent: 'anonymous',
112+
});
113+
sdk.track('signup_started');
114+
</script>
115+
```
116+
117+
## Cookies
118+
119+
All cookies are first-party, `SameSite=Lax`, `Secure` on HTTPS, shared with the pixel:
120+
121+
| Cookie | Lifetime | Purpose |
122+
|--------|----------|---------|
123+
| `imtbl_anon_id` | 2 years | Anonymous device ID |
124+
| `_imtbl_sid` | 30 min (rolling) | Session continuity |
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Audience SDK — Demo</title>
7+
<style>
8+
:root {
9+
--bg: #fafafa; --card: #ffffff; --border: #e4e4e7; --accent: #71717a;
10+
--text: #3f3f46; --text-strong: #18181b; --text-muted: #a1a1aa;
11+
--danger: #dc2626; --primary-bg: #18181b; --primary-text: #fafafa;
12+
--primary-hover: #3f3f46; --btn-bg: #f4f4f5; --btn-border: #d4d4d8;
13+
--btn-hover: #e4e4e7; --input-bg: #ffffff; --log-bg: #f4f4f5;
14+
--log-border: #e4e4e7; --log-time: #a1a1aa; --log-method: #18181b;
15+
}
16+
* { box-sizing: border-box; margin: 0; padding: 0; }
17+
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); padding: 32px 24px; max-width: 960px; margin: 0 auto; }
18+
h1 { font-size: 22px; font-weight: 600; margin-bottom: 4px; color: var(--text-strong); letter-spacing: -0.3px; }
19+
.subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 28px; }
20+
.section { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 18px; margin-bottom: 14px; }
21+
.section h2 { font-size: 11px; color: var(--accent); margin-bottom: 14px; text-transform: uppercase; letter-spacing: 1.2px; font-weight: 600; }
22+
.row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
23+
button { background: var(--btn-bg); border: 1px solid var(--btn-border); color: var(--text); padding: 8px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; transition: all 0.15s; }
24+
button:hover { background: var(--btn-hover); border-color: var(--accent); color: var(--text-strong); }
25+
button.primary { background: var(--primary-bg); border-color: var(--primary-bg); color: var(--primary-text); font-weight: 500; }
26+
button.primary:hover { background: var(--primary-hover); }
27+
button.danger { border-color: var(--danger); color: var(--danger); }
28+
button.danger:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); }
29+
input, select { background: var(--input-bg); border: 1px solid var(--btn-border); color: var(--text); padding: 8px 10px; border-radius: 6px; font-size: 13px; font-family: inherit; }
30+
input:focus, select:focus { outline: none; border-color: var(--accent); }
31+
input { width: 200px; }
32+
select { min-width: 120px; }
33+
label { font-size: 11px; color: var(--text-muted); display: block; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
34+
.field { display: flex; flex-direction: column; }
35+
#log { background: var(--log-bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; font-size: 12px; line-height: 1.7; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace; }
36+
.log-entry { border-bottom: 1px solid var(--log-border); padding: 4px 0; }
37+
.log-time { color: var(--log-time); }
38+
.log-method { color: var(--log-method); font-weight: 600; }
39+
.log-ok { color: var(--accent); }
40+
.log-err { color: var(--danger); }
41+
.log-info { color: var(--accent); }
42+
.status-bar { display: flex; gap: 16px; font-size: 12px; padding: 10px 0; }
43+
.status-bar span { color: var(--text-muted); }
44+
.status-bar strong { color: var(--text-strong); }
45+
#consent-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
46+
</style>
47+
<link rel="preconnect" href="https://fonts.googleapis.com">
48+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
49+
</head>
50+
<body>
51+
<h1>Audience SDK — Demo</h1>
52+
<p class="subtitle">End-to-end testing against dev/sandbox backend. Open DevTools Network tab to see requests.</p>
53+
54+
<div class="status-bar">
55+
<span>Environment: <strong id="env-display"></strong></span>
56+
<span>Consent: <span id="consent-badge"></span></span>
57+
<span>Anonymous ID: <strong id="anon-display"></strong></span>
58+
<span>User ID: <strong id="user-display">none</strong></span>
59+
</div>
60+
61+
<div class="section">
62+
<h2>1. Initialise</h2>
63+
<div class="row">
64+
<div class="field">
65+
<label>Publishable Key</label>
66+
<input id="pk" type="text" placeholder="pk_imtbl_..." value="">
67+
</div>
68+
<div class="field">
69+
<label>Environment</label>
70+
<select id="env">
71+
<option value="dev">dev</option>
72+
<option value="sandbox" selected>sandbox</option>
73+
<option value="production">production</option>
74+
</select>
75+
</div>
76+
<div class="field">
77+
<label>Initial Consent</label>
78+
<select id="init-consent">
79+
<option value="none">none</option>
80+
<option value="anonymous" selected>anonymous</option>
81+
<option value="full">full</option>
82+
</select>
83+
</div>
84+
</div>
85+
<div class="row">
86+
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text)">
87+
<input type="checkbox" id="debug-mode" checked> Debug mode
88+
</label>
89+
</div>
90+
<div class="row" style="margin-top:8px">
91+
<button class="primary" onclick="initSDK()">Init SDK</button>
92+
<button class="danger" onclick="destroySDK()">Destroy</button>
93+
</div>
94+
</div>
95+
96+
<div class="section">
97+
<h2>2. Consent</h2>
98+
<div class="row">
99+
<button onclick="setConsent('none')">Set: none</button>
100+
<button onclick="setConsent('anonymous')">Set: anonymous</button>
101+
<button onclick="setConsent('full')">Set: full</button>
102+
</div>
103+
</div>
104+
105+
<div class="section">
106+
<h2>3. Page Tracking</h2>
107+
<div class="row">
108+
<button onclick="firePage()">page()</button>
109+
<button onclick="firePage({section:'shop',category:'weapons'})">page({section, category})</button>
110+
</div>
111+
</div>
112+
113+
<div class="section">
114+
<h2>4. Track Events</h2>
115+
<div class="row">
116+
<button onclick="trackEvent('sign_up', {method:'google'})">SignUp</button>
117+
<button onclick="trackEvent('purchase', {currency:'USD',value:9.99,itemId:'sword_01'})">Purchase</button>
118+
<button onclick="trackEvent('wishlist_add', {gameId:'game_123'})">WishlistAdd</button>
119+
<button onclick="trackEvent('game_launch', {platform:'webgl'})">GameLaunch</button>
120+
</div>
121+
<div class="row" style="margin-top:8px">
122+
<div class="field">
123+
<label>Custom Event</label>
124+
<input id="custom-event" type="text" placeholder="event_name" value="beta_key_redeemed">
125+
</div>
126+
<button onclick="trackCustom()" style="align-self:flex-end">Track Custom</button>
127+
</div>
128+
</div>
129+
130+
<div class="section">
131+
<h2>5. Identity</h2>
132+
<div class="row">
133+
<div class="field">
134+
<label>User ID</label>
135+
<input id="uid" type="text" placeholder="user@example.com" value="user@example.com">
136+
</div>
137+
<button onclick="doIdentify()" style="align-self:flex-end" class="primary">identify()</button>
138+
</div>
139+
<div class="row" style="margin-top:8px">
140+
<button onclick="doReset()" class="danger">reset()</button>
141+
</div>
142+
</div>
143+
144+
<div class="section">
145+
<h2>Event Log</h2>
146+
<div id="log"></div>
147+
<button onclick="document.getElementById('log').innerHTML=''" style="margin-top:8px">Clear Log</button>
148+
</div>
149+
150+
<script src="../dist/cdn/imtbl-audience.js"></script>
151+
<script>
152+
var ImmutableAudienceSDK = window.ImmutableAudienceSDK;
153+
var sdk = null;
154+
var currentConsent = null;
155+
156+
function log(method, detail, type) {
157+
type = type || 'info';
158+
var el = document.getElementById('log');
159+
var time = new Date().toISOString().slice(11, 23);
160+
var cls = type === 'ok' ? 'log-ok' : type === 'err' ? 'log-err' : 'log-info';
161+
var detailStr = typeof detail === 'object' ? JSON.stringify(detail, null, 2) : detail;
162+
el.innerHTML += '<div class="log-entry"><span class="log-time">' + time + '</span> <span class="log-method">' + method + '</span> <span class="' + cls + '">' + detailStr + '</span></div>';
163+
el.scrollTop = el.scrollHeight;
164+
}
165+
166+
function updateStatus() {
167+
var anonCookie = document.cookie.match(/imtbl_anon_id=([^;]*)/);
168+
document.getElementById('anon-display').textContent = anonCookie ? anonCookie[1].slice(0, 12) + '...' : 'none';
169+
var badge = document.getElementById('consent-badge');
170+
badge.textContent = currentConsent || '—';
171+
badge.style.background = currentConsent === 'full' ? '#18181b' : currentConsent === 'anonymous' ? '#888888' : currentConsent === 'none' ? '#dc2626' : '#444444';
172+
badge.style.color = '#fff';
173+
}
174+
175+
window.initSDK = function() {
176+
if (sdk) { log('init', 'Already initialised — destroy first', 'err'); return; }
177+
var pk = document.getElementById('pk').value.trim();
178+
if (!pk) { log('init', 'Enter a publishable key', 'err'); return; }
179+
var env = document.getElementById('env').value;
180+
var consent = document.getElementById('init-consent').value;
181+
var debug = document.getElementById('debug-mode').checked;
182+
183+
sdk = new ImmutableAudienceSDK({ publishableKey: pk, environment: env, consent: consent, debug: debug });
184+
currentConsent = consent;
185+
document.getElementById('env-display').textContent = env;
186+
log('init', { environment: env, consent: consent, debug: debug }, 'ok');
187+
updateStatus();
188+
};
189+
190+
window.destroySDK = function() {
191+
if (!sdk) { log('destroy', 'Not initialised', 'err'); return; }
192+
sdk.destroy();
193+
sdk = null;
194+
document.getElementById('user-display').textContent = 'none';
195+
log('destroy', 'SDK stopped', 'ok');
196+
};
197+
198+
window.setConsent = function(level) {
199+
if (!sdk) { log('setConsent', 'Init SDK first', 'err'); return; }
200+
sdk.setConsent(level);
201+
currentConsent = level;
202+
log('setConsent', level, 'ok');
203+
updateStatus();
204+
};
205+
206+
window.firePage = function(props) {
207+
if (!sdk) { log('page', 'Init SDK first', 'err'); return; }
208+
sdk.page(props);
209+
log('page', props || '(no properties)', 'ok');
210+
};
211+
212+
window.trackEvent = function(event, properties) {
213+
if (!sdk) { log('track', 'Init SDK first', 'err'); return; }
214+
sdk.track(event, properties);
215+
log('track', { event: event, properties: properties }, 'ok');
216+
};
217+
218+
window.trackCustom = function() {
219+
var name = document.getElementById('custom-event').value.trim();
220+
if (!sdk) { log('track', 'Init SDK first', 'err'); return; }
221+
if (!name) { log('track', 'Enter an event name', 'err'); return; }
222+
sdk.track(name, { source: 'demo' });
223+
log('track', { event: name }, 'ok');
224+
};
225+
226+
window.doIdentify = function() {
227+
if (!sdk) { log('identify', 'Init SDK first', 'err'); return; }
228+
var uid = document.getElementById('uid').value.trim();
229+
if (!uid) { log('identify', 'Enter a user ID', 'err'); return; }
230+
sdk.identify(uid, { name: 'Demo User' });
231+
document.getElementById('user-display').textContent = uid;
232+
log('identify', { userId: uid }, 'ok');
233+
};
234+
235+
window.doReset = function() {
236+
if (!sdk) { log('reset', 'Init SDK first', 'err'); return; }
237+
sdk.reset();
238+
document.getElementById('user-display').textContent = 'none';
239+
log('reset', 'userId cleared', 'ok');
240+
updateStatus();
241+
};
242+
243+
updateStatus();
244+
</script>
245+
</body>
246+
</html>

packages/audience/sdk/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@
4848
"scripts": {
4949
"build": "pnpm transpile && pnpm typegen",
5050
"transpile": "tsup src/index.ts --config ../../../tsup.config.js",
51+
"transpile:cdn": "tsup --config tsup.cdn.js",
5152
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
5253
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
5354
"test": "jest --passWithNoTests",
5455
"test:watch": "jest --watch",
56+
"demo": "pnpm build && pnpm transpile:cdn && npx serve -l 3456 --cors .",
5557
"typecheck": "tsc --customConditions development --noEmit --jsx preserve"
5658
},
5759
"type": "module",

packages/audience/sdk/src/cdn.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Audience SDK CDN entry point — self-contained IIFE bundle.
3+
* Assigns ImmutableAudienceSDK to window for script-tag usage.
4+
*/
5+
import { ImmutableAudienceSDK } from './index';
6+
7+
if (typeof window !== 'undefined') {
8+
(window as any).ImmutableAudienceSDK = ImmutableAudienceSDK;
9+
}
10+
11+
export { ImmutableAudienceSDK };
12+
export type {
13+
AudienceSDKConfig,
14+
} from './index';

0 commit comments

Comments
 (0)