Add programmatic consent API to @probo/cookie-banner

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-13 14:59:48 +04:00
committed by Émile Ré
parent 2040931405
commit 4078567c04
5 changed files with 121 additions and 1 deletions

View File

@@ -38,6 +38,12 @@ await Promise.all([
outfile: "dist/cookie-banner-headless.mjs", outfile: "dist/cookie-banner-headless.mjs",
format: "esm", format: "esm",
}), }),
esbuild.build({
...shared,
entryPoints: ["src/consent.ts"],
outfile: "dist/cookie-banner-consent.mjs",
format: "esm",
}),
esbuild.build({ esbuild.build({
...shared, ...shared,
entryPoints: ["src/themed-banner/iife.ts"], entryPoints: ["src/themed-banner/iife.ts"],

View File

@@ -16,6 +16,11 @@
"types": "./dist/headless/index.d.ts", "types": "./dist/headless/index.d.ts",
"import": "./dist/cookie-banner-headless.mjs", "import": "./dist/cookie-banner-headless.mjs",
"default": "./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": { "scripts": {

View File

@@ -16,6 +16,7 @@ import {
deactivateElements, deactivateElements,
observeAndActivate, observeAndActivate,
} from "./activation"; } from "./activation";
import { getConsent } from "./consent";
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, ReportQueue, ResourceDetector, StorageDetector } from "./detectors"; import { CookieDetector, ReportQueue, ResourceDetector, StorageDetector } from "./detectors";
@@ -109,6 +110,7 @@ export class CookieBannerClient {
}; };
this._gpcApplied = cookie.action === "GPC"; this._gpcApplied = cookie.action === "GPC";
this.activate(cookie.data); this.activate(cookie.data);
getConsent()._setReady(cookie.data, true);
void flush(this.bannerId); void flush(this.bannerId);
return; return;
} }
@@ -140,16 +142,24 @@ export class CookieBannerClient {
config.consent_expiry_days, config.consent_expiry_days,
); );
this.activate(apiConsent.consent_data); this.activate(apiConsent.consent_data);
getConsent()._setReady(apiConsent.consent_data, true);
} else { } else {
this.consent = null; this.consent = null;
} }
} }
if (!this.consent && this.gpcDetected) { if (!this.consent && this.gpcDetected) {
const gpcData: Record<string, boolean> = {};
for (const cat of config.categories) {
gpcData[cat.slug] = cat.kind === "NECESSARY";
}
getConsent()._setReady(gpcData, false);
this.gpc(); this.gpc();
this._gpcApplied = true; this._gpcApplied = true;
} else if (!this.consent) { } else if (!this.consent) {
this.activate(this.buildDefaultConsentData()); const defaults = this.buildDefaultConsentData();
this.activate(defaults);
getConsent()._setReady(defaults, false);
} }
void flush(this.bannerId); void flush(this.bannerId);
@@ -263,6 +273,7 @@ export class CookieBannerClient {
); );
this.activate(consentData); this.activate(consentData);
getConsent()._notify(consentData);
const url = new URL(`${this.bannerId}/consents`, this.baseUrl); const url = new URL(`${this.bannerId}/consents`, this.baseUrl);
const body = { const body = {

View File

@@ -0,0 +1,91 @@
// 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.
export type ConsentData = Record<string, boolean>;
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;
}

View File

@@ -12,10 +12,17 @@
// 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 { getConsent } from "../consent";
import { registerCookieBanner } from "./index"; import { registerCookieBanner } from "./index";
registerCookieBanner(); registerCookieBanner();
const w = window as unknown as Record<string, unknown>;
if (!w.Probo) {
w.Probo = {};
}
(w.Probo as Record<string, unknown>).consent = getConsent();
const script = document.currentScript as HTMLScriptElement | null; const script = document.currentScript as HTMLScriptElement | null;
if (script) { if (script) {