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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { removeCookies } from "./cookie-utils";
|
||||||
import { LOCK_ICON } from "./html";
|
import { LOCK_ICON } from "./html";
|
||||||
|
|
||||||
const ATTR_CATEGORY = "data-cookie-consent";
|
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(
|
export function deactivateElements(
|
||||||
consentData: Record<string, boolean>,
|
consentData: Record<string, boolean>,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
deactivateElements,
|
deactivateElements,
|
||||||
observeAndActivate,
|
observeAndActivate,
|
||||||
} from "./activation";
|
} from "./activation";
|
||||||
import { getConsentCookie, setConsentCookie } from "./cookie";
|
import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie";
|
||||||
|
import { CookieDetector } from "./detector";
|
||||||
import { NotFoundError } from "./errors";
|
import { NotFoundError } from "./errors";
|
||||||
import { fetchJSON } from "./http";
|
import { fetchJSON } from "./http";
|
||||||
import { enqueue, flush } from "./queue";
|
import { enqueue, flush } from "./queue";
|
||||||
@@ -77,6 +78,7 @@ export class CookieBannerClient {
|
|||||||
private bannerConfig: BannerConfig | null = null;
|
private bannerConfig: BannerConfig | null = null;
|
||||||
private consent: VisitorConsent | null = null;
|
private consent: VisitorConsent | null = null;
|
||||||
private observer: MutationObserver | null = null;
|
private observer: MutationObserver | null = null;
|
||||||
|
private detector: CookieDetector | null = null;
|
||||||
|
|
||||||
constructor(config: CookieBannerClientOptions) {
|
constructor(config: CookieBannerClientOptions) {
|
||||||
let base = config.baseUrl;
|
let base = config.baseUrl;
|
||||||
@@ -93,6 +95,8 @@ export class CookieBannerClient {
|
|||||||
const config = await fetchJSON<BannerConfig>(configUrl);
|
const config = await fetchJSON<BannerConfig>(configUrl);
|
||||||
this.bannerConfig = config;
|
this.bannerConfig = config;
|
||||||
|
|
||||||
|
this.startDetector(config);
|
||||||
|
|
||||||
const cookie = getConsentCookie();
|
const cookie = getConsentCookie();
|
||||||
if (cookie && cookie.v === config.version && cookie.vid === this.visitorId) {
|
if (cookie && cookie.v === config.version && cookie.vid === this.visitorId) {
|
||||||
this.consent = {
|
this.consent = {
|
||||||
@@ -242,7 +246,24 @@ export class CookieBannerClient {
|
|||||||
this.observer = observeAndActivate(consentData, categoryLabels);
|
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 {
|
destroy(): void {
|
||||||
|
if (this.detector) {
|
||||||
|
this.detector.stop();
|
||||||
|
this.detector = null;
|
||||||
|
}
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
this.observer = null;
|
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";
|
import type { ConsentAction } from "./client";
|
||||||
|
|
||||||
const COOKIE_NAME = "probo_consent";
|
export const COOKIE_NAME = "probo_consent";
|
||||||
const SECONDS_PER_DAY = 86400;
|
const SECONDS_PER_DAY = 86400;
|
||||||
|
|
||||||
export interface ConsentCookie {
|
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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { COOKIE_NAME } from "./cookie";
|
||||||
import { fetchJSON } from "./http";
|
import { fetchJSON } from "./http";
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = "probo_consent";
|
|
||||||
const MAX_QUEUE_SIZE = 10;
|
const MAX_QUEUE_SIZE = 10;
|
||||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ interface PendingConsent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function storageKey(bannerId: string): string {
|
function storageKey(bannerId: string): string {
|
||||||
return `${STORAGE_KEY_PREFIX}:${bannerId}:queue`;
|
return `${COOKIE_NAME}:${bannerId}:queue`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readQueue(bannerId: string): PendingConsent[] {
|
function readQueue(bannerId: string): PendingConsent[] {
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = "probo_consent";
|
import { COOKIE_NAME } from "./cookie";
|
||||||
|
|
||||||
export function getOrCreateVisitorId(bannerId: string): string {
|
export function getOrCreateVisitorId(bannerId: string): string {
|
||||||
const key = `${STORAGE_KEY_PREFIX}:${bannerId}:vid`;
|
const key = `${COOKIE_NAME}:${bannerId}:vid`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(key);
|
const stored = localStorage.getItem(key);
|
||||||
|
|||||||
Reference in New Issue
Block a user