@mycookies/widget
v0.2.4
Published
Privacy-first cookie consent client library with embeddable widget and headless SDK from My Cookies service.
Maintainers
Readme
@mycookies/widget
Privacy-first cookie consent client library for the MyCookies service. Ships two consumption modes — an embeddable consent UI widget and a headless SDK. Tenant configuration is delivered as a server-signed remote payload (categories, branding, integrations). When enabled on your subscription, consent decisions are recorded to the MyCookies backend for compliance audit.
- Zero runtime dependencies — no bloat on your end-user pages
- Shadow DOM isolation — widget styles never conflict with your site
- GDPR, ePrivacy, CCPA/CPRA, TCF v2 compliant consent flows
- ECDSA-verified remote config — tenant configuration is signed server-side and verified client-side before use
- Tree-shakeable — headless entry omits all UI code
Table of Contents
- Installation
- Quick Start
- Entry Points
- Factory Functions
- Consent Engine API
- Integrations
- Remote Configuration
- Storage Adapters
- TypeScript Types
- Error Handling
Installation
npm install @mycookies/widget
# or
yarn add @mycookies/widgetPrerequisites: A MyCookies subscription UUID is required. Sign up at mycookies.ws to obtain one. All production factory functions require a valid UUID v4 subscription identifier.
Quick Start
Embeddable Widget
Drop in a fully managed consent banner and modal. The widget resolves your tenant configuration from the MyCookies backend, verifies the ECDSA signature, and renders the UI automatically.
import { createWidget } from '@mycookies/widget';
const widget = await createWidget({
subscriptionId: 'xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx',
});
// Programmatically open the preferences modal
widget.showPreferences();
// Access the underlying engine
const engine = widget.getEngine();
engine.onConsentChange((state) => {
console.log('Consent updated:', state);
});
// Tear down on SPA route change
widget.destroy();Headless SDK
Use the engine directly without any UI — ideal for SPAs and custom consent experiences.
import { createValidatedEngine } from '@mycookies/widget/headless';
const { engine, integrations, poller } = await createValidatedEngine({
subscriptionId: 'xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx',
});
// React to consent changes
engine.onConsentChange((state) => {
if (state.analytics) {
initAnalytics();
}
});
// Check a specific category
if (engine.hasConsent('marketing')) {
loadMarketingPixel();
}
// Tear down on SPA route change
poller?.destroy();
integrations.scriptManager?.destroy();
integrations.consentLogger?.destroy();CDN Script Tag
For non-bundled sites, load the IIFE bundle directly. The script auto-initializes using the data-subscription attribute.
<!-- Full widget with consent UI -->
<script
src="https://cdn.mycookies.ws/mycookies.min.iife.js"
data-subscription="xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx"
data-theme="auto"
></script>After the script loads, the global window.MyCookies is populated:
// Access the widget and engine from anywhere on the page
window.MyCookies.widget.showPreferences();
window.MyCookies.engine.hasConsent('analytics');
window.MyCookies.poller; // ConfigPoller instance, if polling is enabledHeadless CDN bundle — engine only, no UI, smaller footprint:
<script
src="https://cdn.mycookies.ws/mycookies-headless.min.iife.js"
data-subscription="xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx"
></script>
<script>
// window.MyCookies.engine is ready after DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
window.MyCookies.engine.onConsentChange((state) => {
console.log(state);
});
});
</script>Note: Both CDN bundles inject Google Consent Mode v2 defaults synchronously at script evaluation time — before GTM loads — when GCM is enabled on your subscription. This solves the GTM chicken-and-egg timing problem automatically.
Entry Points
The package exposes three sub-entry points:
| Entry point | Use case | Includes UI |
|---|---|---|
| @mycookies/widget | Full library — widget + engine + all utilities | Yes |
| @mycookies/widget/headless | Engine only — no UI imports, smaller bundle | No |
Import from @mycookies/widget/headless in projects that use a custom consent UI to avoid pulling in Shadow DOM and rendering code.
Factory Functions
All production factory functions require a valid UUID v4 subscriptionId and perform:
- UUID v4 format validation
- Remote config fetch with ECDSA signature verification
- Domain authorization check against
allowedDomainsin the signed payload - Integration auto-wiring from the remote
integrationsconfig
createWidget
import { createWidget } from '@mycookies/widget';
const widget = await createWidget(options);Options (CreateWidgetOptions):
| Option | Type | Required | Description |
|---|---|---|---|
| subscriptionId | string | Yes | UUID v4 subscription identifier |
| remote.timeoutMs | number | No | Config fetch timeout in ms (default: 5000) |
| remote.onError | (error: ConfigError) => void | No | Called when remote config fails |
| theme | 'light' \| 'dark' \| 'auto' | No | Widget color scheme. 'auto' follows OS preference |
| customCssUrl | string | No | URL of a CSS file injected into the widget Shadow DOM |
| scrollLock | boolean | No | Lock body scroll when modal is open (default: true) |
| onScrollLockChange | (locked: boolean) => void | No | Custom scroll lock handler for framework integrations |
| banner | BannerConfig | No | Banner UI overrides |
| modal | ModalConfig | No | Modal UI overrides |
| floatingButton | FloatingButtonConfig | No | Floating action button overrides |
| engine | ConsentEngine | No | Provide a pre-created engine — skips integration auto-wiring |
| storage | IStorage | No | Custom storage adapter (default: auto-detected) |
| polling | PollingFactoryOptions | No | Local overrides for background config polling |
Returns: Promise<IWidgetController>
interface IWidgetController {
/** Programmatically open the preferences modal. */
showPreferences(): void;
/** Get the underlying ConsentEngine. */
getEngine(): ConsentEngine;
/** Destroy the widget and release all resources. */
destroy(): void;
}createValidatedEngine
import { createValidatedEngine } from '@mycookies/widget/headless';
const { engine, integrations, poller } = await createValidatedEngine(options);Options (CreateValidatedEngineOptions):
| Option | Type | Required | Description |
|---|---|---|---|
| subscriptionId | string | Yes | UUID v4 subscription identifier |
| remote.timeoutMs | number | No | Config fetch timeout in ms (default: 5000) |
| remote.onError | (error: ConfigError) => void | No | Called when remote config fails |
| storage | IStorage | No | Custom storage adapter |
| polling | PollingFactoryOptions | No | Local overrides for background config polling |
Returns: Promise<ValidatedEngineResult>
interface ValidatedEngineResult {
engine: ConsentEngine;
integrations: WiredIntegrations; // { scriptManager, consentLogger }
poller: ConfigPoller | null;
}Polling overrides (PollingFactoryOptions):
const { engine } = await createValidatedEngine({
subscriptionId: '...',
polling: {
enabled: false, // disable background polling entirely
intervalMs: 300_000, // override poll interval (min: 60000ms)
},
});Consent Engine API
The ConsentEngine manages consent state, persists preferences, and notifies subscribers of changes. The engine is always created and initialized internally by the factory functions — access it via widget.getEngine() or the engine property of ValidatedEngineResult.
const widget = await createWidget({ subscriptionId: '...' });
const engine = widget.getEngine();
// or via headless:
const { engine } = await createValidatedEngine({ subscriptionId: '...' });Methods
| Method | Signature | Description |
|---|---|---|
| init | (config: ConsentConfig) => void | Initialize with categories and subscription ID. Throws if called twice. |
| updateConsent | (update: Partial<ConsentState>) => void | Update one or more consent categories. Always persists — even when state is unchanged, so the widget knows the user has actively decided. |
| getConsentState | () => ConsentState | Returns a shallow copy of the current consent state. |
| hasConsent | (category: string) => boolean | Returns true if the given category is explicitly granted. |
| hasStoredConsent | () => boolean | Returns true if a persisted consent envelope exists for this subscription. |
| onConsentChange | (callback: ConsentChangeCallback) => () => void | Subscribe to consent changes. Returns an unsubscribe function. Can be called before init(). |
| registerProvider | (provider: IConsentProvider) => void | Register a consent provider plugin. Late registration calls provider.init() immediately. |
| getEventBus | () => EventBus | Access the internal event bus for channel-based communication. |
Listening to consent changes
const unsubscribe = engine.onConsentChange((state) => {
// state is a ConsentState: Record<string, boolean>
if (state.analytics) {
enableAnalytics();
} else {
disableAnalytics();
}
});
// Stop listening
unsubscribe();Storage key format
Consent state is persisted under the key _mycookies_consent_v1_{subscriptionId} using the configured storage adapter (localStorage by default).
Integrations
Integrations are configured server-side in your MyCookies portal and delivered via the signed remote payload. The factory functions auto-wire enabled integrations before engine.init() so they receive the initial consent state.
Google Consent Mode v2
When enabled on your subscription, the factory automatically registers a GoogleConsentModeProvider that updates gtag('consent', 'update', ...) whenever consent changes.
The CDN bundles also inject gtag('consent', 'default', ...) synchronously at script evaluation time — before GTM loads — using cached config on repeat visits or safe all-denied defaults on the first visit.
Manual registration (advanced):
import { GoogleConsentModeProvider } from '@mycookies/widget/headless';
const gcm = new GoogleConsentModeProvider({
consentMode: 'advanced', // 'basic' | 'advanced'
categoryMapping: {
analytics_storage: 'analytics',
ad_storage: 'marketing',
ad_user_data: 'marketing',
ad_personalization: 'marketing',
},
waitForUpdate: 500, // ms to wait before GTM fires tags
});
engine.registerProvider(gcm);Script Manager
ScriptManager manages consent-gated <script> tags. When a category is granted, it activates matching scripts; when revoked, it removes them from the DOM.
Mark scripts with data-consent-category:
<script type="text/plain" data-consent-category="analytics">
// This script runs only when 'analytics' consent is granted
gtag('config', 'GA-XXXXX');
</script>Manual usage:
import { ScriptManager } from '@mycookies/widget/headless';
const sm = new ScriptManager(engine, {
observe: true, // Watch for dynamically added scripts (MutationObserver)
scanExisting: true, // Scan <script> tags already in the DOM on start
});
sm.start();
// Clean up
sm.destroy();Security note:
ScriptManagerstrips allon*event handler attributes (onclick,onerror, etc.) from activated scripts to prevent XSS via attribute passthrough.
Consent Event Logging
When your subscription has at least one legal framework configured (e.g. GDPR), the remote payload includes consentLogging: true. The factory automatically wires a ConsentLogger that sends an event record to the MyCookies backend after every user consent decision.
What is logged:
| Field | Value |
|---|---|
| visitorId | Stable anonymous ID stored in localStorage (_mycookies_visitor_v1) |
| action | accept_all, reject_all, or save_preferences |
| granted | Array of accepted category IDs |
| denied | Array of rejected category IDs |
| configSignature | Config envelope signature for server-side audit |
| timestamp | ISO-8601 client-side time of the decision |
Logging is fire-and-forget — it never blocks the consent UI flow and never throws.
For headless consumers, access the logger from ValidatedEngineResult:
const { integrations } = await createValidatedEngine({ subscriptionId: '...' });
// integrations.consentLogger is null when logging is not enabled
integrations.consentLogger?.destroy(); // call during teardownRemote Configuration
The resolveConfig function runs the full 7-step verification pipeline independently of the factory functions.
import { resolveConfig, ConfigErrorCode } from '@mycookies/widget';
const result = await resolveConfig({
subscriptionId: 'xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx',
timeoutMs: 5000,
});
if (result.ok) {
console.log(result.payload.categories);
console.log(result.payload.tier); // 'free' | 'pro' | 'enterprise'
} else {
console.error(result.error.code, result.error.message);
}ConfigErrorCode values:
| Code | Trigger |
|---|---|
| INVALID_SUBSCRIPTION_ID | Not a valid UUID v4 |
| FETCH_FAILED | Network error |
| FETCH_TIMEOUT | Request exceeded timeoutMs |
| HTTP_NOT_FOUND | API returned 404 |
| HTTP_FORBIDDEN | API returned 403 |
| HTTP_ERROR | Other non-2xx status |
| MALFORMED_RESPONSE | Invalid JSON or unexpected shape |
| CRYPTO_UNAVAILABLE | crypto.subtle not available (non-HTTPS context) |
| UNKNOWN_KEY_ID | kid does not match any embedded public key |
| SIGNATURE_INVALID | ECDSA verification failed |
| DOMAIN_UNAUTHORIZED | Current hostname not in allowedDomains |
Config Cache
ConfigCache provides localStorage-backed stale-while-revalidate caching. The factory functions use it automatically. Access it directly when you need synchronous reads (e.g. for GCM defaults before the async pipeline completes):
import { ConfigCache, LocalStorageAdapter } from '@mycookies/widget';
const cache = new ConfigCache(new LocalStorageAdapter());
// Read without signature verification — fast, synchronous
const cached = cache.getUnverified(subscriptionId);
if (cached?.integrations?.googleConsentMode?.enabled) {
// GCM is enabled for this subscription
}Config Poller
ConfigPoller runs a background setTimeout loop that refreshes the cache when the TTL expires. It is page-visibility-aware — it pauses when the tab is hidden and catches up on resume.
// The poller is returned by factory functions.
// Call destroy() during SPA teardown to stop background activity.
const { poller } = await createValidatedEngine({ subscriptionId: '...' });
poller?.destroy();Note: The poller updates only the cache — it never disrupts the live engine state mid-session. New config takes effect on the next page load.
Storage Adapters
The library ships three storage adapters and an auto-detection helper:
import {
LocalStorageAdapter,
CookieStorageAdapter,
detectStorage,
} from '@mycookies/widget';
// Auto-detect the best available adapter
const storage = detectStorage();
// Use a specific adapter
const storage = new LocalStorageAdapter();
const storage = new CookieStorageAdapter({ sameSite: 'Lax', secure: true });
// Pass to a factory
const { engine } = await createValidatedEngine({
subscriptionId: '...',
storage,
});Implement a custom adapter by satisfying the IStorage interface:
import type { IStorage } from '@mycookies/widget';
class MyCustomAdapter implements IStorage {
get<T>(key: string): T | null { /* ... */ }
set<T>(key: string, value: T): void { /* ... */ }
clear(key: string): void { /* ... */ }
}TypeScript Types
All public types are exported from both entry points:
import type {
// Factory options
CreateWidgetOptions,
CreateValidatedEngineOptions,
RemoteFactoryOptions,
PollingFactoryOptions,
// Results
ValidatedEngineResult,
WiredIntegrations,
IWidgetController,
// Engine
ConsentState,
ConsentCategory,
ConsentConfig,
ConsentChangeCallback,
// Remote config
RemoteConfigPayload,
RemoteConfigResponse,
RemotePollingConfig,
SubscriptionUuid,
// Integrations
IntegrationConfig,
RemoteGoogleConsentModeConfig,
RemoteScriptManagerConfig,
ConsentAction,
ConsentLoggerOptions,
// Storage
IStorage,
// UI
WidgetTheme,
BannerConfig,
ModalConfig,
FloatingButtonConfig,
} from '@mycookies/widget';Error Handling
Factory functions throw a ConfigError when remote resolution fails. Always wrap calls in try/catch or use the remote.onError callback for graceful degradation:
import { createWidget, ConfigError, ConfigErrorCode } from '@mycookies/widget';
try {
const widget = await createWidget({
subscriptionId: '...',
remote: {
onError: (error) => {
// Called before the throw — use for logging/monitoring
console.error('[consent] config error:', error.code);
},
},
});
} catch (error) {
if (error instanceof ConfigError) {
switch (error.code) {
case ConfigErrorCode.DOMAIN_UNAUTHORIZED:
// This domain is not in the subscription's allowedDomains list
break;
case ConfigErrorCode.FETCH_TIMEOUT:
// Network is slow — consider a longer timeoutMs
break;
default:
// Handle other error codes
}
}
}Note:
resolveConfignever throws — it returns a discriminated union{ ok: true, payload }or{ ok: false, error }. Factory functions (createWidget,createValidatedEngine) do throw on failure so thatawaitcallers get standardtry/catcherror handling.
License
UNLICENSED — All rights reserved. MyCookies
