cookie-banner: share one ReportQueue across detectors

The three detectors (cookies, storage, resources) each duplicated the
same debounce, batch, retry, and NotFoundError plumbing and each fired
its own POST /report despite the server already accepting a unified
{cookies, storage, resources} payload. Collapse the three sender paths
into a single ReportQueue so a 2 s debounce window produces one request
instead of up to three, dedup is centralised behind type-namespaced
keys (c:/s:/r:) that cannot collide across detectors, and a tab-close
drain via sendBeacon (with keepalive fetch fallback) saves the last
debounce window of detections that previously vanished on unload.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-11 13:04:22 +04:00
parent c8f130ed45
commit bec0254597
8 changed files with 406 additions and 296 deletions

View File

@@ -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 {

View File

@@ -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<string>;
private readonly reported: Set<string> = new Set();
private readonly pending: Map<string, DetectedCookieEntry> = new Map();
private timer: ReturnType<typeof setTimeout> | null = null;
private flushing = false;
private originalDescriptor: PropertyDescriptor | null = null;
private cookieStoreHandler: ((event: CookieChangeEvent) => void) | null = null;
constructor(baseUrl: URL, bannerId: string, knownNames: Set<string>) {
this.reportUrl = new URL(`${bannerId}/report`, baseUrl);
this.proboOrigin = baseUrl.origin;
constructor(queue: ReportQueue, apiOrigin: string, knownNames: Set<string>) {
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();
});
}
}

View File

@@ -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";

View File

@@ -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;

View File

@@ -0,0 +1,283 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<string, QueuedItem> = new Map();
private readonly reported: Set<string> = new Set();
private readonly notFoundListeners: Set<() => void> = new Set();
private timer: ReturnType<typeof setTimeout> | 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);
}
}
}

View File

@@ -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<string> = new Set();
private readonly pending: Map<string, DetectedStorageEntry> = new Map();
private timer: ReturnType<typeof setTimeout> | 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();
});
}
}

View File

@@ -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<string> = new Set();
private readonly pending: Map<string, DetectedResourceEntry> = new Map();
private timer: ReturnType<typeof setTimeout> | 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<HTMLIFrameElement>("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 });
}
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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;
}