diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index 5dd957b82..9d78fc219 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -18,7 +18,7 @@ import { } from "./activation"; import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie"; import type { Detector } from "./detectors"; -import { CookieDetector, StorageDetector, ThirdPartyDetector } from "./detectors"; +import { CookieDetector, ReportQueue, StorageDetector, ThirdPartyDetector } from "./detectors"; import { NotFoundError } from "./errors"; import { fetchJSON } from "./http"; import { detectLanguage } from "./i18n"; @@ -58,6 +58,7 @@ export class CookieBannerClient { private consent: VisitorConsent | null = null; private observer: MutationObserver | null = null; private detectors: Detector[] = []; + private reportQueue: ReportQueue | null = null; private _gpcApplied = false; constructor(config: CookieBannerClientOptions) { @@ -308,10 +309,14 @@ export class CookieBannerClient { } } + const reportUrl = new URL(`${this.bannerId}/report`, this.baseUrl); + this.reportQueue = new ReportQueue(reportUrl); + + const apiOrigin = this.baseUrl.origin; this.detectors = [ - new CookieDetector(this.baseUrl, this.bannerId, knownNames), - new StorageDetector(this.baseUrl, this.bannerId), - new ThirdPartyDetector(this.baseUrl, this.bannerId), + new CookieDetector(this.reportQueue, apiOrigin, knownNames), + new StorageDetector(this.reportQueue, apiOrigin), + new ThirdPartyDetector(this.reportQueue, apiOrigin), ]; for (const d of this.detectors) { @@ -324,6 +329,10 @@ export class CookieBannerClient { d.stop(); } this.detectors = []; + if (this.reportQueue) { + this.reportQueue.stop(); + this.reportQueue = null; + } } destroy(): void { diff --git a/packages/cookie-banner/src/detectors/cookie-detector.ts b/packages/cookie-banner/src/detectors/cookie-detector.ts index 0d41fa4fe..89238acca 100644 --- a/packages/cookie-banner/src/detectors/cookie-detector.ts +++ b/packages/cookie-banner/src/detectors/cookie-detector.ts @@ -14,19 +14,10 @@ import { isDeletion, parseCookieName, parseMaxAgeSeconds } from "../cookie-utils"; import type { Detector } from "./detector"; -import { NotFoundError } from "../errors"; -import { fetchJSON } from "../http"; import { getInitiatorURL } from "./initiator"; +import type { ReportQueue } from "./report-queue"; +import type { DetectedCookieEntry } from "./types"; -interface DetectedCookieEntry { - name: string; - max_age_seconds: number | null; - source: "script" | "pre-existing" | "http"; - initiator_url?: string; -} - -const DEBOUNCE_MS = 2_000; -const MAX_COOKIES_PER_REQUEST = 100; const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; function isExtensionCaller(): boolean { @@ -35,23 +26,21 @@ function isExtensionCaller(): boolean { } export class CookieDetector implements Detector { - private readonly reportUrl: URL; - private readonly proboOrigin: string; + private readonly queue: ReportQueue; + private readonly apiOrigin: string; private readonly knownNames: Set; - private readonly reported: Set = new Set(); - private readonly pending: Map = new Map(); - private timer: ReturnType | null = null; - private flushing = false; private originalDescriptor: PropertyDescriptor | null = null; private cookieStoreHandler: ((event: CookieChangeEvent) => void) | null = null; - constructor(baseUrl: URL, bannerId: string, knownNames: Set) { - this.reportUrl = new URL(`${bannerId}/report`, baseUrl); - this.proboOrigin = baseUrl.origin; + constructor(queue: ReportQueue, apiOrigin: string, knownNames: Set) { + this.queue = queue; + this.apiOrigin = apiOrigin; this.knownNames = knownNames; } start(): void { + this.queue.onNotFound(() => this.stop()); + const desc = Object.getOwnPropertyDescriptor(Document.prototype, "cookie") ?? Object.getOwnPropertyDescriptor(HTMLDocument.prototype, "cookie"); @@ -80,15 +69,6 @@ export class CookieDetector implements Detector { } stop(): void { - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } - - if (this.pending.size > 0) { - this.flush(); - } - if (this.cookieStoreHandler && typeof cookieStore !== "undefined") { cookieStore.removeEventListener("change", this.cookieStoreHandler); this.cookieStoreHandler = null; @@ -105,20 +85,18 @@ export class CookieDetector implements Detector { if (isExtensionCaller()) return; const name = parseCookieName(raw); - if (!name || this.knownNames.has(name) || this.reported.has(name)) return; + if (!name || this.knownNames.has(name)) return; const maxAgeSeconds = parseMaxAgeSeconds(raw); - const initiatorUrl = getInitiatorURL(this.proboOrigin); + const initiatorUrl = getInitiatorURL(this.apiOrigin); - this.reported.add(name); const entry: DetectedCookieEntry = { name, max_age_seconds: maxAgeSeconds, source: "script", }; if (initiatorUrl) entry.initiator_url = initiatorUrl; - this.pending.set(name, entry); - this.scheduleFlush(); + this.queue.reportCookie(entry); } private scanExisting(): void { @@ -127,15 +105,9 @@ export class CookieDetector implements Detector { for (const pair of cookieStr.split(";")) { const name = pair.split("=")[0]?.trim(); - if (!name || this.knownNames.has(name) || this.reported.has(name)) { - continue; - } - this.reported.add(name); - this.pending.set(name, { name, max_age_seconds: null, source: "pre-existing" }); - } + if (!name || this.knownNames.has(name)) continue; - if (this.pending.size > 0) { - this.scheduleFlush(); + this.queue.reportCookie({ name, max_age_seconds: null, source: "pre-existing" }); } } @@ -146,66 +118,20 @@ export class CookieDetector implements Detector { this.cookieStoreHandler = (event: CookieChangeEvent) => { for (const cookie of event.changed) { - if (this.knownNames.has(cookie.name) || this.reported.has(cookie.name)) continue; + if (this.knownNames.has(cookie.name)) continue; const maxAge = cookie.expires ? Math.round((cookie.expires - Date.now()) / 1000) : null; - this.reported.add(cookie.name); - this.pending.set(cookie.name, { + this.queue.reportCookie({ name: cookie.name, max_age_seconds: maxAge && maxAge > 0 ? maxAge : null, source: "http", }); } - if (this.pending.size > 0) this.scheduleFlush(); }; cookieStore.addEventListener("change", this.cookieStoreHandler); } - - private scheduleFlush(): void { - if (this.timer || this.flushing) return; - this.timer = setTimeout(() => { - this.timer = null; - this.flush(); - }, DEBOUNCE_MS); - } - - // flush sends one batch from `pending` and only removes entries on - // success. Transient failures leave entries in `pending` so they are - // retried on the next flush. `flushing` guards against re-sending an - // in-flight batch when new entries arrive mid-request. - private flush(): void { - if (this.flushing) return; - if (this.pending.size === 0) return; - - const batchKeys: string[] = []; - const entries: DetectedCookieEntry[] = []; - for (const [key, entry] of this.pending) { - batchKeys.push(key); - entries.push(entry); - if (entries.length >= MAX_COOKIES_PER_REQUEST) break; - } - - this.flushing = true; - void fetchJSON(this.reportUrl, { - method: "POST", - body: { cookies: entries }, - }) - .then(() => { - for (const key of batchKeys) this.pending.delete(key); - }) - .catch((err) => { - if (err instanceof NotFoundError) { - this.pending.clear(); - this.stop(); - } - }) - .finally(() => { - this.flushing = false; - if (this.pending.size > 0) this.scheduleFlush(); - }); - } } diff --git a/packages/cookie-banner/src/detectors/index.ts b/packages/cookie-banner/src/detectors/index.ts index 27c05f654..ba26ef92f 100644 --- a/packages/cookie-banner/src/detectors/index.ts +++ b/packages/cookie-banner/src/detectors/index.ts @@ -16,3 +16,12 @@ export type { Detector } from "./detector"; export { CookieDetector } from "./cookie-detector"; export { StorageDetector } from "./storage-detector"; export { ThirdPartyDetector } from "./third-party-detector"; +export { ReportQueue } from "./report-queue"; +export type { + CookieSource, + DetectedCookieEntry, + DetectedResourceEntry, + DetectedStorageEntry, + ResourceType, + StorageType, +} from "./types"; diff --git a/packages/cookie-banner/src/detectors/initiator.ts b/packages/cookie-banner/src/detectors/initiator.ts index 910f3554f..bc0f9bc39 100644 --- a/packages/cookie-banner/src/detectors/initiator.ts +++ b/packages/cookie-banner/src/detectors/initiator.ts @@ -20,12 +20,12 @@ const MAX_INITIATOR_URL_LENGTH = 1024; // getInitiatorURL walks the current call stack and returns the first // third-party script URL (as origin+pathname). It deliberately skips: // - browser extension URLs (chrome/moz/safari-web-extension://) -// - the Probo SDK's own origin (instrumentation frames) +// - the SDK's API origin (instrumentation frames) // - the page's own origin (we want the third-party loader, not first-party code) // // Returns null when no third-party frame is found (anonymous/eval/inline // scripts, or writes originating from the page itself). -export function getInitiatorURL(proboOrigin: string): string | null { +export function getInitiatorURL(apiOrigin: string): string | null { const stack = new Error().stack; if (!stack) return null; @@ -44,7 +44,7 @@ export function getInitiatorURL(proboOrigin: string): string | null { continue; } - if (parsed.origin === proboOrigin) continue; + if (parsed.origin === apiOrigin) continue; if (parsed.origin === location.origin) continue; const result = parsed.origin + parsed.pathname; diff --git a/packages/cookie-banner/src/detectors/report-queue.ts b/packages/cookie-banner/src/detectors/report-queue.ts new file mode 100644 index 000000000..248d28d51 --- /dev/null +++ b/packages/cookie-banner/src/detectors/report-queue.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { NotFoundError } from "../errors"; +import { fetchJSON } from "../http"; +import type { + DetectedCookieEntry, + DetectedResourceEntry, + DetectedStorageEntry, +} from "./types"; + +const DEBOUNCE_MS = 2_000; + +// Must match `maxDetectedTrackersPerRequest` in +// pkg/server/api/cookiebanner/v1/handler.go. Bumping requires a +// coordinated server-side change; sending more in one request will be +// rejected with 400. +const MAX_ITEMS_PER_REQUEST = 100; + +type QueuedItem = + | { kind: "cookie"; entry: DetectedCookieEntry } + | { kind: "storage"; entry: DetectedStorageEntry } + | { kind: "resource"; entry: DetectedResourceEntry }; + +interface Batch { + cookies?: DetectedCookieEntry[]; + storage?: DetectedStorageEntry[]; + resources?: DetectedResourceEntry[]; +} + +// ReportQueue centralises debounce, batching, retry, dedup and the +// page-lifecycle drain for the three tracker detectors. Every reported +// item lives in a single `pending` Map keyed with a type-namespaced +// dedup key (`c:`, `s:`, `r:`) so that, e.g., a cookie literally named +// `s:local_storage:foo` cannot collide with a localStorage entry whose +// key is `foo`. The queue owns the only `reported` Set; detectors are +// pure producers that call `reportCookie/Storage/Resource` and don't +// track what they've already sent. +export class ReportQueue { + private readonly reportUrl: URL; + private readonly pending: Map = new Map(); + private readonly reported: Set = new Set(); + private readonly notFoundListeners: Set<() => void> = new Set(); + private timer: ReturnType | null = null; + private flushing = false; + private stopped = false; + private pageHideHandler: (() => void) | null = null; + private visibilityHandler: (() => void) | null = null; + + constructor(reportUrl: URL) { + this.reportUrl = reportUrl; + this.attachLifecycleListeners(); + } + + reportCookie(entry: DetectedCookieEntry): void { + this.enqueue(`c:${entry.name}`, { kind: "cookie", entry }); + } + + reportStorage(entry: DetectedStorageEntry): void { + this.enqueue(`s:${entry.storage_type}:${entry.key}`, { kind: "storage", entry }); + } + + reportResource(entry: DetectedResourceEntry): void { + this.enqueue(`r:${entry.resource_type}:${entry.url}`, { kind: "resource", entry }); + } + + onNotFound(cb: () => void): void { + this.notFoundListeners.add(cb); + } + + stop(): void { + if (this.stopped) return; + this.stopped = true; + + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + this.detachLifecycleListeners(); + + if (this.pending.size > 0) { + this.flushSync(); + } + } + + private enqueue(key: string, item: QueuedItem): void { + if (this.stopped) return; + if (this.reported.has(key)) return; + + this.reported.add(key); + this.pending.set(key, item); + this.scheduleFlush(); + } + + private scheduleFlush(): void { + if (this.timer || this.flushing || this.stopped) return; + + this.timer = setTimeout(() => { + this.timer = null; + this.flush(); + }, DEBOUNCE_MS); + } + + // flush sends one batch from `pending` and only removes entries on + // success. Transient failures leave entries in `pending` so they are + // retried on the next flush. `flushing` guards against re-sending an + // in-flight batch when new entries arrive mid-request. + private flush(): void { + if (this.flushing || this.stopped) return; + if (this.pending.size === 0) return; + + const { keys, body } = this.takeBatch(); + + this.flushing = true; + void fetchJSON(this.reportUrl, { + method: "POST", + body, + }) + .then(() => { + for (const key of keys) this.pending.delete(key); + }) + .catch((err) => { + if (err instanceof NotFoundError) { + this.pending.clear(); + this.notifyNotFound(); + } + }) + .finally(() => { + this.flushing = false; + if (!this.stopped && this.pending.size > 0) { + this.scheduleFlush(); + } + }); + } + + // takeBatch pulls up to MAX_ITEMS_PER_REQUEST items in insertion + // order (Map preserves it) and partitions them into the three arrays + // the server expects. Insertion-order is naturally fair: items are + // sent in the order the detectors observed them. + private takeBatch(): { keys: string[]; body: Batch } { + const keys: string[] = []; + const cookies: DetectedCookieEntry[] = []; + const storage: DetectedStorageEntry[] = []; + const resources: DetectedResourceEntry[] = []; + + for (const [key, item] of this.pending) { + keys.push(key); + switch (item.kind) { + case "cookie": + cookies.push(item.entry); + break; + case "storage": + storage.push(item.entry); + break; + case "resource": + resources.push(item.entry); + break; + } + if (keys.length >= MAX_ITEMS_PER_REQUEST) break; + } + + const body: Batch = {}; + if (cookies.length > 0) body.cookies = cookies; + if (storage.length > 0) body.storage = storage; + if (resources.length > 0) body.resources = resources; + + return { keys, body }; + } + + private notifyNotFound(): void { + for (const cb of this.notFoundListeners) { + try { + cb(); + } catch { + // Listeners must not throw across the queue; swallow so other + // detectors still get notified. + } + } + } + + // attachLifecycleListeners wires the queue to `pagehide` and + // `visibilitychange:hidden` so a tab close, navigation, or + // mobile-Safari background drains the queue synchronously instead of + // losing the last 0--DEBOUNCE_MS window of detections. Both events + // are listened to because: + // - `pagehide` is the canonical "page is going away" signal but + // does not always fire when an iOS tab is backgrounded; + // - `visibilitychange` to `hidden` covers the iOS background case + // and most mobile browsers. + // `flushSync` is idempotent (no-op when pending is empty), so firing + // twice is harmless. + // + // We deliberately avoid `unload`: it disables bfcache and is + // unreliable on mobile Safari. + private attachLifecycleListeners(): void { + if (typeof window === "undefined") return; + + this.pageHideHandler = () => this.flushSync(); + window.addEventListener("pagehide", this.pageHideHandler, { capture: true }); + + if (typeof document !== "undefined") { + this.visibilityHandler = () => { + if (document.visibilityState === "hidden") this.flushSync(); + }; + document.addEventListener("visibilitychange", this.visibilityHandler); + } + } + + private detachLifecycleListeners(): void { + if (this.pageHideHandler && typeof window !== "undefined") { + window.removeEventListener("pagehide", this.pageHideHandler, { capture: true }); + this.pageHideHandler = null; + } + if (this.visibilityHandler && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this.visibilityHandler); + this.visibilityHandler = null; + } + } + + // flushSync drains up to MAX_ITEMS_PER_REQUEST items via a transport + // that survives the document unloading. `navigator.sendBeacon` is + // tried first; if it is unavailable or refuses the payload (queue + // full / size cap exceeded), we fall back to `fetch` with + // `keepalive: true`. Both transports cap the total in-flight body + // size at ~64 KB across all requests, so we send at most one batch + // and accept losing the tail on pages with > 100 pending items at + // unload time -- still strictly better than the previous behaviour, + // which lost the entire last debounce window. + private flushSync(): void { + if (this.pending.size === 0) return; + + const { keys, body } = this.takeBatch(); + const payload = JSON.stringify(body); + + let sent = false; + if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") { + const blob = new Blob([payload], { type: "application/json" }); + try { + sent = navigator.sendBeacon(this.reportUrl.toString(), blob); + } catch { + sent = false; + } + } + + if (!sent && typeof fetch === "function") { + try { + void fetch(this.reportUrl.toString(), { + method: "POST", + mode: "cors", + credentials: "omit", + keepalive: true, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-SDK-Version": __SDK_VERSION__, + }, + body: payload, + }).catch(() => { + // fire-and-forget: the page is unloading, no retry possible. + }); + sent = true; + } catch { + sent = false; + } + } + + if (sent) { + for (const key of keys) this.pending.delete(key); + } + } +} diff --git a/packages/cookie-banner/src/detectors/storage-detector.ts b/packages/cookie-banner/src/detectors/storage-detector.ts index f6d45aac1..4361677a5 100644 --- a/packages/cookie-banner/src/detectors/storage-detector.ts +++ b/packages/cookie-banner/src/detectors/storage-detector.ts @@ -13,23 +13,10 @@ // PERFORMANCE OF THIS SOFTWARE. import type { Detector } from "./detector"; -import { NotFoundError } from "../errors"; -import { fetchJSON } from "../http"; import { getInitiatorURL } from "./initiator"; +import type { ReportQueue } from "./report-queue"; +import type { DetectedStorageEntry } from "./types"; -interface DetectedStorageEntry { - key: string; - storage_type: - | "local_storage" - | "session_storage" - | "indexed_db" - | "cache_storage"; - value_size: number | null; - initiator_url?: string; -} - -const DEBOUNCE_MS = 2_000; -const MAX_ITEMS_PER_REQUEST = 100; const OWN_KEY_PREFIX = "probo_consent:"; const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; @@ -39,22 +26,20 @@ function isExtensionCaller(): boolean { } export class StorageDetector implements Detector { - private readonly reportUrl: URL; - private readonly proboOrigin: string; - private readonly reported: Set = new Set(); - private readonly pending: Map = new Map(); - private timer: ReturnType | null = null; - private flushing = false; + private readonly queue: ReportQueue; + private readonly apiOrigin: string; private originalSetItem: typeof Storage.prototype.setItem | null = null; private originalIDBOpen: typeof IDBFactory.prototype.open | null = null; private originalCachesOpen: typeof CacheStorage.prototype.open | null = null; - constructor(baseUrl: URL, bannerId: string) { - this.reportUrl = new URL(`${bannerId}/report`, baseUrl); - this.proboOrigin = baseUrl.origin; + constructor(queue: ReportQueue, apiOrigin: string) { + this.queue = queue; + this.apiOrigin = apiOrigin; } start(): void { + this.queue.onNotFound(() => this.stop()); + this.wrapStorage(); this.wrapIndexedDB(); this.wrapCacheStorage(); @@ -63,15 +48,6 @@ export class StorageDetector implements Detector { } stop(): void { - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } - - if (this.pending.size > 0) { - this.flush(); - } - if (this.originalSetItem) { Storage.prototype.setItem = this.originalSetItem; this.originalSetItem = null; @@ -140,46 +116,31 @@ export class StorageDetector implements Detector { ): void { if (key.startsWith(OWN_KEY_PREFIX)) return; - const reportKey = `${storageType}:${key}`; - if (this.reported.has(reportKey)) return; + const initiatorUrl = getInitiatorURL(this.apiOrigin); - const initiatorUrl = getInitiatorURL(this.proboOrigin); - - this.reported.add(reportKey); const entry: DetectedStorageEntry = { key, storage_type: storageType, value_size: value.length * 2, }; if (initiatorUrl) entry.initiator_url = initiatorUrl; - this.pending.set(reportKey, entry); - this.scheduleFlush(); + this.queue.reportStorage(entry); } private onIndexedDBOpen(name: string): void { - const reportKey = `indexed_db:${name}`; - if (this.reported.has(reportKey)) return; - - this.reported.add(reportKey); - this.pending.set(reportKey, { + this.queue.reportStorage({ key: name, storage_type: "indexed_db", value_size: null, }); - this.scheduleFlush(); } private onCacheStorageOpen(name: string): void { - const reportKey = `cache_storage:${name}`; - if (this.reported.has(reportKey)) return; - - this.reported.add(reportKey); - this.pending.set(reportKey, { + this.queue.reportStorage({ key: name, storage_type: "cache_storage", value_size: null, }); - this.scheduleFlush(); } // scanCacheStorage enumerates pre-existing cache buckets created @@ -213,64 +174,12 @@ export class StorageDetector implements Detector { const key = storage.key(i); if (!key || key.startsWith(OWN_KEY_PREFIX)) continue; - const reportKey = `${storageType}:${key}`; - if (this.reported.has(reportKey)) continue; - const value = storage.getItem(key); - this.reported.add(reportKey); - this.pending.set(reportKey, { + this.queue.reportStorage({ key, storage_type: storageType, value_size: value ? value.length * 2 : null, }); } - - if (this.pending.size > 0) { - this.scheduleFlush(); - } - } - - private scheduleFlush(): void { - if (this.timer || this.flushing) return; - this.timer = setTimeout(() => { - this.timer = null; - this.flush(); - }, DEBOUNCE_MS); - } - - // flush sends one batch from `pending` and only removes entries on - // success. Transient failures leave entries in `pending` so they are - // retried on the next flush. `flushing` guards against re-sending an - // in-flight batch when new entries arrive mid-request. - private flush(): void { - if (this.flushing) return; - if (this.pending.size === 0) return; - - const batchKeys: string[] = []; - const entries: DetectedStorageEntry[] = []; - for (const [key, entry] of this.pending) { - batchKeys.push(key); - entries.push(entry); - if (entries.length >= MAX_ITEMS_PER_REQUEST) break; - } - - this.flushing = true; - void fetchJSON(this.reportUrl, { - method: "POST", - body: { storage: entries }, - }) - .then(() => { - for (const key of batchKeys) this.pending.delete(key); - }) - .catch((err) => { - if (err instanceof NotFoundError) { - this.pending.clear(); - this.stop(); - } - }) - .finally(() => { - this.flushing = false; - if (this.pending.size > 0) this.scheduleFlush(); - }); } } diff --git a/packages/cookie-banner/src/detectors/third-party-detector.ts b/packages/cookie-banner/src/detectors/third-party-detector.ts index 1963c7c20..0060a1791 100644 --- a/packages/cookie-banner/src/detectors/third-party-detector.ts +++ b/packages/cookie-banner/src/detectors/third-party-detector.ts @@ -13,27 +13,9 @@ // PERFORMANCE OF THIS SOFTWARE. import type { Detector } from "./detector"; -import { NotFoundError } from "../errors"; -import { fetchJSON } from "../http"; +import type { ReportQueue } from "./report-queue"; +import type { ResourceType } from "./types"; -type ResourceType = - | "script" - | "iframe" - | "image" - | "stylesheet" - | "font" - | "beacon" - | "fetch" - | "media" - | "service_worker"; - -interface DetectedResourceEntry { - url: string; - resource_type: ResourceType; -} - -const DEBOUNCE_MS = 2_000; -const MAX_ITEMS_PER_REQUEST = 100; const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//; // Map browser-reported PerformanceResourceTiming.initiatorType to the @@ -73,24 +55,22 @@ function mapInitiatorType(it: string): ResourceType | null { } export class ThirdPartyDetector implements Detector { - private readonly reportUrl: URL; + private readonly queue: ReportQueue; private readonly pageOrigin: string; - private readonly proboOrigin: string; - private readonly reported: Set = new Set(); - private readonly pending: Map = new Map(); - private timer: ReturnType | null = null; - private flushing = false; + private readonly apiOrigin: string; private observer: MutationObserver | null = null; private perfObserver: PerformanceObserver | null = null; private originalSWRegister: typeof ServiceWorkerContainer.prototype.register | null = null; - constructor(baseUrl: URL, bannerId: string) { - this.reportUrl = new URL(`${bannerId}/report`, baseUrl); + constructor(queue: ReportQueue, apiOrigin: string) { + this.queue = queue; this.pageOrigin = location.origin; - this.proboOrigin = baseUrl.origin; + this.apiOrigin = apiOrigin; } start(): void { + this.queue.onNotFound(() => this.stop()); + this.scanExisting(); this.observeMutations(); this.observePerformance(); @@ -99,15 +79,6 @@ export class ThirdPartyDetector implements Detector { } stop(): void { - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } - - if (this.pending.size > 0) { - this.flush(); - } - if (this.observer) { this.observer.disconnect(); this.observer = null; @@ -135,10 +106,6 @@ export class ThirdPartyDetector implements Detector { for (const iframe of document.querySelectorAll("iframe[src]")) { this.processResource(iframe.src, "iframe"); } - - if (this.pending.size > 0) { - this.scheduleFlush(); - } } private observeMutations(): void { @@ -250,61 +217,12 @@ export class ThirdPartyDetector implements Detector { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return; // Service workers are always same-origin per browser security rules, // so we never drop them based on the page origin -- they are tracked - // regardless of where the script lives. The proboOrigin guard still + // regardless of where the script lives. The apiOrigin guard still // applies so we never report our own SDK assets. - if (parsed.origin === this.proboOrigin) return; + if (parsed.origin === this.apiOrigin) return; if (resourceType !== "service_worker" && parsed.origin === this.pageOrigin) return; const identifier = parsed.origin + parsed.pathname; - const reportKey = `${resourceType}:${identifier}`; - if (this.reported.has(reportKey)) return; - - this.reported.add(reportKey); - this.pending.set(reportKey, { url: identifier, resource_type: resourceType }); - this.scheduleFlush(); - } - - private scheduleFlush(): void { - if (this.timer || this.flushing) return; - this.timer = setTimeout(() => { - this.timer = null; - this.flush(); - }, DEBOUNCE_MS); - } - - // flush sends one batch from `pending` and only removes entries on - // success. Transient failures leave entries in `pending` so they are - // retried on the next flush. `flushing` guards against re-sending an - // in-flight batch when new entries arrive mid-request. - private flush(): void { - if (this.flushing) return; - if (this.pending.size === 0) return; - - const batchKeys: string[] = []; - const entries: DetectedResourceEntry[] = []; - for (const [key, entry] of this.pending) { - batchKeys.push(key); - entries.push(entry); - if (entries.length >= MAX_ITEMS_PER_REQUEST) break; - } - - this.flushing = true; - void fetchJSON(this.reportUrl, { - method: "POST", - body: { resources: entries }, - }) - .then(() => { - for (const key of batchKeys) this.pending.delete(key); - }) - .catch((err) => { - if (err instanceof NotFoundError) { - this.pending.clear(); - this.stop(); - } - }) - .finally(() => { - this.flushing = false; - if (this.pending.size > 0) this.scheduleFlush(); - }); + this.queue.reportResource({ url: identifier, resource_type: resourceType }); } } diff --git a/packages/cookie-banner/src/detectors/types.ts b/packages/cookie-banner/src/detectors/types.ts new file mode 100644 index 000000000..191788f68 --- /dev/null +++ b/packages/cookie-banner/src/detectors/types.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// The string-literal unions below mirror the server's enum values +// accepted by `POST /{bannerID}/report` in +// pkg/server/api/cookiebanner/v1/handler.go. Any change here must be +// matched server-side or the request will be rejected. + +export type CookieSource = "script" | "pre-existing" | "http"; + +export type StorageType = + | "local_storage" + | "session_storage" + | "indexed_db" + | "cache_storage"; + +export type ResourceType = + | "script" + | "iframe" + | "image" + | "stylesheet" + | "font" + | "beacon" + | "fetch" + | "media" + | "service_worker"; + +export interface DetectedCookieEntry { + name: string; + max_age_seconds: number | null; + source: CookieSource; + initiator_url?: string; +} + +export interface DetectedStorageEntry { + key: string; + storage_type: StorageType; + value_size: number | null; + initiator_url?: string; +} + +export interface DetectedResourceEntry { + url: string; + resource_type: ResourceType; +}