Add SDK client with consent cookie caching
CookieBannerClient wraps the cookie banner REST API with methods to load config, accept/reject/customize consent, and manage visitor identity. Consent state is persisted in a probo_consent cookie to skip API calls on return visits, with version-aware invalidation and configurable expiry. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
232
packages/cookie-banner/src/client.ts
Normal file
232
packages/cookie-banner/src/client.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) 2025-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 { getConsentCookie, setConsentCookie } from "./cookie";
|
||||
import { NotFoundError } from "./errors";
|
||||
import { fetchJSON } from "./http";
|
||||
|
||||
export interface CookieItem {
|
||||
name: string;
|
||||
duration: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
cookies: CookieItem[];
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
banner_id: string;
|
||||
version: number;
|
||||
privacy_policy_url: string;
|
||||
consent_expiry_days: number;
|
||||
consent_mode: "OPT_IN" | "OPT_OUT";
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export type ConsentAction = "ACCEPT_ALL" | "REJECT_ALL" | "CUSTOMIZE" | "GPC";
|
||||
|
||||
export interface VisitorConsent {
|
||||
visitor_id: string;
|
||||
version: number;
|
||||
action: ConsentAction;
|
||||
consent_data: Record<string, boolean>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ConsentRecord {
|
||||
id: string;
|
||||
visitor_id: string;
|
||||
action: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CookieBannerClientOptions {
|
||||
bannerId: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = "probo_consent";
|
||||
|
||||
function getOrCreateVisitorId(bannerId: string): string {
|
||||
const key = `${STORAGE_KEY_PREFIX}:${bannerId}:vid`;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, id);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export class CookieBannerClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly bannerId: string;
|
||||
private readonly visitorId: string;
|
||||
|
||||
private bannerConfig: BannerConfig | null = null;
|
||||
private consent: VisitorConsent | null = null;
|
||||
|
||||
constructor(config: CookieBannerClientOptions) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
this.bannerId = config.bannerId;
|
||||
this.visitorId = getOrCreateVisitorId(config.bannerId);
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const configUrl = `${this.baseUrl}/${this.bannerId}/config`;
|
||||
const config = await fetchJSON<BannerConfig>(configUrl);
|
||||
this.bannerConfig = config;
|
||||
|
||||
const cookie = getConsentCookie();
|
||||
if (cookie && cookie.v === config.version && cookie.vid === this.visitorId) {
|
||||
this.consent = {
|
||||
visitor_id: cookie.vid,
|
||||
version: cookie.v,
|
||||
action: cookie.action,
|
||||
consent_data: cookie.data,
|
||||
created_at: "",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const consentUrl = `${this.baseUrl}/${this.bannerId}/consents/${this.visitorId}`;
|
||||
const apiConsent = await fetchJSON<VisitorConsent>(consentUrl).catch(
|
||||
(err) => {
|
||||
if (err instanceof NotFoundError) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
|
||||
if (apiConsent && apiConsent.version === config.version) {
|
||||
this.consent = apiConsent;
|
||||
setConsentCookie(
|
||||
{
|
||||
v: apiConsent.version,
|
||||
vid: apiConsent.visitor_id,
|
||||
action: apiConsent.action,
|
||||
data: apiConsent.consent_data,
|
||||
},
|
||||
config.consent_expiry_days,
|
||||
);
|
||||
} else {
|
||||
this.consent = null;
|
||||
}
|
||||
}
|
||||
|
||||
get config(): BannerConfig {
|
||||
if (!this.bannerConfig) {
|
||||
throw new Error("CookieBannerClient not loaded: call load() first");
|
||||
}
|
||||
return this.bannerConfig;
|
||||
}
|
||||
|
||||
get visitorConsent(): VisitorConsent | null {
|
||||
return this.consent;
|
||||
}
|
||||
|
||||
get hasConsent(): boolean {
|
||||
return this.consent !== null;
|
||||
}
|
||||
|
||||
async acceptAll(): Promise<ConsentRecord> {
|
||||
const cfg = this.config;
|
||||
|
||||
const consentData: Record<string, boolean> = {};
|
||||
for (const cat of cfg.categories) {
|
||||
consentData[cat.name] = true;
|
||||
}
|
||||
|
||||
return this.recordConsent("ACCEPT_ALL", consentData);
|
||||
}
|
||||
|
||||
async rejectAll(): Promise<ConsentRecord> {
|
||||
const cfg = this.config;
|
||||
|
||||
const consentData: Record<string, boolean> = {};
|
||||
for (const cat of cfg.categories) {
|
||||
consentData[cat.name] = cat.required;
|
||||
}
|
||||
|
||||
return this.recordConsent("REJECT_ALL", consentData);
|
||||
}
|
||||
|
||||
async customize(
|
||||
categories: Record<string, boolean>,
|
||||
): Promise<ConsentRecord> {
|
||||
const cfg = this.config;
|
||||
|
||||
const consentData: Record<string, boolean> = {};
|
||||
for (const cat of cfg.categories) {
|
||||
consentData[cat.name] = cat.required || !!categories[cat.name];
|
||||
}
|
||||
|
||||
return this.recordConsent("CUSTOMIZE", consentData);
|
||||
}
|
||||
|
||||
private async recordConsent(
|
||||
action: ConsentAction,
|
||||
consentData: Record<string, boolean>,
|
||||
): Promise<ConsentRecord> {
|
||||
const cfg = this.config;
|
||||
const url = `${this.baseUrl}/${this.bannerId}/consents`;
|
||||
|
||||
const record = await fetchJSON<ConsentRecord>(url, {
|
||||
method: "POST",
|
||||
body: {
|
||||
visitor_id: this.visitorId,
|
||||
version: cfg.version,
|
||||
action,
|
||||
consent_data: consentData,
|
||||
},
|
||||
});
|
||||
|
||||
this.consent = {
|
||||
visitor_id: this.visitorId,
|
||||
version: cfg.version,
|
||||
action,
|
||||
consent_data: consentData,
|
||||
created_at: record.created_at,
|
||||
};
|
||||
|
||||
setConsentCookie(
|
||||
{
|
||||
v: cfg.version,
|
||||
vid: this.visitorId,
|
||||
action,
|
||||
data: consentData,
|
||||
},
|
||||
cfg.consent_expiry_days,
|
||||
);
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
||||
56
packages/cookie-banner/src/cookie.ts
Normal file
56
packages/cookie-banner/src/cookie.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2025-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 type { ConsentAction } from "./client";
|
||||
|
||||
const COOKIE_NAME = "probo_consent";
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
|
||||
export interface ConsentCookie {
|
||||
v: number;
|
||||
vid: string;
|
||||
action: ConsentAction;
|
||||
data: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function getConsentCookie(): ConsentCookie | null {
|
||||
try {
|
||||
const prefix = `${COOKIE_NAME}=`;
|
||||
const entry = document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(prefix));
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(decodeURIComponent(entry.substring(prefix.length)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setConsentCookie(
|
||||
value: ConsentCookie,
|
||||
expiryDays: number,
|
||||
): void {
|
||||
const maxAge = expiryDays * SECONDS_PER_DAY;
|
||||
const encoded = encodeURIComponent(JSON.stringify(value));
|
||||
|
||||
document.cookie = `${COOKIE_NAME}=${encoded}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function clearConsentCookie(): void {
|
||||
document.cookie = `${COOKIE_NAME}=; path=/; max-age=0; SameSite=Lax`;
|
||||
}
|
||||
@@ -14,6 +14,17 @@
|
||||
|
||||
export const VERSION = "0.0.0";
|
||||
|
||||
export { CookieBannerClient } from "./client";
|
||||
export type {
|
||||
BannerConfig,
|
||||
Category,
|
||||
ConsentAction,
|
||||
ConsentRecord,
|
||||
CookieBannerClientOptions,
|
||||
CookieItem,
|
||||
VisitorConsent,
|
||||
} from "./client";
|
||||
export type { ConsentCookie } from "./cookie";
|
||||
export {
|
||||
ApiError,
|
||||
BadRequestError,
|
||||
@@ -24,12 +35,3 @@ export {
|
||||
} from "./errors";
|
||||
export { fetchJSON } from "./http";
|
||||
export type { RequestOptions } from "./http";
|
||||
|
||||
export type CookieBannerConfig = {
|
||||
bannerId: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export async function init(_config: CookieBannerConfig): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user