Files
cdp/ingestion/sdk/web/cdp.ts
2026-05-25 10:16:31 +07:00

226 lines
6.9 KiB
TypeScript

// 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<string, unknown>;
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<CDPConfig> | 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 };