Skip to content

Commit 7c161d7

Browse files
feat(audience): fix core bugs and add capabilities for web SDK
Bug fixes: - Fix generateId fallback to UUID v4 format (backend requires UUID) - Replace sendBeacon with fetch+keepalive (sendBeacon can't set auth header) - Cap batch size at 100 messages per flush (backend maxItems limit) New capabilities: - Export getCookie/setCookie/deleteCookie; add domain param for cross-subdomain - collectContext() now accepts optional library/version params (backward compatible) - Add queue.purge(), transform(), flushUnload(), onFlush, staleFilter - Add storage prefix param for per-surface localStorage isolation - Add transport keepalive option - Add ConsentLevel, ConsentStatus, SESSION_COOKIE shared types - Add validation module (isTimestampValid, isAliasValid, truncate) - Add packages/audience/sdk to pnpm workspace Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a684a42 commit 7c161d7

14 files changed

Lines changed: 393 additions & 134 deletions

File tree

packages/audience/core/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const FLUSH_INTERVAL_MS = 5_000;
1313
export const FLUSH_SIZE = 20;
1414

1515
export const COOKIE_NAME = 'imtbl_anon_id';
16+
export const SESSION_COOKIE = '_imtbl_sid';
1617
export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years
1718

1819
export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment];

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { collectContext } from './context';
22

33
describe('collectContext', () => {
4-
it('includes library name and version', () => {
4+
it('defaults to @imtbl/audience library name', () => {
55
const ctx = collectContext();
66
expect(ctx.library).toBe('@imtbl/audience');
77
expect(ctx.libraryVersion).toBeDefined();
88
});
99

10+
it('accepts custom library name and version', () => {
11+
const ctx = collectContext('@imtbl/audience-web-sdk', '1.0.0');
12+
expect(ctx.library).toBe('@imtbl/audience-web-sdk');
13+
expect(ctx.libraryVersion).toBe('1.0.0');
14+
});
15+
1016
it('collects browser signals in jsdom', () => {
1117
const ctx = collectContext();
1218
expect(ctx.userAgent).toBeDefined();

packages/audience/core/src/context.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@ import { isBrowser } from './utils';
44
// WARNING: DO NOT CHANGE THE STRING BELOW. IT GETS REPLACED AT BUILD TIME.
55
const SDK_VERSION = '__SDK_VERSION__';
66

7-
export function collectContext(): EventContext {
7+
/**
8+
* Collect browser context for event payloads.
9+
*
10+
* Callers may pass their own library name and version when multiple surfaces
11+
* (web SDK, pixel, Unity, Unreal) share this function and each must identify
12+
* itself. Defaults to '@imtbl/audience' with the build-time SDK version.
13+
*/
14+
export function collectContext(
15+
library = '@imtbl/audience',
16+
version = SDK_VERSION,
17+
): EventContext {
818
const context: EventContext = {
9-
library: '@imtbl/audience',
10-
libraryVersion: SDK_VERSION,
19+
library,
20+
libraryVersion: version,
1121
};
1222

1323
if (!isBrowser()) return context;

packages/audience/core/src/cookie.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,35 @@
11
import { COOKIE_NAME, COOKIE_MAX_AGE_SECONDS } from './config';
22
import { isBrowser, generateId } from './utils';
33

4-
function getCookie(name: string): string | undefined {
4+
export function getCookie(name: string): string | undefined {
55
if (!isBrowser()) return undefined;
66
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
77
return match ? decodeURIComponent(match[1]) : undefined;
88
}
99

10-
function setCookie(name: string, value: string, maxAge: number): void {
10+
export function setCookie(name: string, value: string, maxAge: number, domain?: string): void {
1111
if (!isBrowser()) return;
1212
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
13-
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
13+
const domainAttr = domain ? `; domain=${domain}` : '';
14+
document.cookie = `${name}=${encodeURIComponent(value)}`
15+
+ `; path=/; max-age=${maxAge}; SameSite=Lax${domainAttr}${secure}`;
16+
}
17+
18+
export function deleteCookie(name: string, domain?: string): void {
19+
setCookie(name, '', 0, domain);
1420
}
1521

1622
/**
1723
* Returns the anonymous ID from the shared cookie, creating one if it doesn't exist.
1824
* Both the web SDK and pixel read/write the same cookie so identity stitching
1925
* works across surfaces on the same domain.
2026
*/
21-
export function getOrCreateAnonymousId(): string {
27+
export function getOrCreateAnonymousId(domain?: string): string {
2228
const existing = getCookie(COOKIE_NAME);
2329
if (existing) return existing;
2430

2531
const id = generateId();
26-
setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS);
32+
setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS, domain);
2733
return id;
2834
}
2935

packages/audience/core/src/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@ export type {
1111
AliasMessage,
1212
Message,
1313
BatchPayload,
14+
ConsentLevel,
15+
ConsentStatus,
1416
} from './types';
1517

16-
export { getOrCreateAnonymousId, getAnonymousId } from './cookie';
18+
export {
19+
getOrCreateAnonymousId,
20+
getAnonymousId,
21+
getCookie,
22+
setCookie,
23+
deleteCookie,
24+
} from './cookie';
1725
export * as storage from './storage';
1826

1927
export {
@@ -23,6 +31,7 @@ export {
2331
FLUSH_INTERVAL_MS,
2432
FLUSH_SIZE,
2533
COOKIE_NAME,
34+
SESSION_COOKIE,
2635
} from './config';
2736

2837
export { generateId, getTimestamp, isBrowser } from './utils';
@@ -31,3 +40,9 @@ export type { Transport } from './transport';
3140
export { httpTransport, httpSend } from './transport';
3241
export { MessageQueue } from './queue';
3342
export { collectContext } from './context';
43+
export {
44+
isTimestampValid,
45+
isAliasValid,
46+
truncate,
47+
truncateSource,
48+
} from './validation';

packages/audience/core/src/queue.test.ts

Lines changed: 82 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,24 @@ function makeMessage(id: string): Message {
1515
};
1616
}
1717

18+
interface QueueOpts {
19+
flushIntervalMs?: number;
20+
flushSize?: number;
21+
onFlush?: (ok: boolean, count: number) => void;
22+
staleFilter?: (msg: Message) => boolean;
23+
}
24+
1825
function createQueue(
1926
transport: Transport,
20-
opts: { flushIntervalMs?: number; flushSize?: number } = {},
27+
opts: QueueOpts = {},
2128
) {
2229
return new MessageQueue(
2330
transport,
2431
'https://api.immutable.com/v1/audience/messages',
2532
'pk_imx_test',
2633
opts.flushIntervalMs ?? 5_000,
2734
opts.flushSize ?? 20,
35+
{ onFlush: opts.onFlush, staleFilter: opts.staleFilter },
2836
);
2937
}
3038

@@ -110,6 +118,17 @@ describe('MessageQueue', () => {
110118
expect(queue.length).toBe(1);
111119
});
112120

121+
it('filters stale messages on restore', () => {
122+
storage.setItem('queue', [makeMessage('stale'), makeMessage('fresh')]);
123+
124+
const send = jest.fn().mockResolvedValue(true);
125+
const queue = createQueue({ send }, {
126+
staleFilter: (m) => m.messageId === 'fresh',
127+
});
128+
129+
expect(queue.length).toBe(1);
130+
});
131+
113132
it('does not flush concurrently', async () => {
114133
let resolveFirst: () => void;
115134
const firstCall = new Promise<boolean>((r) => { resolveFirst = () => r(true); });
@@ -144,7 +163,6 @@ describe('MessageQueue', () => {
144163
it('handles messages enqueued during flush', async () => {
145164
let queue: ReturnType<typeof createQueue>;
146165
const send = jest.fn().mockImplementation(async () => {
147-
// Simulate a message arriving during the network request
148166
queue.enqueue(makeMessage('late'));
149167
return true;
150168
});
@@ -154,28 +172,52 @@ describe('MessageQueue', () => {
154172

155173
await queue.flush();
156174

157-
// The original message was sent, but the late one should remain
158175
expect(queue.length).toBe(1);
159176
});
160-
});
161177

162-
describe('page-unload flush', () => {
163-
let sendBeaconSpy: jest.SpyInstance;
178+
it('calls onFlush callback', async () => {
179+
const onFlush = jest.fn();
180+
const send = jest.fn().mockResolvedValue(true);
181+
const queue = createQueue({ send }, { onFlush });
164182

165-
beforeEach(() => {
166-
sendBeaconSpy = jest.fn().mockReturnValue(true);
167-
Object.defineProperty(navigator, 'sendBeacon', {
168-
value: sendBeaconSpy,
169-
writable: true,
170-
configurable: true,
171-
});
183+
queue.enqueue(makeMessage('1'));
184+
await queue.flush();
185+
186+
expect(onFlush).toHaveBeenCalledWith(true, 1);
172187
});
173188

174-
afterEach(() => {
175-
sendBeaconSpy.mockRestore?.();
189+
it('purges messages matching a predicate', () => {
190+
const send = jest.fn().mockResolvedValue(true);
191+
const queue = createQueue({ send });
192+
193+
queue.enqueue(makeMessage('1'));
194+
queue.enqueue({ ...makeMessage('2'), type: 'identify' } as any);
195+
queue.enqueue(makeMessage('3'));
196+
197+
queue.purge((m) => m.type === 'identify');
198+
expect(queue.length).toBe(2);
176199
});
177200

178-
it('flushes via sendBeacon on visibilitychange to hidden', () => {
201+
it('transforms messages in place', async () => {
202+
const send = jest.fn().mockResolvedValue(true);
203+
const queue = createQueue({ send });
204+
205+
queue.enqueue({ ...makeMessage('1'), userId: 'should-strip' } as any);
206+
207+
queue.transform((m) => {
208+
const cleaned = { ...m };
209+
delete (cleaned as any).userId;
210+
return cleaned;
211+
});
212+
213+
await queue.flush();
214+
const msg = send.mock.calls[0][2].messages[0];
215+
expect((msg as any).userId).toBeUndefined();
216+
});
217+
});
218+
219+
describe('page-unload flush (keepalive)', () => {
220+
it('flushes via keepalive fetch on visibilitychange to hidden', () => {
179221
const send = jest.fn().mockResolvedValue(true);
180222
const queue = createQueue({ send });
181223
queue.start();
@@ -189,10 +231,11 @@ describe('page-unload flush', () => {
189231
});
190232
document.dispatchEvent(new Event('visibilitychange'));
191233

192-
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
193-
expect(sendBeaconSpy).toHaveBeenCalledWith(
234+
expect(send).toHaveBeenCalledWith(
194235
'https://api.immutable.com/v1/audience/messages',
195-
expect.any(Blob),
236+
'pk_imx_test',
237+
expect.objectContaining({ messages: expect.any(Array) }),
238+
{ keepalive: true },
196239
);
197240
expect(queue.length).toBe(0);
198241

@@ -204,28 +247,33 @@ describe('page-unload flush', () => {
204247
});
205248
});
206249

207-
it('flushes via sendBeacon on pagehide', () => {
250+
it('flushes via keepalive fetch on pagehide', () => {
208251
const send = jest.fn().mockResolvedValue(true);
209252
const queue = createQueue({ send });
210253
queue.start();
211254

212255
queue.enqueue(makeMessage('1'));
213256
window.dispatchEvent(new Event('pagehide'));
214257

215-
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
258+
expect(send).toHaveBeenCalledWith(
259+
'https://api.immutable.com/v1/audience/messages',
260+
'pk_imx_test',
261+
expect.objectContaining({ messages: expect.any(Array) }),
262+
{ keepalive: true },
263+
);
216264
expect(queue.length).toBe(0);
217265

218266
queue.stop();
219267
});
220268

221-
it('does not fire beacon when queue is empty', () => {
269+
it('does not fire unload flush when queue is empty', () => {
222270
const send = jest.fn().mockResolvedValue(true);
223271
const queue = createQueue({ send });
224272
queue.start();
225273

226274
window.dispatchEvent(new Event('pagehide'));
227275

228-
expect(sendBeaconSpy).not.toHaveBeenCalled();
276+
expect(send).not.toHaveBeenCalled();
229277

230278
queue.stop();
231279
});
@@ -239,7 +287,7 @@ describe('page-unload flush', () => {
239287
queue.enqueue(makeMessage('1'));
240288
window.dispatchEvent(new Event('pagehide'));
241289

242-
expect(sendBeaconSpy).not.toHaveBeenCalled();
290+
expect(send).not.toHaveBeenCalled();
243291
});
244292

245293
it('destroy stops the queue and flushes remaining messages', () => {
@@ -251,52 +299,21 @@ describe('page-unload flush', () => {
251299
queue.enqueue(makeMessage('2'));
252300
queue.destroy();
253301

254-
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
302+
expect(send).toHaveBeenCalledWith(
303+
expect.any(String),
304+
expect.any(String),
305+
expect.objectContaining({ messages: expect.any(Array) }),
306+
{ keepalive: true },
307+
);
255308
expect(queue.length).toBe(0);
256309

257310
// Listeners removed — no double flush
258311
queue.enqueue(makeMessage('3'));
259312
window.dispatchEvent(new Event('pagehide'));
260-
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
261-
});
262-
263-
it('falls back to async flush if sendBeacon returns false', async () => {
264-
sendBeaconSpy.mockReturnValue(false);
265-
const send = jest.fn().mockResolvedValue(true);
266-
const queue = createQueue({ send });
267-
queue.start();
268-
269-
queue.enqueue(makeMessage('1'));
270-
window.dispatchEvent(new Event('pagehide'));
271-
272-
// sendBeacon failed, so async flush should have been triggered
273-
await Promise.resolve();
274-
expect(send).toHaveBeenCalledTimes(1);
275-
276-
queue.stop();
277-
});
278-
279-
it('falls back to async flush if sendBeacon is unavailable', async () => {
280-
Object.defineProperty(navigator, 'sendBeacon', {
281-
value: undefined,
282-
writable: true,
283-
configurable: true,
284-
});
285-
286-
const send = jest.fn().mockResolvedValue(true);
287-
const queue = createQueue({ send });
288-
queue.start();
289-
290-
queue.enqueue(makeMessage('1'));
291-
window.dispatchEvent(new Event('pagehide'));
292-
293-
await Promise.resolve();
294313
expect(send).toHaveBeenCalledTimes(1);
295-
296-
queue.stop();
297314
});
298315

299-
it('skips beacon if an async flush is already in flight', async () => {
316+
it('skips unload flush if an async flush is already in flight', async () => {
300317
let resolveFlush: () => void;
301318
const flushPromise = new Promise<boolean>((r) => { resolveFlush = () => r(true); });
302319
const send = jest.fn().mockReturnValueOnce(flushPromise);
@@ -308,9 +325,10 @@ describe('page-unload flush', () => {
308325
// Start an async flush (sets flushing = true)
309326
const pending = queue.flush();
310327

311-
// pagehide fires while async flush is in flight — beacon should be skipped
328+
// pagehide fires while async flush is in flight — unload flush should be skipped
312329
window.dispatchEvent(new Event('pagehide'));
313-
expect(sendBeaconSpy).not.toHaveBeenCalled();
330+
// Only 1 call (the async flush), no keepalive call
331+
expect(send).toHaveBeenCalledTimes(1);
314332

315333
resolveFlush!();
316334
await pending;

0 commit comments

Comments
 (0)