From 4078567c045f46079561b49c239f7d62b3db240c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 13 May 2026 14:59:48 +0400 Subject: [PATCH] Add programmatic consent API to @probo/cookie-banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose a ConsentManager singleton via `@probo/cookie-banner/consent` (ESM) and `window.Probo.consent` (IIFE) so customers can check and react to consent state in their bundled JavaScript code, solving the problem of third-party SDKs initialized programmatically that cannot be blocked via data-cookie-consent attributes. Signed-off-by: Émile Ré --- packages/cookie-banner/build.mjs | 6 ++ packages/cookie-banner/package.json | 5 + packages/cookie-banner/src/client.ts | 13 ++- packages/cookie-banner/src/consent.ts | 91 +++++++++++++++++++ .../cookie-banner/src/themed-banner/iife.ts | 7 ++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 packages/cookie-banner/src/consent.ts diff --git a/packages/cookie-banner/build.mjs b/packages/cookie-banner/build.mjs index 126e785e5..ec8e3ea3f 100644 --- a/packages/cookie-banner/build.mjs +++ b/packages/cookie-banner/build.mjs @@ -38,6 +38,12 @@ await Promise.all([ outfile: "dist/cookie-banner-headless.mjs", format: "esm", }), + esbuild.build({ + ...shared, + entryPoints: ["src/consent.ts"], + outfile: "dist/cookie-banner-consent.mjs", + format: "esm", + }), esbuild.build({ ...shared, entryPoints: ["src/themed-banner/iife.ts"], diff --git a/packages/cookie-banner/package.json b/packages/cookie-banner/package.json index 224fc2765..78a0a961a 100644 --- a/packages/cookie-banner/package.json +++ b/packages/cookie-banner/package.json @@ -16,6 +16,11 @@ "types": "./dist/headless/index.d.ts", "import": "./dist/cookie-banner-headless.mjs", "default": "./dist/cookie-banner-headless.mjs" + }, + "./consent": { + "types": "./dist/consent.d.ts", + "import": "./dist/cookie-banner-consent.mjs", + "default": "./dist/cookie-banner-consent.mjs" } }, "scripts": { diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index 38ccd9e1e..c31d6294d 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -16,6 +16,7 @@ import { deactivateElements, observeAndActivate, } from "./activation"; +import { getConsent } from "./consent"; import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie"; import type { Detector } from "./detectors"; import { CookieDetector, ReportQueue, ResourceDetector, StorageDetector } from "./detectors"; @@ -109,6 +110,7 @@ export class CookieBannerClient { }; this._gpcApplied = cookie.action === "GPC"; this.activate(cookie.data); + getConsent()._setReady(cookie.data, true); void flush(this.bannerId); return; } @@ -140,16 +142,24 @@ export class CookieBannerClient { config.consent_expiry_days, ); this.activate(apiConsent.consent_data); + getConsent()._setReady(apiConsent.consent_data, true); } else { this.consent = null; } } if (!this.consent && this.gpcDetected) { + const gpcData: Record = {}; + for (const cat of config.categories) { + gpcData[cat.slug] = cat.kind === "NECESSARY"; + } + getConsent()._setReady(gpcData, false); this.gpc(); this._gpcApplied = true; } else if (!this.consent) { - this.activate(this.buildDefaultConsentData()); + const defaults = this.buildDefaultConsentData(); + this.activate(defaults); + getConsent()._setReady(defaults, false); } void flush(this.bannerId); @@ -263,6 +273,7 @@ export class CookieBannerClient { ); this.activate(consentData); + getConsent()._notify(consentData); const url = new URL(`${this.bannerId}/consents`, this.baseUrl); const body = { diff --git a/packages/cookie-banner/src/consent.ts b/packages/cookie-banner/src/consent.ts new file mode 100644 index 000000000..d7e96984a --- /dev/null +++ b/packages/cookie-banner/src/consent.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +export type ConsentData = Record; +type Callback = (consent: ConsentData) => void; + +export class ConsentManager { + private _ready = false; + private _hasResponse = false; + private _consent: ConsentData = {}; + private readonly _readyListeners: Callback[] = []; + private readonly _changeListeners: Callback[] = []; + + get ready(): boolean { + return this._ready; + } + + get hasResponse(): boolean { + return this._hasResponse; + } + + has(category: string): boolean { + return !!this._consent[category]; + } + + getAll(): ConsentData { + return { ...this._consent }; + } + + onReady(cb: Callback): () => void { + if (this._ready) { + cb({ ...this._consent }); + return () => {}; + } + this._readyListeners.push(cb); + return () => { + const idx = this._readyListeners.indexOf(cb); + if (idx !== -1) this._readyListeners.splice(idx, 1); + }; + } + + onChange(cb: Callback): () => void { + this._changeListeners.push(cb); + return () => { + const idx = this._changeListeners.indexOf(cb); + if (idx !== -1) this._changeListeners.splice(idx, 1); + }; + } + + /** @internal Called by CookieBannerClient when consent state is first resolved. */ + _setReady(consent: ConsentData, hasResponse: boolean): void { + this._consent = consent; + this._hasResponse = hasResponse; + this._ready = true; + for (const cb of this._readyListeners.splice(0)) { + cb({ ...this._consent }); + } + for (const cb of this._changeListeners) { + cb({ ...this._consent }); + } + } + + /** @internal Called by CookieBannerClient when consent changes after user action. */ + _notify(consent: ConsentData): void { + this._consent = consent; + this._hasResponse = true; + for (const cb of this._changeListeners) { + cb({ ...this._consent }); + } + } +} + +let instance: ConsentManager | null = null; + +export function getConsent(): ConsentManager { + if (!instance) { + instance = new ConsentManager(); + } + return instance; +} diff --git a/packages/cookie-banner/src/themed-banner/iife.ts b/packages/cookie-banner/src/themed-banner/iife.ts index 8ea75a6ca..ea7403e85 100644 --- a/packages/cookie-banner/src/themed-banner/iife.ts +++ b/packages/cookie-banner/src/themed-banner/iife.ts @@ -12,10 +12,17 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { getConsent } from "../consent"; import { registerCookieBanner } from "./index"; registerCookieBanner(); +const w = window as unknown as Record; +if (!w.Probo) { + w.Probo = {}; +} +(w.Probo as Record).consent = getConsent(); + const script = document.currentScript as HTMLScriptElement | null; if (script) {