Skip to content
codefastlabs

Command Palette

Search for a command to run...

v0.7.1· Changelog

@codefast/tracking

0.7.1

Patch Changes

  • #748 bde6d1b Thanks @thevuong! - Reset the consent banner's preferences layer during render instead of via a mirror-state effect — same reopen semantics with one less state variable. Every exported declaration now carries a doc summary, and orphaned @since 1.0.0-canary stamps are rewritten to the current track.

0.7.0

0.6.2

0.6.1

0.6.0

Patch Changes

  • #690 f4b1aa6 Thanks @thevuong! - Report the failures that were being swallowed or mislabelled, and derive the types the build emits.

    @codefast/di:

    • @inject, @postConstruct and @preDestroy on a static member now throw StaticMemberDecoratorError instead of InternalError. All three act on one instance, so this is caller misuse — and InternalError means the library broke, which sent anyone catching it to file a bug against their own mistake. SPEC §10 already recorded that mistake for predicate ambiguity.
    • AsyncResolutionError names the token the caller asked for and the token whose factory is async, which is what SPEC has always specified. Every throw site passed the same token twice, so the message read "Token 'X' requires async resolution because 'X' in its dependency chain has an async factory"; a resolve(App) that fails on an async Database now says so. asyncSourceToken defaults to tokenName for the case where the requested binding is itself the source.
    • A MetadataReader that names a @postConstruct/@preDestroy method the instance does not have raises InvalidMetadataError instead of skipping the hook — a hook that silently never runs is the failure a caller cannot see. InvalidMetadataError's message no longer says "constructor", since it now covers both answers; the specifics moved into reason.
    • MissingScopeContextError from ScopeManager names its token instead of "(unknown)", and the scoped read takes one map lookup where it took two.
    • Token, Constructor and InjectionDescriptor declare out Value, so the compiler checks the covariance the engine already relied on.

    Repo-wide: isolatedDeclarations is on for every package that emits declarations, so a public type can always be written down from the source file alone. allowJs is gone from the shared base config — no package has JavaScript sources. @codefast/theme and @codefast/tracking gained explicit annotations on four exported constants to satisfy it; the emitted types are unchanged. @codefast/ui and @codefast/benchmark-viewer opt out for reasons recorded in their configs.

0.5.0

Minor Changes

  • #605 cb46bdd Thanks @thevuong! - Adds the shared ad-destination frame for consuming one { ads, analytics } decision across ad platforms (spec-destinations §5): toAdConsentState(decision) normalizes it to the two independent levers — analytics drives whether events transmit, ads drives Limited Data Use — so per-vendor mappings cannot drift. Ships a reference Meta destination (createMetaDestination, toMetaDataProcessingOptions) that maps each event and the live ads decision to Meta's dataProcessingOptions (geolocated LDU when ads is denied) and hands it to an injected transport. Consent-restriction mapping only — the Pixel/CAPI transport and credentials are the integrator's to supply; an ad sink is never exempt.

  • #612 1337fc3 Thanks @thevuong! - Adds the TCF/GPP interop reconciler (spec-ad-consent-frameworks): the system reads an external CMP and reconciles it with the native { ads, analytics } decision — it never becomes a CMP or mints TC/GPP strings. reconcileAdFrameworkConsent({ native, cmp, hasGlobalPrivacyControlSignal }) applies the §3 precedence (a governing CMP overrides its categories; fail-closed to denied while the CMP is loading; a missing or out-of-scope CMP leaves native standing; GPC only tightens ads), covering conformance vectors V1–V6. hasTcfApi/hasGppApi detect the __tcfapi/__gpp read APIs without invoking them. TCF purpose ids and the Google vendor id are deliberately not hard-coded — that mapping is ad-ops policy, so the caller derives the CmpConsentSignal it passes in.

  • #615 2bcd31f Thanks @thevuong! - Let codefast mirror generate package.json#exports from dist/, the same as every other library package (di, theme), instead of hand-curating them under mirror's preserve mode. The per-module build output is unchanged, so mirror emits a subpath for each built module, and the root becomes the client entry.

    Breaking:

    • The root @codefast/tracking is now the client entry — it re-exports the isomorphic core plus the whole browser surface (createClientTracker, createConsentRuntime, the React bindings, the gtag + ad-network destinations). Server code must import the core it needs from @codefast/tracking/core/*, not from the root.
    • The ./client, ./server, ./core, ./react, and ./destinations group barrels are gone. Use the client root for browser code, or a module's own subpath for granular/server imports (@codefast/tracking/server/initial-consent, @codefast/tracking/client/gpc, …).
    • The TanStack Start adapter is now @codefast/tracking/adapters/tanstack-start (was /tanstack-start); the import-protection deny-list is @codefast/tracking/tooling/import-protection (was /import-protection). SERVER_ONLY_SUBPATHS now denies server/** and adapters/**.
  • #563 bad015c Thanks @thevuong! - Switch gtag/GTM bootstraps to Google Consent Mode advanced:

    • buildGtagConsentBootstrapScript / buildGtmConsentBootstrapScript always set Consent Mode v2 default (from stored decision or region fallback), then always load gtag.js / gtm.js — even when analytics/ads storage is denied — so cookieless pings and consent modeling can run.
    • Runtime grants/denies still use updateGoogleConsent; loadGtagScript / loadGtmScript remain idempotent safety nets when the bootstrap did not run.
    • The package's first-party consent gate is unchanged — identifiers and non-exempt destinations stay blocked without consent; only Google tag script loading changes.
  • #563 bad015c Thanks @thevuong! - Tighten the package's API contracts and framework independence, found in an architecture audit.

    Breaking:

    • ClientTrackerOptions.anonymousId is now () => string only — the plain-string form is removed. A resolver was already the documented best practice (defers minting an id until an event is actually allowed to send); the string form let callers accidentally mint one as an import-time side effect. Wrap a stable value in a resolver: anonymousId: () => myId.
    • Destination.send now always returns Promise<void> — the previous Promise<void> | void let sync and async destinations disagree on contract. Mark a synchronous send async so a thrown error rejects the returned promise instead of throwing synchronously.

    Also:

    • createVercelAnalyticsDestination now imports track from the framework-agnostic @vercel/analytics instead of @vercel/analytics/react — the destination renders nothing, so it had no reason to depend on React.
    • Adds assertNever (@codefast/tracking/core) and wires it into the default case of every switch (event.type) across the GA4/Vercel destinations — extending TrackedEvent with a new variant now fails to compile at every switch instead of silently falling through.
    • The package root (@codefast/tracking) now re-exports #/core's surface by explicit name instead of export *, matching the client/server/destinations/react subpaths, which were already explicit.
    • useConsent's returned object and its save/denyAll/grantAll callbacks are now memoized (useMemo/useCallback), so a consumer passing the hook's result down as a prop or effect dependency doesn't get a new reference every render.
  • #617 b979371 Thanks @thevuong! - Harden the package from a full audit — correctness, coverage, and a leaner public surface.

    • isConsentReceiptInput now validates method and subjectIdType against their enums, not just typeof === "string" — the untrusted-body guard no longer narrows a bogus value to a closed union member.
    • CookieAnonymousId gains current() — a non-minting read of the existing id (undefined when none) so a consent receipt stamps the id the visitor already carries instead of a throwaway that never correlates for erasure.
    • coarsenIp rejects out-of-range IPv4 octets ("999.…") rather than storing a malformed coarse value.
    • Microsoft UET consent routes through the shared toAdConsentState ad lever, so its ad_storage mapping can't drift from Meta/TikTok.
    • Dropped unused foreign type re-exports so each type has one home: InitialConsent no longer re-exported from adapters/tanstack-start or server/initial-consent (import it from core/consent), and the AnonymousIdResponseCookieOptions alias is gone — setAnonymousIdResponseCookie takes AnonymousIdCookieOptions from server/anonymous-id-cookie directly.
    • Collapsed the TrackedEvent envelope to a single interface — the unused TrackedEventBase and TrackEvent names are gone (TrackedEvent keeps the type: "track" discriminant for a future additive union).

    Also adds test coverage for the previously-untested recordConsentReceiptFromRequest adapter path (no-store header, body-IP rejection, coarsened IP, PII-free ack).

  • #617 b979371 Thanks @thevuong! - Remove two leftover indirection layers in the server lane that no call site used.

    Breaking:

    • The @codefast/tracking/adapters/request-context subpath is gone. Its RequestContext seam (a getHeader/setHeader interface) plus the parallel *FromContext/*OnContext helpers existed only to back a hypothetical future ./next/./remix adapter, but there was exactly one adapter and it duplicated every signature and doc comment. @codefast/tracking/adapters/tanstack-start now calls getRequestHeader/setResponseHeader directly; its public surface (resolveInitialConsentFromRequest, setAnonymousIdResponseCookie, clearAnonymousIdResponseCookie, recordConsentReceiptFromRequest) is unchanged.
    • resolveRegion(headers) is removed from @codefast/tracking/server/region. It was a pre-fail-closed leftover with no production call site, and its missing-geo semantics (unknown region → opt-out) contradicted the fail-closed invariant the server-first path relies on. Use resolveRegionFromCountryCode (what the production path already uses via resolveInitialConsent), or resolveInitialConsentFromRequest for the full per-request resolution.
  • 08f10fb Thanks @thevuong! - Rebuild ConsentBanner as composable compound parts (ConsentBannerTitle/Description/Actions/Accept/Reject/Customize/Preferences/Category/Save) — the root owns visibility (needsPrompt, overridable via open for a "Cookie settings" reopen) and the preferences-layer state, action parts wire their own clicks and compose the consumer's onClick, so any markup including a design system's button styles slots in via className. The monolithic message/acceptLabel/categories props are gone. An optional plain-CSS default theme ships at @codefast/tracking/css/consent.css — data-slot selectors, --consent-* custom properties with light-dark() fallbacks, zero Tailwind dependency.

  • #565 1e80096 Thanks @thevuong! - Remove defaultConsentExpression from gtag/GTM consent bootstraps. Pass a literal defaultConsent (strictest bake on shared HTML) and upgrade after hydration via the server-fn lane + updateGoogleConsent.

  • #567 74c52ac Thanks @thevuong! - Collapse the consent "must match" contracts into one ConsentConfig, and add createConsentRuntime.

    Previously storageKey, policyVersion, and requestedCategories had to be hand-threaded — matching exactly — through useConsent, createIsAnalyticsAllowed, and the gtag consent bootstrap; one drifted string was a silent consent bug. Now:

    • ConsentConfig + defineConsentConfig (root/core) — the one bag for storageKey, policyVersion, and requestedCategories. Isomorphic plain data: the same object is imported on both sides.
    • createConsentRuntime (client) — derives the live client instances from the config: the shared ConsentStorage, the initial-consent store over your server lane, ensureInitialConsentResolved, and the isAnalyticsAllowed tracker gate wired to the store's resolved mode (GPC read from the real navigator signal by default).

    Breaking option changes (config-first):

    • useConsent({ policyVersion, requestedCategories?, ... })useConsent({ config, ... }). The ["analytics"] default for requestedCategories is gone — the config always states the requested purposes explicitly.
    • createIsAnalyticsAllowed({ policyVersion, requestedCategories, ... })createIsAnalyticsAllowed({ config, ... }).
    • GtagConsentBootstrapOptions (and the <GtagConsentBootstrap /> props): consentStorageKey + policyVersionconfig.
  • 079b8df Thanks @thevuong! - Rebuild the consent layer on useSyncExternalStore and expose data-slot styling hooks on the consent UI.

    • useConsent treats the stored ConsentRecord as the single source of truth: the server snapshot is always "no decision yet" (hydration-safe by construction on prerendered pages), a decision made in one tab syncs to every other tab, and a record saved under an older policyVersion is ignored so bumping the version re-prompts as documented.
    • Breaking: ConsentStorage gains a required subscribe(listener) method — custom implementations must notify on changes. createLocalStorageConsentStorage implements it (same-tab saves plus the cross-tab storage event) and now degrades a blocked localStorage (private mode/quota) to a session-scoped in-memory record instead of re-prompting in a loop.
    • Breaking: ConsentBanner renders a labeled region instead of a non-modal <dialog> (which neither traps focus nor blocks, so the dialog semantics over-promised). Both components extend their host element's ComponentProps and expose data-slot attributes (consent-message, consent-actions, consent-action, consent-toggle) for Tailwind **:data-[slot=...] styling.
  • #563 bad015c Thanks @thevuong! - Gate the client tracker on consent and keep destinations from leaking pre-consent or duplicate data.

    createClientTracker gains isTrackingAllowed?: () => boolean, consulted per event — while it returns false nothing is sent or queued, so a mid-session consent change applies immediately. anonymousId also accepts a () => string resolver, invoked only when an event is actually allowed to send, so apps can defer minting an identifier cookie until consent exists. storage is now optional; without it the queue lives in memory only instead of persisting to localStorage. The Vercel destination takes an options object ({ name?, trackPageViews? } replaces the positional name), drops $page_viewed unless trackPageViews is on — the mounted <Analytics /> component already tracks page views natively — and drops $identify/$group, which Vercel Analytics has no identity API to translate to. The global gtag type gains the config and js command signatures so apps can queue them directly, e.g. when loading gtag.js on demand for basic Consent Mode.

  • #563 bad015c Thanks @thevuong! - Consent internals cleanup. The gtag and GTM bootstraps now share one preamble builder (googleConsentBootstrapPreamble — generated output unchanged), toGoogleConsentParams derives from the signal map instead of hand-writing it, the runtime consent setters (updateGoogleConsent, setGoogleConsentDefault, setGoogleAdsDataRedaction, setGoogleUrlPassthrough) accept a dataLayerName, VercelAnalyticsDestinationOptions is exported from the destinations barrel, and the cookie-string parser is shared as readCookieValue (@codefast/tracking/core/cookie). Removed never-consumed exports: GOOGLE_CONSENT_SIGNAL_CATEGORIES, GoogleConsentSignal, isGa4EventName, consentDecisionShapeCheckExpression, consentSignalAssignmentsExpression.

  • #567 74c52ac Thanks @thevuong! - Cut every lane that shipped with zero consumer call sites — the package now covers exactly a consented gtag + Vercel Analytics setup on TanStack Start, and nothing speculative. Removed (recoverable from git history when a real need returns):

    • Server-side tracking: createServerTracker, the beacon relay/ingest lane (relayTrackedEvents, createTrackedEventIngestHandler), deriveEventId, the consent-cookie mirror (withConsentCookieMirror, codec, readConsentDecisionCookie/readConsentDecisionRequestCookie), ConsentConfig.decisionCookieName, and the GA4 Measurement Protocol destination (its subpath included).
    • Offline queue machinery: EventQueue, createLocalStorageQueueStorage, attachClientLifecycle, flushWithBeacon, createHttpDestination, Destination.delivery/sendBatch — every real destination (gtag.js, Vercel) owns its own in-page queue and unload delivery.
    • Segment-style event kinds: identify/group/alias/page on the tracker and the envelope union, EventDefinition.owner + EventsOf (with no server side there is nothing to split), attachRouterPageTracking — page views belong to gtag config + Enhanced Measurement and Vercel's native <Analytics />.
    • GTM: destination, bootstrap, loader.
    • Unused gtag helpers: setGoogleConsentDefault, setGoogleAdsDataRedaction, setGoogleUrlPassthrough, extractGa4ClientId/extractGa4SessionId.

    Follow-on API changes: ClientTracker is now track() only (clear() had nothing left to clear, so ConsentWithdrawalHandlerOptions.clearTracker is gone too); catalogs drop the owner tag ({ schema } only); TrackedEvent is the track envelope alone, still discriminated on type so a future kind is additive.

  • #606 5a4ff42 Thanks @thevuong! - Adds createDurableReceiptStore({ backend }) — a durable ReceiptStore over an injected ReceiptStoreBackend (a minimal id-keyed get/put primitive). The package supplies the append-only contract and adaptation; the deployment supplies the backend client (Vercel KV, Postgres, an append-only log), so no database dependency is baked in. put MUST be idempotent-by-id so the append-only guarantee holds atomically under retries/concurrency (e.g. KV set-if-absent, Postgres INSERT … ON CONFLICT DO NOTHING) — the frame delegates rather than doing a racy get-then-put. Pair it with a real backend in production, where createInMemoryReceiptStore is not a lawful store on its own.

  • #604 7bb4be7 Thanks @thevuong! - createClientTracker now accepts an optional isExemptionAllowed gate, consulted before an exempt destination receives an event while the consent gate is closed. ePrivacy audience-measurement exemption is jurisdiction-dependent (spec-destinations §2), so it must be gateable per region rather than assumed global — returning false withholds even exempt sinks where exemption is not defensible. Omit it to keep the prior behavior (exempt everywhere). The gate is irrelevant once consent is granted, since every destination then receives the full envelope.

  • 41951df Thanks @thevuong! - Expose the stored decision from useConsent and ignore tampered consent records.

    • UseConsentResult gains decision — the stored decision under the current policy version, undefined until the visitor makes one. Consumers need it to replay a returning visitor's decision into Google Consent Mode (e.g. from an effect) without conflating "denied" with "no decision yet", which the boolean isTrackingAllowed cannot distinguish.
    • useConsent now counts only a well-formed decision ("granted"/"denied"): the record is tamperable plain JSON, and a garbage value re-prompts instead of silently denying. This matches how a pre-hydration Consent Mode bootstrap reading the same record should treat it.
    • Documented that createLocalStorageConsentStorage persists the record as plain JSON.stringify(ConsentRecord) — a stable contract, so inline scripts can read the decision synchronously before any tag fires.
  • #565 1e80096 Thanks @thevuong! - resolveInitialConsent (née buildInitialConsent) now fails closed for a missing country code: an unknown visitor (prerender crawl, host without a geo header) resolves to the strictest opt-in default instead of "other"'s analytics-granted opt-out. A known non-EU country still resolves to opt-out — unknown is not known-elsewhere. Behavior change only for callers that passed countryCode: undefined and relied on the opt-out fallback; callers that guarded the missing case themselves can drop the guard.

  • 079b8df Thanks @thevuong! - Align the Google Analytics (gtag) destination with GA4's event and consent semantics.

    • GA4 rejects $-prefixed event names, so the tracker's built-ins are now translated instead of forwarded verbatim: $identifygtag('set', { user_id }), $group → the recommended join_group event (group_id param), and other invalid names are warned about and dropped instead of being sent to nowhere.
    • $page_viewed is dropped by default — gtag('config') plus Enhanced Measurement (on by default in GA4 admin) already report page views, so forwarding it double-counted. Opt in with trackPageViews: true after disabling both.
    • setGoogleConsentDefault/updateGoogleConsent now grant analytics_storage only; the ad_* Consent Mode v2 categories stay denied unless the new includeAds option is set, since an analytics-only banner never asked the visitor about ads data sharing.
    • Both consent functions define the standard gtag.js queueing stub themselves, so the default signal can be issued before the tag loads — as their docs always promised.
  • 079b8df Thanks @thevuong! - Correlate GA4 Measurement Protocol hits with gtag.js's own identifiers.

    GA4 joins hits on gtag's client ID (the _ga cookie), not on an app-generated anonymous ID — MP events sent with our ID landed on a different GA4 user than the visitor's client-side hits. New extractGa4ClientId/extractGa4SessionId helpers read gtag's _ga/_ga_<stream> request cookies (both GS1 and GS2 formats) so the destination can echo them via the new clientId/sessionId options. Events now also carry engagement_time_msec, session_id, and timestamp_micros — without them GA4 accepts the hit but leaves it out of realtime and session-scoped reports, and retried events drift to receipt time. $group maps to join_group; $alias is dropped (GA4 merges identities via user_id).

  • #608 6cdd930 Thanks @thevuong! - Adds GA4 DSR delegation (spec-data-subject-rights §3): the system delegates per-visitor deletion to the platform rather than building a deletion store. buildGa4UserDeletionRequest({ propertyId, clientId }) returns the network-free request shape and submitGa4UserDeletion({ …, accessToken, transport? }) POSTs it — targeting the current Analytics Admin API properties.submitUserDeletion (the legacy v3 userDeletionRequests:upsert was sunset with Universal Analytics), keyed by a flat clientId. Authorization is the caller's: pass a bearer token for the analytics.edit scope; no OAuth or HTTP client is baked in. Server-only (@codefast/tracking/server).

  • #563 bad015c Thanks @thevuong! - Improve gtag/GTM loader DX without changing consent-first loading:

    • ensureGtag / loadGtagScript / buildGtagConsentBootstrapScript accept optional dataLayerName, nonce (CSP), and debugMode.
    • Add createGoogleTagManagerDestination, buildGtmConsentBootstrapScript, and loadGtmScript for consent-gated GTM.
    • Add <GtagConsentBootstrap /> — a framework-agnostic inline script wrapper for the pre-hydration bootstrap.
  • #563 bad015c Thanks @thevuong! - Rename the http-destination module to http, matching the create<X>Destination file-naming convention used by every other destination. Breaking for deep imports only: @codefast/tracking/destinations/http-destination is now @codefast/tracking/destinations/http; imports from the @codefast/tracking/destinations barrel are unaffected.

  • #617 b979371 Thanks @thevuong! - Adopt TanStack Start's first-class server helpers in the adapter instead of hand-rolling over raw request/response headers.

    • The anonymous-id cookie is now written with setCookie/deleteCookie (from @tanstack/react-start/server) rather than setResponseHeader("set-cookie", …). The raw header call replaces any existing Set-Cookie on the response — it would clobber a session or framework cookie set on the same response — whereas setCookie appends. No behavior change to the emitted cookie (still Path=/; Max-Age=1y; SameSite=Lax; Secure, not HttpOnly).
    • The connection IP for consent receipts is now read with getRequestIP({ xForwardedFor: true }) — the maintained, platform-aware path — instead of hand-parsing x-forwarded-for/x-real-ip.

    Breaking (@codefast/tracking/server/anonymous-id-cookie): the string builders buildAnonymousIdSetCookie/buildClearAnonymousIdSetCookie are replaced by resolveAnonymousIdCookie/resolveClearAnonymousIdCookie, which return the validated name/value plus cookie attributes for a framework setCookie/deleteCookie call. isValidAnonymousId is unchanged; the cookie-name guard is now the exported assertValidAnonymousIdCookieName.

  • 079b8df Thanks @thevuong! - Deliver events to SDK-backed destinations at track time instead of through the batching queue.

    Destination gains an optional delivery: "immediate" | "queued" field. The Google Analytics and Vercel destinations are marked "immediate" — their SDKs own batching and unload delivery, so routing them through the queue only delayed events and replayed stale ones next session with wrong timestamps. The queue keeps serving HTTP destinations and the flushWithBeacon path unchanged.

  • #567 74c52ac Thanks @thevuong! - Collapse the export map to group entries — per-file subpaths froze the internal file layout into public API.

    Breaking: deep subpaths (./client/*, ./core/*, ./server/*, ./react/*, and per-file ./destinations/*) no longer resolve. Import from the group entry instead:

    • @codefast/tracking/core and @codefast/tracking/core/*@codefast/tracking (the root has always re-exported the whole isomorphic core surface).
    • @codefast/tracking/client/*@codefast/tracking/client; same pattern for server and react.
    • Google helpers → @codefast/tracking/destinations.

    One destination keeps a dedicated subpath on purpose: @codefast/tracking/destinations/vercel-analytics — its top-level @vercel/analytics import would make the optional peer mandatory for every barrel consumer.

    All entries are unbundled ESM with sideEffects: false, so group imports tree-shake per file — the trim changes what is addressable, not what ships.

  • #607 749dd16 Thanks @thevuong! - Adds server-side GA4 Measurement Protocol primitives for forwarding a server-owned event (re-added now that a consumer tracks one — a server-recorded consent decision): sendMeasurementProtocolEvents POSTs { client_id, events, consent? } to the credentialed /mp/collect endpoint through an injected transport (default fetch), so no HTTP client or credentials are baked in; extractGaClientId derives the GA4 client_id from a _ga cookie; toMeasurementProtocolConsent maps the package ConsentDecision to the MP consent signals. Server-only (@codefast/tracking/server). The caller owns the credentials and the consent gate.

  • #602 5ca04e2 Thanks @thevuong! - createClientTracker now accepts an optional onDeliveryError hook, called once per failed delivery (a destination throwing synchronously or rejecting) with { destination, error, event }. The tracker still swallows the failure so tracking never breaks the interaction — the hook is a metering seam for wiring delivery failures to a monitor in production. The hook is itself guarded, so a throwing observer can't break the interaction either. Exposes the DeliveryErrorContext type from @codefast/tracking/client.

  • #610 fdb8d7c Thanks @thevuong! - Adds the per-destination erasure capability for DSR withdrawal (spec-data-subject-rights §3, DSR-V2/V4): Destination gains an optional onErasure(id) hook, and createClientTracker returns an erase(id) method that invokes each destination's onErasure once on withdrawal, swallowing failures so a destination can never break the flow. The reference createMetaDestination implements onErasure as cookie-clear (via an injected clearCookies seam) plus stop-send — Meta exposes no per-visitor deletion API, so the binding never fabricates one. Destinations with nothing to erase omit the hook.

  • 2ebb0c0 Thanks @thevuong! - Make consent per-category, mirroring Google Consent Mode v2. ConsentDecision is now { ads: boolean, analytics: boolean } instead of a single "granted" | "denied" flag, useConsent takes the categories the app's prompt asks about (grantAll/denyAll/save replace grant/deny), and ConsentBanner gains a per-category preferences layer plus a ReactNode message for the privacy-policy link. The GA4 helpers map the decision onto the v2 signals (ads drives ad_storage/ad_user_data/ad_personalization), take wait_for_update/region, and gain setGoogleAdsDataRedaction/setGoogleUrlPassthrough; the destination-side includeAds override is gone — the visitor's decision carries ads consent. resolveDefaultConsent replaces shouldTrackByDefault and honors GPC as an ads-only opt-out. Previously stored string decisions fail shape validation and re-prompt, no policy-version bump needed.

  • #563 bad015c Thanks @thevuong! - Add three helpers that pull common consent/tracking wiring out of consumer apps and into the package:

    • resolveEffectiveConsent(storage, policyVersion, categories, mode, hasGpc) and readStoredDecision(storage, policyVersion) (@codefast/tracking/core) — the same "stored decision, else region default" rule useConsent applies internally, now exposed so a non-React gate (e.g. a tracker's isTrackingAllowed option) doesn't have to reimplement it by hand.
    • buildGtagConsentBootstrapScript(options) (@codefast/tracking/destinations) — generates the pre-hydration <script> source that applies Google Consent Mode v2's default signal from the stored decision (or a supplied fallback) and conditionally loads gtag.js, replacing a hand-written JS string per app.
    • createCookieAnonymousId(options) (@codefast/tracking/client) — an opt-in document.cookie-backed anonymous id getOrCreate/clear pair for apps that don't need a custom identity strategy.

    None of these change existing exports' behavior; useConsent is refactored internally to use readStoredDecision but its output is unchanged.

  • #565 1e80096 Thanks @thevuong! - Remove the deprecated edge-middleware cookie bootstrap path:

    • Drop buildInitialConsentBootstrapScript and the @codefast/tracking/destinations/initial-consent-bootstrap subpath.
    • Resolve region consent via a server function (resolveInitialConsent) plus a client snapshot instead — see apps/ui visitor-consent.ts / resolve-visitor-consent.ts.
  • #563 bad015c Thanks @thevuong! - Rename UseConsentResult.needsPrompt to isPromptNeeded — a boolean should read as an assertion, matching isTrackingAllowed on the same result (Swift API Design Guidelines pass).

    Breaking: consumers of useConsent/ConsentBanner reading needsPrompt must switch to isPromptNeeded.

  • #563 bad015c Thanks @thevuong! - Add deriveEventId(requestId, discriminant) (@codefast/tracking/core) and wire it into createServerTracker: pass requestId on ServerTrackerContext to make a server-owned event's eventId deterministic instead of random. Retrying the same request with the same track/group/alias call now reproduces the same eventId, so a destination that dedupes on it treats the retry as a no-op instead of double-counting — closing the gap between the package's documented idempotency intent and its previous always-random default. Omitting requestId keeps the existing random behavior, so this is additive and non-breaking.

  • #566 ffd777c Thanks @thevuong! - Modernize the package around server-first React frameworks and shrink what the client pays for.

    Breaking (pre-release):

    • Event catalogs now accept any Standard Schema library (zod, zod/mini, valibot) — EventDefinition is typed on StandardSchemaV1, validation runs through the new assertValidEventProperties, and zod is no longer a dependency (@standard-schema/spec is the only one).
    • buildInitialConsentresolveInitialConsent; ServerTrackContextServerTrackerContext.
    • attachClientLifecycle drops flushIntervalMs — the queue schedules its own flushes (one-shot idle timer armed only while events are pending, offline-aware); the lifecycle keeps hide/pagehide delivery (beacon, or a keepalive fetch fallback) and flush-on-reconnect.
    • The ./destinations barrel is browser-lane only: import createVercelAnalyticsDestination from ./destinations/vercel-analytics (its top-level @vercel/analytics import made the optional peer mandatory for barrel consumers) and createGa4MeasurementProtocolDestination from its own subpath.
    • ./server, ./server/*, ./tanstack-start, and ./destinations/ga4-measurement-protocol are server-only by contract: on TanStack Start, deny them in the client environment via importProtection.client.specifiers (README shows the config) so a leak fails the build with a traced violation instead of silently shipping server code or the GA4 apiSecret.

    New:

    • @codefast/tracking/tanstack-start (optional peer on @tanstack/react-start): resolveInitialConsentFromRequest, setAnonymousIdResponseCookie/clearAnonymousIdResponseCookie, readAnonymousIdRequestCookie, readConsentDecisionRequestCookie, resolveServerTrackerContextFromRequest — consumers' server functions become one-liners.
    • createInitialConsentStore (client) + useInitialConsent (react): the whole post-hydration region-resolution lane — strictest-until-resolved, single-flight, per-session cache validated by the new isInitialConsent guard, fail-closed-but-retryable errors, retry on tab-visible.
    • createServerTracker: waitUntil hands delivery (and its retry ladder) to the platform's post-response scheduler; withContext binds per-request identity once.
    • Beacon receive half: relayTrackedEvents + createTrackedEventIngestHandler (Request → Response) validate client envelopes, re-stamp server-read identity, and keep client eventIds so re-sent beacons dedupe.
    • Consent-aware server tracking: consent-cookie codec (core), withConsentCookieMirror (client), readConsentRecordCookie/readConsentDecisionCookie (server).
    • Transport hardening: requestTimeoutMs (default 10s) on the fetch destinations, keepalive threading for unload flushes, Destination.sendBatch (implemented by createHttpDestination — one POST per queue flush).
    • CookieAnonymousId.refresh() prolongs an existing id without ever minting; STRICTEST_INITIAL_CONSENT, CONSENT_REGIONS, isConsentRegion are exported from core; the localStorage consent storage caches parsed records behind a raw-string compare; client-only react modules ship "use client".
  • #563 bad015c Thanks @thevuong! - Add server-persisted anonymous id ("client mints, server persists"). createServerPersistedAnonymousId (client) keeps the lazy, post-consent minting of createCookieAnonymousId and delegates the durable cookie write to an app-supplied server round-trip, so the id outlives Safari ITP's 7-day cap on script-written cookies. The server half — readAnonymousIdCookie, buildAnonymousIdSetCookie, buildClearAnonymousIdSetCookie, isValidAnonymousId — is framework-agnostic string-in/string-out: always Secure; SameSite=Lax, validates the cookie name, and throws on any non-UUID id so a public persist endpoint can never echo attacker input into a response header. The server persists and prolongs an id the client hands it; it never mints one per request.

  • #563 bad015c Thanks @thevuong! - Add a TanStack Start wiring kit so consumer apps no longer hand-roll consent/bootstrap glue:

    • resolveInitialConsent (née buildInitialConsent) + exported EU_COUNTRY_CODES / OPT_IN_EQUIVALENT_COUNTRY_CODES (@codefast/tracking/server) — region → mode → default decision for server functions (or a fail-closed bake when country is unknown).
    • clearGoogleAnalyticsCookies (@codefast/tracking/destinations) — expire _ga / _ga_* on consent withdrawal.
    • createIsTrackingAllowed / createConsentWithdrawalHandler (@codefast/tracking/client) — tracker gate + revoke clears.
    • useGoogleConsentSync (@codefast/tracking/react) — Consent Mode update + optional gtag load, including cross-tab / privacy-page decisions.

    InitialConsent is exported from @codefast/tracking / @codefast/tracking/core. None of these change existing export behavior.

  • #565 1e80096 Thanks @thevuong! - Rename public APIs to follow Swift API Design Guidelines (name by role; nouns for properties; imperative verbs for builders; assertion-form booleans).

    Breaking:

    • Envelope field: TrackEvent.props / PageViewEvent.propsproperties (and the track/page method parameters). Segment-style name; Props is reserved for React components.
    • Analytics gate: isTrackingAllowedisAnalyticsAllowed on ClientTrackerOptions and UseConsentResult (the gate reads the analytics category only). createIsTrackingAllowedcreateIsAnalyticsAllowed; subpath ./client/is-tracking-allowed./client/is-analytics-allowed.
    • Options naming (drop Create* / Build* filler): CreateIsAnalyticsAllowedOptionsIsAnalyticsAllowedOptions; CreateConsentWithdrawalHandlerOptionsConsentWithdrawalHandlerOptions; BuildInitialConsentOptionsInitialConsentOptions; BuildAnonymousIdSetCookieOptionsAnonymousIdSetCookieOptions. ClientLifecycleOptions stays (product-named, no verb prefix).
    • Prompt scope option: categoriesrequestedCategories on UseConsentOptions, IsAnalyticsAllowedOptions, and InitialConsentOptions.
    • Destination.consent / VercelAnalyticsDestinationOptions.consentconsentRequirement ("exempt" | "required").
    • googleConsentBootstrapPreamblebuildGoogleConsentBootstrapPreamble; dataLayerOfensureDataLayer.
    • UseConsentResult.savesaveDecision.
    • Demote package-private deep exports: remove ./client/queue, ./destinations/shared, and ./destinations/google-consent from package.json#exports. EventQueue / EventQueueOptions leave the ./client barrel; EventQueueStorage stays (custom offline persistence). Consent Mode helpers remain on ./destinations / ./react.
    • Options-object for multi-arg consent resolvers (clarity at the call site): resolveDefaultConsent(mode, requestedCategories, hasGlobalPrivacyControlSignal)resolveDefaultConsent(options) and resolveEffectiveConsent(storage, policyVersion, requestedCategories, mode, hasGlobalPrivacyControlSignal)resolveEffectiveConsent(options); new ResolveDefaultConsentOptions / ResolveEffectiveConsentOptions types. readStoredDecision(storage, policyVersion) keeps its positional args.
    • GA4 Measurement Protocol debug flag: Ga4MeasurementProtocolDestinationOptions.debugdebugMode (matches debugMode on the gtag options).
    • Server group signature: ServerTracker.group(groupId, traits, context)group(groupId, context, traits?) so traits is truly optional instead of a forced undefined.
    • Remove assertNever from the public exports (generic, non-tracking helper; internal-only now).
    • readCookieValue is now also exported from the root entry (previously only on ./core).
  • #611 a5527c3 Thanks @thevuong! - Extends the ad-destination frame with two more reference vendors (spec-destinations §5), consuming the same { ads, analytics } decision: createMicrosoftUetDestination / toMicrosoftUetConsent map ads to UET's only enforced signal, ad_storage (UET has no analytics_storage); createTiktokDestination / toTiktokConsent map ads to TikTok's single limited_data_use boolean (not Meta's dataProcessingOptions structure). Both take an injected transport (no pixel id, tag id, or network client baked in) and implement onErasure as cookie-clear + stop-send, since neither exposes a per-visitor deletion API. consentRequirement stays "required".

  • #563 bad015c Thanks @thevuong! - Discriminate event envelopes by type and add a consent-exempt destination lane.

    TrackedEvent is now a union discriminated on type: "track" | "page" | "identify" | "group" | "alias" (Segment-style) instead of encoding built-ins into $-prefixed magic names — destinations translate each kind into their own vocabulary via an exhaustive switch (identify carries traits, group carries groupId/traits, alias carries previousId), and the app-chosen name only exists on track/page. This changes the wire format seen by HTTP destinations and the queue storage; stale queue records without a type are dropped silently. Destination gains consent?: "exempt" | "required" — while the tracker's consent gate is closed, exempt immediate destinations keep receiving track/page events stripped of anonymousId/userId (identity kinds and queueing stay fully gated), so cookieless sinks like Vercel Analytics can keep counting interactions without consent-gated identifiers. The Vercel destination accepts the flag via its options and still defaults to "required".

Patch Changes

  • #676 641e233 Thanks @thevuong! - Collapse the types and default lanes of package.json#imports from fallback arrays to single strings.

    Node resolves an imports array by taking the first candidate it can parse, without checking that the file exists and without falling through — a specifier whose first candidate is missing throws ERR_MODULE_NOT_FOUND rather than trying the second. ./dist/*/index.js and ./dist/*/index.d.ts could therefore never be reached, so they read as a safety net that does not exist. The source lane keeps its extension candidates, which only tsc and Vite read and both probe.

  • #565 1e80096 Thanks @thevuong! - Prefer the live anonymous-id cookie over the in-memory cache so a cross-tab consent withdrawal cannot revive the pre-withdrawal identity on re-grant.

  • #563 bad015c Thanks @thevuong! - Fix bugs found in review of the consent-gated tracking pipeline, and dedupe the GA4/Vercel destinations.

    createClientTracker's identify() no longer commits userId to the tracker's closure while consent is denied — a denied identify could otherwise leak its userId onto a later, allowed track/page/group call. createServerTracker's per-request eventId derivation now factors in userId, so two alias() calls with the same previousId but different merge targets in one request no longer collide on eventId. createLocalStorageQueueStorage.load() now drops pre-migration/malformed queue records via a new isTrackedEvent guard (exported from @codefast/tracking/core), instead of relying on each destination's switch to silently no-op on an unrecognized shape.

    Also: the GA4 (gtag/Measurement Protocol) and Vercel Analytics destinations now share one prop-flattening helper and one groupjoin_group mapping (@codefast/tracking/destinations's internal shared.ts) instead of three near-duplicate implementations; buildGtagConsentBootstrapScript's pre-hydration Consent Mode signal mapping is generated from the same table toGoogleConsentParams uses instead of a hand-duplicated literal; and the package's ensureGtag gtag.js stub helper, plus a new loadGtagScript(options) (loads gtag.js on demand, idempotent), are now exported so apps don't have to reimplement on-demand script loading themselves.

  • #565 1e80096 Thanks @thevuong! - Guard createLocalStorageConsentStorage with isConsentRecord so malformed localStorage JSON cannot be treated as a valid consent record.

  • 46c32d6 Thanks @thevuong! - Default engagement_time_msec on Measurement Protocol events to 100ms — the fallback Google's own MP documentation prescribes when the elapsed time since the previous event is unknown — instead of 1ms.

  • #565 1e80096 Thanks @thevuong! - Skip clearOnServer when the anonymous-id cookie is already gone, so a second withdrawal clear in the same tick does not fire a redundant server round-trip.

0.5.0-canary.9

Patch Changes

  • #676 641e233 Thanks @thevuong! - Collapse the types and default lanes of package.json#imports from fallback arrays to single strings.

    Node resolves an imports array by taking the first candidate it can parse, without checking that the file exists and without falling through — a specifier whose first candidate is missing throws ERR_MODULE_NOT_FOUND rather than trying the second. ./dist/*/index.js and ./dist/*/index.d.ts could therefore never be reached, so they read as a safety net that does not exist. The source lane keeps its extension candidates, which only tsc and Vite read and both probe.

0.5.0-canary.8

0.5.0-canary.7

0.5.0-canary.6

Minor Changes

  • #605 cb46bdd Thanks @thevuong! - Adds the shared ad-destination frame for consuming one { ads, analytics } decision across ad platforms (spec-destinations §5): toAdConsentState(decision) normalizes it to the two independent levers — analytics drives whether events transmit, ads drives Limited Data Use — so per-vendor mappings cannot drift. Ships a reference Meta destination (createMetaDestination, toMetaDataProcessingOptions) that maps each event and the live ads decision to Meta's dataProcessingOptions (geolocated LDU when ads is denied) and hands it to an injected transport. Consent-restriction mapping only — the Pixel/CAPI transport and credentials are the integrator's to supply; an ad sink is never exempt.

  • #612 1337fc3 Thanks @thevuong! - Adds the TCF/GPP interop reconciler (spec-ad-consent-frameworks): the system reads an external CMP and reconciles it with the native { ads, analytics } decision — it never becomes a CMP or mints TC/GPP strings. reconcileAdFrameworkConsent({ native, cmp, hasGlobalPrivacyControlSignal }) applies the §3 precedence (a governing CMP overrides its categories; fail-closed to denied while the CMP is loading; a missing or out-of-scope CMP leaves native standing; GPC only tightens ads), covering conformance vectors V1–V6. hasTcfApi/hasGppApi detect the __tcfapi/__gpp read APIs without invoking them. TCF purpose ids and the Google vendor id are deliberately not hard-coded — that mapping is ad-ops policy, so the caller derives the CmpConsentSignal it passes in.

  • #615 2bcd31f Thanks @thevuong! - Let codefast mirror generate package.json#exports from dist/, the same as every other library package (di, theme), instead of hand-curating them under mirror's preserve mode. The per-module build output is unchanged, so mirror emits a subpath for each built module, and the root becomes the client entry.

    Breaking:

    • The root @codefast/tracking is now the client entry — it re-exports the isomorphic core plus the whole browser surface (createClientTracker, createConsentRuntime, the React bindings, the gtag + ad-network destinations). Server code must import the core it needs from @codefast/tracking/core/*, not from the root.
    • The ./client, ./server, ./core, ./react, and ./destinations group barrels are gone. Use the client root for browser code, or a module's own subpath for granular/server imports (@codefast/tracking/server/initial-consent, @codefast/tracking/client/gpc, …).
    • The TanStack Start adapter is now @codefast/tracking/adapters/tanstack-start (was /tanstack-start); the import-protection deny-list is @codefast/tracking/tooling/import-protection (was /import-protection). SERVER_ONLY_SUBPATHS now denies server/** and adapters/**.
  • #563 bad015c Thanks @thevuong! - Switch gtag/GTM bootstraps to Google Consent Mode advanced:

    • buildGtagConsentBootstrapScript / buildGtmConsentBootstrapScript always set Consent Mode v2 default (from stored decision or region fallback), then always load gtag.js / gtm.js — even when analytics/ads storage is denied — so cookieless pings and consent modeling can run.
    • Runtime grants/denies still use updateGoogleConsent; loadGtagScript / loadGtmScript remain idempotent safety nets when the bootstrap did not run.
    • The package's first-party consent gate is unchanged — identifiers and non-exempt destinations stay blocked without consent; only Google tag script loading changes.
  • #563 bad015c Thanks @thevuong! - Tighten the package's API contracts and framework independence, found in an architecture audit.

    Breaking:

    • ClientTrackerOptions.anonymousId is now () => string only — the plain-string form is removed. A resolver was already the documented best practice (defers minting an id until an event is actually allowed to send); the string form let callers accidentally mint one as an import-time side effect. Wrap a stable value in a resolver: anonymousId: () => myId.
    • Destination.send now always returns Promise<void> — the previous Promise<void> | void let sync and async destinations disagree on contract. Mark a synchronous send async so a thrown error rejects the returned promise instead of throwing synchronously.

    Also:

    • createVercelAnalyticsDestination now imports track from the framework-agnostic @vercel/analytics instead of @vercel/analytics/react — the destination renders nothing, so it had no reason to depend on React.
    • Adds assertNever (@codefast/tracking/core) and wires it into the default case of every switch (event.type) across the GA4/Vercel destinations — extending TrackedEvent with a new variant now fails to compile at every switch instead of silently falling through.
    • The package root (@codefast/tracking) now re-exports #/core's surface by explicit name instead of export *, matching the client/server/destinations/react subpaths, which were already explicit.
    • useConsent's returned object and its save/denyAll/grantAll callbacks are now memoized (useMemo/useCallback), so a consumer passing the hook's result down as a prop or effect dependency doesn't get a new reference every render.
  • #617 b979371 Thanks @thevuong! - Harden the package from a full audit — correctness, coverage, and a leaner public surface.

    • isConsentReceiptInput now validates method and subjectIdType against their enums, not just typeof === "string" — the untrusted-body guard no longer narrows a bogus value to a closed union member.
    • CookieAnonymousId gains current() — a non-minting read of the existing id (undefined when none) so a consent receipt stamps the id the visitor already carries instead of a throwaway that never correlates for erasure.
    • coarsenIp rejects out-of-range IPv4 octets ("999.…") rather than storing a malformed coarse value.
    • Microsoft UET consent routes through the shared toAdConsentState ad lever, so its ad_storage mapping can't drift from Meta/TikTok.
    • Dropped unused foreign type re-exports so each type has one home: InitialConsent no longer re-exported from adapters/tanstack-start or server/initial-consent (import it from core/consent), and the AnonymousIdResponseCookieOptions alias is gone — setAnonymousIdResponseCookie takes AnonymousIdCookieOptions from server/anonymous-id-cookie directly.
    • Collapsed the TrackedEvent envelope to a single interface — the unused TrackedEventBase and TrackEvent names are gone (TrackedEvent keeps the type: "track" discriminant for a future additive union).

    Also adds test coverage for the previously-untested recordConsentReceiptFromRequest adapter path (no-store header, body-IP rejection, coarsened IP, PII-free ack).

  • #617 b979371 Thanks @thevuong! - Remove two leftover indirection layers in the server lane that no call site used.

    Breaking:

    • The @codefast/tracking/adapters/request-context subpath is gone. Its RequestContext seam (a getHeader/setHeader interface) plus the parallel *FromContext/*OnContext helpers existed only to back a hypothetical future ./next/./remix adapter, but there was exactly one adapter and it duplicated every signature and doc comment. @codefast/tracking/adapters/tanstack-start now calls getRequestHeader/setResponseHeader directly; its public surface (resolveInitialConsentFromRequest, setAnonymousIdResponseCookie, clearAnonymousIdResponseCookie, recordConsentReceiptFromRequest) is unchanged.
    • resolveRegion(headers) is removed from @codefast/tracking/server/region. It was a pre-fail-closed leftover with no production call site, and its missing-geo semantics (unknown region → opt-out) contradicted the fail-closed invariant the server-first path relies on. Use resolveRegionFromCountryCode (what the production path already uses via resolveInitialConsent), or resolveInitialConsentFromRequest for the full per-request resolution.
  • 08f10fb Thanks @thevuong! - Rebuild ConsentBanner as composable compound parts (ConsentBannerTitle/Description/Actions/Accept/Reject/Customize/Preferences/Category/Save) — the root owns visibility (needsPrompt, overridable via open for a "Cookie settings" reopen) and the preferences-layer state, action parts wire their own clicks and compose the consumer's onClick, so any markup including a design system's button styles slots in via className. The monolithic message/acceptLabel/categories props are gone. An optional plain-CSS default theme ships at @codefast/tracking/css/consent.css — data-slot selectors, --consent-* custom properties with light-dark() fallbacks, zero Tailwind dependency.

  • #565 1e80096 Thanks @thevuong! - Remove defaultConsentExpression from gtag/GTM consent bootstraps. Pass a literal defaultConsent (strictest bake on shared HTML) and upgrade after hydration via the server-fn lane + updateGoogleConsent.

  • #567 74c52ac Thanks @thevuong! - Collapse the consent "must match" contracts into one ConsentConfig, and add createConsentRuntime.

    Previously storageKey, policyVersion, and requestedCategories had to be hand-threaded — matching exactly — through useConsent, createIsAnalyticsAllowed, and the gtag consent bootstrap; one drifted string was a silent consent bug. Now:

    • ConsentConfig + defineConsentConfig (root/core) — the one bag for storageKey, policyVersion, and requestedCategories. Isomorphic plain data: the same object is imported on both sides.
    • createConsentRuntime (client) — derives the live client instances from the config: the shared ConsentStorage, the initial-consent store over your server lane, ensureInitialConsentResolved, and the isAnalyticsAllowed tracker gate wired to the store's resolved mode (GPC read from the real navigator signal by default).

    Breaking option changes (config-first):

    • useConsent({ policyVersion, requestedCategories?, ... })useConsent({ config, ... }). The ["analytics"] default for requestedCategories is gone — the config always states the requested purposes explicitly.
    • createIsAnalyticsAllowed({ policyVersion, requestedCategories, ... })createIsAnalyticsAllowed({ config, ... }).
    • GtagConsentBootstrapOptions (and the <GtagConsentBootstrap /> props): consentStorageKey + policyVersionconfig.
  • 079b8df Thanks @thevuong! - Rebuild the consent layer on useSyncExternalStore and expose data-slot styling hooks on the consent UI.

    • useConsent treats the stored ConsentRecord as the single source of truth: the server snapshot is always "no decision yet" (hydration-safe by construction on prerendered pages), a decision made in one tab syncs to every other tab, and a record saved under an older policyVersion is ignored so bumping the version re-prompts as documented.
    • Breaking: ConsentStorage gains a required subscribe(listener) method — custom implementations must notify on changes. createLocalStorageConsentStorage implements it (same-tab saves plus the cross-tab storage event) and now degrades a blocked localStorage (private mode/quota) to a session-scoped in-memory record instead of re-prompting in a loop.
    • Breaking: ConsentBanner renders a labeled region instead of a non-modal <dialog> (which neither traps focus nor blocks, so the dialog semantics over-promised). Both components extend their host element's ComponentProps and expose data-slot attributes (consent-message, consent-actions, consent-action, consent-toggle) for Tailwind **:data-[slot=...] styling.
  • #563 bad015c Thanks @thevuong! - Gate the client tracker on consent and keep destinations from leaking pre-consent or duplicate data.

    createClientTracker gains isTrackingAllowed?: () => boolean, consulted per event — while it returns false nothing is sent or queued, so a mid-session consent change applies immediately. anonymousId also accepts a () => string resolver, invoked only when an event is actually allowed to send, so apps can defer minting an identifier cookie until consent exists. storage is now optional; without it the queue lives in memory only instead of persisting to localStorage. The Vercel destination takes an options object ({ name?, trackPageViews? } replaces the positional name), drops $page_viewed unless trackPageViews is on — the mounted <Analytics /> component already tracks page views natively — and drops $identify/$group, which Vercel Analytics has no identity API to translate to. The global gtag type gains the config and js command signatures so apps can queue them directly, e.g. when loading gtag.js on demand for basic Consent Mode.

  • #563 bad015c Thanks @thevuong! - Consent internals cleanup. The gtag and GTM bootstraps now share one preamble builder (googleConsentBootstrapPreamble — generated output unchanged), toGoogleConsentParams derives from the signal map instead of hand-writing it, the runtime consent setters (updateGoogleConsent, setGoogleConsentDefault, setGoogleAdsDataRedaction, setGoogleUrlPassthrough) accept a dataLayerName, VercelAnalyticsDestinationOptions is exported from the destinations barrel, and the cookie-string parser is shared as readCookieValue (@codefast/tracking/core/cookie). Removed never-consumed exports: GOOGLE_CONSENT_SIGNAL_CATEGORIES, GoogleConsentSignal, isGa4EventName, consentDecisionShapeCheckExpression, consentSignalAssignmentsExpression.

  • #567 74c52ac Thanks @thevuong! - Cut every lane that shipped with zero consumer call sites — the package now covers exactly a consented gtag + Vercel Analytics setup on TanStack Start, and nothing speculative. Removed (recoverable from git history when a real need returns):

    • Server-side tracking: createServerTracker, the beacon relay/ingest lane (relayTrackedEvents, createTrackedEventIngestHandler), deriveEventId, the consent-cookie mirror (withConsentCookieMirror, codec, readConsentDecisionCookie/readConsentDecisionRequestCookie), ConsentConfig.decisionCookieName, and the GA4 Measurement Protocol destination (its subpath included).
    • Offline queue machinery: EventQueue, createLocalStorageQueueStorage, attachClientLifecycle, flushWithBeacon, createHttpDestination, Destination.delivery/sendBatch — every real destination (gtag.js, Vercel) owns its own in-page queue and unload delivery.
    • Segment-style event kinds: identify/group/alias/page on the tracker and the envelope union, EventDefinition.owner + EventsOf (with no server side there is nothing to split), attachRouterPageTracking — page views belong to gtag config + Enhanced Measurement and Vercel's native <Analytics />.
    • GTM: destination, bootstrap, loader.
    • Unused gtag helpers: setGoogleConsentDefault, setGoogleAdsDataRedaction, setGoogleUrlPassthrough, extractGa4ClientId/extractGa4SessionId.

    Follow-on API changes: ClientTracker is now track() only (clear() had nothing left to clear, so ConsentWithdrawalHandlerOptions.clearTracker is gone too); catalogs drop the owner tag ({ schema } only); TrackedEvent is the track envelope alone, still discriminated on type so a future kind is additive.

  • #606 5a4ff42 Thanks @thevuong! - Adds createDurableReceiptStore({ backend }) — a durable ReceiptStore over an injected ReceiptStoreBackend (a minimal id-keyed get/put primitive). The package supplies the append-only contract and adaptation; the deployment supplies the backend client (Vercel KV, Postgres, an append-only log), so no database dependency is baked in. put MUST be idempotent-by-id so the append-only guarantee holds atomically under retries/concurrency (e.g. KV set-if-absent, Postgres INSERT … ON CONFLICT DO NOTHING) — the frame delegates rather than doing a racy get-then-put. Pair it with a real backend in production, where createInMemoryReceiptStore is not a lawful store on its own.

  • #604 7bb4be7 Thanks @thevuong! - createClientTracker now accepts an optional isExemptionAllowed gate, consulted before an exempt destination receives an event while the consent gate is closed. ePrivacy audience-measurement exemption is jurisdiction-dependent (spec-destinations §2), so it must be gateable per region rather than assumed global — returning false withholds even exempt sinks where exemption is not defensible. Omit it to keep the prior behavior (exempt everywhere). The gate is irrelevant once consent is granted, since every destination then receives the full envelope.

  • 41951df Thanks @thevuong! - Expose the stored decision from useConsent and ignore tampered consent records.

    • UseConsentResult gains decision — the stored decision under the current policy version, undefined until the visitor makes one. Consumers need it to replay a returning visitor's decision into Google Consent Mode (e.g. from an effect) without conflating "denied" with "no decision yet", which the boolean isTrackingAllowed cannot distinguish.
    • useConsent now counts only a well-formed decision ("granted"/"denied"): the record is tamperable plain JSON, and a garbage value re-prompts instead of silently denying. This matches how a pre-hydration Consent Mode bootstrap reading the same record should treat it.
    • Documented that createLocalStorageConsentStorage persists the record as plain JSON.stringify(ConsentRecord) — a stable contract, so inline scripts can read the decision synchronously before any tag fires.
  • #565 1e80096 Thanks @thevuong! - resolveInitialConsent (née buildInitialConsent) now fails closed for a missing country code: an unknown visitor (prerender crawl, host without a geo header) resolves to the strictest opt-in default instead of "other"'s analytics-granted opt-out. A known non-EU country still resolves to opt-out — unknown is not known-elsewhere. Behavior change only for callers that passed countryCode: undefined and relied on the opt-out fallback; callers that guarded the missing case themselves can drop the guard.

  • 079b8df Thanks @thevuong! - Align the Google Analytics (gtag) destination with GA4's event and consent semantics.

    • GA4 rejects $-prefixed event names, so the tracker's built-ins are now translated instead of forwarded verbatim: $identifygtag('set', { user_id }), $group → the recommended join_group event (group_id param), and other invalid names are warned about and dropped instead of being sent to nowhere.
    • $page_viewed is dropped by default — gtag('config') plus Enhanced Measurement (on by default in GA4 admin) already report page views, so forwarding it double-counted. Opt in with trackPageViews: true after disabling both.
    • setGoogleConsentDefault/updateGoogleConsent now grant analytics_storage only; the ad_* Consent Mode v2 categories stay denied unless the new includeAds option is set, since an analytics-only banner never asked the visitor about ads data sharing.
    • Both consent functions define the standard gtag.js queueing stub themselves, so the default signal can be issued before the tag loads — as their docs always promised.
  • 079b8df Thanks @thevuong! - Correlate GA4 Measurement Protocol hits with gtag.js's own identifiers.

    GA4 joins hits on gtag's client ID (the _ga cookie), not on an app-generated anonymous ID — MP events sent with our ID landed on a different GA4 user than the visitor's client-side hits. New extractGa4ClientId/extractGa4SessionId helpers read gtag's _ga/_ga_<stream> request cookies (both GS1 and GS2 formats) so the destination can echo them via the new clientId/sessionId options. Events now also carry engagement_time_msec, session_id, and timestamp_micros — without them GA4 accepts the hit but leaves it out of realtime and session-scoped reports, and retried events drift to receipt time. $group maps to join_group; $alias is dropped (GA4 merges identities via user_id).

  • #608 6cdd930 Thanks @thevuong! - Adds GA4 DSR delegation (spec-data-subject-rights §3): the system delegates per-visitor deletion to the platform rather than building a deletion store. buildGa4UserDeletionRequest({ propertyId, clientId }) returns the network-free request shape and submitGa4UserDeletion({ …, accessToken, transport? }) POSTs it — targeting the current Analytics Admin API properties.submitUserDeletion (the legacy v3 userDeletionRequests:upsert was sunset with Universal Analytics), keyed by a flat clientId. Authorization is the caller's: pass a bearer token for the analytics.edit scope; no OAuth or HTTP client is baked in. Server-only (@codefast/tracking/server).

  • #563 bad015c Thanks @thevuong! - Improve gtag/GTM loader DX without changing consent-first loading:

    • ensureGtag / loadGtagScript / buildGtagConsentBootstrapScript accept optional dataLayerName, nonce (CSP), and debugMode.
    • Add createGoogleTagManagerDestination, buildGtmConsentBootstrapScript, and loadGtmScript for consent-gated GTM.
    • Add <GtagConsentBootstrap /> — a framework-agnostic inline script wrapper for the pre-hydration bootstrap.
  • #563 bad015c Thanks @thevuong! - Rename the http-destination module to http, matching the create<X>Destination file-naming convention used by every other destination. Breaking for deep imports only: @codefast/tracking/destinations/http-destination is now @codefast/tracking/destinations/http; imports from the @codefast/tracking/destinations barrel are unaffected.

  • #617 b979371 Thanks @thevuong! - Adopt TanStack Start's first-class server helpers in the adapter instead of hand-rolling over raw request/response headers.

    • The anonymous-id cookie is now written with setCookie/deleteCookie (from @tanstack/react-start/server) rather than setResponseHeader("set-cookie", …). The raw header call replaces any existing Set-Cookie on the response — it would clobber a session or framework cookie set on the same response — whereas setCookie appends. No behavior change to the emitted cookie (still Path=/; Max-Age=1y; SameSite=Lax; Secure, not HttpOnly).
    • The connection IP for consent receipts is now read with getRequestIP({ xForwardedFor: true }) — the maintained, platform-aware path — instead of hand-parsing x-forwarded-for/x-real-ip.

    Breaking (@codefast/tracking/server/anonymous-id-cookie): the string builders buildAnonymousIdSetCookie/buildClearAnonymousIdSetCookie are replaced by resolveAnonymousIdCookie/resolveClearAnonymousIdCookie, which return the validated name/value plus cookie attributes for a framework setCookie/deleteCookie call. isValidAnonymousId is unchanged; the cookie-name guard is now the exported assertValidAnonymousIdCookieName.

  • 079b8df Thanks @thevuong! - Deliver events to SDK-backed destinations at track time instead of through the batching queue.

    Destination gains an optional delivery: "immediate" | "queued" field. The Google Analytics and Vercel destinations are marked "immediate" — their SDKs own batching and unload delivery, so routing them through the queue only delayed events and replayed stale ones next session with wrong timestamps. The queue keeps serving HTTP destinations and the flushWithBeacon path unchanged.

  • #567 74c52ac Thanks @thevuong! - Collapse the export map to group entries — per-file subpaths froze the internal file layout into public API.

    Breaking: deep subpaths (./client/*, ./core/*, ./server/*, ./react/*, and per-file ./destinations/*) no longer resolve. Import from the group entry instead:

    • @codefast/tracking/core and @codefast/tracking/core/*@codefast/tracking (the root has always re-exported the whole isomorphic core surface).
    • @codefast/tracking/client/*@codefast/tracking/client; same pattern for server and react.
    • Google helpers → @codefast/tracking/destinations.

    One destination keeps a dedicated subpath on purpose: @codefast/tracking/destinations/vercel-analytics — its top-level @vercel/analytics import would make the optional peer mandatory for every barrel consumer.

    All entries are unbundled ESM with sideEffects: false, so group imports tree-shake per file — the trim changes what is addressable, not what ships.

  • #607 749dd16 Thanks @thevuong! - Adds server-side GA4 Measurement Protocol primitives for forwarding a server-owned event (re-added now that a consumer tracks one — a server-recorded consent decision): sendMeasurementProtocolEvents POSTs { client_id, events, consent? } to the credentialed /mp/collect endpoint through an injected transport (default fetch), so no HTTP client or credentials are baked in; extractGaClientId derives the GA4 client_id from a _ga cookie; toMeasurementProtocolConsent maps the package ConsentDecision to the MP consent signals. Server-only (@codefast/tracking/server). The caller owns the credentials and the consent gate.

  • #602 5ca04e2 Thanks @thevuong! - createClientTracker now accepts an optional onDeliveryError hook, called once per failed delivery (a destination throwing synchronously or rejecting) with { destination, error, event }. The tracker still swallows the failure so tracking never breaks the interaction — the hook is a metering seam for wiring delivery failures to a monitor in production. The hook is itself guarded, so a throwing observer can't break the interaction either. Exposes the DeliveryErrorContext type from @codefast/tracking/client.

  • #610 fdb8d7c Thanks @thevuong! - Adds the per-destination erasure capability for DSR withdrawal (spec-data-subject-rights §3, DSR-V2/V4): Destination gains an optional onErasure(id) hook, and createClientTracker returns an erase(id) method that invokes each destination's onErasure once on withdrawal, swallowing failures so a destination can never break the flow. The reference createMetaDestination implements onErasure as cookie-clear (via an injected clearCookies seam) plus stop-send — Meta exposes no per-visitor deletion API, so the binding never fabricates one. Destinations with nothing to erase omit the hook.

  • 2ebb0c0 Thanks @thevuong! - Make consent per-category, mirroring Google Consent Mode v2. ConsentDecision is now { ads: boolean, analytics: boolean } instead of a single "granted" | "denied" flag, useConsent takes the categories the app's prompt asks about (grantAll/denyAll/save replace grant/deny), and ConsentBanner gains a per-category preferences layer plus a ReactNode message for the privacy-policy link. The GA4 helpers map the decision onto the v2 signals (ads drives ad_storage/ad_user_data/ad_personalization), take wait_for_update/region, and gain setGoogleAdsDataRedaction/setGoogleUrlPassthrough; the destination-side includeAds override is gone — the visitor's decision carries ads consent. resolveDefaultConsent replaces shouldTrackByDefault and honors GPC as an ads-only opt-out. Previously stored string decisions fail shape validation and re-prompt, no policy-version bump needed.

  • #563 bad015c Thanks @thevuong! - Add three helpers that pull common consent/tracking wiring out of consumer apps and into the package:

    • resolveEffectiveConsent(storage, policyVersion, categories, mode, hasGpc) and readStoredDecision(storage, policyVersion) (@codefast/tracking/core) — the same "stored decision, else region default" rule useConsent applies internally, now exposed so a non-React gate (e.g. a tracker's isTrackingAllowed option) doesn't have to reimplement it by hand.
    • buildGtagConsentBootstrapScript(options) (@codefast/tracking/destinations) — generates the pre-hydration <script> source that applies Google Consent Mode v2's default signal from the stored decision (or a supplied fallback) and conditionally loads gtag.js, replacing a hand-written JS string per app.
    • createCookieAnonymousId(options) (@codefast/tracking/client) — an opt-in document.cookie-backed anonymous id getOrCreate/clear pair for apps that don't need a custom identity strategy.

    None of these change existing exports' behavior; useConsent is refactored internally to use readStoredDecision but its output is unchanged.

  • #565 1e80096 Thanks @thevuong! - Remove the deprecated edge-middleware cookie bootstrap path:

    • Drop buildInitialConsentBootstrapScript and the @codefast/tracking/destinations/initial-consent-bootstrap subpath.
    • Resolve region consent via a server function (resolveInitialConsent) plus a client snapshot instead — see apps/ui visitor-consent.ts / resolve-visitor-consent.ts.
  • #563 bad015c Thanks @thevuong! - Rename UseConsentResult.needsPrompt to isPromptNeeded — a boolean should read as an assertion, matching isTrackingAllowed on the same result (Swift API Design Guidelines pass).

    Breaking: consumers of useConsent/ConsentBanner reading needsPrompt must switch to isPromptNeeded.

  • #563 bad015c Thanks @thevuong! - Add deriveEventId(requestId, discriminant) (@codefast/tracking/core) and wire it into createServerTracker: pass requestId on ServerTrackerContext to make a server-owned event's eventId deterministic instead of random. Retrying the same request with the same track/group/alias call now reproduces the same eventId, so a destination that dedupes on it treats the retry as a no-op instead of double-counting — closing the gap between the package's documented idempotency intent and its previous always-random default. Omitting requestId keeps the existing random behavior, so this is additive and non-breaking.

  • #566 ffd777c Thanks @thevuong! - Modernize the package around server-first React frameworks and shrink what the client pays for.

    Breaking (pre-release):

    • Event catalogs now accept any Standard Schema library (zod, zod/mini, valibot) — EventDefinition is typed on StandardSchemaV1, validation runs through the new assertValidEventProperties, and zod is no longer a dependency (@standard-schema/spec is the only one).
    • buildInitialConsentresolveInitialConsent; ServerTrackContextServerTrackerContext.
    • attachClientLifecycle drops flushIntervalMs — the queue schedules its own flushes (one-shot idle timer armed only while events are pending, offline-aware); the lifecycle keeps hide/pagehide delivery (beacon, or a keepalive fetch fallback) and flush-on-reconnect.
    • The ./destinations barrel is browser-lane only: import createVercelAnalyticsDestination from ./destinations/vercel-analytics (its top-level @vercel/analytics import made the optional peer mandatory for barrel consumers) and createGa4MeasurementProtocolDestination from its own subpath.
    • ./server, ./server/*, ./tanstack-start, and ./destinations/ga4-measurement-protocol are server-only by contract: on TanStack Start, deny them in the client environment via importProtection.client.specifiers (README shows the config) so a leak fails the build with a traced violation instead of silently shipping server code or the GA4 apiSecret.

    New:

    • @codefast/tracking/tanstack-start (optional peer on @tanstack/react-start): resolveInitialConsentFromRequest, setAnonymousIdResponseCookie/clearAnonymousIdResponseCookie, readAnonymousIdRequestCookie, readConsentDecisionRequestCookie, resolveServerTrackerContextFromRequest — consumers' server functions become one-liners.
    • createInitialConsentStore (client) + useInitialConsent (react): the whole post-hydration region-resolution lane — strictest-until-resolved, single-flight, per-session cache validated by the new isInitialConsent guard, fail-closed-but-retryable errors, retry on tab-visible.
    • createServerTracker: waitUntil hands delivery (and its retry ladder) to the platform's post-response scheduler; withContext binds per-request identity once.
    • Beacon receive half: relayTrackedEvents + createTrackedEventIngestHandler (Request → Response) validate client envelopes, re-stamp server-read identity, and keep client eventIds so re-sent beacons dedupe.
    • Consent-aware server tracking: consent-cookie codec (core), withConsentCookieMirror (client), readConsentRecordCookie/readConsentDecisionCookie (server).
    • Transport hardening: requestTimeoutMs (default 10s) on the fetch destinations, keepalive threading for unload flushes, Destination.sendBatch (implemented by createHttpDestination — one POST per queue flush).
    • CookieAnonymousId.refresh() prolongs an existing id without ever minting; STRICTEST_INITIAL_CONSENT, CONSENT_REGIONS, isConsentRegion are exported from core; the localStorage consent storage caches parsed records behind a raw-string compare; client-only react modules ship "use client".
  • #563 bad015c Thanks @thevuong! - Add server-persisted anonymous id ("client mints, server persists"). createServerPersistedAnonymousId (client) keeps the lazy, post-consent minting of createCookieAnonymousId and delegates the durable cookie write to an app-supplied server round-trip, so the id outlives Safari ITP's 7-day cap on script-written cookies. The server half — readAnonymousIdCookie, buildAnonymousIdSetCookie, buildClearAnonymousIdSetCookie, isValidAnonymousId — is framework-agnostic string-in/string-out: always Secure; SameSite=Lax, validates the cookie name, and throws on any non-UUID id so a public persist endpoint can never echo attacker input into a response header. The server persists and prolongs an id the client hands it; it never mints one per request.

  • #563 bad015c Thanks @thevuong! - Add a TanStack Start wiring kit so consumer apps no longer hand-roll consent/bootstrap glue:

    • resolveInitialConsent (née buildInitialConsent) + exported EU_COUNTRY_CODES / OPT_IN_EQUIVALENT_COUNTRY_CODES (@codefast/tracking/server) — region → mode → default decision for server functions (or a fail-closed bake when country is unknown).
    • clearGoogleAnalyticsCookies (@codefast/tracking/destinations) — expire _ga / _ga_* on consent withdrawal.
    • createIsTrackingAllowed / createConsentWithdrawalHandler (@codefast/tracking/client) — tracker gate + revoke clears.
    • useGoogleConsentSync (@codefast/tracking/react) — Consent Mode update + optional gtag load, including cross-tab / privacy-page decisions.

    InitialConsent is exported from @codefast/tracking / @codefast/tracking/core. None of these change existing export behavior.

  • #565 1e80096 Thanks @thevuong! - Rename public APIs to follow Swift API Design Guidelines (name by role; nouns for properties; imperative verbs for builders; assertion-form booleans).

    Breaking:

    • Envelope field: TrackEvent.props / PageViewEvent.propsproperties (and the track/page method parameters). Segment-style name; Props is reserved for React components.
    • Analytics gate: isTrackingAllowedisAnalyticsAllowed on ClientTrackerOptions and UseConsentResult (the gate reads the analytics category only). createIsTrackingAllowedcreateIsAnalyticsAllowed; subpath ./client/is-tracking-allowed./client/is-analytics-allowed.
    • Options naming (drop Create* / Build* filler): CreateIsAnalyticsAllowedOptionsIsAnalyticsAllowedOptions; CreateConsentWithdrawalHandlerOptionsConsentWithdrawalHandlerOptions; BuildInitialConsentOptionsInitialConsentOptions; BuildAnonymousIdSetCookieOptionsAnonymousIdSetCookieOptions. ClientLifecycleOptions stays (product-named, no verb prefix).
    • Prompt scope option: categoriesrequestedCategories on UseConsentOptions, IsAnalyticsAllowedOptions, and InitialConsentOptions.
    • Destination.consent / VercelAnalyticsDestinationOptions.consentconsentRequirement ("exempt" | "required").
    • googleConsentBootstrapPreamblebuildGoogleConsentBootstrapPreamble; dataLayerOfensureDataLayer.
    • UseConsentResult.savesaveDecision.
    • Demote package-private deep exports: remove ./client/queue, ./destinations/shared, and ./destinations/google-consent from package.json#exports. EventQueue / EventQueueOptions leave the ./client barrel; EventQueueStorage stays (custom offline persistence). Consent Mode helpers remain on ./destinations / ./react.
    • Options-object for multi-arg consent resolvers (clarity at the call site): resolveDefaultConsent(mode, requestedCategories, hasGlobalPrivacyControlSignal)resolveDefaultConsent(options) and resolveEffectiveConsent(storage, policyVersion, requestedCategories, mode, hasGlobalPrivacyControlSignal)resolveEffectiveConsent(options); new ResolveDefaultConsentOptions / ResolveEffectiveConsentOptions types. readStoredDecision(storage, policyVersion) keeps its positional args.
    • GA4 Measurement Protocol debug flag: Ga4MeasurementProtocolDestinationOptions.debugdebugMode (matches debugMode on the gtag options).
    • Server group signature: ServerTracker.group(groupId, traits, context)group(groupId, context, traits?) so traits is truly optional instead of a forced undefined.
    • Remove assertNever from the public exports (generic, non-tracking helper; internal-only now).
    • readCookieValue is now also exported from the root entry (previously only on ./core).
  • #611 a5527c3 Thanks @thevuong! - Extends the ad-destination frame with two more reference vendors (spec-destinations §5), consuming the same { ads, analytics } decision: createMicrosoftUetDestination / toMicrosoftUetConsent map ads to UET's only enforced signal, ad_storage (UET has no analytics_storage); createTiktokDestination / toTiktokConsent map ads to TikTok's single limited_data_use boolean (not Meta's dataProcessingOptions structure). Both take an injected transport (no pixel id, tag id, or network client baked in) and implement onErasure as cookie-clear + stop-send, since neither exposes a per-visitor deletion API. consentRequirement stays "required".

  • #563 bad015c Thanks @thevuong! - Discriminate event envelopes by type and add a consent-exempt destination lane.

    TrackedEvent is now a union discriminated on type: "track" | "page" | "identify" | "group" | "alias" (Segment-style) instead of encoding built-ins into $-prefixed magic names — destinations translate each kind into their own vocabulary via an exhaustive switch (identify carries traits, group carries groupId/traits, alias carries previousId), and the app-chosen name only exists on track/page. This changes the wire format seen by HTTP destinations and the queue storage; stale queue records without a type are dropped silently. Destination gains consent?: "exempt" | "required" — while the tracker's consent gate is closed, exempt immediate destinations keep receiving track/page events stripped of anonymousId/userId (identity kinds and queueing stay fully gated), so cookieless sinks like Vercel Analytics can keep counting interactions without consent-gated identifiers. The Vercel destination accepts the flag via its options and still defaults to "required".

Patch Changes

  • #565 1e80096 Thanks @thevuong! - Prefer the live anonymous-id cookie over the in-memory cache so a cross-tab consent withdrawal cannot revive the pre-withdrawal identity on re-grant.

  • #563 bad015c Thanks @thevuong! - Fix bugs found in review of the consent-gated tracking pipeline, and dedupe the GA4/Vercel destinations.

    createClientTracker's identify() no longer commits userId to the tracker's closure while consent is denied — a denied identify could otherwise leak its userId onto a later, allowed track/page/group call. createServerTracker's per-request eventId derivation now factors in userId, so two alias() calls with the same previousId but different merge targets in one request no longer collide on eventId. createLocalStorageQueueStorage.load() now drops pre-migration/malformed queue records via a new isTrackedEvent guard (exported from @codefast/tracking/core), instead of relying on each destination's switch to silently no-op on an unrecognized shape.

    Also: the GA4 (gtag/Measurement Protocol) and Vercel Analytics destinations now share one prop-flattening helper and one groupjoin_group mapping (@codefast/tracking/destinations's internal shared.ts) instead of three near-duplicate implementations; buildGtagConsentBootstrapScript's pre-hydration Consent Mode signal mapping is generated from the same table toGoogleConsentParams uses instead of a hand-duplicated literal; and the package's ensureGtag gtag.js stub helper, plus a new loadGtagScript(options) (loads gtag.js on demand, idempotent), are now exported so apps don't have to reimplement on-demand script loading themselves.

  • #565 1e80096 Thanks @thevuong! - Guard createLocalStorageConsentStorage with isConsentRecord so malformed localStorage JSON cannot be treated as a valid consent record.

  • 46c32d6 Thanks @thevuong! - Default engagement_time_msec on Measurement Protocol events to 100ms — the fallback Google's own MP documentation prescribes when the elapsed time since the previous event is unknown — instead of 1ms.

  • #565 1e80096 Thanks @thevuong! - Skip clearOnServer when the anonymous-id cookie is already gone, so a second withdrawal clear in the same tick does not fire a redundant server round-trip.

1.0.0-canary.7

Minor Changes

  • #605 cb46bdd Thanks @thevuong! - Adds the shared ad-destination frame for consuming one { ads, analytics } decision across ad platforms (spec-destinations §5): toAdConsentState(decision) normalizes it to the two independent levers — analytics drives whether events transmit, ads drives Limited Data Use — so per-vendor mappings cannot drift. Ships a reference Meta destination (createMetaDestination, toMetaDataProcessingOptions) that maps each event and the live ads decision to Meta's dataProcessingOptions (geolocated LDU when ads is denied) and hands it to an injected transport. Consent-restriction mapping only — the Pixel/CAPI transport and credentials are the integrator's to supply; an ad sink is never exempt.

  • #612 1337fc3 Thanks @thevuong! - Adds the TCF/GPP interop reconciler (spec-ad-consent-frameworks): the system reads an external CMP and reconciles it with the native { ads, analytics } decision — it never becomes a CMP or mints TC/GPP strings. reconcileAdFrameworkConsent({ native, cmp, hasGlobalPrivacyControlSignal }) applies the §3 precedence (a governing CMP overrides its categories; fail-closed to denied while the CMP is loading; a missing or out-of-scope CMP leaves native standing; GPC only tightens ads), covering conformance vectors V1–V6. hasTcfApi/hasGppApi detect the __tcfapi/__gpp read APIs without invoking them. TCF purpose ids and the Google vendor id are deliberately not hard-coded — that mapping is ad-ops policy, so the caller derives the CmpConsentSignal it passes in.

  • #615 2bcd31f Thanks @thevuong! - Let codefast mirror generate package.json#exports from dist/, the same as every other library package (di, theme), instead of hand-curating them under mirror's preserve mode. The per-module build output is unchanged, so mirror emits a subpath for each built module, and the root becomes the client entry.

    Breaking:

    • The root @codefast/tracking is now the client entry — it re-exports the isomorphic core plus the whole browser surface (createClientTracker, createConsentRuntime, the React bindings, the gtag + ad-network destinations). Server code must import the core it needs from @codefast/tracking/core/*, not from the root.
    • The ./client, ./server, ./core, ./react, and ./destinations group barrels are gone. Use the client root for browser code, or a module's own subpath for granular/server imports (@codefast/tracking/server/initial-consent, @codefast/tracking/client/gpc, …).
    • The TanStack Start adapter is now @codefast/tracking/adapters/tanstack-start (was /tanstack-start); the import-protection deny-list is @codefast/tracking/tooling/import-protection (was /import-protection). SERVER_ONLY_SUBPATHS now denies server/** and adapters/**.
  • #617 b979371 Thanks @thevuong! - Harden the package from a full audit — correctness, coverage, and a leaner public surface.

    • isConsentReceiptInput now validates method and subjectIdType against their enums, not just typeof === "string" — the untrusted-body guard no longer narrows a bogus value to a closed union member.
    • CookieAnonymousId gains current() — a non-minting read of the existing id (undefined when none) so a consent receipt stamps the id the visitor already carries instead of a throwaway that never correlates for erasure.
    • coarsenIp rejects out-of-range IPv4 octets ("999.…") rather than storing a malformed coarse value.
    • Microsoft UET consent routes through the shared toAdConsentState ad lever, so its ad_storage mapping can't drift from Meta/TikTok.
    • Dropped unused foreign type re-exports so each type has one home: InitialConsent no longer re-exported from adapters/tanstack-start or server/initial-consent (import it from core/consent), and the AnonymousIdResponseCookieOptions alias is gone — setAnonymousIdResponseCookie takes AnonymousIdCookieOptions from server/anonymous-id-cookie directly.
    • Collapsed the TrackedEvent envelope to a single interface — the unused TrackedEventBase and TrackEvent names are gone (TrackedEvent keeps the type: "track" discriminant for a future additive union).

    Also adds test coverage for the previously-untested recordConsentReceiptFromRequest adapter path (no-store header, body-IP rejection, coarsened IP, PII-free ack).

  • #617 b979371 Thanks @thevuong! - Remove two leftover indirection layers in the server lane that no call site used.

    Breaking:

    • The @codefast/tracking/adapters/request-context subpath is gone. Its RequestContext seam (a getHeader/setHeader interface) plus the parallel *FromContext/*OnContext helpers existed only to back a hypothetical future ./next/./remix adapter, but there was exactly one adapter and it duplicated every signature and doc comment. @codefast/tracking/adapters/tanstack-start now calls getRequestHeader/setResponseHeader directly; its public surface (resolveInitialConsentFromRequest, setAnonymousIdResponseCookie, clearAnonymousIdResponseCookie, recordConsentReceiptFromRequest) is unchanged.
    • resolveRegion(headers) is removed from @codefast/tracking/server/region. It was a pre-fail-closed leftover with no production call site, and its missing-geo semantics (unknown region → opt-out) contradicted the fail-closed invariant the server-first path relies on. Use resolveRegionFromCountryCode (what the production path already uses via resolveInitialConsent), or resolveInitialConsentFromRequest for the full per-request resolution.
  • #606 5a4ff42 Thanks @thevuong! - Adds createDurableReceiptStore({ backend }) — a durable ReceiptStore over an injected ReceiptStoreBackend (a minimal id-keyed get/put primitive). The package supplies the append-only contract and adaptation; the deployment supplies the backend client (Vercel KV, Postgres, an append-only log), so no database dependency is baked in. put MUST be idempotent-by-id so the append-only guarantee holds atomically under retries/concurrency (e.g. KV set-if-absent, Postgres INSERT … ON CONFLICT DO NOTHING) — the frame delegates rather than doing a racy get-then-put. Pair it with a real backend in production, where createInMemoryReceiptStore is not a lawful store on its own.

  • #604 7bb4be7 Thanks @thevuong! - createClientTracker now accepts an optional isExemptionAllowed gate, consulted before an exempt destination receives an event while the consent gate is closed. ePrivacy audience-measurement exemption is jurisdiction-dependent (spec-destinations §2), so it must be gateable per region rather than assumed global — returning false withholds even exempt sinks where exemption is not defensible. Omit it to keep the prior behavior (exempt everywhere). The gate is irrelevant once consent is granted, since every destination then receives the full envelope.

  • #608 6cdd930 Thanks @thevuong! - Adds GA4 DSR delegation (spec-data-subject-rights §3): the system delegates per-visitor deletion to the platform rather than building a deletion store. buildGa4UserDeletionRequest({ propertyId, clientId }) returns the network-free request shape and submitGa4UserDeletion({ …, accessToken, transport? }) POSTs it — targeting the current Analytics Admin API properties.submitUserDeletion (the legacy v3 userDeletionRequests:upsert was sunset with Universal Analytics), keyed by a flat clientId. Authorization is the caller's: pass a bearer token for the analytics.edit scope; no OAuth or HTTP client is baked in. Server-only (@codefast/tracking/server).

  • #617 b979371 Thanks @thevuong! - Adopt TanStack Start's first-class server helpers in the adapter instead of hand-rolling over raw request/response headers.

    • The anonymous-id cookie is now written with setCookie/deleteCookie (from @tanstack/react-start/server) rather than setResponseHeader("set-cookie", …). The raw header call replaces any existing Set-Cookie on the response — it would clobber a session or framework cookie set on the same response — whereas setCookie appends. No behavior change to the emitted cookie (still Path=/; Max-Age=1y; SameSite=Lax; Secure, not HttpOnly).
    • The connection IP for consent receipts is now read with getRequestIP({ xForwardedFor: true }) — the maintained, platform-aware path — instead of hand-parsing x-forwarded-for/x-real-ip.

    Breaking (@codefast/tracking/server/anonymous-id-cookie): the string builders buildAnonymousIdSetCookie/buildClearAnonymousIdSetCookie are replaced by resolveAnonymousIdCookie/resolveClearAnonymousIdCookie, which return the validated name/value plus cookie attributes for a framework setCookie/deleteCookie call. isValidAnonymousId is unchanged; the cookie-name guard is now the exported assertValidAnonymousIdCookieName.

  • #607 749dd16 Thanks @thevuong! - Adds server-side GA4 Measurement Protocol primitives for forwarding a server-owned event (re-added now that a consumer tracks one — a server-recorded consent decision): sendMeasurementProtocolEvents POSTs { client_id, events, consent? } to the credentialed /mp/collect endpoint through an injected transport (default fetch), so no HTTP client or credentials are baked in; extractGaClientId derives the GA4 client_id from a _ga cookie; toMeasurementProtocolConsent maps the package ConsentDecision to the MP consent signals. Server-only (@codefast/tracking/server). The caller owns the credentials and the consent gate.

  • #602 5ca04e2 Thanks @thevuong! - createClientTracker now accepts an optional onDeliveryError hook, called once per failed delivery (a destination throwing synchronously or rejecting) with { destination, error, event }. The tracker still swallows the failure so tracking never breaks the interaction — the hook is a metering seam for wiring delivery failures to a monitor in production. The hook is itself guarded, so a throwing observer can't break the interaction either. Exposes the DeliveryErrorContext type from @codefast/tracking/client.

  • #610 fdb8d7c Thanks @thevuong! - Adds the per-destination erasure capability for DSR withdrawal (spec-data-subject-rights §3, DSR-V2/V4): Destination gains an optional onErasure(id) hook, and createClientTracker returns an erase(id) method that invokes each destination's onErasure once on withdrawal, swallowing failures so a destination can never break the flow. The reference createMetaDestination implements onErasure as cookie-clear (via an injected clearCookies seam) plus stop-send — Meta exposes no per-visitor deletion API, so the binding never fabricates one. Destinations with nothing to erase omit the hook.

  • #611 a5527c3 Thanks @thevuong! - Extends the ad-destination frame with two more reference vendors (spec-destinations §5), consuming the same { ads, analytics } decision: createMicrosoftUetDestination / toMicrosoftUetConsent map ads to UET's only enforced signal, ad_storage (UET has no analytics_storage); createTiktokDestination / toTiktokConsent map ads to TikTok's single limited_data_use boolean (not Meta's dataProcessingOptions structure). Both take an injected transport (no pixel id, tag id, or network client baked in) and implement onErasure as cookie-clear + stop-send, since neither exposes a per-visitor deletion API. consentRequirement stays "required".

1.0.0-canary.6

Major Changes

  • #565 1e80096 Thanks @thevuong! - Remove defaultConsentExpression from gtag/GTM consent bootstraps. Pass a literal defaultConsent (strictest bake on shared HTML) and upgrade after hydration via the server-fn lane + updateGoogleConsent.

  • #567 74c52ac Thanks @thevuong! - Collapse the consent "must match" contracts into one ConsentConfig, and add createConsentRuntime.

    Previously storageKey, policyVersion, and requestedCategories had to be hand-threaded — matching exactly — through useConsent, createIsAnalyticsAllowed, and the gtag consent bootstrap; one drifted string was a silent consent bug. Now:

    • ConsentConfig + defineConsentConfig (root/core) — the one bag for storageKey, policyVersion, and requestedCategories. Isomorphic plain data: the same object is imported on both sides.
    • createConsentRuntime (client) — derives the live client instances from the config: the shared ConsentStorage, the initial-consent store over your server lane, ensureInitialConsentResolved, and the isAnalyticsAllowed tracker gate wired to the store's resolved mode (GPC read from the real navigator signal by default).

    Breaking option changes (config-first):

    • useConsent({ policyVersion, requestedCategories?, ... })useConsent({ config, ... }). The ["analytics"] default for requestedCategories is gone — the config always states the requested purposes explicitly.
    • createIsAnalyticsAllowed({ policyVersion, requestedCategories, ... })createIsAnalyticsAllowed({ config, ... }).
    • GtagConsentBootstrapOptions (and the <GtagConsentBootstrap /> props): consentStorageKey + policyVersionconfig.
  • #567 74c52ac Thanks @thevuong! - Cut every lane that shipped with zero consumer call sites — the package now covers exactly a consented gtag + Vercel Analytics setup on TanStack Start, and nothing speculative. Removed (recoverable from git history when a real need returns):

    • Server-side tracking: createServerTracker, the beacon relay/ingest lane (relayTrackedEvents, createTrackedEventIngestHandler), deriveEventId, the consent-cookie mirror (withConsentCookieMirror, codec, readConsentDecisionCookie/readConsentDecisionRequestCookie), ConsentConfig.decisionCookieName, and the GA4 Measurement Protocol destination (its subpath included).
    • Offline queue machinery: EventQueue, createLocalStorageQueueStorage, attachClientLifecycle, flushWithBeacon, createHttpDestination, Destination.delivery/sendBatch — every real destination (gtag.js, Vercel) owns its own in-page queue and unload delivery.
    • Segment-style event kinds: identify/group/alias/page on the tracker and the envelope union, EventDefinition.owner + EventsOf (with no server side there is nothing to split), attachRouterPageTracking — page views belong to gtag config + Enhanced Measurement and Vercel's native <Analytics />.
    • GTM: destination, bootstrap, loader.
    • Unused gtag helpers: setGoogleConsentDefault, setGoogleAdsDataRedaction, setGoogleUrlPassthrough, extractGa4ClientId/extractGa4SessionId.

    Follow-on API changes: ClientTracker is now track() only (clear() had nothing left to clear, so ConsentWithdrawalHandlerOptions.clearTracker is gone too); catalogs drop the owner tag ({ schema } only); TrackedEvent is the track envelope alone, still discriminated on type so a future kind is additive.

  • #567 74c52ac Thanks @thevuong! - Collapse the export map to group entries — per-file subpaths froze the internal file layout into public API.

    Breaking: deep subpaths (./client/*, ./core/*, ./server/*, ./react/*, and per-file ./destinations/*) no longer resolve. Import from the group entry instead:

    • @codefast/tracking/core and @codefast/tracking/core/*@codefast/tracking (the root has always re-exported the whole isomorphic core surface).
    • @codefast/tracking/client/*@codefast/tracking/client; same pattern for server and react.
    • Google helpers → @codefast/tracking/destinations.

    One destination keeps a dedicated subpath on purpose: @codefast/tracking/destinations/vercel-analytics — its top-level @vercel/analytics import would make the optional peer mandatory for every barrel consumer.

    All entries are unbundled ESM with sideEffects: false, so group imports tree-shake per file — the trim changes what is addressable, not what ships.

  • #565 1e80096 Thanks @thevuong! - Remove the deprecated edge-middleware cookie bootstrap path:

    • Drop buildInitialConsentBootstrapScript and the @codefast/tracking/destinations/initial-consent-bootstrap subpath.
    • Resolve region consent via a server function (resolveInitialConsent) plus a client snapshot instead — see apps/ui visitor-consent.ts / resolve-visitor-consent.ts.
  • #565 1e80096 Thanks @thevuong! - Rename public APIs to follow Swift API Design Guidelines (name by role; nouns for properties; imperative verbs for builders; assertion-form booleans).

    Breaking:

    • Envelope field: TrackEvent.props / PageViewEvent.propsproperties (and the track/page method parameters). Segment-style name; Props is reserved for React components.
    • Analytics gate: isTrackingAllowedisAnalyticsAllowed on ClientTrackerOptions and UseConsentResult (the gate reads the analytics category only). createIsTrackingAllowedcreateIsAnalyticsAllowed; subpath ./client/is-tracking-allowed./client/is-analytics-allowed.
    • Options naming (drop Create* / Build* filler): CreateIsAnalyticsAllowedOptionsIsAnalyticsAllowedOptions; CreateConsentWithdrawalHandlerOptionsConsentWithdrawalHandlerOptions; BuildInitialConsentOptionsInitialConsentOptions; BuildAnonymousIdSetCookieOptionsAnonymousIdSetCookieOptions. ClientLifecycleOptions stays (product-named, no verb prefix).
    • Prompt scope option: categoriesrequestedCategories on UseConsentOptions, IsAnalyticsAllowedOptions, and InitialConsentOptions.
    • Destination.consent / VercelAnalyticsDestinationOptions.consentconsentRequirement ("exempt" | "required").
    • googleConsentBootstrapPreamblebuildGoogleConsentBootstrapPreamble; dataLayerOfensureDataLayer.
    • UseConsentResult.savesaveDecision.
    • Demote package-private deep exports: remove ./client/queue, ./destinations/shared, and ./destinations/google-consent from package.json#exports. EventQueue / EventQueueOptions leave the ./client barrel; EventQueueStorage stays (custom offline persistence). Consent Mode helpers remain on ./destinations / ./react.
    • Options-object for multi-arg consent resolvers (clarity at the call site): resolveDefaultConsent(mode, requestedCategories, hasGlobalPrivacyControlSignal)resolveDefaultConsent(options) and resolveEffectiveConsent(storage, policyVersion, requestedCategories, mode, hasGlobalPrivacyControlSignal)resolveEffectiveConsent(options); new ResolveDefaultConsentOptions / ResolveEffectiveConsentOptions types. readStoredDecision(storage, policyVersion) keeps its positional args.
    • GA4 Measurement Protocol debug flag: Ga4MeasurementProtocolDestinationOptions.debugdebugMode (matches debugMode on the gtag options).
    • Server group signature: ServerTracker.group(groupId, traits, context)group(groupId, context, traits?) so traits is truly optional instead of a forced undefined.
    • Remove assertNever from the public exports (generic, non-tracking helper; internal-only now).
    • readCookieValue is now also exported from the root entry (previously only on ./core).

Minor Changes

  • #563 bad015c Thanks @thevuong! - Switch gtag/GTM bootstraps to Google Consent Mode advanced:

    • buildGtagConsentBootstrapScript / buildGtmConsentBootstrapScript always set Consent Mode v2 default (from stored decision or region fallback), then always load gtag.js / gtm.js — even when analytics/ads storage is denied — so cookieless pings and consent modeling can run.
    • Runtime grants/denies still use updateGoogleConsent; loadGtagScript / loadGtmScript remain idempotent safety nets when the bootstrap did not run.
    • The package's first-party consent gate is unchanged — identifiers and non-exempt destinations stay blocked without consent; only Google tag script loading changes.
  • #563 bad015c Thanks @thevuong! - Tighten the package's API contracts and framework independence, found in an architecture audit.

    Breaking:

    • ClientTrackerOptions.anonymousId is now () => string only — the plain-string form is removed. A resolver was already the documented best practice (defers minting an id until an event is actually allowed to send); the string form let callers accidentally mint one as an import-time side effect. Wrap a stable value in a resolver: anonymousId: () => myId.
    • Destination.send now always returns Promise<void> — the previous Promise<void> | void let sync and async destinations disagree on contract. Mark a synchronous send async so a thrown error rejects the returned promise instead of throwing synchronously.

    Also:

    • createVercelAnalyticsDestination now imports track from the framework-agnostic @vercel/analytics instead of @vercel/analytics/react — the destination renders nothing, so it had no reason to depend on React.
    • Adds assertNever (@codefast/tracking/core) and wires it into the default case of every switch (event.type) across the GA4/Vercel destinations — extending TrackedEvent with a new variant now fails to compile at every switch instead of silently falling through.
    • The package root (@codefast/tracking) now re-exports #/core's surface by explicit name instead of export *, matching the client/server/destinations/react subpaths, which were already explicit.
    • useConsent's returned object and its save/denyAll/grantAll callbacks are now memoized (useMemo/useCallback), so a consumer passing the hook's result down as a prop or effect dependency doesn't get a new reference every render.
  • #563 bad015c Thanks @thevuong! - Gate the client tracker on consent and keep destinations from leaking pre-consent or duplicate data.

    createClientTracker gains isTrackingAllowed?: () => boolean, consulted per event — while it returns false nothing is sent or queued, so a mid-session consent change applies immediately. anonymousId also accepts a () => string resolver, invoked only when an event is actually allowed to send, so apps can defer minting an identifier cookie until consent exists. storage is now optional; without it the queue lives in memory only instead of persisting to localStorage. The Vercel destination takes an options object ({ name?, trackPageViews? } replaces the positional name), drops $page_viewed unless trackPageViews is on — the mounted <Analytics /> component already tracks page views natively — and drops $identify/$group, which Vercel Analytics has no identity API to translate to. The global gtag type gains the config and js command signatures so apps can queue them directly, e.g. when loading gtag.js on demand for basic Consent Mode.

  • #563 bad015c Thanks @thevuong! - Consent internals cleanup. The gtag and GTM bootstraps now share one preamble builder (googleConsentBootstrapPreamble — generated output unchanged), toGoogleConsentParams derives from the signal map instead of hand-writing it, the runtime consent setters (updateGoogleConsent, setGoogleConsentDefault, setGoogleAdsDataRedaction, setGoogleUrlPassthrough) accept a dataLayerName, VercelAnalyticsDestinationOptions is exported from the destinations barrel, and the cookie-string parser is shared as readCookieValue (@codefast/tracking/core/cookie). Removed never-consumed exports: GOOGLE_CONSENT_SIGNAL_CATEGORIES, GoogleConsentSignal, isGa4EventName, consentDecisionShapeCheckExpression, consentSignalAssignmentsExpression.

  • #565 1e80096 Thanks @thevuong! - resolveInitialConsent (née buildInitialConsent) now fails closed for a missing country code: an unknown visitor (prerender crawl, host without a geo header) resolves to the strictest opt-in default instead of "other"'s analytics-granted opt-out. A known non-EU country still resolves to opt-out — unknown is not known-elsewhere. Behavior change only for callers that passed countryCode: undefined and relied on the opt-out fallback; callers that guarded the missing case themselves can drop the guard.

  • #563 bad015c Thanks @thevuong! - Improve gtag/GTM loader DX without changing consent-first loading:

    • ensureGtag / loadGtagScript / buildGtagConsentBootstrapScript accept optional dataLayerName, nonce (CSP), and debugMode.
    • Add createGoogleTagManagerDestination, buildGtmConsentBootstrapScript, and loadGtmScript for consent-gated GTM.
    • Add <GtagConsentBootstrap /> — a framework-agnostic inline script wrapper for the pre-hydration bootstrap.
  • #563 bad015c Thanks @thevuong! - Rename the http-destination module to http, matching the create<X>Destination file-naming convention used by every other destination. Breaking for deep imports only: @codefast/tracking/destinations/http-destination is now @codefast/tracking/destinations/http; imports from the @codefast/tracking/destinations barrel are unaffected.

  • #563 bad015c Thanks @thevuong! - Add three helpers that pull common consent/tracking wiring out of consumer apps and into the package:

    • resolveEffectiveConsent(storage, policyVersion, categories, mode, hasGpc) and readStoredDecision(storage, policyVersion) (@codefast/tracking/core) — the same "stored decision, else region default" rule useConsent applies internally, now exposed so a non-React gate (e.g. a tracker's isTrackingAllowed option) doesn't have to reimplement it by hand.
    • buildGtagConsentBootstrapScript(options) (@codefast/tracking/destinations) — generates the pre-hydration <script> source that applies Google Consent Mode v2's default signal from the stored decision (or a supplied fallback) and conditionally loads gtag.js, replacing a hand-written JS string per app.
    • createCookieAnonymousId(options) (@codefast/tracking/client) — an opt-in document.cookie-backed anonymous id getOrCreate/clear pair for apps that don't need a custom identity strategy.

    None of these change existing exports' behavior; useConsent is refactored internally to use readStoredDecision but its output is unchanged.

  • #563 bad015c Thanks @thevuong! - Rename UseConsentResult.needsPrompt to isPromptNeeded — a boolean should read as an assertion, matching isTrackingAllowed on the same result (Swift API Design Guidelines pass).

    Breaking: consumers of useConsent/ConsentBanner reading needsPrompt must switch to isPromptNeeded.

  • #563 bad015c Thanks @thevuong! - Add deriveEventId(requestId, discriminant) (@codefast/tracking/core) and wire it into createServerTracker: pass requestId on ServerTrackerContext to make a server-owned event's eventId deterministic instead of random. Retrying the same request with the same track/group/alias call now reproduces the same eventId, so a destination that dedupes on it treats the retry as a no-op instead of double-counting — closing the gap between the package's documented idempotency intent and its previous always-random default. Omitting requestId keeps the existing random behavior, so this is additive and non-breaking.

  • #566 ffd777c Thanks @thevuong! - Modernize the package around server-first React frameworks and shrink what the client pays for.

    Breaking (pre-release):

    • Event catalogs now accept any Standard Schema library (zod, zod/mini, valibot) — EventDefinition is typed on StandardSchemaV1, validation runs through the new assertValidEventProperties, and zod is no longer a dependency (@standard-schema/spec is the only one).
    • buildInitialConsentresolveInitialConsent; ServerTrackContextServerTrackerContext.
    • attachClientLifecycle drops flushIntervalMs — the queue schedules its own flushes (one-shot idle timer armed only while events are pending, offline-aware); the lifecycle keeps hide/pagehide delivery (beacon, or a keepalive fetch fallback) and flush-on-reconnect.
    • The ./destinations barrel is browser-lane only: import createVercelAnalyticsDestination from ./destinations/vercel-analytics (its top-level @vercel/analytics import made the optional peer mandatory for barrel consumers) and createGa4MeasurementProtocolDestination from its own subpath.
    • ./server, ./server/*, ./tanstack-start, and ./destinations/ga4-measurement-protocol are server-only by contract: on TanStack Start, deny them in the client environment via importProtection.client.specifiers (README shows the config) so a leak fails the build with a traced violation instead of silently shipping server code or the GA4 apiSecret.

    New:

    • @codefast/tracking/tanstack-start (optional peer on @tanstack/react-start): resolveInitialConsentFromRequest, setAnonymousIdResponseCookie/clearAnonymousIdResponseCookie, readAnonymousIdRequestCookie, readConsentDecisionRequestCookie, resolveServerTrackerContextFromRequest — consumers' server functions become one-liners.
    • createInitialConsentStore (client) + useInitialConsent (react): the whole post-hydration region-resolution lane — strictest-until-resolved, single-flight, per-session cache validated by the new isInitialConsent guard, fail-closed-but-retryable errors, retry on tab-visible.
    • createServerTracker: waitUntil hands delivery (and its retry ladder) to the platform's post-response scheduler; withContext binds per-request identity once.
    • Beacon receive half: relayTrackedEvents + createTrackedEventIngestHandler (Request → Response) validate client envelopes, re-stamp server-read identity, and keep client eventIds so re-sent beacons dedupe.
    • Consent-aware server tracking: consent-cookie codec (core), withConsentCookieMirror (client), readConsentRecordCookie/readConsentDecisionCookie (server).
    • Transport hardening: requestTimeoutMs (default 10s) on the fetch destinations, keepalive threading for unload flushes, Destination.sendBatch (implemented by createHttpDestination — one POST per queue flush).
    • CookieAnonymousId.refresh() prolongs an existing id without ever minting; STRICTEST_INITIAL_CONSENT, CONSENT_REGIONS, isConsentRegion are exported from core; the localStorage consent storage caches parsed records behind a raw-string compare; client-only react modules ship "use client".
  • #563 bad015c Thanks @thevuong! - Add server-persisted anonymous id ("client mints, server persists"). createServerPersistedAnonymousId (client) keeps the lazy, post-consent minting of createCookieAnonymousId and delegates the durable cookie write to an app-supplied server round-trip, so the id outlives Safari ITP's 7-day cap on script-written cookies. The server half — readAnonymousIdCookie, buildAnonymousIdSetCookie, buildClearAnonymousIdSetCookie, isValidAnonymousId — is framework-agnostic string-in/string-out: always Secure; SameSite=Lax, validates the cookie name, and throws on any non-UUID id so a public persist endpoint can never echo attacker input into a response header. The server persists and prolongs an id the client hands it; it never mints one per request.

  • #563 bad015c Thanks @thevuong! - Add a TanStack Start wiring kit so consumer apps no longer hand-roll consent/bootstrap glue:

    • resolveInitialConsent (née buildInitialConsent) + exported EU_COUNTRY_CODES / OPT_IN_EQUIVALENT_COUNTRY_CODES (@codefast/tracking/server) — region → mode → default decision for server functions (or a fail-closed bake when country is unknown).
    • clearGoogleAnalyticsCookies (@codefast/tracking/destinations) — expire _ga / _ga_* on consent withdrawal.
    • createIsTrackingAllowed / createConsentWithdrawalHandler (@codefast/tracking/client) — tracker gate + revoke clears.
    • useGoogleConsentSync (@codefast/tracking/react) — Consent Mode update + optional gtag load, including cross-tab / privacy-page decisions.

    InitialConsent is exported from @codefast/tracking / @codefast/tracking/core. None of these change existing export behavior.

  • #563 bad015c Thanks @thevuong! - Discriminate event envelopes by type and add a consent-exempt destination lane.

    TrackedEvent is now a union discriminated on type: "track" | "page" | "identify" | "group" | "alias" (Segment-style) instead of encoding built-ins into $-prefixed magic names — destinations translate each kind into their own vocabulary via an exhaustive switch (identify carries traits, group carries groupId/traits, alias carries previousId), and the app-chosen name only exists on track/page. This changes the wire format seen by HTTP destinations and the queue storage; stale queue records without a type are dropped silently. Destination gains consent?: "exempt" | "required" — while the tracker's consent gate is closed, exempt immediate destinations keep receiving track/page events stripped of anonymousId/userId (identity kinds and queueing stay fully gated), so cookieless sinks like Vercel Analytics can keep counting interactions without consent-gated identifiers. The Vercel destination accepts the flag via its options and still defaults to "required".

Patch Changes

  • #565 1e80096 Thanks @thevuong! - Prefer the live anonymous-id cookie over the in-memory cache so a cross-tab consent withdrawal cannot revive the pre-withdrawal identity on re-grant.

  • #563 bad015c Thanks @thevuong! - Fix bugs found in review of the consent-gated tracking pipeline, and dedupe the GA4/Vercel destinations.

    createClientTracker's identify() no longer commits userId to the tracker's closure while consent is denied — a denied identify could otherwise leak its userId onto a later, allowed track/page/group call. createServerTracker's per-request eventId derivation now factors in userId, so two alias() calls with the same previousId but different merge targets in one request no longer collide on eventId. createLocalStorageQueueStorage.load() now drops pre-migration/malformed queue records via a new isTrackedEvent guard (exported from @codefast/tracking/core), instead of relying on each destination's switch to silently no-op on an unrecognized shape.

    Also: the GA4 (gtag/Measurement Protocol) and Vercel Analytics destinations now share one prop-flattening helper and one groupjoin_group mapping (@codefast/tracking/destinations's internal shared.ts) instead of three near-duplicate implementations; buildGtagConsentBootstrapScript's pre-hydration Consent Mode signal mapping is generated from the same table toGoogleConsentParams uses instead of a hand-duplicated literal; and the package's ensureGtag gtag.js stub helper, plus a new loadGtagScript(options) (loads gtag.js on demand, idempotent), are now exported so apps don't have to reimplement on-demand script loading themselves.

  • #565 1e80096 Thanks @thevuong! - Guard createLocalStorageConsentStorage with isConsentRecord so malformed localStorage JSON cannot be treated as a valid consent record.

  • #565 1e80096 Thanks @thevuong! - Skip clearOnServer when the anonymous-id cookie is already gone, so a second withdrawal clear in the same tick does not fire a redundant server round-trip.

0.5.0-canary.5

Minor Changes

  • 08f10fb Thanks @thevuong! - Rebuild ConsentBanner as composable compound parts (ConsentBannerTitle/Description/Actions/Accept/Reject/Customize/Preferences/Category/Save) — the root owns visibility (needsPrompt, overridable via open for a "Cookie settings" reopen) and the preferences-layer state, action parts wire their own clicks and compose the consumer's onClick, so any markup including a design system's button styles slots in via className. The monolithic message/acceptLabel/categories props are gone. An optional plain-CSS default theme ships at @codefast/tracking/css/consent.css — data-slot selectors, --consent-* custom properties with light-dark() fallbacks, zero Tailwind dependency.

0.5.0-canary.4

Minor Changes

  • 079b8df Thanks @thevuong! - Rebuild the consent layer on useSyncExternalStore and expose data-slot styling hooks on the consent UI.

    • useConsent treats the stored ConsentRecord as the single source of truth: the server snapshot is always "no decision yet" (hydration-safe by construction on prerendered pages), a decision made in one tab syncs to every other tab, and a record saved under an older policyVersion is ignored so bumping the version re-prompts as documented.
    • Breaking: ConsentStorage gains a required subscribe(listener) method — custom implementations must notify on changes. createLocalStorageConsentStorage implements it (same-tab saves plus the cross-tab storage event) and now degrades a blocked localStorage (private mode/quota) to a session-scoped in-memory record instead of re-prompting in a loop.
    • Breaking: ConsentBanner renders a labeled region instead of a non-modal <dialog> (which neither traps focus nor blocks, so the dialog semantics over-promised). Both components extend their host element's ComponentProps and expose data-slot attributes (consent-message, consent-actions, consent-action, consent-toggle) for Tailwind **:data-[slot=...] styling.
  • 41951df Thanks @thevuong! - Expose the stored decision from useConsent and ignore tampered consent records.

    • UseConsentResult gains decision — the stored decision under the current policy version, undefined until the visitor makes one. Consumers need it to replay a returning visitor's decision into Google Consent Mode (e.g. from an effect) without conflating "denied" with "no decision yet", which the boolean isTrackingAllowed cannot distinguish.
    • useConsent now counts only a well-formed decision ("granted"/"denied"): the record is tamperable plain JSON, and a garbage value re-prompts instead of silently denying. This matches how a pre-hydration Consent Mode bootstrap reading the same record should treat it.
    • Documented that createLocalStorageConsentStorage persists the record as plain JSON.stringify(ConsentRecord) — a stable contract, so inline scripts can read the decision synchronously before any tag fires.
  • 079b8df Thanks @thevuong! - Align the Google Analytics (gtag) destination with GA4's event and consent semantics.

    • GA4 rejects $-prefixed event names, so the tracker's built-ins are now translated instead of forwarded verbatim: $identifygtag('set', { user_id }), $group → the recommended join_group event (group_id param), and other invalid names are warned about and dropped instead of being sent to nowhere.
    • $page_viewed is dropped by default — gtag('config') plus Enhanced Measurement (on by default in GA4 admin) already report page views, so forwarding it double-counted. Opt in with trackPageViews: true after disabling both.
    • setGoogleConsentDefault/updateGoogleConsent now grant analytics_storage only; the ad_* Consent Mode v2 categories stay denied unless the new includeAds option is set, since an analytics-only banner never asked the visitor about ads data sharing.
    • Both consent functions define the standard gtag.js queueing stub themselves, so the default signal can be issued before the tag loads — as their docs always promised.
  • 079b8df Thanks @thevuong! - Correlate GA4 Measurement Protocol hits with gtag.js's own identifiers.

    GA4 joins hits on gtag's client ID (the _ga cookie), not on an app-generated anonymous ID — MP events sent with our ID landed on a different GA4 user than the visitor's client-side hits. New extractGa4ClientId/extractGa4SessionId helpers read gtag's _ga/_ga_<stream> request cookies (both GS1 and GS2 formats) so the destination can echo them via the new clientId/sessionId options. Events now also carry engagement_time_msec, session_id, and timestamp_micros — without them GA4 accepts the hit but leaves it out of realtime and session-scoped reports, and retried events drift to receipt time. $group maps to join_group; $alias is dropped (GA4 merges identities via user_id).

  • 079b8df Thanks @thevuong! - Deliver events to SDK-backed destinations at track time instead of through the batching queue.

    Destination gains an optional delivery: "immediate" | "queued" field. The Google Analytics and Vercel destinations are marked "immediate" — their SDKs own batching and unload delivery, so routing them through the queue only delayed events and replayed stale ones next session with wrong timestamps. The queue keeps serving HTTP destinations and the flushWithBeacon path unchanged.

  • 2ebb0c0 Thanks @thevuong! - Make consent per-category, mirroring Google Consent Mode v2. ConsentDecision is now { ads: boolean, analytics: boolean } instead of a single "granted" | "denied" flag, useConsent takes the categories the app's prompt asks about (grantAll/denyAll/save replace grant/deny), and ConsentBanner gains a per-category preferences layer plus a ReactNode message for the privacy-policy link. The GA4 helpers map the decision onto the v2 signals (ads drives ad_storage/ad_user_data/ad_personalization), take wait_for_update/region, and gain setGoogleAdsDataRedaction/setGoogleUrlPassthrough; the destination-side includeAds override is gone — the visitor's decision carries ads consent. resolveDefaultConsent replaces shouldTrackByDefault and honors GPC as an ads-only opt-out. Previously stored string decisions fail shape validation and re-prompt, no policy-version bump needed.

Patch Changes

  • 46c32d6 Thanks @thevuong! - Default engagement_time_msec on Measurement Protocol events to 100ms — the fallback Google's own MP documentation prescribes when the elapsed time since the previous event is unknown — instead of 1ms.