npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cookieyes/core

v0.9.0

Published

Headless consent engine — the single source of truth for all CookieYes consent logic

Readme


Building a UI? Use an adapter

@cookieyes/core is the headless engine — it has no components. Most developers want a framework adapter, which bundles core plus ready-made banner/dialog UI:

Use @cookieyes/core directly only for vanilla JS, a custom framework, or your own UI.

Key features

  • Headless engine — all consent logic, no UI, no framework assumptions.
  • Zero dependencies — nothing pulled into your bundle but the engine itself.
  • Offline or self-hosted — cookie-only, or POST every decision to your own backend.
  • GDPR & CCPA — regulation-aware consent state and payloads.
  • Cookie utilities — read/write/parse the consent cookie directly if you need to.

Prerequisites

  • Node.js ≥ 20
  • A JavaScript environment with document/window (browser or SSR with a DOM). No framework required.

Quick start

1. Install

npm install @cookieyes/core
pnpm add @cookieyes/core
yarn add @cookieyes/core
bun add @cookieyes/core

2. Initialise the runtime

Which API should I use? consentStore.subscribe is the recommended way to read consent outside React. See the shared decision tree if you're not sure which API applies to your situation — core also exposes a handful of lower-level options (see Low-level / advanced API) for specific edge cases.

initCookieYes() (an alias of getOrCreateConsentRuntime()) returns a process-wide singleton with a consentStore (reactive state) and a consentManager (imperative API).

import { initCookieYes } from "@cookieyes/core";

const { consentManager, consentStore } = initCookieYes({
  mode: "cookie-only",   // "cookie-only" | "self-hosted"
  regulation: "GDPR",    // "GDPR" | "CCPA"
  colorScheme: "system", // "light" | "dark" | "system"
});

3. React to consent changes

// consentStore.subscribe fires on every state change — category saves,
// transient preference-dialog toggles, and the dialog opening/closing.
// That's the right level for "should this script run right now?" checks:
const unsubscribe = consentStore.subscribe((state) => {
  if (state.has("analytics")) {
    // load analytics scripts (gtag, Mixpanel, …)
  }
});

// Only saved preference changes (not transient UI toggles)
consentStore
  .getState()
  .subscribeToConsentChanges(({ allowedCategories, deniedCategories }) => {
    console.log("Allowed:", allowedCategories, "Denied:", deniedCategories);
  });

4. Drive it imperatively

consentStore.getState().has("analytics");          // → boolean
consentStore.getState().saveConsents("all");        // accept all
consentStore.getState().saveConsents("necessary");  // reject all (necessary only)
consentStore.getState().setConsent("analytics", true);
consentManager.showPreferences();                    // open the preferences dialog
consentManager.resetConsent();                       // clear + re-prompt
unsubscribe();

Self-hosted mode

mode: "self-hosted" POSTs a ConsentPayload to your endpoint on every decision. Provide either an apiUrl or a custom backend adapter for full control:

initCookieYes({
  mode: "self-hosted",
  backend: {
    async persist(payload) {
      const res = await fetch("https://your-backend.example.com/v1/consent", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      // Throw when the record was not stored, so the SDK keeps it and tries again.
      if (!res.ok) throw new Error(`Consent record not stored: HTTP ${res.status}`);
    },
  },
});

With apiUrl, a 2xx response confirms the record. With a backend, resolving confirms it and throwing (or rejecting) tells the SDK it was not stored.

Each decision is one record, so it can serve as proof of consent:

{
  "recordId": "9lBc…mzw.mul2v4rq.9wicmt", // same decision, same id: drop repeats on your server
  "consentId": "9lBc…mzw",                // the visitor; stable until resetConsent()
  "categories": { "necessary": true, "analytics": false /* … */ },
  "regulation": "GDPR",
  "domain": "example.com",
  "decidedAt": "2026-09-28T10:01:21.638Z", // when the visitor decided, not when it was sent
  "taxonomyHash": "2fbx48",                // the category set the decision was made against
  "action": "accept_all",                  // accept_all | reject_all | accept_selected | save
  "source": "banner"                       // banner | preferences | optout | api
}

A decision made from your own code is recorded as "api". If you build your own banner, pass where it is: consentStore.getState().saveConsents("all", "banner") or consentManager.acceptAll("banner").

When your server is down

A record your server has not confirmed is not lost. The SDK saves each record in the visitor's localStorage (key cookieyes-consent-records) before sending it, and removes it only once your server confirms it. It sends a kept record again:

  • on the next page load,
  • as soon as the browser is back online,
  • on a timer while the page stays open: about 10 seconds, then 1 minute, then every 5 minutes, each with a random spread so visitors don't all retry at once.

This also covers a visitor who closes the tab while a record is still being sent.

| Limit | Value | | --- | --- | | Records kept per browser | 10 (the oldest is dropped first) | | Oldest record kept | 7 days |

If storage is full or blocked, the record is still sent once, just not kept for a retry. cookie-only mode sends and keeps nothing.

Storing records on your server

A record can reach your server more than once: for example, when your server stored it but its reply never reached the browser. Store each record once by its recordId, and ignore one you already have. A retried record is sent unchanged, so its decidedAt is still the moment of the decision.

Your server is where proof of consent lives. The copy in the browser is only kept until your server confirms it. How long to keep records is your decision: typically for as long as you rely on the consent, and at least 24 months for opt-out requests under the CCPA. Check the period that applies to you with your legal team.

Deprecated: mode: "offline"

"offline" was renamed to "cookie-only" — same behavior, clearer name. It still works today and logs a one-time console warning, and will be removed 3 releases from now.

 getOrCreateConsentRuntime({
-  mode: "offline",
+  mode: "cookie-only",
 });

API

initCookieYes(config) / getOrCreateConsentRuntime(config)

Both accept the canonical CookieYesConfig and return { consentManager, consentStore } (a singleton — call resetConsentRuntime() to clear it, mainly for tests). initCookieYes is an alias provided so one setup name reads across every package. Every option is documented once in Configuration. Migrating off the deprecated overrides.regulation / backendURL keys? See the migration guide.

config (CookieYesConfig):

| Option | Type | Notes | |--------|------|-------| | mode | "cookie-only" \| "self-hosted" | Required. See Deprecated for the retired "offline" name. | | regulation | "GDPR" \| "CCPA" | Force the applicable regulation. (The deprecated overrides.regulation alias still works.) | | apiUrl | string | Self-hosted: endpoint the payload is POSTed to. (The deprecated backendURL alias still works.) | | backend | ConsentBackend | Self-hosted: custom persist(payload) adapter. | | apiKey | string | Optional auth key. | | colorScheme | "light" \| "dark" \| "system" | | | theme | ThemeConfig | Color / spacing tokens. | | i18n | I18nConfig | Translation messages / locale. | | networkBlocker | NetworkBlockerConfig | Block network requests by category. | | reloadOnRevoke | boolean | Reload the page when consent is revoked. | | onConsentReady / onConsentUpdate | (state) => void | Low-level lifecycle callbacks — see below. |

consentStore — subscribe(listener) and getState(). State (ConsentStoreState) includes consentId, hasActed, categories, regulation, lastRenewed, activeUI, plus the methods has(), saveConsents(), setConsent(), and the low-level subscribeToConsentChanges() (below).

createConsentManager(config) (low-level)

The underlying manager, if you want to bypass the store. Returns a ConsentManager with state (consentId, hasActed, categories, regulation, lastRenewed, isPreferencesOpen) and methods (acceptAll(), rejectAll(), acceptSelected(cats), updateCategory(cat, val), savePreferences(), resetConsent(), showPreferences(), hidePreferences(), subscribe(fn), registerScript(entry)).

The applicable regulation comes from your top-level regulation config (the deprecated overrides.regulation alias still works) and behaves as GDPR when unset. The core engine does not perform IP-based geo-detection.

Reacting to consent changes

To run your own code when a visitor grants or withdraws consent, use consentStore.on(type, listener, options?):

const { consentStore } = initCookieYes({ mode: "cookie-only" });

// "change" fires only when a category actually differs — the right place to
// (re)load a script, so a re-confirm of the same choices doesn't run it again.
const off = consentStore.on("change", ({ changedCategories }) => {
  if (changedCategories.includes("analytics")) loadAnalytics();
});

// "save" fires on every save, even an unchanged re-confirm.
consentStore.on("save", () => toast("Preferences saved"));

off(); // stop listening when you're done
  • The listener fires once immediately with the current state (isInitial: true), so a late listener isn't blind to earlier choices.
  • Pass { category: "analytics" } to only hear about one category.
  • Payload: { categories, changedCategories, isInitial }.

This is the recommended way to react to consent. The paths below still work but aren't the primary one.

Translations & language

For a framework-less custom UI, consentStore carries the active language and lets you switch it live (no reload):

const { consentStore } = initCookieYes({
  mode: "cookie-only",
  i18n: { messages: { fr } }, // languages you support; each may be partial
});

consentStore.translations.acceptAll;   // text for the active language
consentStore.getLanguageInfo();         // { language, direction, languages }

consentStore.subscribe(() => render()); // re-render on consent OR language change
await consentStore.setLanguage("fr");   // switch live; loads via i18n.loadLanguage if needed

Missing text falls back to English; custom categories translate by id (see the configuration guide). In React, use the useTranslations() / useLanguage() hooks instead.

Region-based regulation (geo-detection)

Optionally pick the regulation from the visitor's region. Fully optional; a manual regulation always wins.

const { consentStore } = initCookieYes({
  mode: "cookie-only",
  region: {
    // `detect` is YOUR function — return the visitor's region, however you get it (sync).
    detect: () => myRegion,                 // "US-CA" | "DE" | undefined
    map: { "US-CA": "CCPA", DE: "GDPR" },   // you own the mapping
    honorGpc: true,                          // default; browser "do not sell" signal → CCPA
  },
});

consentStore.getRegion(); // { region, regulation, source, confidence }

detect runs synchronously — you return a region you already have (server-injected value, your own lookup done beforehand, etc.). Unknown/failed detection → the strictest regulation (default GDPR), never a skipped banner. In self-hosted mode the detected region is included on the consent-log payload.

Low-level / advanced API

You shouldn't need these for a typical integration — each exists for a specific narrower situation than consentStore.on / consentStore.subscribe:

| API | Use when | |---|---| | subscribeToConsentChanges(listener) (on consentStore.getState()) | Predates consentStore.on; fires on saved changes but without the change/save split, the isInitial flag, or per-category filtering. Prefer on. | | onConsentReady (config option) | You need a one-time callback right after the initial state is known — e.g. conditionally loading analytics on first load — rather than an ongoing subscription. | | onConsentUpdate (config option) | A saved-changes callback registered once at config time instead of dynamically after mount. Prefer consentStore.on unless you specifically need a config-time callback. | | createConsentManager(config) | Bypasses consentStore entirely for direct access to the manager: acceptAll(), rejectAll(), acceptSelected(cats), updateCategory(cat, val), savePreferences(), resetConsent(), showPreferences(), hidePreferences(), subscribe(fn), registerScript(entry). config (ConsentConfig) accepts regulation, colorScheme, theme, apiUrl, apiKey, backend, reloadOnRevoke, onConsentReady, onConsentUpdate. | | parseCookie / serializeCookie | Reading or writing the raw cookieyes-consent cookie directly — e.g. in a Next.js Server Component or route handler, where no live runtime or React hooks are available. |

Stopping tracking when consent is withdrawn

When a visitor revokes consent, the SDK stops tracking without reloading the page — nothing they were doing (form input, scroll position, an open dialog) is lost.

Gating vs. stopping. The cleanest way to handle a third-party script is to gate it — never load it until its category is granted — with the integrations option and a preset from @cookieyes/scripts. The layers below are for scripts that are already loaded (first-party vendors, your own tags) and need to be stopped when consent is withdrawn.

There are three layers:

  1. Network blocking (networkBlocker / blockNetwork) — see Network blocking for what it can and cannot stop, its measured cost, and starter rules for common vendors. It intercepts fetch, XMLHttpRequest, and navigator.sendBeacon to blocked domains, in real time, for as long as the page is open. sendBeacon matters because GA4/Meta use it for exit/unload tracking that fetch/XHR interception misses.

  2. Built-in stop-handlers (builtInIntegrations) — call a vendor's own documented "stop" API on revoke, and resume it on re-accept:

    getOrCreateConsentRuntime({
      mode: "cookie-only",
      builtInIntegrations: [
        { vendor: "meta" },  // fbq('consent','revoke'|'grant')
      ],
    });

    builtInIntegrations was previously called integrations; that name now refers to the gated presets from @cookieyes/scripts (see the note above).

    Google Analytics & Tag Manager are handled automatically — you don't list them here. The SDK broadcasts Google Consent Mode v2 whenever a dataLayer is present (see Google Consent Mode below). You still set the deny-by-default state in your gtag snippet.

  3. Your own scripts (customStopHandlers) — for anything without a built-in integration. Provide a clean stop()/resume(), or register it as reload-only so revoking it shows the reload notice rather than silently continuing to track:

    customStopHandlers: [
      { id: "my-tool", category: "analytics", stop: () => window.myTool?.disable() },
      { id: "legacy-widget", category: "advertisement", needsReload: true },
    ]

Vendor audit — which stop cleanly, which need a reload

| Vendor | Runtime stop | How | |--------|-------------|-----| | Google Analytics 4 / Tag Manager | ✅ automatic | Consent Mode v2 broadcast — no builtInIntegrations entry needed (see below). | | Meta Pixel | ✅ clean | fbq('consent', 'revoke') / 'grant' | | TikTok Pixel | ⚠️ reload | No runtime stop we could confidently verify; modelled as reload-only. | | LinkedIn Insight Tag | ⚠️ reload | No documented runtime opt-out after load. | | Hotjar | ⚠️ reload | No documented "stop after load"; gate before load instead. | | Segment (analytics.js) | ⚠️ reload | No documented runtime "stop all"; gate analytics.load(). |

"Reload" vendors surface the reload notice (below) on a genuine revoke — the SDK never continues tracking them silently. Any of them can be upgraded to a clean stop later (in resolveBuiltInIntegration) once a real runtime API is confirmed.

Better still, gate the vendor instead of stopping it after the fact: @cookieyes/scripts ships a Segment preset (segment()) that never loads until consent and is removed on revoke — so there's nothing to reload.

Reload notice

If a revoked tool has no clean runtime stop, the manager computes manager.reloadNotice ({ required, reasons }) automatically on revoke, with manager.dismissReloadNotice() to clear it. The state is automatic; showing it is up to you.

If you configure any reload-only tool, surface this state to the visitor — otherwise a revoke that needs a reload is silent and that tool keeps running. In React that means rendering the built-in <ReloadNotice /> (dismissible, role="alert", wording via translations); it never reloads on its own. Outside React, read manager.reloadNotice.required and render your own prompt.

reloadOnRevoke (legacy, off by default)

reloadOnRevoke performs a full page reload on revoke. It is off by default — the clean stop-handlers above are the safe path. Turn it on only if you explicitly want the old behavior; note it erases whatever the visitor was doing.

Consent categories

By default the SDK ships the familiar five:

necessary (always on), functional, analytics, performance, advertisement.

Configure nothing and you get exactly these, unchanged.

Defining your own categories

Pass a categories array to use your own taxonomy — rename, add, remove, or restructure. Each entry is a CategoryDef:

getOrCreateConsentRuntime({
  mode: "cookie-only",
  categories: [
    { id: "essential", required: true, label: "Strictly Necessary" },
    { id: "marketing", label: "Marketing & Ads",
      gcm: ["ad_storage", "ad_user_data", "ad_personalization"] },
    { id: "insights", label: "Product Insights",
      gcm: ["analytics_storage"] },
  ],
});
  • id — the stable key stored in the cookie and used everywhere (banner, preferences UI, read APIs, gate/integration category names, events). Pick it once and keep it stable; renaming an id is a taxonomy change (see below).
  • required — the always-on, non-optional category. Mark it explicitly — it is never inferred from the name necessary, so you can rename it freely. At least one category must be required: true.
  • label / description — shown in the preferences UI. For the five built-in ids these fall back to the translation strings if omitted; for a custom id with no label, the UI falls back to the id itself.
  • gcm — which Google Consent Mode signals this category governs (see below).

Id rules. An id must be a non-empty string, unique within the list, and must not contain , or : or be one of the cookie's reserved keys (consentid, consent, action, tax, lastRenewedDate) — those would corrupt the stored cookie. Otherwise any string is fine (spaces and unicode are OK).

Invalid config is safe. If the array is empty, has duplicate/reserved/invalid ids, or has no required category, the SDK logs a console.warn and falls back to the built-in five rather than leaving you a broken or unprotected banner.

Changing your taxonomy later (upgrade behaviour)

Every stored consent record is stamped with a taxonomy signature (a hash of the ids, required flags, and gcm mappings — visible as taxonomyHash on the snapshot and tax: in the cookie). This lets the SDK tell what a returning visitor actually agreed to.

  • Signature unchanged → the returning visitor's stored consent is reused silently. No re-prompt.
  • Signature changed (you renamed/added/removed a category or changed a gcm mapping) → the SDK re-requests consent: it discards the stale record and shows the banner again, so the visitor consents against the taxonomy that's actually in effect. This is the one documented outcome for a taxonomy change.
  • Legacy cookies written before this feature (no tax: stamp) are treated as the built-in five: if you're still on the default taxonomy they're honoured as-is (returning visitors are never silently reset by upgrading the SDK); if you've since moved to a custom taxonomy they re-request like any other change.

Google Consent Mode v2

If a Google dataLayer is present on the page, the SDK broadcasts all seven Consent Mode v2 signals — on load and on every consent change — for every visitor. This is what governs Google Analytics 4 and Tag Manager; you do not register them under builtInIntegrations.

Each signal is granted when any granted category maps to it (via its gcm field), otherwise denied. security_storage is always granted. The built-in five map like this:

| Category | GCM signals | |----------|-------------| | necessary | (none — security_storage is always granted) | | functional | functionality_storage, personalization_storage | | analytics | analytics_storage | | performance | (none) | | advertisement | ad_storage, ad_user_data, ad_personalization |

Under the hood the broadcast does the equivalent of:

dataLayer.push(["consent", "update", {
  ad_storage: "denied",
  ad_user_data: "denied",
  ad_personalization: "denied",
  analytics_storage: "granted",
  functionality_storage: "granted",
  personalization_storage: "granted",
  security_storage: "granted",
}]);

You still own the default. Consent Mode requires a deny-by-default state set before your Google tags load — the SDK can't set it because it doesn't control that load order. Put it in your gtag bootstrap snippet:

gtag('consent', 'default', {
  ad_storage: 'denied',
  ad_user_data: 'denied',
  ad_personalization: 'denied',
  analytics_storage: 'denied',
  functionality_storage: 'denied',
  personalization_storage: 'denied',
  security_storage: 'granted',
  wait_for_update: 500,
});

Set all seven signals explicitly: deny the six consent-gated ones and grant security_storage (it's strictly necessary). Leaving any signal unspecified makes Google treat it as granted until the SDK's update fires, leaking it for that first moment. The SDK owns the update; you own the default.

To wire Consent Mode to a custom taxonomy, put the gcm field on whichever of your categories should drive each signal — see the example under Defining your own categories (the marketing and insights entries carry gcm mappings). A signal no category maps to simply stays denied.

Cookie

Consent is persisted in the cookieyes-consent cookie (SameSite=Lax, path=/). It stores each category id as id:yes|no, plus a tax: stamp recording the taxonomy signature that was in effect when the consent was recorded. Use parseCookie / serializeCookie from this package to read or write it directly.

Troubleshooting

The runtime isn't initialising (or hooks/consumers see no state). initCookieYes() returns a singleton — the first call wins, later calls return the same instance. Call it once at startup before anything reads consent. In tests, call resetConsentRuntime() between cases or state leaks across them.

Mode / config type errors. CookieYesConfig is a discriminated union on mode. Backend keys (apiUrl, apiKey, backend) are only valid with mode: "self-hosted" — supplying them under mode: "cookie-only" is a compile error. mode: "self-hosted" needs either apiUrl or a backend adapter.

Consent doesn't persist between reloads. State lives in the cookieyes-consent cookie. Confirm it isn't blocked by a browser privacy setting or extension, that you're on a document-bearing environment (not a bare Node worker), and that you aren't calling resetConsentRuntime() on every load.

Still stuck? Open an issue.

Community & support

(A community chat channel is on the roadmap.)

Contributing

Contributions are welcome. Read our Contributing Guidelines and Code of Conduct, then open a pull request.

Security

Found a vulnerability? Do not open a public issue — follow our Security Policy and use GitHub's private vulnerability reporting.

License

MIT — see LICENSE.