Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/audience/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const FLUSH_INTERVAL_MS = 5_000;
export const FLUSH_SIZE = 20;

export const COOKIE_NAME = 'imtbl_anon_id';
export const SESSION_COOKIE = '_imtbl_sid';
export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years

export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment];
8 changes: 7 additions & 1 deletion packages/audience/core/src/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { collectContext } from './context';

describe('collectContext', () => {
it('includes library name and version', () => {
it('defaults to @imtbl/audience library name', () => {
const ctx = collectContext();
expect(ctx.library).toBe('@imtbl/audience');
expect(ctx.libraryVersion).toBeDefined();
});

it('accepts custom library name and version', () => {
const ctx = collectContext('@imtbl/audience-web-sdk', '1.0.0');
expect(ctx.library).toBe('@imtbl/audience-web-sdk');
expect(ctx.libraryVersion).toBe('1.0.0');
});

it('collects browser signals in jsdom', () => {
const ctx = collectContext();
expect(ctx.userAgent).toBeDefined();
Expand Down
16 changes: 13 additions & 3 deletions packages/audience/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@ import { isBrowser } from './utils';
// WARNING: DO NOT CHANGE THE STRING BELOW. IT GETS REPLACED AT BUILD TIME.
const SDK_VERSION = '__SDK_VERSION__';

export function collectContext(): EventContext {
/**
* Collect browser context for event payloads.
*
* Callers may pass their own library name and version when multiple surfaces
* (web SDK, pixel, Unity, Unreal) share this function and each must identify
* itself. Defaults to '@imtbl/audience' with the build-time SDK version.
*/
export function collectContext(
library = '@imtbl/audience',
version = SDK_VERSION,
): EventContext {
const context: EventContext = {
library: '@imtbl/audience',
libraryVersion: SDK_VERSION,
library,
libraryVersion: version,
};

if (!isBrowser()) return context;
Expand Down
16 changes: 11 additions & 5 deletions packages/audience/core/src/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
import { COOKIE_NAME, COOKIE_MAX_AGE_SECONDS } from './config';
import { isBrowser, generateId } from './utils';

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

function setCookie(name: string, value: string, maxAge: number): void {
export function setCookie(name: string, value: string, maxAge: number, domain?: string): void {
if (!isBrowser()) return;
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
const domainAttr = domain ? `; domain=${domain}` : '';
document.cookie = `${name}=${encodeURIComponent(value)}`
+ `; path=/; max-age=${maxAge}; SameSite=Lax${domainAttr}${secure}`;
}

export function deleteCookie(name: string, domain?: string): void {
setCookie(name, '', 0, domain);
}

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

const id = generateId();
setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS);
setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS, domain);
return id;
}

Expand Down
17 changes: 16 additions & 1 deletion packages/audience/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ export type {
AliasMessage,
Message,
BatchPayload,
ConsentLevel,
ConsentStatus,
} from './types';

export { getOrCreateAnonymousId, getAnonymousId } from './cookie';
export {
getOrCreateAnonymousId,
getAnonymousId,
getCookie,
setCookie,
deleteCookie,
} from './cookie';
export * as storage from './storage';

export {
Expand All @@ -23,6 +31,7 @@ export {
FLUSH_INTERVAL_MS,
FLUSH_SIZE,
COOKIE_NAME,
SESSION_COOKIE,
} from './config';

export { generateId, getTimestamp, isBrowser } from './utils';
Expand All @@ -31,3 +40,9 @@ export type { Transport } from './transport';
export { httpTransport, httpSend } from './transport';
export { MessageQueue } from './queue';
export { collectContext } from './context';
export {
isTimestampValid,
isAliasValid,
truncate,
truncateSource,
} from './validation';
146 changes: 82 additions & 64 deletions packages/audience/core/src/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,24 @@ function makeMessage(id: string): Message {
};
}

interface QueueOpts {
flushIntervalMs?: number;
flushSize?: number;
onFlush?: (ok: boolean, count: number) => void;
staleFilter?: (msg: Message) => boolean;
}

function createQueue(
transport: Transport,
opts: { flushIntervalMs?: number; flushSize?: number } = {},
opts: QueueOpts = {},
) {
return new MessageQueue(
transport,
'https://api.immutable.com/v1/audience/messages',
'pk_imx_test',
opts.flushIntervalMs ?? 5_000,
opts.flushSize ?? 20,
{ onFlush: opts.onFlush, staleFilter: opts.staleFilter },
);
}

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

it('filters stale messages on restore', () => {
storage.setItem('queue', [makeMessage('stale'), makeMessage('fresh')]);

const send = jest.fn().mockResolvedValue(true);
const queue = createQueue({ send }, {
staleFilter: (m) => m.messageId === 'fresh',
});

expect(queue.length).toBe(1);
});

it('does not flush concurrently', async () => {
let resolveFirst: () => void;
const firstCall = new Promise<boolean>((r) => { resolveFirst = () => r(true); });
Expand Down Expand Up @@ -144,7 +163,6 @@ describe('MessageQueue', () => {
it('handles messages enqueued during flush', async () => {
let queue: ReturnType<typeof createQueue>;
const send = jest.fn().mockImplementation(async () => {
// Simulate a message arriving during the network request
queue.enqueue(makeMessage('late'));
return true;
});
Expand All @@ -154,28 +172,52 @@ describe('MessageQueue', () => {

await queue.flush();

// The original message was sent, but the late one should remain
expect(queue.length).toBe(1);
});
});

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

beforeEach(() => {
sendBeaconSpy = jest.fn().mockReturnValue(true);
Object.defineProperty(navigator, 'sendBeacon', {
value: sendBeaconSpy,
writable: true,
configurable: true,
});
queue.enqueue(makeMessage('1'));
await queue.flush();

expect(onFlush).toHaveBeenCalledWith(true, 1);
});

afterEach(() => {
sendBeaconSpy.mockRestore?.();
it('purges messages matching a predicate', () => {
const send = jest.fn().mockResolvedValue(true);
const queue = createQueue({ send });

queue.enqueue(makeMessage('1'));
queue.enqueue({ ...makeMessage('2'), type: 'identify' } as any);
queue.enqueue(makeMessage('3'));

queue.purge((m) => m.type === 'identify');
expect(queue.length).toBe(2);
});

it('flushes via sendBeacon on visibilitychange to hidden', () => {
it('transforms messages in place', async () => {
const send = jest.fn().mockResolvedValue(true);
const queue = createQueue({ send });

queue.enqueue({ ...makeMessage('1'), userId: 'should-strip' } as any);

queue.transform((m) => {
const cleaned = { ...m };
delete (cleaned as any).userId;
return cleaned;
});

await queue.flush();
const msg = send.mock.calls[0][2].messages[0];
expect((msg as any).userId).toBeUndefined();
});
});

describe('page-unload flush (keepalive)', () => {
it('flushes via keepalive fetch on visibilitychange to hidden', () => {
const send = jest.fn().mockResolvedValue(true);
const queue = createQueue({ send });
queue.start();
Expand All @@ -189,10 +231,11 @@ describe('page-unload flush', () => {
});
document.dispatchEvent(new Event('visibilitychange'));

expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
expect(sendBeaconSpy).toHaveBeenCalledWith(
expect(send).toHaveBeenCalledWith(
'https://api.immutable.com/v1/audience/messages',
expect.any(Blob),
'pk_imx_test',
expect.objectContaining({ messages: expect.any(Array) }),
{ keepalive: true },
);
expect(queue.length).toBe(0);

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

it('flushes via sendBeacon on pagehide', () => {
it('flushes via keepalive fetch on pagehide', () => {
const send = jest.fn().mockResolvedValue(true);
const queue = createQueue({ send });
queue.start();

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

expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
'https://api.immutable.com/v1/audience/messages',
'pk_imx_test',
expect.objectContaining({ messages: expect.any(Array) }),
{ keepalive: true },
);
expect(queue.length).toBe(0);

queue.stop();
});

it('does not fire beacon when queue is empty', () => {
it('does not fire unload flush when queue is empty', () => {
const send = jest.fn().mockResolvedValue(true);
const queue = createQueue({ send });
queue.start();

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

expect(sendBeaconSpy).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();

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

expect(sendBeaconSpy).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});

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

expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.objectContaining({ messages: expect.any(Array) }),
{ keepalive: true },
);
expect(queue.length).toBe(0);

// Listeners removed — no double flush
queue.enqueue(makeMessage('3'));
window.dispatchEvent(new Event('pagehide'));
expect(sendBeaconSpy).toHaveBeenCalledTimes(1);
});

it('falls back to async flush if sendBeacon returns false', async () => {
sendBeaconSpy.mockReturnValue(false);
const send = jest.fn().mockResolvedValue(true);
const queue = createQueue({ send });
queue.start();

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

// sendBeacon failed, so async flush should have been triggered
await Promise.resolve();
expect(send).toHaveBeenCalledTimes(1);

queue.stop();
});

it('falls back to async flush if sendBeacon is unavailable', async () => {
Object.defineProperty(navigator, 'sendBeacon', {
value: undefined,
writable: true,
configurable: true,
});

const send = jest.fn().mockResolvedValue(true);
const queue = createQueue({ send });
queue.start();

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

await Promise.resolve();
expect(send).toHaveBeenCalledTimes(1);

queue.stop();
});

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

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

resolveFlush!();
await pending;
Expand Down
Loading
Loading