From 9d6ef1ec3958e337c3f5a11f10892a18ca9b371a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 16 Sep 2026 04:46:17 +0800 Subject: [PATCH] Initialize anonymous identity and track registered events --- README.md | 14 ++++++++ package.json | 27 ++++++++++++--- src/index.ts | 76 +++++++++++++++++++++++++++++++++++++++++-- tests/client.test.mjs | 49 ++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 tests/client.test.mjs diff --git a/README.md b/README.md index 2411798..40ceb7f 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,17 @@ export function ServerStatus() { ``` 非组件场景可以使用 `createClient({ baseUrl }).health()`。 + +## 事件初始化与上报 + +将 `AdCloudProvider` 挂到应用入口,默认在用户进入、组件挂载时初始化匿名身份;`useAdCloud().track` 会等待同一个初始化请求完成。 +客户端需配置 `baseUrl`、`appId`、应用 `collectionKey` 和可选 `eventVersion`(默认 1),并在后台注册、发布对应自定义事件。 +例如 `client.track('add_cart', { product_id: 'camera', quantity: 1, category_id: 'digital' })`。 + +`createClient(...).initialize()` 可用于非 Provider 接入,首次 track 也会自动初始化。提供 `autoInitialize={false}` 可延后 Provider 初始化;track 仍会初始化。 +请求使用 credentials: include,匿名 token 默认保存在按 API 地址和应用隔离的 localStorage,可传入 storage: null 禁用或提供自定义存储。服务器仍提供 HttpOnly Cookie;不能靠设备指纹跨站识别。 +`prepareEvent` 创建带固定 event_id / occurred_at 的可序列化请求,`sendEvent` 可重试同一请求;不要为同一次重试重新调用 track,否则会生成新 ID。本版没有自动重试、离线队列或批量发送。 +服务器错误(未注册、参数错误、凭据错误)作为 Promise rejection 返回调用方。Provider 初始化失败抛出错误,可使用 React ErrorBoundary。 +仅 collectionKey 可配置到 SDK;不得放入 server_key、management_key 或平台 SERVICE_TOKEN。客户端事件是行为信号,不作为付款、投递结果或广告计费的可信证据。 + +检查:`npm test` 包含 TypeScript 构建和初始化、身份恢复、幂等请求及应用隔离测试。 diff --git a/package.json b/package.json index a6b686b..dac99e1 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,26 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", - "exports": {".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"}}, - "files": ["dist"], - "scripts": {"build": "tsc -p tsconfig.json", "typecheck": "tsc --noEmit"}, - "peerDependencies": {"react": ">=18 <20"}, - "devDependencies": {"@types/react": "^19.0.0", "react": "^19.0.0", "typescript": "^5.0.0"} + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test tests/*.test.mjs" + }, + "peerDependencies": { + "react": ">=18 <20" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0", + "typescript": "^5.0.0" + } } diff --git a/src/index.ts b/src/index.ts index 2ce37b9..e0d4af4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { createContext, createElement, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; export interface HealthResponse { status: "ok"; @@ -6,11 +6,62 @@ export interface HealthResponse { export interface ClientOptions { baseUrl: string; + appId?: string; + collectionKey?: string; + eventVersion?: number; + storage?: Pick | null; } -export function createClient({ baseUrl }: ClientOptions) { +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +export interface AnonymousIdentity { anonymous_id: string; anonymous_token: string; master_id: string } +export interface PreparedEvent { + app_id: string; name: string; event_version: number; event_id: string; + occurred_at: string; properties: Record; + session_id?: string; context?: Record; +} +export interface EventReceipt extends AnonymousIdentity { event_id: string; duplicate: boolean; standard_event: string; rule_revision: number } + +export function createClient({ baseUrl, appId, collectionKey, eventVersion = 1, storage }: ClientOptions) { const url = baseUrl.replace(/\/+$/, ""); + const storageKey = `adcloud:anonymous:${url}:${appId || "default"}`; + let activeStorage = storage; + if (activeStorage === undefined) { + try { activeStorage = typeof window !== "undefined" ? window.localStorage : null; } + catch { activeStorage = null; } + } + let initialization: Promise | null = null; + async function request(path: string, body: unknown, headers: Record = {}, signal?: AbortSignal): Promise { + const response = await fetch(`${url}${path}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body), signal }); + if (!response.ok) { + const error = await response.json().catch(() => ({})) as { detail?: unknown }; + throw new Error(`AdCloud HTTP ${response.status}: ${typeof error.detail === "string" ? error.detail : "Request failed"}`); + } + return response.json() as Promise; + } + function initialize(signal?: AbortSignal): Promise { + if (!initialization) { + let token: string | null = null; + try { token = activeStorage?.getItem(storageKey) || null; } catch { /* Storage may be blocked. */ } + initialization = request("/v1/identity/anonymous", token ? { anonymous_token: token } : {}, {}, signal) + .then((identity) => { try { activeStorage?.setItem(storageKey, identity.anonymous_token); } catch { /* Cookie can still persist identity. */ } return identity; }) + .catch((error: unknown) => { initialization = null; throw error; }); + } + return initialization; + } + function prepareEvent(name: string, properties: Record, options: Partial> = {}): PreparedEvent { + if (!appId || !collectionKey) throw new Error("Tracking requires appId and a collectionKey"); + return { app_id: appId, name, properties: structuredClone(properties), event_version: options.event_version ?? eventVersion, event_id: options.event_id || crypto.randomUUID(), occurred_at: options.occurred_at || new Date().toISOString(), session_id: options.session_id, context: options.context }; + } + async function sendEvent(event: PreparedEvent, signal?: AbortSignal): Promise { + if (!appId || !collectionKey || event.app_id !== appId) throw new Error("Event must belong to this configured application"); + const identity = await initialize(); + return request("/v1/events", { ...event, anonymous_token: identity.anonymous_token }, { "X-Adcloud-Key": collectionKey }, signal); + } return { + initialize, prepareEvent, sendEvent, + track(name: string, properties: Record, options?: Parameters[2], signal?: AbortSignal) { + return sendEvent(prepareEvent(name, properties, options), signal); + }, async health(signal?: AbortSignal): Promise { const response = await fetch(`${url}/health`, { signal }); if (!response.ok) { @@ -46,3 +97,24 @@ export function useHealth(baseUrl: string) { return { data, error, loading }; } + + +export type AdCloudClient = ReturnType; +const AdCloudContext = createContext(null); +export function AdCloudProvider({ children, autoInitialize = true, ...options }: ClientOptions & { children: ReactNode; autoInitialize?: boolean }) { + const client = useMemo(() => createClient(options), [options.baseUrl, options.appId, options.collectionKey, options.eventVersion, options.storage]); + const [initializationError, setInitializationError] = useState(null); + useEffect(() => { + let active = true; + setInitializationError(null); + if (autoInitialize) client.initialize().catch((error: unknown) => { if (active) setInitializationError(error instanceof Error ? error : new Error(String(error))); }); + return () => { active = false; }; + }, [client, autoInitialize]); + if (initializationError) throw initializationError; + return createElement(AdCloudContext.Provider, { value: client }, children); +} +export function useAdCloud(): AdCloudClient { + const client = useContext(AdCloudContext); + if (!client) throw new Error("useAdCloud requires AdCloudProvider"); + return client; +} diff --git a/tests/client.test.mjs b/tests/client.test.mjs new file mode 100644 index 0000000..6ec33f1 --- /dev/null +++ b/tests/client.test.mjs @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createClient } from '../dist/index.js'; + +const identity = { anonymous_id: 'anonymous', master_id: 'master', anonymous_token: 'signed-anonymous-token' }; +const response = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +test('concurrent tracks initialize once, reuse identity and replay the same event envelope', async () => { + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options, body: JSON.parse(options.body) }); + if (url.endsWith('/anonymous')) return response(identity); + return response({ ...identity, event_id: JSON.parse(options.body).event_id, duplicate: false }); + }; + const values = new Map(); + const client = createClient({ baseUrl: 'https://api.example/', appId: 'shop', collectionKey: 'collection-demo', storage: { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) } }); + const properties = { product_id: 'camera', quantity: 1 }; + const prepared = client.prepareEvent('add_cart', properties); + properties.quantity = 999; + await Promise.all([client.sendEvent(prepared), client.track('page', { page_id: 'home' })]); + await client.sendEvent(prepared); + assert.equal(calls.filter((call) => call.url.endsWith('/anonymous')).length, 1); + const events = calls.filter((call) => call.url.endsWith('/events')); + assert.equal(events[0].body.properties.quantity, 1); + assert.equal(events[0].body.event_id, events[2].body.event_id); + assert.equal(events[0].body.occurred_at, events[2].body.occurred_at); + assert.equal(events[0].body.anonymous_token, identity.anonymous_token); + assert.equal(events[0].options.headers['X-Adcloud-Key'], 'collection-demo'); + assert.equal(events[0].options.credentials, 'include'); + assert.equal(values.size, 1); +}); + +test('initialization failure can retry and persisted anonymous token is restored', async () => { + let calls = 0; + globalThis.fetch = async (_url, options) => { + calls++; + assert.equal(JSON.parse(options.body).anonymous_token, identity.anonymous_token); + return calls === 1 ? response({ detail: 'Unavailable' }, 503) : response(identity); + }; + const client = createClient({ baseUrl: 'https://api.example', storage: { getItem: () => identity.anonymous_token, setItem: () => {} } }); + await assert.rejects(client.initialize(), /HTTP 503/); + assert.deepEqual(await client.initialize(), identity); +}); + +test('tracking requires collection configuration and cannot send another app envelope', async () => { + assert.throws(() => createClient({ baseUrl: 'https://api.example', storage: null }).prepareEvent('page', {}), /requires appId/); + const client = createClient({ baseUrl: 'https://api.example', appId: 'shop', collectionKey: 'collection-demo', storage: null }); + await assert.rejects(client.sendEvent({ ...client.prepareEvent('page', {}), app_id: 'other' }), /configured application/); +});