Skip to content

Commit 5b0b6d7

Browse files
nattb8claude
andcommitted
feat: add core types, utils, config, storage, and cookie
Add the foundational modules for @imtbl/audience-core: - types: message types matching the audience service API contract - utils: isBrowser, generateId (UUID v4), getTimestamp - config: environment URLs, endpoint paths, flush/cookie constants - storage: localStorage wrapper with prefix and graceful degradation - cookie: shared anonymous ID (imtbl_anon_id) for cross-surface stitching Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 74afc12 commit 5b0b6d7

10 files changed

Lines changed: 326 additions & 3 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getBaseUrl } from './config';
2+
3+
describe('getBaseUrl', () => {
4+
it('returns dev URL', () => {
5+
expect(getBaseUrl('dev')).toBe('https://api.dev.immutable.com');
6+
});
7+
8+
it('returns sandbox URL', () => {
9+
expect(getBaseUrl('sandbox')).toBe('https://api.sandbox.immutable.com');
10+
});
11+
12+
it('returns production URL', () => {
13+
expect(getBaseUrl('production')).toBe('https://api.immutable.com');
14+
});
15+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Environment } from './types';
2+
3+
const BASE_URLS: Record<Environment, string> = {
4+
dev: 'https://api.dev.immutable.com',
5+
sandbox: 'https://api.sandbox.immutable.com',
6+
production: 'https://api.immutable.com',
7+
};
8+
9+
export const INGEST_PATH = '/v1/audience/messages';
10+
export const CONSENT_PATH = '/v1/audience/tracking-consent';
11+
12+
export const FLUSH_INTERVAL_MS = 5_000;
13+
export const FLUSH_SIZE = 20;
14+
15+
export const COOKIE_NAME = 'imtbl_anon_id';
16+
export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years
17+
18+
export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment];
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { getOrCreateAnonymousId, getAnonymousId } from './cookie';
2+
import { COOKIE_NAME } from './config';
3+
4+
function clearCookies() {
5+
document.cookie.split(';').forEach((c) => {
6+
const name = c.split('=')[0].trim();
7+
document.cookie = `${name}=; max-age=0; path=/`;
8+
});
9+
}
10+
11+
beforeEach(clearCookies);
12+
13+
describe('getOrCreateAnonymousId', () => {
14+
it('generates a new ID when no cookie exists', () => {
15+
const id = getOrCreateAnonymousId();
16+
expect(id).toBeTruthy();
17+
expect(typeof id).toBe('string');
18+
});
19+
20+
it('persists the ID in a cookie', () => {
21+
const id = getOrCreateAnonymousId();
22+
expect(document.cookie).toContain(`${COOKIE_NAME}=${id}`);
23+
});
24+
25+
it('returns the same ID on subsequent calls', () => {
26+
const first = getOrCreateAnonymousId();
27+
const second = getOrCreateAnonymousId();
28+
expect(second).toBe(first);
29+
});
30+
31+
it('returns an existing cookie value if already set', () => {
32+
document.cookie = `${COOKIE_NAME}=existing-id; path=/`;
33+
expect(getOrCreateAnonymousId()).toBe('existing-id');
34+
});
35+
});
36+
37+
describe('getAnonymousId', () => {
38+
it('returns undefined when no cookie exists', () => {
39+
expect(getAnonymousId()).toBeUndefined();
40+
});
41+
42+
it('returns the cookie value when set', () => {
43+
document.cookie = `${COOKIE_NAME}=test-id; path=/`;
44+
expect(getAnonymousId()).toBe('test-id');
45+
});
46+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { COOKIE_NAME, COOKIE_MAX_AGE_SECONDS } from './config';
2+
import { isBrowser, generateId } from './utils';
3+
4+
function getCookie(name: string): string | undefined {
5+
if (!isBrowser()) return undefined;
6+
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
7+
return match ? decodeURIComponent(match[1]) : undefined;
8+
}
9+
10+
function setCookie(name: string, value: string, maxAge: number): void {
11+
if (!isBrowser()) return;
12+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`;
13+
}
14+
15+
/**
16+
* Returns the anonymous ID from the shared cookie, creating one if it doesn't exist.
17+
* Both the web SDK and pixel read/write the same cookie so identity stitching
18+
* works across surfaces on the same domain.
19+
*/
20+
export function getOrCreateAnonymousId(): string {
21+
const existing = getCookie(COOKIE_NAME);
22+
if (existing) return existing;
23+
24+
const id = generateId();
25+
setCookie(COOKIE_NAME, id, COOKIE_MAX_AGE_SECONDS);
26+
return id;
27+
}
28+
29+
export function getAnonymousId(): string | undefined {
30+
return getCookie(COOKIE_NAME);
31+
}
Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1-
// @imtbl/audience-core — shared foundation for the web SDK and pixel.
2-
// Source modules will be added in follow-up PRs (SDK-33, SDK-34).
3-
export {};
1+
export type {
2+
Environment,
3+
Surface,
4+
MessageType,
5+
EventContext,
6+
UserTraits,
7+
TrackMessage,
8+
PageMessage,
9+
ScreenMessage,
10+
IdentifyMessage,
11+
AliasMessage,
12+
Message,
13+
BatchPayload,
14+
} from './types';
15+
16+
export { getOrCreateAnonymousId, getAnonymousId } from './cookie';
17+
export * as storage from './storage';
18+
19+
export {
20+
getBaseUrl,
21+
INGEST_PATH,
22+
CONSENT_PATH,
23+
FLUSH_INTERVAL_MS,
24+
FLUSH_SIZE,
25+
COOKIE_NAME,
26+
} from './config';
27+
28+
export { generateId, getTimestamp, isBrowser } from './utils';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as storage from './storage';
2+
3+
afterEach(() => {
4+
localStorage.clear();
5+
});
6+
7+
describe('storage', () => {
8+
it('round-trips a string value', () => {
9+
storage.setItem('key', 'hello');
10+
expect(storage.getItem<string>('key')).toBe('hello');
11+
});
12+
13+
it('round-trips an object value', () => {
14+
const obj = { a: 1, b: 'two' };
15+
storage.setItem('key', obj);
16+
expect(storage.getItem('key')).toEqual(obj);
17+
});
18+
19+
it('returns undefined for missing keys', () => {
20+
expect(storage.getItem('missing')).toBeUndefined();
21+
});
22+
23+
it('removes items', () => {
24+
storage.setItem('key', 'value');
25+
storage.removeItem('key');
26+
expect(storage.getItem('key')).toBeUndefined();
27+
});
28+
29+
it('prefixes keys in localStorage', () => {
30+
storage.setItem('test', 'val');
31+
expect(localStorage.getItem('__imtbl_audience_test')).toBe('"val"');
32+
});
33+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { isBrowser } from './utils';
2+
3+
const PREFIX = '__imtbl_audience_';
4+
5+
function hasLocalStorage(): boolean {
6+
try {
7+
return isBrowser() && typeof localStorage !== 'undefined';
8+
} catch {
9+
return false;
10+
}
11+
}
12+
13+
export function getItem<T>(key: string): T | undefined {
14+
if (!hasLocalStorage()) return undefined;
15+
try {
16+
const raw = localStorage.getItem(`${PREFIX}${key}`);
17+
return raw ? (JSON.parse(raw) as T) : undefined;
18+
} catch {
19+
return undefined;
20+
}
21+
}
22+
23+
export function setItem(key: string, value: unknown): void {
24+
if (!hasLocalStorage()) return;
25+
try {
26+
localStorage.setItem(`${PREFIX}${key}`, JSON.stringify(value));
27+
} catch {
28+
// Storage full or unavailable.
29+
}
30+
}
31+
32+
export function removeItem(key: string): void {
33+
if (!hasLocalStorage()) return;
34+
try {
35+
localStorage.removeItem(`${PREFIX}${key}`);
36+
} catch {
37+
// Ignore.
38+
}
39+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
export type Environment = 'dev' | 'sandbox' | 'production';
2+
3+
export type Surface = 'web' | 'pixel' | 'unity' | 'unreal';
4+
5+
export type MessageType = 'track' | 'page' | 'screen' | 'identify' | 'alias';
6+
7+
export interface EventContext {
8+
library: string;
9+
libraryVersion: string;
10+
userAgent?: string;
11+
locale?: string;
12+
timezone?: string;
13+
screen?: string;
14+
pageUrl?: string;
15+
pagePath?: string;
16+
pageReferrer?: string;
17+
pageTitle?: string;
18+
}
19+
20+
export interface UserTraits {
21+
email?: string;
22+
name?: string;
23+
[key: string]: string | number | boolean | undefined;
24+
}
25+
26+
interface BaseMessage {
27+
type: MessageType;
28+
messageId: string;
29+
eventTimestamp: string;
30+
anonymousId: string;
31+
surface: Surface;
32+
context: EventContext;
33+
}
34+
35+
export interface TrackMessage extends BaseMessage {
36+
type: 'track';
37+
eventName: string;
38+
properties?: Record<string, unknown>;
39+
userId?: string;
40+
}
41+
42+
export interface PageMessage extends BaseMessage {
43+
type: 'page';
44+
properties?: Record<string, unknown>;
45+
userId?: string;
46+
}
47+
48+
export interface ScreenMessage extends BaseMessage {
49+
type: 'screen';
50+
eventName?: string;
51+
properties?: Record<string, unknown>;
52+
userId?: string;
53+
}
54+
55+
export interface IdentifyMessage extends BaseMessage {
56+
type: 'identify';
57+
userId?: string;
58+
identityType?: string;
59+
traits?: UserTraits;
60+
}
61+
62+
export interface AliasMessage extends BaseMessage {
63+
type: 'alias';
64+
fromId: string;
65+
fromType?: string;
66+
toId: string;
67+
toType?: string;
68+
}
69+
70+
export type Message =
71+
| TrackMessage
72+
| PageMessage
73+
| ScreenMessage
74+
| IdentifyMessage
75+
| AliasMessage;
76+
77+
export interface BatchPayload {
78+
messages: Message[];
79+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { isBrowser, generateId, getTimestamp } from './utils';
2+
3+
describe('isBrowser', () => {
4+
it('returns true in jsdom', () => {
5+
expect(isBrowser()).toBe(true);
6+
});
7+
});
8+
9+
describe('generateId', () => {
10+
it('returns a non-empty string', () => {
11+
const id = generateId();
12+
expect(typeof id).toBe('string');
13+
expect(id.length).toBeGreaterThan(0);
14+
});
15+
16+
it('returns unique values', () => {
17+
const ids = new Set(Array.from({ length: 100 }, () => generateId()));
18+
expect(ids.size).toBe(100);
19+
});
20+
});
21+
22+
describe('getTimestamp', () => {
23+
it('returns an ISO 8601 string', () => {
24+
const ts = getTimestamp();
25+
expect(new Date(ts).toISOString()).toBe(ts);
26+
});
27+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const isBrowser = (): boolean => typeof window !== 'undefined' && typeof document !== 'undefined';
2+
3+
export const generateId = (): string => {
4+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
5+
return crypto.randomUUID();
6+
}
7+
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
8+
};
9+
10+
export const getTimestamp = (): string => new Date().toISOString();

0 commit comments

Comments
 (0)