Add cookie detector to JS SDK for automatic cookie discovery

Overrides document.cookie setter to intercept cookie writes and detect
unknown cookies not present in the banner config. Also scans pre-existing
cookies on startup. Detected cookies are debounced and reported in batches
to the detected-cookies endpoint with inferred durations from max-age/expires.

Extracts shared cookie helpers (parseCookieName, parseDuration, isDeletion,
removeCookies) into cookie-utils.ts, used by both activation.ts and
detector.ts. Unifies the duplicated "probo_consent" constant into a single
export from cookie.ts.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-22 16:59:02 +04:00
parent e52b7cc19e
commit 137b63a47e
7 changed files with 291 additions and 32 deletions

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { removeCookies } from "./cookie-utils";
import { LOCK_ICON } from "./html";
const ATTR_CATEGORY = "data-cookie-consent";
@@ -315,31 +316,6 @@ function deactivateElement(el: Element, label?: string): void {
}
}
function getCandidateDomains(hostname: string): string[] {
const parts = hostname.split(".");
if (parts.length <= 1) return [];
const candidates: string[] = [];
// Try progressively broader parent domains. The browser silently
// ignores attempts to clear cookies on public suffixes, so
// over-trying is safe and avoids maintaining a TLD list.
for (let i = 0; i < parts.length - 1; i++) {
candidates.push("." + parts.slice(i).join("."));
}
return candidates;
}
function removeCookies(names: string[]): void {
const domains = getCandidateDomains(location.hostname);
for (const name of names) {
document.cookie = `${name}=; path=/; max-age=0`;
for (const domain of domains) {
document.cookie = `${name}=; path=/; domain=${domain}; max-age=0`;
}
}
}
export function deactivateElements(
consentData: Record<string, boolean>,

View File

@@ -18,7 +18,8 @@ import {
deactivateElements,
observeAndActivate,
} from "./activation";
import { getConsentCookie, setConsentCookie } from "./cookie";
import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie";
import { CookieDetector } from "./detector";
import { NotFoundError } from "./errors";
import { fetchJSON } from "./http";
import { enqueue, flush } from "./queue";
@@ -77,6 +78,7 @@ export class CookieBannerClient {
private bannerConfig: BannerConfig | null = null;
private consent: VisitorConsent | null = null;
private observer: MutationObserver | null = null;
private detector: CookieDetector | null = null;
constructor(config: CookieBannerClientOptions) {
let base = config.baseUrl;
@@ -93,6 +95,8 @@ export class CookieBannerClient {
const config = await fetchJSON<BannerConfig>(configUrl);
this.bannerConfig = config;
this.startDetector(config);
const cookie = getConsentCookie();
if (cookie && cookie.v === config.version && cookie.vid === this.visitorId) {
this.consent = {
@@ -242,7 +246,24 @@ export class CookieBannerClient {
this.observer = observeAndActivate(consentData, categoryLabels);
}
private startDetector(config: BannerConfig): void {
const knownNames = new Set<string>();
knownNames.add(COOKIE_NAME);
for (const cat of config.categories) {
for (const cookie of cat.cookies) {
knownNames.add(cookie.name);
}
}
this.detector = new CookieDetector(this.baseUrl, this.bannerId, knownNames);
this.detector.start();
}
destroy(): void {
if (this.detector) {
this.detector.stop();
this.detector = null;
}
if (this.observer) {
this.observer.disconnect();
this.observer = null;

View File

@@ -0,0 +1,133 @@
// 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.
// [unitSeconds, singular, plural, snapBuffer]
// snapBuffer: if the remainder is within this many seconds of the next
// whole unit, round up instead of carrying into smaller units.
const DURATION_UNITS: [number, string, string, number][] = [
[365 * 24 * 3600, "year", "years", 21 * 24 * 3600],
[30 * 24 * 3600, "month", "months", 2 * 24 * 3600],
[7 * 24 * 3600, "week", "weeks", 12 * 3600],
[24 * 3600, "day", "days", 0],
[3600, "hour", "hours", 5 * 60],
[60, "minute", "minutes", 15],
];
function humanizeDuration(seconds: number): string {
if (seconds <= 0) return "session";
let remaining = seconds;
const parts: string[] = [];
for (const [unit, singular, plural, snap] of DURATION_UNITS) {
if (remaining >= unit) {
let count = Math.floor(remaining / unit);
const leftover = remaining - count * unit;
if (leftover >= unit - snap) {
count++;
remaining = 0;
} else if (leftover <= snap) {
remaining = 0;
} else {
remaining = leftover;
}
parts.push(count === 1 ? `1 ${singular}` : `${count} ${plural}`);
}
}
return parts.length > 0 ? parts.join(", ") : "session";
}
export function parseCookieName(raw: string): string {
const eqIdx = raw.indexOf("=");
if (eqIdx === -1) return raw.trim();
return raw.substring(0, eqIdx).trim();
}
export function parseDuration(raw: string): string {
const parts = raw.split(";").map((s) => s.trim());
for (const part of parts) {
const lower = part.toLowerCase();
if (lower.startsWith("max-age=")) {
const val = parseInt(part.substring(8), 10);
if (isNaN(val) || val <= 0) return "session";
return humanizeDuration(val);
}
}
for (const part of parts) {
const lower = part.toLowerCase();
if (lower.startsWith("expires=")) {
const dateStr = part.substring(8);
const expires = new Date(dateStr);
if (isNaN(expires.getTime())) return "session";
const deltaSeconds = Math.round(
(expires.getTime() - Date.now()) / 1000,
);
if (deltaSeconds <= 0) return "session";
return humanizeDuration(deltaSeconds);
}
}
return "session";
}
export function isDeletion(raw: string): boolean {
const parts = raw.split(";").map((s) => s.trim().toLowerCase());
for (const part of parts) {
if (part.startsWith("max-age=")) {
const val = parseInt(part.substring(8), 10);
if (val <= 0) return true;
}
if (part.startsWith("expires=")) {
const dateStr = part.substring(8);
const expires = new Date(dateStr);
if (!isNaN(expires.getTime()) && expires.getTime() <= Date.now()) {
return true;
}
}
}
return false;
}
function getCandidateDomains(hostname: string): string[] {
const parts = hostname.split(".");
if (parts.length <= 1) return [];
const candidates: string[] = [];
// Try progressively broader parent domains. The browser silently
// ignores attempts to clear cookies on public suffixes, so
// over-trying is safe and avoids maintaining a TLD list.
for (let i = 0; i < parts.length - 1; i++) {
candidates.push("." + parts.slice(i).join("."));
}
return candidates;
}
export function removeCookies(names: string[]): void {
const domains = getCandidateDomains(location.hostname);
for (const name of names) {
document.cookie = `${name}=; path=/; max-age=0`;
for (const domain of domains) {
document.cookie = `${name}=; path=/; domain=${domain}; max-age=0`;
}
}
}

View File

@@ -14,7 +14,7 @@
import type { ConsentAction } from "./client";
const COOKIE_NAME = "probo_consent";
export const COOKIE_NAME = "probo_consent";
const SECONDS_PER_DAY = 86400;
export interface ConsentCookie {

View File

@@ -0,0 +1,130 @@
// 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 { isDeletion, parseCookieName, parseDuration } from "./cookie-utils";
import { fetchJSON } from "./http";
interface DetectedCookieEntry {
name: string;
duration: string;
}
const DEBOUNCE_MS = 2_000;
export class CookieDetector {
private readonly reportUrl: URL;
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 originalDescriptor: PropertyDescriptor | null = null;
constructor(baseUrl: URL, bannerId: string, knownNames: Set<string>) {
this.reportUrl = new URL(`${bannerId}/detected-cookies`, baseUrl);
this.knownNames = knownNames;
}
start(): void {
const desc =
Object.getOwnPropertyDescriptor(Document.prototype, "cookie") ??
Object.getOwnPropertyDescriptor(HTMLDocument.prototype, "cookie");
if (!desc?.set || !desc?.get) return;
this.originalDescriptor = desc;
const self = this;
const originalGet = desc.get;
const originalSet = desc.set;
Object.defineProperty(document, "cookie", {
configurable: true,
get() {
return originalGet.call(this);
},
set(value: string) {
originalSet.call(this, value);
self.onCookieSet(value);
},
});
this.scanExisting();
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.pending.size > 0) {
this.flush();
}
if (this.originalDescriptor) {
Object.defineProperty(document, "cookie", this.originalDescriptor);
this.originalDescriptor = null;
}
}
private onCookieSet(raw: string): void {
if (isDeletion(raw)) return;
const name = parseCookieName(raw);
if (!name || this.knownNames.has(name) || this.reported.has(name)) return;
const duration = parseDuration(raw);
this.reported.add(name);
this.pending.set(name, { name, duration });
this.scheduleFlush();
}
private scanExisting(): void {
const cookieStr = document.cookie;
if (!cookieStr) return;
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, duration: "session" });
}
if (this.pending.size > 0) {
this.scheduleFlush();
}
}
private scheduleFlush(): void {
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = null;
this.flush();
}, DEBOUNCE_MS);
}
private flush(): void {
const entries = Array.from(this.pending.values());
this.pending.clear();
if (entries.length === 0) return;
void fetchJSON(this.reportUrl, {
method: "POST",
body: { cookies: entries },
}).catch(() => {});
}
}

View File

@@ -12,9 +12,8 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { COOKIE_NAME } from "./cookie";
import { fetchJSON } from "./http";
const STORAGE_KEY_PREFIX = "probo_consent";
const MAX_QUEUE_SIZE = 10;
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -25,7 +24,7 @@ interface PendingConsent {
}
function storageKey(bannerId: string): string {
return `${STORAGE_KEY_PREFIX}:${bannerId}:queue`;
return `${COOKIE_NAME}:${bannerId}:queue`;
}
function readQueue(bannerId: string): PendingConsent[] {

View File

@@ -12,10 +12,10 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
const STORAGE_KEY_PREFIX = "probo_consent";
import { COOKIE_NAME } from "./cookie";
export function getOrCreateVisitorId(bannerId: string): string {
const key = `${STORAGE_KEY_PREFIX}:${bannerId}:vid`;
const key = `${COOKIE_NAME}:${bannerId}:vid`;
try {
const stored = localStorage.getItem(key);