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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
283
packages/cookie-banner/src/detectors/report-queue.ts
Normal file
283
packages/cookie-banner/src/detectors/report-queue.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
56
packages/cookie-banner/src/detectors/types.ts
Normal file
56
packages/cookie-banner/src/detectors/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user