From af5b7972907f050a0d442b68c5fd1f11dd5af788 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 16 Sep 2026 04:49:22 +0800 Subject: [PATCH] Handle identity changes and keep SDK outages from interrupting pages --- README.md | 4 +++- src/index.ts | 27 ++++++++++++++++----------- tests/client.test.mjs | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 40ceb7f..9d12541 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ export function ServerStatus() { `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。 +服务器错误(未注册、参数错误、凭据错误)作为 Promise rejection 返回调用方。Provider 初始化失败通过可选 onInitializationError 回调报告,不中断客户页面。 仅 collectionKey 可配置到 SDK;不得放入 server_key、management_key 或平台 SERVICE_TOKEN。客户端事件是行为信号,不作为付款、投递结果或广告计费的可信证据。 检查:`npm test` 包含 TypeScript 构建和初始化、身份恢复、幂等请求及应用隔离测试。 + +登录/身份缝合后,可把受控登录接口返回的新匿名身份传给 client.updateIdentity,更新 SDK 缓存;退出登录调用 client.resetIdentity,签发新匿名身份并覆盖本地存储。SDK 不持有身份管理接口的服务端凭据。 diff --git a/src/index.ts b/src/index.ts index e0d4af4..bca4d84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,13 @@ export function createClient({ baseUrl, appId, collectionKey, eventVersion = 1, catch { activeStorage = null; } } let initialization: Promise | null = null; + function updateIdentity(identity: AnonymousIdentity): AnonymousIdentity { + if (!identity || ![identity.anonymous_id, identity.master_id, identity.anonymous_token].every((value) => typeof value === "string" && value.length > 0)) throw new Error("Invalid anonymous identity response"); + const copy = Object.freeze({ anonymous_id: identity.anonymous_id, master_id: identity.master_id, anonymous_token: identity.anonymous_token }); + try { activeStorage?.setItem(storageKey, copy.anonymous_token); } catch { /* Cookie can still persist identity. */ } + initialization = Promise.resolve(copy); + return copy; + } 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) { @@ -38,19 +45,19 @@ export function createClient({ baseUrl, appId, collectionKey, eventVersion = 1, } return response.json() as Promise; } - function initialize(signal?: AbortSignal): Promise { + function initialize(signal?: AbortSignal, rotate = false): 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; }) + initialization = request(rotate ? "/v1/identity/anonymous?rotate=true" : "/v1/identity/anonymous", token ? { anonymous_token: token } : {}, {}, signal) + .then(updateIdentity) .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 }; + 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 ? structuredClone(options.context) : undefined }; } 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"); @@ -58,7 +65,8 @@ export function createClient({ baseUrl, appId, collectionKey, eventVersion = 1, return request("/v1/events", { ...event, anonymous_token: identity.anonymous_token }, { "X-Adcloud-Key": collectionKey }, signal); } return { - initialize, prepareEvent, sendEvent, + initialize, updateIdentity, prepareEvent, sendEvent, + resetIdentity(signal?: AbortSignal) { initialization = null; return initialize(signal, true); }, track(name: string, properties: Record, options?: Parameters[2], signal?: AbortSignal) { return sendEvent(prepareEvent(name, properties, options), signal); }, @@ -101,16 +109,13 @@ export function useHealth(baseUrl: string) { export type AdCloudClient = ReturnType; const AdCloudContext = createContext(null); -export function AdCloudProvider({ children, autoInitialize = true, ...options }: ClientOptions & { children: ReactNode; autoInitialize?: boolean }) { +export function AdCloudProvider({ children, autoInitialize = true, onInitializationError, ...options }: ClientOptions & { children: ReactNode; autoInitialize?: boolean; onInitializationError?: (error: Error) => void }) { 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))); }); + if (autoInitialize) client.initialize().catch((error: unknown) => { if (active) onInitializationError?.(error instanceof Error ? error : new Error(String(error))); }); return () => { active = false; }; - }, [client, autoInitialize]); - if (initializationError) throw initializationError; + }, [client, autoInitialize, onInitializationError]); return createElement(AdCloudContext.Provider, { value: client }, children); } export function useAdCloud(): AdCloudClient { diff --git a/tests/client.test.mjs b/tests/client.test.mjs index 6ec33f1..6d78154 100644 --- a/tests/client.test.mjs +++ b/tests/client.test.mjs @@ -47,3 +47,20 @@ test('tracking requires collection configuration and cannot send another app env 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/); }); + +test('login identity update and logout rotation replace the cached tracking identity', async () => { + const requests = []; + const next = { ...identity, anonymous_id: 'new-anonymous', anonymous_token: 'new-signed-token' }; + globalThis.fetch = async (url, options) => { + requests.push({ url, body: JSON.parse(options.body) }); + if (url.endsWith('?rotate=true')) return response(next); + return response(identity); + }; + const client = createClient({ baseUrl: 'https://api.example', appId: 'shop', collectionKey: 'collection-demo', storage: null }); + client.updateIdentity(next); + await client.track('page', { page_id: 'home' }); + assert.equal(requests[0].body.anonymous_token, next.anonymous_token); + await client.resetIdentity(); + assert.equal(requests[1].url, 'https://api.example/v1/identity/anonymous?rotate=true'); + assert.throws(() => client.updateIdentity({}), /Invalid anonymous identity/); +});