From e61549dab512379cb0ae8b480bed021a6282fe1b Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:15:56 -0400 Subject: [PATCH 1/2] feat: add EID cache with per-source TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move extended EID caching logic from SDK wrapper bundles into the web-sdk. Wrappers currently copy-paste ~143 lines of merge/TTL code per customer — this centralizes it behind an `eidCache` config option. When `eidCache.enabled`, targeting() merges fresh EIDs with previously cached EIDs from localStorage, applying per-source TTL expiry (uidapi.com=1d, id5-sync.com=7d, default=3d). Configurable via the ttl map. Also exports mergeWithCache() for wrappers using resolveMultiNodeTargeting, and dispatches the optableResolved event for backward compatibility with existing Prebid RTD listeners. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/config.ts | 6 + lib/core/eid-cache.test.ts | 293 +++++++++++++++++++++++++++++++ lib/core/eid-cache.ts | 131 ++++++++++++++ lib/core/events/cache-refresh.ts | 27 +-- lib/edge/targeting.ts | 7 +- lib/sdk.ts | 3 +- 6 files changed, 453 insertions(+), 14 deletions(-) create mode 100644 lib/core/eid-cache.test.ts create mode 100644 lib/core/eid-cache.ts diff --git a/lib/config.ts b/lib/config.ts index d7bd2f2f..cbdedfbb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,6 +1,7 @@ import { getConsent, inferRegulation } from "./core/regs/consent"; import type { CMPApiConfig, Consent } from "./core/regs/consent"; import type { PageContextConfig } from "./core/context"; +import type { EidCacheConfig } from "./core/eid-cache"; type Experiment = never; @@ -72,6 +73,8 @@ type InitConfig = { // When enabled, context is sent with the first witness() call after page load. // Set to true for defaults, or provide a PageContextConfig object for customization. pageContext?: PageContextConfig | boolean; + // EID cache configuration + eidCache?: EidCacheConfig; }; type ResolvedConfig = { @@ -92,6 +95,7 @@ type ResolvedConfig = { abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; timeout?: string; + eidCache?: EidCacheConfig; }; const DCN_DEFAULTS = { @@ -127,6 +131,7 @@ function getConfig(init: InitConfig): ResolvedConfig { abTests: init.abTests, additionalTargetingSignals: init.additionalTargetingSignals, timeout: init.timeout, + eidCache: init.eidCache, }; if (init.consent?.static) { @@ -158,5 +163,6 @@ export type { MatcherOverride, Experiment, PageContextConfig, + EidCacheConfig, }; export { getConfig, DCN_DEFAULTS, generateSessionID }; diff --git a/lib/core/eid-cache.test.ts b/lib/core/eid-cache.test.ts new file mode 100644 index 00000000..925a0449 --- /dev/null +++ b/lib/core/eid-cache.test.ts @@ -0,0 +1,293 @@ +import { mergeWithCache } from "./eid-cache"; +import type { EidCacheConfig } from "./eid-cache"; +import type { TargetingResponse } from "../edge/targeting"; + +const createTargetingResponse = ({ + data = [], + eids = [], + refs = undefined, + resolved_ids = [], +}: { + data?: any; + eids?: any; + refs?: any; + resolved_ids?: any; +}): TargetingResponse => { + return { + refs, + resolved_ids, + ortb2: { + user: { + data, + eids, + }, + }, + }; +}; + +const mockLocalStorage = (() => { + let store: Record = {}; + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + clear: () => { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, "localStorage", { + value: mockLocalStorage, +}); + +beforeEach(() => { + mockLocalStorage.clear(); +}); + +describe("mergeWithCache", () => { + const enabledConfig: EidCacheConfig = { enabled: true }; + + test("returns fresh response when cache is disabled", () => { + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }], + }); + + const result = mergeWithCache(freshResponse, { enabled: false }); + + expect(result).toEqual(freshResponse); + }); + + test("fresh response with empty cache returns fresh response and writes to localStorage", () => { + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }], + }); + + const result = mergeWithCache(freshResponse, enabledConfig); + + expect(result.ortb2.user.eids).toEqual([ + { source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }, + ]); + + const stored = JSON.parse(mockLocalStorage.getItem("OPTABLE_RESOLVED")!); + expect(stored.ortb2.user.eids).toHaveLength(1); + + const timestamps = JSON.parse(mockLocalStorage.getItem("OPTABLE_TIMESTAMPS")!); + expect(timestamps["matcher_one::uid2.com"]).toBeDefined(); + }); + + test("fresh response with valid cached EIDs merges correctly", () => { + const now = Date.now(); + mockLocalStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { + user: { + eids: [{ source: "id5-sync.com", matcher: "matcher_cached", uids: [{ id: "cached_id" }] }], + }, + }, + }) + ); + mockLocalStorage.setItem("OPTABLE_TIMESTAMPS", JSON.stringify({ "matcher_cached::id5-sync.com": now })); + + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_fresh", uids: [{ id: "fresh_id" }] }], + }); + + const result = mergeWithCache(freshResponse, enabledConfig); + + expect(result.ortb2.user.eids).toHaveLength(2); + expect(result.ortb2.user.eids).toContainEqual({ + source: "id5-sync.com", + matcher: "matcher_cached", + uids: [{ id: "cached_id" }], + }); + expect(result.ortb2.user.eids).toContainEqual({ + source: "uid2.com", + matcher: "matcher_fresh", + uids: [{ id: "fresh_id" }], + }); + }); + + test("fresh response overrides cached EID on collision", () => { + const now = Date.now(); + mockLocalStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { + user: { + eids: [{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "old_id" }] }], + }, + }, + }) + ); + mockLocalStorage.setItem("OPTABLE_TIMESTAMPS", JSON.stringify({ "matcher_one::uid2.com": now })); + + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "new_id" }] }], + }); + + const result = mergeWithCache(freshResponse, enabledConfig); + + expect(result.ortb2.user.eids).toHaveLength(1); + expect(result.ortb2.user.eids[0].uids[0].id).toBe("new_id"); + }); + + test("cached EIDs past TTL are evicted", () => { + const pastTime = Date.now() - 8 * 24 * 60 * 60 * 1000; + mockLocalStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { + user: { + eids: [{ source: "id5-sync.com", matcher: "matcher_old", uids: [{ id: "old_id" }] }], + }, + }, + }) + ); + mockLocalStorage.setItem("OPTABLE_TIMESTAMPS", JSON.stringify({ "matcher_old::id5-sync.com": pastTime })); + + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_fresh", uids: [{ id: "fresh_id" }] }], + }); + + const result = mergeWithCache(freshResponse, enabledConfig); + + expect(result.ortb2.user.eids).toHaveLength(1); + expect(result.ortb2.user.eids[0].source).toBe("uid2.com"); + + const timestamps = JSON.parse(mockLocalStorage.getItem("OPTABLE_TIMESTAMPS")!); + expect(timestamps["matcher_old::id5-sync.com"]).toBeUndefined(); + }); + + test("cached EIDs within TTL and not in fresh response are preserved", () => { + const now = Date.now(); + mockLocalStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { + user: { + eids: [{ source: "id5-sync.com", matcher: "matcher_cached", uids: [{ id: "cached_id" }] }], + }, + }, + }) + ); + mockLocalStorage.setItem("OPTABLE_TIMESTAMPS", JSON.stringify({ "matcher_cached::id5-sync.com": now })); + + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_fresh", uids: [{ id: "fresh_id" }] }], + }); + + const result = mergeWithCache(freshResponse, enabledConfig); + + expect(result.ortb2.user.eids).toHaveLength(2); + expect(result.ortb2.user.eids.map((e) => e.source)).toContain("id5-sync.com"); + expect(result.ortb2.user.eids.map((e) => e.source)).toContain("uid2.com"); + }); + + test("per-source TTL override works", () => { + const shortTime = Date.now() - 2 * 24 * 60 * 60 * 1000; + mockLocalStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { + user: { + eids: [{ source: "custom.com", matcher: "matcher_custom", uids: [{ id: "custom_id" }] }], + }, + }, + }) + ); + mockLocalStorage.setItem("OPTABLE_TIMESTAMPS", JSON.stringify({ "matcher_custom::custom.com": shortTime })); + + const freshResponse = createTargetingResponse({ + eids: [], + }); + + const configWithCustomTTL: EidCacheConfig = { + enabled: true, + ttl: { "custom.com": 1 * 24 * 60 * 60 * 1000 }, + }; + + const result = mergeWithCache(freshResponse, configWithCustomTTL); + + expect(result.ortb2.user.eids).toHaveLength(0); + }); + + test("custom storage keys work", () => { + const customConfig: EidCacheConfig = { + enabled: true, + storageKey: "CUSTOM_RESOLVED", + timestampKey: "CUSTOM_TIMESTAMPS", + }; + + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }], + }); + + mergeWithCache(freshResponse, customConfig); + + expect(mockLocalStorage.getItem("CUSTOM_RESOLVED")).toBeDefined(); + expect(mockLocalStorage.getItem("CUSTOM_TIMESTAMPS")).toBeDefined(); + expect(mockLocalStorage.getItem("OPTABLE_RESOLVED")).toBeNull(); + }); + + test("key format matcher::source is correct", () => { + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }], + }); + + mergeWithCache(freshResponse, enabledConfig); + + const timestamps = JSON.parse(mockLocalStorage.getItem("OPTABLE_TIMESTAMPS")!); + expect(Object.keys(timestamps)).toContain("matcher_one::uid2.com"); + }); + + test("handles malformed localStorage gracefully", () => { + mockLocalStorage.setItem("OPTABLE_RESOLVED", "invalid json"); + mockLocalStorage.setItem("OPTABLE_TIMESTAMPS", "{invalid}"); + + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }], + }); + + const result = mergeWithCache(freshResponse, enabledConfig); + + expect(result.ortb2.user.eids).toHaveLength(1); + expect(result.ortb2.user.eids[0].uids[0].id).toBe("uid123"); + }); + + test("handles missing localStorage gracefully", () => { + const freshResponse = createTargetingResponse({ + eids: [{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }], + }); + + const result = mergeWithCache(freshResponse, enabledConfig); + + expect(result.ortb2.user.eids).toHaveLength(1); + }); + + test("ignores EIDs with no uids", () => { + mockLocalStorage.setItem( + "OPTABLE_RESOLVED", + JSON.stringify({ + ortb2: { + user: { + eids: [{ source: "uid2.com", matcher: "matcher_empty", uids: [] }], + }, + }, + }) + ); + mockLocalStorage.setItem("OPTABLE_TIMESTAMPS", JSON.stringify({ "matcher_empty::uid2.com": Date.now() })); + + const freshResponse = createTargetingResponse({ + eids: [{ source: "id5-sync.com", matcher: "matcher_fresh", uids: [{ id: "fresh_id" }] }], + }); + + const result = mergeWithCache(freshResponse, enabledConfig); + + expect(result.ortb2.user.eids).toHaveLength(1); + expect(result.ortb2.user.eids[0].source).toBe("id5-sync.com"); + }); +}); diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts new file mode 100644 index 00000000..6b7c08aa --- /dev/null +++ b/lib/core/eid-cache.ts @@ -0,0 +1,131 @@ +import type { TargetingResponse } from "../edge/targeting"; +import type { EID } from "iab-openrtb/v26"; + +interface EidCacheConfig { + enabled: boolean; + ttl?: Record; + storageKey?: string; + timestampKey?: string; +} + +const DEFAULT_TTL: Record = { + "uidapi.com": 1 * 24 * 60 * 60 * 1000, + "id5-sync.com": 7 * 24 * 60 * 60 * 1000, + default: 3 * 24 * 60 * 60 * 1000, +}; + +const DEFAULT_STORAGE_KEY = "OPTABLE_RESOLVED"; +const DEFAULT_TIMESTAMP_KEY = "OPTABLE_TIMESTAMPS"; + +function getKey(eid: EID): string { + return `${eid.matcher}::${eid.source}`; +} + +function getTTL(source: string, config: EidCacheConfig): number { + return config.ttl?.[source] ?? DEFAULT_TTL[source] ?? DEFAULT_TTL.default; +} + +function readFromStorage(key: string): any { + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function writeToStorage(key: string, value: any): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Silent failure on storage errors + } +} + +export function mergeWithCache(freshResponse: TargetingResponse, config: EidCacheConfig): TargetingResponse { + if (!config.enabled) { + return freshResponse; + } + + const storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY; + const timestampKey = config.timestampKey ?? DEFAULT_TIMESTAMP_KEY; + + const cached = readFromStorage(storageKey) ?? { ortb2: { user: { eids: [] } } }; + const timestamps = readFromStorage(timestampKey) ?? {}; + + const now = Date.now(); + const eidMap = new Map(); + + const cachedEids: EID[] = cached?.ortb2?.user?.eids ?? []; + const freshEids: EID[] = freshResponse?.ortb2?.user?.eids ?? []; + + const freshKeys = new Set(freshEids.map(getKey)); + + for (const eid of cachedEids) { + if (!eid.uids?.length) { + continue; + } + + const key = getKey(eid); + + if (freshKeys.has(key)) { + continue; + } + + const ts = timestamps[key]; + const expiry = getTTL(eid.source, config); + + if (!ts) { + timestamps[key] = now; + eidMap.set(key, eid); + } else if (now - ts < expiry) { + eidMap.set(key, eid); + } else { + delete timestamps[key]; + } + } + + for (const eid of freshEids) { + if (!eid.uids?.length) { + continue; + } + + const key = getKey(eid); + eidMap.set(key, eid); + timestamps[key] = now; + } + + const mergedEids: EID[] = []; + const validTimestamps: Record = {}; + + eidMap.forEach((eid, key) => { + mergedEids.push(eid); + validTimestamps[key] = timestamps[key]; + }); + + const mergedResponse: TargetingResponse = { + ...freshResponse, + ortb2: { + ...freshResponse.ortb2, + user: { + ...freshResponse.ortb2?.user, + eids: mergedEids, + }, + }, + }; + + writeToStorage(storageKey, { + ortb2: { + user: { + data: freshResponse.ortb2?.user?.data, + eids: mergedEids, + }, + }, + }); + + writeToStorage(timestampKey, validTimestamps); + + return mergedResponse; +} + +export type { EidCacheConfig }; diff --git a/lib/core/events/cache-refresh.ts b/lib/core/events/cache-refresh.ts index d0abc200..a138d792 100644 --- a/lib/core/events/cache-refresh.ts +++ b/lib/core/events/cache-refresh.ts @@ -6,18 +6,21 @@ const targetingEventName = "optable-targeting:change"; function sendTargetingUpdateEvent(config: ResolvedConfig, response: TargetingResponse) { const matchers = response.ortb2?.user?.eids?.map((x) => x.matcher); - window.dispatchEvent( - new CustomEvent(targetingEventName, { - detail: { - instance: config.node || config.host, - resolved: !!response.ortb2?.user?.eids?.length, - resolvedIDs: response.resolved_ids ?? [], - abTestID: response.ab_test_id ?? undefined, - ortb2: response.ortb2, - provenance: new Set(matchers), - }, - }) - ); + const eventDetail = { + instance: config.node || config.host, + resolved: !!response.ortb2?.user?.eids?.length, + resolvedIDs: response.resolved_ids ?? [], + abTestID: response.ab_test_id ?? undefined, + ortb2: response.ortb2, + provenance: new Set(matchers), + }; + + window.dispatchEvent(new CustomEvent(targetingEventName, { detail: eventDetail })); + + if (config.eidCache?.enabled) { + window.dispatchEvent(new CustomEvent("optableResolved", { detail: eventDetail })); + document.dispatchEvent(new CustomEvent("optableResolved", { detail: eventDetail })); + } } export { sendTargetingUpdateEvent }; diff --git a/lib/edge/targeting.ts b/lib/edge/targeting.ts index a3996b91..58cc6ba5 100644 --- a/lib/edge/targeting.ts +++ b/lib/edge/targeting.ts @@ -4,6 +4,7 @@ import { LocalStorage } from "../core/storage"; import * as ortb2 from "iab-openrtb/v26"; import * as adcom from "iab-adcom"; import { sendTargetingUpdateEvent } from "../core/events/cache-refresh"; +import { mergeWithCache } from "../core/eid-cache"; type Identifier = { id: string; @@ -91,11 +92,15 @@ async function Targeting(config: ResolvedConfig, req: TargetingRequest): Promise const path = "/v2/targeting?" + searchParams.toString(); - const response: TargetingResponse = await fetch(path, config, { + let response: TargetingResponse = await fetch(path, config, { method: "GET", headers: { Accept: "application/json" }, }); + if (response && config.eidCache?.enabled) { + response = mergeWithCache(response, config.eidCache); + } + if (response) { const ls = new LocalStorage(config); ls.setTargeting(response); diff --git a/lib/sdk.ts b/lib/sdk.ts index c4031d41..d666bfed 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -215,5 +215,6 @@ function normalizeTargetingRequest(input: string | TargetingRequest): TargetingR } export { OptableSDK, normalizeTargetingRequest }; -export type { InitConfig }; +export { mergeWithCache } from "./core/eid-cache"; +export type { InitConfig, EidCacheConfig } from "./config"; export default OptableSDK; From 30536283994bf7bc0553eb6f0de2c6cf81d817ae Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:36:51 -0400 Subject: [PATCH 2/2] style: fix prettier formatting in eid-cache tests Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/core/eid-cache.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/core/eid-cache.test.ts b/lib/core/eid-cache.test.ts index 925a0449..b0c01a20 100644 --- a/lib/core/eid-cache.test.ts +++ b/lib/core/eid-cache.test.ts @@ -67,9 +67,7 @@ describe("mergeWithCache", () => { const result = mergeWithCache(freshResponse, enabledConfig); - expect(result.ortb2.user.eids).toEqual([ - { source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }, - ]); + expect(result.ortb2.user.eids).toEqual([{ source: "uid2.com", matcher: "matcher_one", uids: [{ id: "uid123" }] }]); const stored = JSON.parse(mockLocalStorage.getItem("OPTABLE_RESOLVED")!); expect(stored.ortb2.user.eids).toHaveLength(1);