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:
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
133
packages/cookie-banner/src/cookie-utils.ts
Normal file
133
packages/cookie-banner/src/cookie-utils.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
130
packages/cookie-banner/src/detector.ts
Normal file
130
packages/cookie-banner/src/detector.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user