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";
|
} from "./activation";
|
||||||
import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie";
|
import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie";
|
||||||
import type { Detector } from "./detectors";
|
import type { Detector } from "./detectors";
|
||||||
import { CookieDetector, StorageDetector, ThirdPartyDetector } from "./detectors";
|
import { CookieDetector, ReportQueue, StorageDetector, ThirdPartyDetector } from "./detectors";
|
||||||
import { NotFoundError } from "./errors";
|
import { NotFoundError } from "./errors";
|
||||||
import { fetchJSON } from "./http";
|
import { fetchJSON } from "./http";
|
||||||
import { detectLanguage } from "./i18n";
|
import { detectLanguage } from "./i18n";
|
||||||
@@ -58,6 +58,7 @@ export class CookieBannerClient {
|
|||||||
private consent: VisitorConsent | null = null;
|
private consent: VisitorConsent | null = null;
|
||||||
private observer: MutationObserver | null = null;
|
private observer: MutationObserver | null = null;
|
||||||
private detectors: Detector[] = [];
|
private detectors: Detector[] = [];
|
||||||
|
private reportQueue: ReportQueue | null = null;
|
||||||
private _gpcApplied = false;
|
private _gpcApplied = false;
|
||||||
|
|
||||||
constructor(config: CookieBannerClientOptions) {
|
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 = [
|
this.detectors = [
|
||||||
new CookieDetector(this.baseUrl, this.bannerId, knownNames),
|
new CookieDetector(this.reportQueue, apiOrigin, knownNames),
|
||||||
new StorageDetector(this.baseUrl, this.bannerId),
|
new StorageDetector(this.reportQueue, apiOrigin),
|
||||||
new ThirdPartyDetector(this.baseUrl, this.bannerId),
|
new ThirdPartyDetector(this.reportQueue, apiOrigin),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const d of this.detectors) {
|
for (const d of this.detectors) {
|
||||||
@@ -324,6 +329,10 @@ export class CookieBannerClient {
|
|||||||
d.stop();
|
d.stop();
|
||||||
}
|
}
|
||||||
this.detectors = [];
|
this.detectors = [];
|
||||||
|
if (this.reportQueue) {
|
||||||
|
this.reportQueue.stop();
|
||||||
|
this.reportQueue = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
|||||||
@@ -14,19 +14,10 @@
|
|||||||
|
|
||||||
import { isDeletion, parseCookieName, parseMaxAgeSeconds } from "../cookie-utils";
|
import { isDeletion, parseCookieName, parseMaxAgeSeconds } from "../cookie-utils";
|
||||||
import type { Detector } from "./detector";
|
import type { Detector } from "./detector";
|
||||||
import { NotFoundError } from "../errors";
|
|
||||||
import { fetchJSON } from "../http";
|
|
||||||
import { getInitiatorURL } from "./initiator";
|
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:\/\//;
|
const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//;
|
||||||
|
|
||||||
function isExtensionCaller(): boolean {
|
function isExtensionCaller(): boolean {
|
||||||
@@ -35,23 +26,21 @@ function isExtensionCaller(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CookieDetector implements Detector {
|
export class CookieDetector implements Detector {
|
||||||
private readonly reportUrl: URL;
|
private readonly queue: ReportQueue;
|
||||||
private readonly proboOrigin: string;
|
private readonly apiOrigin: string;
|
||||||
private readonly knownNames: Set<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 originalDescriptor: PropertyDescriptor | null = null;
|
||||||
private cookieStoreHandler: ((event: CookieChangeEvent) => void) | null = null;
|
private cookieStoreHandler: ((event: CookieChangeEvent) => void) | null = null;
|
||||||
|
|
||||||
constructor(baseUrl: URL, bannerId: string, knownNames: Set<string>) {
|
constructor(queue: ReportQueue, apiOrigin: string, knownNames: Set<string>) {
|
||||||
this.reportUrl = new URL(`${bannerId}/report`, baseUrl);
|
this.queue = queue;
|
||||||
this.proboOrigin = baseUrl.origin;
|
this.apiOrigin = apiOrigin;
|
||||||
this.knownNames = knownNames;
|
this.knownNames = knownNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
|
this.queue.onNotFound(() => this.stop());
|
||||||
|
|
||||||
const desc =
|
const desc =
|
||||||
Object.getOwnPropertyDescriptor(Document.prototype, "cookie") ??
|
Object.getOwnPropertyDescriptor(Document.prototype, "cookie") ??
|
||||||
Object.getOwnPropertyDescriptor(HTMLDocument.prototype, "cookie");
|
Object.getOwnPropertyDescriptor(HTMLDocument.prototype, "cookie");
|
||||||
@@ -80,15 +69,6 @@ export class CookieDetector implements Detector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pending.size > 0) {
|
|
||||||
this.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.cookieStoreHandler && typeof cookieStore !== "undefined") {
|
if (this.cookieStoreHandler && typeof cookieStore !== "undefined") {
|
||||||
cookieStore.removeEventListener("change", this.cookieStoreHandler);
|
cookieStore.removeEventListener("change", this.cookieStoreHandler);
|
||||||
this.cookieStoreHandler = null;
|
this.cookieStoreHandler = null;
|
||||||
@@ -105,20 +85,18 @@ export class CookieDetector implements Detector {
|
|||||||
if (isExtensionCaller()) return;
|
if (isExtensionCaller()) return;
|
||||||
|
|
||||||
const name = parseCookieName(raw);
|
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 maxAgeSeconds = parseMaxAgeSeconds(raw);
|
||||||
const initiatorUrl = getInitiatorURL(this.proboOrigin);
|
const initiatorUrl = getInitiatorURL(this.apiOrigin);
|
||||||
|
|
||||||
this.reported.add(name);
|
|
||||||
const entry: DetectedCookieEntry = {
|
const entry: DetectedCookieEntry = {
|
||||||
name,
|
name,
|
||||||
max_age_seconds: maxAgeSeconds,
|
max_age_seconds: maxAgeSeconds,
|
||||||
source: "script",
|
source: "script",
|
||||||
};
|
};
|
||||||
if (initiatorUrl) entry.initiator_url = initiatorUrl;
|
if (initiatorUrl) entry.initiator_url = initiatorUrl;
|
||||||
this.pending.set(name, entry);
|
this.queue.reportCookie(entry);
|
||||||
this.scheduleFlush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private scanExisting(): void {
|
private scanExisting(): void {
|
||||||
@@ -127,15 +105,9 @@ export class CookieDetector implements Detector {
|
|||||||
|
|
||||||
for (const pair of cookieStr.split(";")) {
|
for (const pair of cookieStr.split(";")) {
|
||||||
const name = pair.split("=")[0]?.trim();
|
const name = pair.split("=")[0]?.trim();
|
||||||
if (!name || this.knownNames.has(name) || this.reported.has(name)) {
|
if (!name || this.knownNames.has(name)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.reported.add(name);
|
|
||||||
this.pending.set(name, { name, max_age_seconds: null, source: "pre-existing" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pending.size > 0) {
|
this.queue.reportCookie({ name, max_age_seconds: null, source: "pre-existing" });
|
||||||
this.scheduleFlush();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,66 +118,20 @@ export class CookieDetector implements Detector {
|
|||||||
|
|
||||||
this.cookieStoreHandler = (event: CookieChangeEvent) => {
|
this.cookieStoreHandler = (event: CookieChangeEvent) => {
|
||||||
for (const cookie of event.changed) {
|
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
|
const maxAge = cookie.expires
|
||||||
? Math.round((cookie.expires - Date.now()) / 1000)
|
? Math.round((cookie.expires - Date.now()) / 1000)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
this.reported.add(cookie.name);
|
this.queue.reportCookie({
|
||||||
this.pending.set(cookie.name, {
|
|
||||||
name: cookie.name,
|
name: cookie.name,
|
||||||
max_age_seconds: maxAge && maxAge > 0 ? maxAge : null,
|
max_age_seconds: maxAge && maxAge > 0 ? maxAge : null,
|
||||||
source: "http",
|
source: "http",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.pending.size > 0) this.scheduleFlush();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cookieStore.addEventListener("change", this.cookieStoreHandler);
|
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 { CookieDetector } from "./cookie-detector";
|
||||||
export { StorageDetector } from "./storage-detector";
|
export { StorageDetector } from "./storage-detector";
|
||||||
export { ThirdPartyDetector } from "./third-party-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
|
// getInitiatorURL walks the current call stack and returns the first
|
||||||
// third-party script URL (as origin+pathname). It deliberately skips:
|
// third-party script URL (as origin+pathname). It deliberately skips:
|
||||||
// - browser extension URLs (chrome/moz/safari-web-extension://)
|
// - 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)
|
// - 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
|
// Returns null when no third-party frame is found (anonymous/eval/inline
|
||||||
// scripts, or writes originating from the page itself).
|
// 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;
|
const stack = new Error().stack;
|
||||||
if (!stack) return null;
|
if (!stack) return null;
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export function getInitiatorURL(proboOrigin: string): string | null {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.origin === proboOrigin) continue;
|
if (parsed.origin === apiOrigin) continue;
|
||||||
if (parsed.origin === location.origin) continue;
|
if (parsed.origin === location.origin) continue;
|
||||||
|
|
||||||
const result = parsed.origin + parsed.pathname;
|
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.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import type { Detector } from "./detector";
|
import type { Detector } from "./detector";
|
||||||
import { NotFoundError } from "../errors";
|
|
||||||
import { fetchJSON } from "../http";
|
|
||||||
import { getInitiatorURL } from "./initiator";
|
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 OWN_KEY_PREFIX = "probo_consent:";
|
||||||
const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//;
|
const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//;
|
||||||
|
|
||||||
@@ -39,22 +26,20 @@ function isExtensionCaller(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class StorageDetector implements Detector {
|
export class StorageDetector implements Detector {
|
||||||
private readonly reportUrl: URL;
|
private readonly queue: ReportQueue;
|
||||||
private readonly proboOrigin: string;
|
private readonly apiOrigin: 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 originalSetItem: typeof Storage.prototype.setItem | null = null;
|
private originalSetItem: typeof Storage.prototype.setItem | null = null;
|
||||||
private originalIDBOpen: typeof IDBFactory.prototype.open | null = null;
|
private originalIDBOpen: typeof IDBFactory.prototype.open | null = null;
|
||||||
private originalCachesOpen: typeof CacheStorage.prototype.open | null = null;
|
private originalCachesOpen: typeof CacheStorage.prototype.open | null = null;
|
||||||
|
|
||||||
constructor(baseUrl: URL, bannerId: string) {
|
constructor(queue: ReportQueue, apiOrigin: string) {
|
||||||
this.reportUrl = new URL(`${bannerId}/report`, baseUrl);
|
this.queue = queue;
|
||||||
this.proboOrigin = baseUrl.origin;
|
this.apiOrigin = apiOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
|
this.queue.onNotFound(() => this.stop());
|
||||||
|
|
||||||
this.wrapStorage();
|
this.wrapStorage();
|
||||||
this.wrapIndexedDB();
|
this.wrapIndexedDB();
|
||||||
this.wrapCacheStorage();
|
this.wrapCacheStorage();
|
||||||
@@ -63,15 +48,6 @@ export class StorageDetector implements Detector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pending.size > 0) {
|
|
||||||
this.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.originalSetItem) {
|
if (this.originalSetItem) {
|
||||||
Storage.prototype.setItem = this.originalSetItem;
|
Storage.prototype.setItem = this.originalSetItem;
|
||||||
this.originalSetItem = null;
|
this.originalSetItem = null;
|
||||||
@@ -140,46 +116,31 @@ export class StorageDetector implements Detector {
|
|||||||
): void {
|
): void {
|
||||||
if (key.startsWith(OWN_KEY_PREFIX)) return;
|
if (key.startsWith(OWN_KEY_PREFIX)) return;
|
||||||
|
|
||||||
const reportKey = `${storageType}:${key}`;
|
const initiatorUrl = getInitiatorURL(this.apiOrigin);
|
||||||
if (this.reported.has(reportKey)) return;
|
|
||||||
|
|
||||||
const initiatorUrl = getInitiatorURL(this.proboOrigin);
|
|
||||||
|
|
||||||
this.reported.add(reportKey);
|
|
||||||
const entry: DetectedStorageEntry = {
|
const entry: DetectedStorageEntry = {
|
||||||
key,
|
key,
|
||||||
storage_type: storageType,
|
storage_type: storageType,
|
||||||
value_size: value.length * 2,
|
value_size: value.length * 2,
|
||||||
};
|
};
|
||||||
if (initiatorUrl) entry.initiator_url = initiatorUrl;
|
if (initiatorUrl) entry.initiator_url = initiatorUrl;
|
||||||
this.pending.set(reportKey, entry);
|
this.queue.reportStorage(entry);
|
||||||
this.scheduleFlush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onIndexedDBOpen(name: string): void {
|
private onIndexedDBOpen(name: string): void {
|
||||||
const reportKey = `indexed_db:${name}`;
|
this.queue.reportStorage({
|
||||||
if (this.reported.has(reportKey)) return;
|
|
||||||
|
|
||||||
this.reported.add(reportKey);
|
|
||||||
this.pending.set(reportKey, {
|
|
||||||
key: name,
|
key: name,
|
||||||
storage_type: "indexed_db",
|
storage_type: "indexed_db",
|
||||||
value_size: null,
|
value_size: null,
|
||||||
});
|
});
|
||||||
this.scheduleFlush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onCacheStorageOpen(name: string): void {
|
private onCacheStorageOpen(name: string): void {
|
||||||
const reportKey = `cache_storage:${name}`;
|
this.queue.reportStorage({
|
||||||
if (this.reported.has(reportKey)) return;
|
|
||||||
|
|
||||||
this.reported.add(reportKey);
|
|
||||||
this.pending.set(reportKey, {
|
|
||||||
key: name,
|
key: name,
|
||||||
storage_type: "cache_storage",
|
storage_type: "cache_storage",
|
||||||
value_size: null,
|
value_size: null,
|
||||||
});
|
});
|
||||||
this.scheduleFlush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanCacheStorage enumerates pre-existing cache buckets created
|
// scanCacheStorage enumerates pre-existing cache buckets created
|
||||||
@@ -213,64 +174,12 @@ export class StorageDetector implements Detector {
|
|||||||
const key = storage.key(i);
|
const key = storage.key(i);
|
||||||
if (!key || key.startsWith(OWN_KEY_PREFIX)) continue;
|
if (!key || key.startsWith(OWN_KEY_PREFIX)) continue;
|
||||||
|
|
||||||
const reportKey = `${storageType}:${key}`;
|
|
||||||
if (this.reported.has(reportKey)) continue;
|
|
||||||
|
|
||||||
const value = storage.getItem(key);
|
const value = storage.getItem(key);
|
||||||
this.reported.add(reportKey);
|
this.queue.reportStorage({
|
||||||
this.pending.set(reportKey, {
|
|
||||||
key,
|
key,
|
||||||
storage_type: storageType,
|
storage_type: storageType,
|
||||||
value_size: value ? value.length * 2 : null,
|
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.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import type { Detector } from "./detector";
|
import type { Detector } from "./detector";
|
||||||
import { NotFoundError } from "../errors";
|
import type { ReportQueue } from "./report-queue";
|
||||||
import { fetchJSON } from "../http";
|
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:\/\//;
|
const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//;
|
||||||
|
|
||||||
// Map browser-reported PerformanceResourceTiming.initiatorType to the
|
// Map browser-reported PerformanceResourceTiming.initiatorType to the
|
||||||
@@ -73,24 +55,22 @@ function mapInitiatorType(it: string): ResourceType | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ThirdPartyDetector implements Detector {
|
export class ThirdPartyDetector implements Detector {
|
||||||
private readonly reportUrl: URL;
|
private readonly queue: ReportQueue;
|
||||||
private readonly pageOrigin: string;
|
private readonly pageOrigin: string;
|
||||||
private readonly proboOrigin: string;
|
private readonly apiOrigin: 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 observer: MutationObserver | null = null;
|
private observer: MutationObserver | null = null;
|
||||||
private perfObserver: PerformanceObserver | null = null;
|
private perfObserver: PerformanceObserver | null = null;
|
||||||
private originalSWRegister: typeof ServiceWorkerContainer.prototype.register | null = null;
|
private originalSWRegister: typeof ServiceWorkerContainer.prototype.register | null = null;
|
||||||
|
|
||||||
constructor(baseUrl: URL, bannerId: string) {
|
constructor(queue: ReportQueue, apiOrigin: string) {
|
||||||
this.reportUrl = new URL(`${bannerId}/report`, baseUrl);
|
this.queue = queue;
|
||||||
this.pageOrigin = location.origin;
|
this.pageOrigin = location.origin;
|
||||||
this.proboOrigin = baseUrl.origin;
|
this.apiOrigin = apiOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
|
this.queue.onNotFound(() => this.stop());
|
||||||
|
|
||||||
this.scanExisting();
|
this.scanExisting();
|
||||||
this.observeMutations();
|
this.observeMutations();
|
||||||
this.observePerformance();
|
this.observePerformance();
|
||||||
@@ -99,15 +79,6 @@ export class ThirdPartyDetector implements Detector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pending.size > 0) {
|
|
||||||
this.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
this.observer = null;
|
this.observer = null;
|
||||||
@@ -135,10 +106,6 @@ export class ThirdPartyDetector implements Detector {
|
|||||||
for (const iframe of document.querySelectorAll<HTMLIFrameElement>("iframe[src]")) {
|
for (const iframe of document.querySelectorAll<HTMLIFrameElement>("iframe[src]")) {
|
||||||
this.processResource(iframe.src, "iframe");
|
this.processResource(iframe.src, "iframe");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.pending.size > 0) {
|
|
||||||
this.scheduleFlush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private observeMutations(): void {
|
private observeMutations(): void {
|
||||||
@@ -250,61 +217,12 @@ export class ThirdPartyDetector implements Detector {
|
|||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
|
||||||
// Service workers are always same-origin per browser security rules,
|
// Service workers are always same-origin per browser security rules,
|
||||||
// so we never drop them based on the page origin -- they are tracked
|
// 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.
|
// 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;
|
if (resourceType !== "service_worker" && parsed.origin === this.pageOrigin) return;
|
||||||
|
|
||||||
const identifier = parsed.origin + parsed.pathname;
|
const identifier = parsed.origin + parsed.pathname;
|
||||||
const reportKey = `${resourceType}:${identifier}`;
|
this.queue.reportResource({ url: identifier, resource_type: resourceType });
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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