// Lightweight CDP tracker for browsers (React / Next.js / vanilla). // Zero dependencies. Copy this file into your app and import the `cdp` object. // // import { cdp } from '@/lib/cdp'; // // // Once, at app startup (e.g. layout.tsx / _app.tsx): // cdp.init({ // writeKey: process.env.NEXT_PUBLIC_CDP_WRITE_KEY!, // endpoint: process.env.NEXT_PUBLIC_CDP_ENDPOINT ?? 'http://localhost:3049', // }); // // cdp.identify('user_42', { plan: 'pro' }); // cdp.track('Button Clicked', { id: 'cta-hero' }); // cdp.page(); // pulls path/url from window.location // // Payload shape matches Segment's Track/Identify/Page API, so the same code // works with Segment if you ever swap endpoints. type Json = Record; type EventType = 'track' | 'identify' | 'page' | 'group' | 'alias' | 'screen'; interface CDPConfig { writeKey: string; /** Base URL of the ingest service. e.g. http://localhost:3049 */ endpoint: string; /** localStorage key for the anonymous id. Default: "cdp_anon". */ anonymousIdKey?: string; /** localStorage key for the resolved user id. Default: "cdp_uid". */ userIdKey?: string; /** Auto-fire `page` on every history change. Default: false. */ autoPage?: boolean; } interface CommonPayload { type: EventType; messageId: string; anonymousId: string; userId?: string; sentAt: string; context: Json; } const DEFAULTS = { anonymousIdKey: 'cdp_anon', userIdKey: 'cdp_uid', autoPage: false, }; class CDPClient { private cfg: Required | null = null; private authHeader = ''; init(config: CDPConfig) { this.cfg = { ...DEFAULTS, ...config }; // Segment-style basic auth: base64(writeKey + ":"). this.authHeader = 'Basic ' + b64(`${config.writeKey}:`); this.ensureAnonymousId(); if (this.cfg.autoPage && typeof window !== 'undefined') { this.installAutoPage(); } } identify(userId: string, traits: Json = {}) { this.setUserId(userId); return this.send('identify', { userId, traits }); } track(event: string, properties: Json = {}) { return this.send('track', { event, properties }); } page(name?: string, properties: Json = {}) { const loc = typeof window !== 'undefined' ? window.location : null; const merged: Json = { ...(loc ? { url: loc.href, path: loc.pathname, referrer: document.referrer } : {}), ...properties, }; return this.send('page', { name, properties: merged }); } group(groupId: string, traits: Json = {}) { return this.send('group', { groupId, traits }); } alias(userId: string, previousId: string) { return this.send('alias', { userId, previousId }); } /** Forget the user id (e.g. on logout). Anonymous id is preserved. */ reset() { if (typeof localStorage === 'undefined' || !this.cfg) return; localStorage.removeItem(this.cfg.userIdKey); } // --------------------------------------------------------------------------- // internals // --------------------------------------------------------------------------- private send(type: EventType, body: Json) { if (!this.cfg) { console.warn('[cdp] send() before init(); call cdp.init() at startup'); return Promise.resolve(); } const payload: CommonPayload & Json = { type, messageId: uuidv4(), anonymousId: this.getAnonymousId(), userId: this.getUserId() ?? undefined, sentAt: new Date().toISOString(), context: this.buildContext(), ...body, }; const url = `${this.cfg.endpoint}/v1/${type}`; const blob = JSON.stringify(payload); // Prefer sendBeacon for unload safety (page-close, route-change). if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) { // sendBeacon can't set Authorization; fall back to fetch with keepalive // when an auth header is required. We always use fetch. } return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: this.authHeader, }, body: blob, keepalive: true, }).catch((err) => { console.warn('[cdp] send failed', err); }); } private buildContext(): Json { if (typeof window === 'undefined') return { library_name: 'cdp-web', library_version: '0.1.0' }; return { library_name: 'cdp-web', library_version: '0.1.0', user_agent: navigator.userAgent, locale: navigator.language, screen_width: window.screen?.width, screen_height: window.screen?.height, }; } private ensureAnonymousId() { if (typeof localStorage === 'undefined' || !this.cfg) return; if (!localStorage.getItem(this.cfg.anonymousIdKey)) { localStorage.setItem(this.cfg.anonymousIdKey, uuidv4()); } } private getAnonymousId(): string { if (!this.cfg || typeof localStorage === 'undefined') return uuidv4(); let id = localStorage.getItem(this.cfg.anonymousIdKey); if (!id) { id = uuidv4(); localStorage.setItem(this.cfg.anonymousIdKey, id); } return id; } private setUserId(id: string) { if (!this.cfg || typeof localStorage === 'undefined') return; localStorage.setItem(this.cfg.userIdKey, id); } private getUserId(): string | null { if (!this.cfg || typeof localStorage === 'undefined') return null; return localStorage.getItem(this.cfg.userIdKey); } private installAutoPage() { let lastPath = location.pathname; const fire = () => { if (location.pathname !== lastPath) { lastPath = location.pathname; this.page(); } }; // Patch pushState/replaceState so SPA route changes fire `page`. const origPush = history.pushState; const origReplace = history.replaceState; history.pushState = function (...args) { const r = origPush.apply(this, args); fire(); return r; }; history.replaceState = function (...args) { const r = origReplace.apply(this, args); fire(); return r; }; window.addEventListener('popstate', fire); // First page load. this.page(); } } // --------------------------------------------------------------------------- // tiny helpers (no deps) // --------------------------------------------------------------------------- function uuidv4(): string { // crypto.randomUUID is available in all evergreen browsers and Node 19+. if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { return (crypto as Crypto).randomUUID(); } // RFC 4122-ish fallback. return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function b64(input: string): string { if (typeof btoa !== 'undefined') return btoa(input); // Node SSR // @ts-expect-error: Buffer exists in Node. return Buffer.from(input, 'utf-8').toString('base64'); } export const cdp = new CDPClient(); export type { CDPConfig };