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",
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"],

View File

@@ -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": {

View File

@@ -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<string, boolean> = {};
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 = {

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
// PERFORMANCE OF THIS SOFTWARE.
import { getConsent } from "../consent";
import { registerCookieBanner } from "./index";
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;
if (script) {