0.7.1
Patch Changes
- #748
bde6d1bThanks @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-canarystamps are rewritten to the current track.
0.7.0
0.6.2
0.6.1
0.6.0
Patch Changes
#690
f4b1aa6Thanks @thevuong! - Report the failures that were being swallowed or mislabelled, and derive the types the build emits.@codefast/di:@inject,@postConstructand@preDestroyon a static member now throwStaticMemberDecoratorErrorinstead ofInternalError. All three act on one instance, so this is caller misuse — andInternalErrormeans 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.AsyncResolutionErrornames 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"; aresolve(App)that fails on an asyncDatabasenow says so.asyncSourceTokendefaults totokenNamefor the case where the requested binding is itself the source.- A
MetadataReaderthat names a@postConstruct/@preDestroymethod the instance does not have raisesInvalidMetadataErrorinstead 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 intoreason. MissingScopeContextErrorfromScopeManagernames its token instead of"(unknown)", and the scoped read takes one map lookup where it took two.Token,ConstructorandInjectionDescriptordeclareout Value, so the compiler checks the covariance the engine already relied on.
Repo-wide:
isolatedDeclarationsis on for every package that emits declarations, so a public type can always be written down from the source file alone.allowJsis gone from the shared base config — no package has JavaScript sources.@codefast/themeand@codefast/trackinggained explicit annotations on four exported constants to satisfy it; the emitted types are unchanged.@codefast/uiand@codefast/benchmark-vieweropt out for reasons recorded in their configs.
0.5.0
Minor Changes
#605
cb46bddThanks @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 —analyticsdrives whether events transmit,adsdrives Limited Data Use — so per-vendor mappings cannot drift. Ships a reference Meta destination (createMetaDestination,toMetaDataProcessingOptions) that maps each event and the liveadsdecision to Meta'sdataProcessingOptions(geolocated LDU whenadsis denied) and hands it to an injectedtransport. Consent-restriction mapping only — the Pixel/CAPI transport and credentials are the integrator's to supply; an ad sink is neverexempt.#612
1337fc3Thanks @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 tightensads), covering conformance vectors V1–V6.hasTcfApi/hasGppApidetect the__tcfapi/__gppread 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 theCmpConsentSignalit passes in.#615
2bcd31fThanks @thevuong! - Letcodefast mirrorgeneratepackage.json#exportsfromdist/, 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/trackingis 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./destinationsgroup 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_SUBPATHSnow deniesserver/**andadapters/**.
- The root
#563
bad015cThanks @thevuong! - Switch gtag/GTM bootstraps to Google Consent Mode advanced:buildGtagConsentBootstrapScript/buildGtmConsentBootstrapScriptalways set Consent Mode v2default(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/loadGtmScriptremain 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
bad015cThanks @thevuong! - Tighten the package's API contracts and framework independence, found in an architecture audit.Breaking:
ClientTrackerOptions.anonymousIdis now() => stringonly — the plain-stringform 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.sendnow always returnsPromise<void>— the previousPromise<void> | voidlet sync and async destinations disagree on contract. Mark a synchronoussendasyncso a thrown error rejects the returned promise instead of throwing synchronously.
Also:
createVercelAnalyticsDestinationnow importstrackfrom the framework-agnostic@vercel/analyticsinstead 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 thedefaultcase of everyswitch (event.type)across the GA4/Vercel destinations — extendingTrackedEventwith 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 ofexport *, matching theclient/server/destinations/reactsubpaths, which were already explicit. useConsent's returned object and itssave/denyAll/grantAllcallbacks 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
b979371Thanks @thevuong! - Harden the package from a full audit — correctness, coverage, and a leaner public surface.isConsentReceiptInputnow validatesmethodandsubjectIdTypeagainst their enums, not justtypeof === "string"— the untrusted-body guard no longer narrows a bogus value to a closed union member.CookieAnonymousIdgainscurrent()— a non-minting read of the existing id (undefinedwhen none) so a consent receipt stamps the id the visitor already carries instead of a throwaway that never correlates for erasure.coarsenIprejects out-of-range IPv4 octets ("999.…") rather than storing a malformed coarse value.- Microsoft UET consent routes through the shared
toAdConsentStatead lever, so itsad_storagemapping can't drift from Meta/TikTok. - Dropped unused foreign type re-exports so each type has one home:
InitialConsentno longer re-exported fromadapters/tanstack-startorserver/initial-consent(import it fromcore/consent), and theAnonymousIdResponseCookieOptionsalias is gone —setAnonymousIdResponseCookietakesAnonymousIdCookieOptionsfromserver/anonymous-id-cookiedirectly. - Collapsed the
TrackedEventenvelope to a single interface — the unusedTrackedEventBaseandTrackEventnames are gone (TrackedEventkeeps thetype: "track"discriminant for a future additive union).
Also adds test coverage for the previously-untested
recordConsentReceiptFromRequestadapter path (no-store header, body-IP rejection, coarsened IP, PII-free ack).#617
b979371Thanks @thevuong! - Remove two leftover indirection layers in the server lane that no call site used.Breaking:
- The
@codefast/tracking/adapters/request-contextsubpath is gone. ItsRequestContextseam (agetHeader/setHeaderinterface) plus the parallel*FromContext/*OnContexthelpers existed only to back a hypothetical future./next/./remixadapter, but there was exactly one adapter and it duplicated every signature and doc comment.@codefast/tracking/adapters/tanstack-startnow callsgetRequestHeader/setResponseHeaderdirectly; 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. UseresolveRegionFromCountryCode(what the production path already uses viaresolveInitialConsent), orresolveInitialConsentFromRequestfor the full per-request resolution.
- The
08f10fbThanks @thevuong! - RebuildConsentBanneras composable compound parts (ConsentBannerTitle/Description/Actions/Accept/Reject/Customize/Preferences/Category/Save) — the root owns visibility (needsPrompt, overridable viaopenfor a "Cookie settings" reopen) and the preferences-layer state, action parts wire their own clicks and compose the consumer'sonClick, so any markup including a design system's button styles slots in viaclassName. The monolithicmessage/acceptLabel/categoriesprops are gone. An optional plain-CSS default theme ships at@codefast/tracking/css/consent.css— data-slot selectors,--consent-*custom properties withlight-dark()fallbacks, zero Tailwind dependency.#565
1e80096Thanks @thevuong! - RemovedefaultConsentExpressionfrom gtag/GTM consent bootstraps. Pass a literaldefaultConsent(strictest bake on shared HTML) and upgrade after hydration via the server-fn lane +updateGoogleConsent.#567
74c52acThanks @thevuong! - Collapse the consent "must match" contracts into oneConsentConfig, and addcreateConsentRuntime.Previously
storageKey,policyVersion, andrequestedCategorieshad to be hand-threaded — matching exactly — throughuseConsent,createIsAnalyticsAllowed, and the gtag consent bootstrap; one drifted string was a silent consent bug. Now:ConsentConfig+defineConsentConfig(root/core) — the one bag forstorageKey,policyVersion, andrequestedCategories. Isomorphic plain data: the same object is imported on both sides.createConsentRuntime(client) — derives the live client instances from the config: the sharedConsentStorage, the initial-consent store over your server lane,ensureInitialConsentResolved, and theisAnalyticsAllowedtracker 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 forrequestedCategoriesis gone — the config always states the requested purposes explicitly.createIsAnalyticsAllowed({ policyVersion, requestedCategories, ... })→createIsAnalyticsAllowed({ config, ... }).GtagConsentBootstrapOptions(and the<GtagConsentBootstrap />props):consentStorageKey+policyVersion→config.
079b8dfThanks @thevuong! - Rebuild the consent layer onuseSyncExternalStoreand exposedata-slotstyling hooks on the consent UI.useConsenttreats the storedConsentRecordas 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 olderpolicyVersionis ignored so bumping the version re-prompts as documented.- Breaking:
ConsentStoragegains a requiredsubscribe(listener)method — custom implementations must notify on changes.createLocalStorageConsentStorageimplements it (same-tab saves plus the cross-tabstorageevent) and now degrades a blockedlocalStorage(private mode/quota) to a session-scoped in-memory record instead of re-prompting in a loop. - Breaking:
ConsentBannerrenders 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'sComponentPropsand exposedata-slotattributes (consent-message,consent-actions,consent-action,consent-toggle) for Tailwind**:data-[slot=...]styling.
#563
bad015cThanks @thevuong! - Gate the client tracker on consent and keep destinations from leaking pre-consent or duplicate data.createClientTrackergainsisTrackingAllowed?: () => boolean, consulted per event — while it returnsfalsenothing is sent or queued, so a mid-session consent change applies immediately.anonymousIdalso accepts a() => stringresolver, invoked only when an event is actually allowed to send, so apps can defer minting an identifier cookie until consent exists.storageis now optional; without it the queue lives in memory only instead of persisting tolocalStorage. The Vercel destination takes an options object ({ name?, trackPageViews? }replaces the positional name), drops$page_viewedunlesstrackPageViewsis 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 globalgtagtype gains theconfigandjscommand signatures so apps can queue them directly, e.g. when loading gtag.js on demand for basic Consent Mode.#563
bad015cThanks @thevuong! - Consent internals cleanup. The gtag and GTM bootstraps now share one preamble builder (googleConsentBootstrapPreamble— generated output unchanged),toGoogleConsentParamsderives from the signal map instead of hand-writing it, the runtime consent setters (updateGoogleConsent,setGoogleConsentDefault,setGoogleAdsDataRedaction,setGoogleUrlPassthrough) accept adataLayerName,VercelAnalyticsDestinationOptionsis exported from the destinations barrel, and the cookie-string parser is shared asreadCookieValue(@codefast/tracking/core/cookie). Removed never-consumed exports:GOOGLE_CONSENT_SIGNAL_CATEGORIES,GoogleConsentSignal,isGa4EventName,consentDecisionShapeCheckExpression,consentSignalAssignmentsExpression.#567
74c52acThanks @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/pageon the tracker and the envelope union,EventDefinition.owner+EventsOf(with no server side there is nothing to split),attachRouterPageTracking— page views belong to gtagconfig+ Enhanced Measurement and Vercel's native<Analytics />. - GTM: destination, bootstrap, loader.
- Unused gtag helpers:
setGoogleConsentDefault,setGoogleAdsDataRedaction,setGoogleUrlPassthrough,extractGa4ClientId/extractGa4SessionId.
Follow-on API changes:
ClientTrackeris nowtrack()only (clear()had nothing left to clear, soConsentWithdrawalHandlerOptions.clearTrackeris gone too); catalogs drop theownertag ({ schema }only);TrackedEventis thetrackenvelope alone, still discriminated ontypeso a future kind is additive.- Server-side tracking:
#606
5a4ff42Thanks @thevuong! - AddscreateDurableReceiptStore({ backend })— a durableReceiptStoreover an injectedReceiptStoreBackend(a minimal id-keyedget/putprimitive). 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.putMUST be idempotent-by-id so the append-only guarantee holds atomically under retries/concurrency (e.g. KV set-if-absent, PostgresINSERT … ON CONFLICT DO NOTHING) — the frame delegates rather than doing a racy get-then-put. Pair it with a real backend in production, wherecreateInMemoryReceiptStoreis not a lawful store on its own.#604
7bb4be7Thanks @thevuong! -createClientTrackernow accepts an optionalisExemptionAllowedgate, consulted before anexemptdestination 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 — returningfalsewithholds 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.41951dfThanks @thevuong! - Expose the stored decision fromuseConsentand ignore tampered consent records.UseConsentResultgainsdecision— the stored decision under the current policy version,undefineduntil 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 booleanisTrackingAllowedcannot distinguish.useConsentnow counts only a well-formeddecision("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
createLocalStorageConsentStoragepersists the record as plainJSON.stringify(ConsentRecord)— a stable contract, so inline scripts can read the decision synchronously before any tag fires.
#565
1e80096Thanks @thevuong! -resolveInitialConsent(néebuildInitialConsent) 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 passedcountryCode: undefinedand relied on the opt-out fallback; callers that guarded the missing case themselves can drop the guard.079b8dfThanks @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:$identify→gtag('set', { user_id }),$group→ the recommendedjoin_groupevent (group_idparam), and other invalid names are warned about and dropped instead of being sent to nowhere. $page_viewedis 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 withtrackPageViews: trueafter disabling both.setGoogleConsentDefault/updateGoogleConsentnow grantanalytics_storageonly; thead_*Consent Mode v2 categories stay denied unless the newincludeAdsoption 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.
- GA4 rejects
079b8dfThanks @thevuong! - Correlate GA4 Measurement Protocol hits with gtag.js's own identifiers.GA4 joins hits on gtag's client ID (the
_gacookie), 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. NewextractGa4ClientId/extractGa4SessionIdhelpers read gtag's_ga/_ga_<stream>request cookies (both GS1 and GS2 formats) so the destination can echo them via the newclientId/sessionIdoptions. Events now also carryengagement_time_msec,session_id, andtimestamp_micros— without them GA4 accepts the hit but leaves it out of realtime and session-scoped reports, and retried events drift to receipt time.$groupmaps tojoin_group;$aliasis dropped (GA4 merges identities viauser_id).#608
6cdd930Thanks @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 andsubmitGa4UserDeletion({ …, accessToken, transport? })POSTs it — targeting the current Analytics Admin APIproperties.submitUserDeletion(the legacy v3userDeletionRequests:upsertwas sunset with Universal Analytics), keyed by a flatclientId. Authorization is the caller's: pass a bearer token for theanalytics.editscope; no OAuth or HTTP client is baked in. Server-only (@codefast/tracking/server).#563
bad015cThanks @thevuong! - Improve gtag/GTM loader DX without changing consent-first loading:ensureGtag/loadGtagScript/buildGtagConsentBootstrapScriptaccept optionaldataLayerName,nonce(CSP), anddebugMode.- Add
createGoogleTagManagerDestination,buildGtmConsentBootstrapScript, andloadGtmScriptfor consent-gated GTM. - Add
<GtagConsentBootstrap />— a framework-agnostic inline script wrapper for the pre-hydration bootstrap.
#563
bad015cThanks @thevuong! - Rename thehttp-destinationmodule tohttp, matching thecreate<X>Destinationfile-naming convention used by every other destination. Breaking for deep imports only:@codefast/tracking/destinations/http-destinationis now@codefast/tracking/destinations/http; imports from the@codefast/tracking/destinationsbarrel are unaffected.#617
b979371Thanks @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 thansetResponseHeader("set-cookie", …). The raw header call replaces any existingSet-Cookieon the response — it would clobber a session or framework cookie set on the same response — whereassetCookieappends. No behavior change to the emitted cookie (stillPath=/; Max-Age=1y; SameSite=Lax; Secure, notHttpOnly). - The connection IP for consent receipts is now read with
getRequestIP({ xForwardedFor: true })— the maintained, platform-aware path — instead of hand-parsingx-forwarded-for/x-real-ip.
Breaking (
@codefast/tracking/server/anonymous-id-cookie): the string buildersbuildAnonymousIdSetCookie/buildClearAnonymousIdSetCookieare replaced byresolveAnonymousIdCookie/resolveClearAnonymousIdCookie, which return the validated name/value plus cookie attributes for a frameworksetCookie/deleteCookiecall.isValidAnonymousIdis unchanged; the cookie-name guard is now the exportedassertValidAnonymousIdCookieName.- The anonymous-id cookie is now written with
079b8dfThanks @thevuong! - Deliver events to SDK-backed destinations at track time instead of through the batching queue.Destinationgains an optionaldelivery: "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 theflushWithBeaconpath unchanged.#567
74c52acThanks @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/coreand@codefast/tracking/core/*→@codefast/tracking(the root has always re-exported the whole isomorphic core surface).@codefast/tracking/client/*→@codefast/tracking/client; same pattern forserverandreact.- Google helpers →
@codefast/tracking/destinations.
One destination keeps a dedicated subpath on purpose:
@codefast/tracking/destinations/vercel-analytics— its top-level@vercel/analyticsimport 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
749dd16Thanks @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):sendMeasurementProtocolEventsPOSTs{ client_id, events, consent? }to the credentialed/mp/collectendpoint through an injectedtransport(defaultfetch), so no HTTP client or credentials are baked in;extractGaClientIdderives the GA4client_idfrom a_gacookie;toMeasurementProtocolConsentmaps the packageConsentDecisionto the MPconsentsignals. Server-only (@codefast/tracking/server). The caller owns the credentials and the consent gate.#602
5ca04e2Thanks @thevuong! -createClientTrackernow accepts an optionalonDeliveryErrorhook, 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 theDeliveryErrorContexttype from@codefast/tracking/client.#610
fdb8d7cThanks @thevuong! - Adds the per-destination erasure capability for DSR withdrawal (spec-data-subject-rights §3, DSR-V2/V4):Destinationgains an optionalonErasure(id)hook, andcreateClientTrackerreturns anerase(id)method that invokes each destination'sonErasureonce on withdrawal, swallowing failures so a destination can never break the flow. The referencecreateMetaDestinationimplementsonErasureas cookie-clear (via an injectedclearCookiesseam) plus stop-send — Meta exposes no per-visitor deletion API, so the binding never fabricates one. Destinations with nothing to erase omit the hook.2ebb0c0Thanks @thevuong! - Make consent per-category, mirroring Google Consent Mode v2.ConsentDecisionis now{ ads: boolean, analytics: boolean }instead of a single"granted" | "denied"flag,useConsenttakes thecategoriesthe app's prompt asks about (grantAll/denyAll/savereplacegrant/deny), andConsentBannergains a per-category preferences layer plus aReactNodemessage for the privacy-policy link. The GA4 helpers map the decision onto the v2 signals (adsdrivesad_storage/ad_user_data/ad_personalization), takewait_for_update/region, and gainsetGoogleAdsDataRedaction/setGoogleUrlPassthrough; the destination-sideincludeAdsoverride is gone — the visitor's decision carries ads consent.resolveDefaultConsentreplacesshouldTrackByDefaultand honors GPC as an ads-only opt-out. Previously stored string decisions fail shape validation and re-prompt, no policy-version bump needed.#563
bad015cThanks @thevuong! - Add three helpers that pull common consent/tracking wiring out of consumer apps and into the package:resolveEffectiveConsent(storage, policyVersion, categories, mode, hasGpc)andreadStoredDecision(storage, policyVersion)(@codefast/tracking/core) — the same "stored decision, else region default" ruleuseConsentapplies internally, now exposed so a non-React gate (e.g. a tracker'sisTrackingAllowedoption) 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-indocument.cookie-backed anonymous idgetOrCreate/clearpair for apps that don't need a custom identity strategy.
None of these change existing exports' behavior;
useConsentis refactored internally to usereadStoredDecisionbut its output is unchanged.#565
1e80096Thanks @thevuong! - Remove the deprecated edge-middleware cookie bootstrap path:- Drop
buildInitialConsentBootstrapScriptand the@codefast/tracking/destinations/initial-consent-bootstrapsubpath. - Resolve region consent via a server function (
resolveInitialConsent) plus a client snapshot instead — seeapps/uivisitor-consent.ts/resolve-visitor-consent.ts.
- Drop
#563
bad015cThanks @thevuong! - RenameUseConsentResult.needsPrompttoisPromptNeeded— a boolean should read as an assertion, matchingisTrackingAllowedon the same result (Swift API Design Guidelines pass).Breaking: consumers of
useConsent/ConsentBannerreadingneedsPromptmust switch toisPromptNeeded.#563
bad015cThanks @thevuong! - AddderiveEventId(requestId, discriminant)(@codefast/tracking/core) and wire it intocreateServerTracker: passrequestIdonServerTrackerContextto make a server-owned event'seventIddeterministic instead of random. Retrying the same request with the sametrack/group/aliascall now reproduces the sameeventId, 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. OmittingrequestIdkeeps the existing random behavior, so this is additive and non-breaking.#566
ffd777cThanks @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) —EventDefinitionis typed onStandardSchemaV1, validation runs through the newassertValidEventProperties, andzodis no longer a dependency (@standard-schema/specis the only one). buildInitialConsent→resolveInitialConsent;ServerTrackContext→ServerTrackerContext.attachClientLifecycledropsflushIntervalMs— 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 keepalivefetchfallback) and flush-on-reconnect.- The
./destinationsbarrel is browser-lane only: importcreateVercelAnalyticsDestinationfrom./destinations/vercel-analytics(its top-level@vercel/analyticsimport made the optional peer mandatory for barrel consumers) andcreateGa4MeasurementProtocolDestinationfrom its own subpath. ./server,./server/*,./tanstack-start, and./destinations/ga4-measurement-protocolare server-only by contract: on TanStack Start, deny them in the client environment viaimportProtection.client.specifiers(README shows the config) so a leak fails the build with a traced violation instead of silently shipping server code or the GA4apiSecret.
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 newisInitialConsentguard, fail-closed-but-retryable errors, retry on tab-visible.createServerTracker:waitUntilhands delivery (and its retry ladder) to the platform's post-response scheduler;withContextbinds per-request identity once.- Beacon receive half:
relayTrackedEvents+createTrackedEventIngestHandler(Request → Response) validate client envelopes, re-stamp server-read identity, and keep clienteventIds 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,keepalivethreading for unload flushes,Destination.sendBatch(implemented bycreateHttpDestination— one POST per queue flush). CookieAnonymousId.refresh()prolongs an existing id without ever minting;STRICTEST_INITIAL_CONSENT,CONSENT_REGIONS,isConsentRegionare exported from core; the localStorage consent storage caches parsed records behind a raw-string compare; client-only react modules ship"use client".
- Event catalogs now accept any Standard Schema library (zod,
#563
bad015cThanks @thevuong! - Add server-persisted anonymous id ("client mints, server persists").createServerPersistedAnonymousId(client) keeps the lazy, post-consent minting ofcreateCookieAnonymousIdand 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: alwaysSecure; 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
bad015cThanks @thevuong! - Add a TanStack Start wiring kit so consumer apps no longer hand-roll consent/bootstrap glue:resolveInitialConsent(néebuildInitialConsent) + exportedEU_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 Modeupdate+ optional gtag load, including cross-tab / privacy-page decisions.
InitialConsentis exported from@codefast/tracking/@codefast/tracking/core. None of these change existing export behavior.#565
1e80096Thanks @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.props→properties(and thetrack/pagemethod parameters). Segment-style name;Propsis reserved for React components. - Analytics gate:
isTrackingAllowed→isAnalyticsAllowedonClientTrackerOptionsandUseConsentResult(the gate reads theanalyticscategory only).createIsTrackingAllowed→createIsAnalyticsAllowed; subpath./client/is-tracking-allowed→./client/is-analytics-allowed. - Options naming (drop
Create*/Build*filler):CreateIsAnalyticsAllowedOptions→IsAnalyticsAllowedOptions;CreateConsentWithdrawalHandlerOptions→ConsentWithdrawalHandlerOptions;BuildInitialConsentOptions→InitialConsentOptions;BuildAnonymousIdSetCookieOptions→AnonymousIdSetCookieOptions.ClientLifecycleOptionsstays (product-named, no verb prefix). - Prompt scope option:
categories→requestedCategoriesonUseConsentOptions,IsAnalyticsAllowedOptions, andInitialConsentOptions. Destination.consent/VercelAnalyticsDestinationOptions.consent→consentRequirement("exempt" | "required").googleConsentBootstrapPreamble→buildGoogleConsentBootstrapPreamble;dataLayerOf→ensureDataLayer.UseConsentResult.save→saveDecision.- Demote package-private deep exports: remove
./client/queue,./destinations/shared, and./destinations/google-consentfrompackage.json#exports.EventQueue/EventQueueOptionsleave the./clientbarrel;EventQueueStoragestays (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)andresolveEffectiveConsent(storage, policyVersion, requestedCategories, mode, hasGlobalPrivacyControlSignal)→resolveEffectiveConsent(options); newResolveDefaultConsentOptions/ResolveEffectiveConsentOptionstypes.readStoredDecision(storage, policyVersion)keeps its positional args. - GA4 Measurement Protocol debug flag:
Ga4MeasurementProtocolDestinationOptions.debug→debugMode(matchesdebugModeon the gtag options). - Server
groupsignature:ServerTracker.group(groupId, traits, context)→group(groupId, context, traits?)sotraitsis truly optional instead of a forcedundefined. - Remove
assertNeverfrom the public exports (generic, non-tracking helper; internal-only now). readCookieValueis now also exported from the root entry (previously only on./core).
- Envelope field:
#611
a5527c3Thanks @thevuong! - Extends the ad-destination frame with two more reference vendors (spec-destinations §5), consuming the same{ ads, analytics }decision:createMicrosoftUetDestination/toMicrosoftUetConsentmapadsto UET's only enforced signal,ad_storage(UET has noanalytics_storage);createTiktokDestination/toTiktokConsentmapadsto TikTok's singlelimited_data_useboolean (not Meta'sdataProcessingOptionsstructure). Both take an injectedtransport(no pixel id, tag id, or network client baked in) and implementonErasureas cookie-clear + stop-send, since neither exposes a per-visitor deletion API.consentRequirementstays"required".#563
bad015cThanks @thevuong! - Discriminate event envelopes bytypeand add a consent-exempt destination lane.TrackedEventis now a union discriminated ontype: "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 exhaustiveswitch(identifycarriestraits,groupcarriesgroupId/traits,aliascarriespreviousId), and the app-chosennameonly exists ontrack/page. This changes the wire format seen by HTTP destinations and the queue storage; stale queue records without atypeare dropped silently.Destinationgainsconsent?: "exempt" | "required"— while the tracker's consent gate is closed, exempt immediate destinations keep receivingtrack/pageevents stripped ofanonymousId/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
641e233Thanks @thevuong! - Collapse thetypesanddefaultlanes ofpackage.json#importsfrom 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_FOUNDrather than trying the second../dist/*/index.jsand./dist/*/index.d.tscould therefore never be reached, so they read as a safety net that does not exist. Thesourcelane keeps its extension candidates, which onlytscand Vite read and both probe.#565
1e80096Thanks @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
bad015cThanks @thevuong! - Fix bugs found in review of the consent-gated tracking pipeline, and dedupe the GA4/Vercel destinations.createClientTracker'sidentify()no longer commitsuserIdto the tracker's closure while consent is denied — a deniedidentifycould otherwise leak itsuserIdonto a later, allowedtrack/page/groupcall.createServerTracker's per-requesteventIdderivation now factors inuserId, so twoalias()calls with the samepreviousIdbut different merge targets in one request no longer collide oneventId.createLocalStorageQueueStorage.load()now drops pre-migration/malformed queue records via a newisTrackedEventguard (exported from@codefast/tracking/core), instead of relying on each destination'sswitchto silently no-op on an unrecognized shape.Also: the GA4 (
gtag/Measurement Protocol) and Vercel Analytics destinations now share one prop-flattening helper and onegroup→join_groupmapping (@codefast/tracking/destinations's internalshared.ts) instead of three near-duplicate implementations;buildGtagConsentBootstrapScript's pre-hydration Consent Mode signal mapping is generated from the same tabletoGoogleConsentParamsuses instead of a hand-duplicated literal; and the package'sensureGtaggtag.js stub helper, plus a newloadGtagScript(options)(loads gtag.js on demand, idempotent), are now exported so apps don't have to reimplement on-demand script loading themselves.#565
1e80096Thanks @thevuong! - GuardcreateLocalStorageConsentStoragewithisConsentRecordso malformed localStorage JSON cannot be treated as a valid consent record.46c32d6Thanks @thevuong! - Defaultengagement_time_msecon 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
1e80096Thanks @thevuong! - SkipclearOnServerwhen 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
641e233Thanks @thevuong! - Collapse thetypesanddefaultlanes ofpackage.json#importsfrom 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_FOUNDrather than trying the second../dist/*/index.jsand./dist/*/index.d.tscould therefore never be reached, so they read as a safety net that does not exist. Thesourcelane keeps its extension candidates, which onlytscand Vite read and both probe.
0.5.0-canary.8
0.5.0-canary.7
0.5.0-canary.6
Minor Changes
#605
cb46bddThanks @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 —analyticsdrives whether events transmit,adsdrives Limited Data Use — so per-vendor mappings cannot drift. Ships a reference Meta destination (createMetaDestination,toMetaDataProcessingOptions) that maps each event and the liveadsdecision to Meta'sdataProcessingOptions(geolocated LDU whenadsis denied) and hands it to an injectedtransport. Consent-restriction mapping only — the Pixel/CAPI transport and credentials are the integrator's to supply; an ad sink is neverexempt.#612
1337fc3Thanks @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 tightensads), covering conformance vectors V1–V6.hasTcfApi/hasGppApidetect the__tcfapi/__gppread 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 theCmpConsentSignalit passes in.#615
2bcd31fThanks @thevuong! - Letcodefast mirrorgeneratepackage.json#exportsfromdist/, 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/trackingis 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./destinationsgroup 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_SUBPATHSnow deniesserver/**andadapters/**.
- The root
#563
bad015cThanks @thevuong! - Switch gtag/GTM bootstraps to Google Consent Mode advanced:buildGtagConsentBootstrapScript/buildGtmConsentBootstrapScriptalways set Consent Mode v2default(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/loadGtmScriptremain 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
bad015cThanks @thevuong! - Tighten the package's API contracts and framework independence, found in an architecture audit.Breaking:
ClientTrackerOptions.anonymousIdis now() => stringonly — the plain-stringform 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.sendnow always returnsPromise<void>— the previousPromise<void> | voidlet sync and async destinations disagree on contract. Mark a synchronoussendasyncso a thrown error rejects the returned promise instead of throwing synchronously.
Also:
createVercelAnalyticsDestinationnow importstrackfrom the framework-agnostic@vercel/analyticsinstead 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 thedefaultcase of everyswitch (event.type)across the GA4/Vercel destinations — extendingTrackedEventwith 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 ofexport *, matching theclient/server/destinations/reactsubpaths, which were already explicit. useConsent's returned object and itssave/denyAll/grantAllcallbacks 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
b979371Thanks @thevuong! - Harden the package from a full audit — correctness, coverage, and a leaner public surface.isConsentReceiptInputnow validatesmethodandsubjectIdTypeagainst their enums, not justtypeof === "string"— the untrusted-body guard no longer narrows a bogus value to a closed union member.CookieAnonymousIdgainscurrent()— a non-minting read of the existing id (undefinedwhen none) so a consent receipt stamps the id the visitor already carries instead of a throwaway that never correlates for erasure.coarsenIprejects out-of-range IPv4 octets ("999.…") rather than storing a malformed coarse value.- Microsoft UET consent routes through the shared
toAdConsentStatead lever, so itsad_storagemapping can't drift from Meta/TikTok. - Dropped unused foreign type re-exports so each type has one home:
InitialConsentno longer re-exported fromadapters/tanstack-startorserver/initial-consent(import it fromcore/consent), and theAnonymousIdResponseCookieOptionsalias is gone —setAnonymousIdResponseCookietakesAnonymousIdCookieOptionsfromserver/anonymous-id-cookiedirectly. - Collapsed the
TrackedEventenvelope to a single interface — the unusedTrackedEventBaseandTrackEventnames are gone (TrackedEventkeeps thetype: "track"discriminant for a future additive union).
Also adds test coverage for the previously-untested
recordConsentReceiptFromRequestadapter path (no-store header, body-IP rejection, coarsened IP, PII-free ack).#617
b979371Thanks @thevuong! - Remove two leftover indirection layers in the server lane that no call site used.Breaking:
- The
@codefast/tracking/adapters/request-contextsubpath is gone. ItsRequestContextseam (agetHeader/setHeaderinterface) plus the parallel*FromContext/*OnContexthelpers existed only to back a hypothetical future./next/./remixadapter, but there was exactly one adapter and it duplicated every signature and doc comment.@codefast/tracking/adapters/tanstack-startnow callsgetRequestHeader/setResponseHeaderdirectly; 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. UseresolveRegionFromCountryCode(what the production path already uses viaresolveInitialConsent), orresolveInitialConsentFromRequestfor the full per-request resolution.
- The
08f10fbThanks @thevuong! - RebuildConsentBanneras composable compound parts (ConsentBannerTitle/Description/Actions/Accept/Reject/Customize/Preferences/Category/Save) — the root owns visibility (needsPrompt, overridable viaopenfor a "Cookie settings" reopen) and the preferences-layer state, action parts wire their own clicks and compose the consumer'sonClick, so any markup including a design system's button styles slots in viaclassName. The monolithicmessage/acceptLabel/categoriesprops are gone. An optional plain-CSS default theme ships at@codefast/tracking/css/consent.css— data-slot selectors,--consent-*custom properties withlight-dark()fallbacks, zero Tailwind dependency.#565
1e80096Thanks @thevuong! - RemovedefaultConsentExpressionfrom gtag/GTM consent bootstraps. Pass a literaldefaultConsent(strictest bake on shared HTML) and upgrade after hydration via the server-fn lane +updateGoogleConsent.#567
74c52acThanks @thevuong! - Collapse the consent "must match" contracts into oneConsentConfig, and addcreateConsentRuntime.Previously
storageKey,policyVersion, andrequestedCategorieshad to be hand-threaded — matching exactly — throughuseConsent,createIsAnalyticsAllowed, and the gtag consent bootstrap; one drifted string was a silent consent bug. Now:ConsentConfig+defineConsentConfig(root/core) — the one bag forstorageKey,policyVersion, andrequestedCategories. Isomorphic plain data: the same object is imported on both sides.createConsentRuntime(client) — derives the live client instances from the config: the sharedConsentStorage, the initial-consent store over your server lane,ensureInitialConsentResolved, and theisAnalyticsAllowedtracker 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 forrequestedCategoriesis gone — the config always states the requested purposes explicitly.createIsAnalyticsAllowed({ policyVersion, requestedCategories, ... })→createIsAnalyticsAllowed({ config, ... }).GtagConsentBootstrapOptions(and the<GtagConsentBootstrap />props):consentStorageKey+policyVersion→config.
079b8dfThanks @thevuong! - Rebuild the consent layer onuseSyncExternalStoreand exposedata-slotstyling hooks on the consent UI.useConsenttreats the storedConsentRecordas 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 olderpolicyVersionis ignored so bumping the version re-prompts as documented.- Breaking:
ConsentStoragegains a requiredsubscribe(listener)method — custom implementations must notify on changes.createLocalStorageConsentStorageimplements it (same-tab saves plus the cross-tabstorageevent) and now degrades a blockedlocalStorage(private mode/quota) to a session-scoped in-memory record instead of re-prompting in a loop. - Breaking:
ConsentBannerrenders 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'sComponentPropsand exposedata-slotattributes (consent-message,consent-actions,consent-action,consent-toggle) for Tailwind**:data-[slot=...]styling.
#563
bad015cThanks @thevuong! - Gate the client tracker on consent and keep destinations from leaking pre-consent or duplicate data.createClientTrackergainsisTrackingAllowed?: () => boolean, consulted per event — while it returnsfalsenothing is sent or queued, so a mid-session consent change applies immediately.anonymousIdalso accepts a() => stringresolver, invoked only when an event is actually allowed to send, so apps can defer minting an identifier cookie until consent exists.storageis now optional; without it the queue lives in memory only instead of persisting tolocalStorage. The Vercel destination takes an options object ({ name?, trackPageViews? }replaces the positional name), drops$page_viewedunlesstrackPageViewsis 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 globalgtagtype gains theconfigandjscommand signatures so apps can queue them directly, e.g. when loading gtag.js on demand for basic Consent Mode.#563
bad015cThanks @thevuong! - Consent internals cleanup. The gtag and GTM bootstraps now share one preamble builder (googleConsentBootstrapPreamble— generated output unchanged),toGoogleConsentParamsderives from the signal map instead of hand-writing it, the runtime consent setters (updateGoogleConsent,setGoogleConsentDefault,setGoogleAdsDataRedaction,setGoogleUrlPassthrough) accept adataLayerName,VercelAnalyticsDestinationOptionsis exported from the destinations barrel, and the cookie-string parser is shared asreadCookieValue(@codefast/tracking/core/cookie). Removed never-consumed exports:GOOGLE_CONSENT_SIGNAL_CATEGORIES,GoogleConsentSignal,isGa4EventName,consentDecisionShapeCheckExpression,consentSignalAssignmentsExpression.#567
74c52acThanks @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/pageon the tracker and the envelope union,EventDefinition.owner+EventsOf(with no server side there is nothing to split),attachRouterPageTracking— page views belong to gtagconfig+ Enhanced Measurement and Vercel's native<Analytics />. - GTM: destination, bootstrap, loader.
- Unused gtag helpers:
setGoogleConsentDefault,setGoogleAdsDataRedaction,setGoogleUrlPassthrough,extractGa4ClientId/extractGa4SessionId.
Follow-on API changes:
ClientTrackeris nowtrack()only (clear()had nothing left to clear, soConsentWithdrawalHandlerOptions.clearTrackeris gone too); catalogs drop theownertag ({ schema }only);TrackedEventis thetrackenvelope alone, still discriminated ontypeso a future kind is additive.- Server-side tracking:
#606
5a4ff42Thanks @thevuong! - AddscreateDurableReceiptStore({ backend })— a durableReceiptStoreover an injectedReceiptStoreBackend(a minimal id-keyedget/putprimitive). 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.putMUST be idempotent-by-id so the append-only guarantee holds atomically under retries/concurrency (e.g. KV set-if-absent, PostgresINSERT … ON CONFLICT DO NOTHING) — the frame delegates rather than doing a racy get-then-put. Pair it with a real backend in production, wherecreateInMemoryReceiptStoreis not a lawful store on its own.#604
7bb4be7Thanks @thevuong! -createClientTrackernow accepts an optionalisExemptionAllowedgate, consulted before anexemptdestination 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 — returningfalsewithholds 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.41951dfThanks @thevuong! - Expose the stored decision fromuseConsentand ignore tampered consent records.UseConsentResultgainsdecision— the stored decision under the current policy version,undefineduntil 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 booleanisTrackingAllowedcannot distinguish.useConsentnow counts only a well-formeddecision("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
createLocalStorageConsentStoragepersists the record as plainJSON.stringify(ConsentRecord)— a stable contract, so inline scripts can read the decision synchronously before any tag fires.
#565
1e80096Thanks @thevuong! -resolveInitialConsent(néebuildInitialConsent) 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 passedcountryCode: undefinedand relied on the opt-out fallback; callers that guarded the missing case themselves can drop the guard.079b8dfThanks @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:$identify→gtag('set', { user_id }),$group→ the recommendedjoin_groupevent (group_idparam), and other invalid names are warned about and dropped instead of being sent to nowhere. $page_viewedis 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 withtrackPageViews: trueafter disabling both.setGoogleConsentDefault/updateGoogleConsentnow grantanalytics_storageonly; thead_*Consent Mode v2 categories stay denied unless the newincludeAdsoption 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.
- GA4 rejects
079b8dfThanks @thevuong! - Correlate GA4 Measurement Protocol hits with gtag.js's own identifiers.GA4 joins hits on gtag's client ID (the
_gacookie), 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. NewextractGa4ClientId/extractGa4SessionIdhelpers read gtag's_ga/_ga_<stream>request cookies (both GS1 and GS2 formats) so the destination can echo them via the newclientId/sessionIdoptions. Events now also carryengagement_time_msec,session_id, andtimestamp_micros— without them GA4 accepts the hit but leaves it out of realtime and session-scoped reports, and retried events drift to receipt time.$groupmaps tojoin_group;$aliasis dropped (GA4 merges identities viauser_id).#608
6cdd930Thanks @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 andsubmitGa4UserDeletion({ …, accessToken, transport? })POSTs it — targeting the current Analytics Admin APIproperties.submitUserDeletion(the legacy v3userDeletionRequests:upsertwas sunset with Universal Analytics), keyed by a flatclientId. Authorization is the caller's: pass a bearer token for theanalytics.editscope; no OAuth or HTTP client is baked in. Server-only (@codefast/tracking/server).#563
bad015cThanks @thevuong! - Improve gtag/GTM loader DX without changing consent-first loading:ensureGtag/loadGtagScript/buildGtagConsentBootstrapScriptaccept optionaldataLayerName,nonce(CSP), anddebugMode.- Add
createGoogleTagManagerDestination,buildGtmConsentBootstrapScript, andloadGtmScriptfor consent-gated GTM. - Add
<GtagConsentBootstrap />— a framework-agnostic inline script wrapper for the pre-hydration bootstrap.
#563
bad015cThanks @thevuong! - Rename thehttp-destinationmodule tohttp, matching thecreate<X>Destinationfile-naming convention used by every other destination. Breaking for deep imports only:@codefast/tracking/destinations/http-destinationis now@codefast/tracking/destinations/http; imports from the@codefast/tracking/destinationsbarrel are unaffected.#617
b979371Thanks @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 thansetResponseHeader("set-cookie", …). The raw header call replaces any existingSet-Cookieon the response — it would clobber a session or framework cookie set on the same response — whereassetCookieappends. No behavior change to the emitted cookie (stillPath=/; Max-Age=1y; SameSite=Lax; Secure, notHttpOnly). - The connection IP for consent receipts is now read with
getRequestIP({ xForwardedFor: true })— the maintained, platform-aware path — instead of hand-parsingx-forwarded-for/x-real-ip.
Breaking (
@codefast/tracking/server/anonymous-id-cookie): the string buildersbuildAnonymousIdSetCookie/buildClearAnonymousIdSetCookieare replaced byresolveAnonymousIdCookie/resolveClearAnonymousIdCookie, which return the validated name/value plus cookie attributes for a frameworksetCookie/deleteCookiecall.isValidAnonymousIdis unchanged; the cookie-name guard is now the exportedassertValidAnonymousIdCookieName.- The anonymous-id cookie is now written with
079b8dfThanks @thevuong! - Deliver events to SDK-backed destinations at track time instead of through the batching queue.Destinationgains an optionaldelivery: "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 theflushWithBeaconpath unchanged.#567
74c52acThanks @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/coreand@codefast/tracking/core/*→@codefast/tracking(the root has always re-exported the whole isomorphic core surface).@codefast/tracking/client/*→@codefast/tracking/client; same pattern forserverandreact.- Google helpers →
@codefast/tracking/destinations.
One destination keeps a dedicated subpath on purpose:
@codefast/tracking/destinations/vercel-analytics— its top-level@vercel/analyticsimport 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
749dd16Thanks @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):sendMeasurementProtocolEventsPOSTs{ client_id, events, consent? }to the credentialed/mp/collectendpoint through an injectedtransport(defaultfetch), so no HTTP client or credentials are baked in;extractGaClientIdderives the GA4client_idfrom a_gacookie;toMeasurementProtocolConsentmaps the packageConsentDecisionto the MPconsentsignals. Server-only (@codefast/tracking/server). The caller owns the credentials and the consent gate.#602
5ca04e2Thanks @thevuong! -createClientTrackernow accepts an optionalonDeliveryErrorhook, 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 theDeliveryErrorContexttype from@codefast/tracking/client.#610
fdb8d7cThanks @thevuong! - Adds the per-destination erasure capability for DSR withdrawal (spec-data-subject-rights §3, DSR-V2/V4):Destinationgains an optionalonErasure(id)hook, andcreateClientTrackerreturns anerase(id)method that invokes each destination'sonErasureonce on withdrawal, swallowing failures so a destination can never break the flow. The referencecreateMetaDestinationimplementsonErasureas cookie-clear (via an injectedclearCookiesseam) plus stop-send — Meta exposes no per-visitor deletion API, so the binding never fabricates one. Destinations with nothing to erase omit the hook.2ebb0c0Thanks @thevuong! - Make consent per-category, mirroring Google Consent Mode v2.ConsentDecisionis now{ ads: boolean, analytics: boolean }instead of a single"granted" | "denied"flag,useConsenttakes thecategoriesthe app's prompt asks about (grantAll/denyAll/savereplacegrant/deny), andConsentBannergains a per-category preferences layer plus aReactNodemessage for the privacy-policy link. The GA4 helpers map the decision onto the v2 signals (adsdrivesad_storage/ad_user_data/ad_personalization), takewait_for_update/region, and gainsetGoogleAdsDataRedaction/setGoogleUrlPassthrough; the destination-sideincludeAdsoverride is gone — the visitor's decision carries ads consent.resolveDefaultConsentreplacesshouldTrackByDefaultand honors GPC as an ads-only opt-out. Previously stored string decisions fail shape validation and re-prompt, no policy-version bump needed.#563
bad015cThanks @thevuong! - Add three helpers that pull common consent/tracking wiring out of consumer apps and into the package:resolveEffectiveConsent(storage, policyVersion, categories, mode, hasGpc)andreadStoredDecision(storage, policyVersion)(@codefast/tracking/core) — the same "stored decision, else region default" ruleuseConsentapplies internally, now exposed so a non-React gate (e.g. a tracker'sisTrackingAllowedoption) 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-indocument.cookie-backed anonymous idgetOrCreate/clearpair for apps that don't need a custom identity strategy.
None of these change existing exports' behavior;
useConsentis refactored internally to usereadStoredDecisionbut its output is unchanged.#565
1e80096Thanks @thevuong! - Remove the deprecated edge-middleware cookie bootstrap path:- Drop
buildInitialConsentBootstrapScriptand the@codefast/tracking/destinations/initial-consent-bootstrapsubpath. - Resolve region consent via a server function (
resolveInitialConsent) plus a client snapshot instead — seeapps/uivisitor-consent.ts/resolve-visitor-consent.ts.
- Drop
#563
bad015cThanks @thevuong! - RenameUseConsentResult.needsPrompttoisPromptNeeded— a boolean should read as an assertion, matchingisTrackingAllowedon the same result (Swift API Design Guidelines pass).Breaking: consumers of
useConsent/ConsentBannerreadingneedsPromptmust switch toisPromptNeeded.#563
bad015cThanks @thevuong! - AddderiveEventId(requestId, discriminant)(@codefast/tracking/core) and wire it intocreateServerTracker: passrequestIdonServerTrackerContextto make a server-owned event'seventIddeterministic instead of random. Retrying the same request with the sametrack/group/aliascall now reproduces the sameeventId, 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. OmittingrequestIdkeeps the existing random behavior, so this is additive and non-breaking.#566
ffd777cThanks @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) —EventDefinitionis typed onStandardSchemaV1, validation runs through the newassertValidEventProperties, andzodis no longer a dependency (@standard-schema/specis the only one). buildInitialConsent→resolveInitialConsent;ServerTrackContext→ServerTrackerContext.attachClientLifecycledropsflushIntervalMs— 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 keepalivefetchfallback) and flush-on-reconnect.- The
./destinationsbarrel is browser-lane only: importcreateVercelAnalyticsDestinationfrom./destinations/vercel-analytics(its top-level@vercel/analyticsimport made the optional peer mandatory for barrel consumers) andcreateGa4MeasurementProtocolDestinationfrom its own subpath. ./server,./server/*,./tanstack-start, and./destinations/ga4-measurement-protocolare server-only by contract: on TanStack Start, deny them in the client environment viaimportProtection.client.specifiers(README shows the config) so a leak fails the build with a traced violation instead of silently shipping server code or the GA4apiSecret.
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 newisInitialConsentguard, fail-closed-but-retryable errors, retry on tab-visible.createServerTracker:waitUntilhands delivery (and its retry ladder) to the platform's post-response scheduler;withContextbinds per-request identity once.- Beacon receive half:
relayTrackedEvents+createTrackedEventIngestHandler(Request → Response) validate client envelopes, re-stamp server-read identity, and keep clienteventIds 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,keepalivethreading for unload flushes,Destination.sendBatch(implemented bycreateHttpDestination— one POST per queue flush). CookieAnonymousId.refresh()prolongs an existing id without ever minting;STRICTEST_INITIAL_CONSENT,CONSENT_REGIONS,isConsentRegionare exported from core; the localStorage consent storage caches parsed records behind a raw-string compare; client-only react modules ship"use client".
- Event catalogs now accept any Standard Schema library (zod,
#563
bad015cThanks @thevuong! - Add server-persisted anonymous id ("client mints, server persists").createServerPersistedAnonymousId(client) keeps the lazy, post-consent minting ofcreateCookieAnonymousIdand 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: alwaysSecure; 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
bad015cThanks @thevuong! - Add a TanStack Start wiring kit so consumer apps no longer hand-roll consent/bootstrap glue:resolveInitialConsent(néebuildInitialConsent) + exportedEU_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 Modeupdate+ optional gtag load, including cross-tab / privacy-page decisions.
InitialConsentis exported from@codefast/tracking/@codefast/tracking/core. None of these change existing export behavior.#565
1e80096Thanks @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.props→properties(and thetrack/pagemethod parameters). Segment-style name;Propsis reserved for React components. - Analytics gate:
isTrackingAllowed→isAnalyticsAllowedonClientTrackerOptionsandUseConsentResult(the gate reads theanalyticscategory only).createIsTrackingAllowed→createIsAnalyticsAllowed; subpath./client/is-tracking-allowed→./client/is-analytics-allowed. - Options naming (drop
Create*/Build*filler):CreateIsAnalyticsAllowedOptions→IsAnalyticsAllowedOptions;CreateConsentWithdrawalHandlerOptions→ConsentWithdrawalHandlerOptions;BuildInitialConsentOptions→InitialConsentOptions;BuildAnonymousIdSetCookieOptions→AnonymousIdSetCookieOptions.ClientLifecycleOptionsstays (product-named, no verb prefix). - Prompt scope option:
categories→requestedCategoriesonUseConsentOptions,IsAnalyticsAllowedOptions, andInitialConsentOptions. Destination.consent/VercelAnalyticsDestinationOptions.consent→consentRequirement("exempt" | "required").googleConsentBootstrapPreamble→buildGoogleConsentBootstrapPreamble;dataLayerOf→ensureDataLayer.UseConsentResult.save→saveDecision.- Demote package-private deep exports: remove
./client/queue,./destinations/shared, and./destinations/google-consentfrompackage.json#exports.EventQueue/EventQueueOptionsleave the./clientbarrel;EventQueueStoragestays (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)andresolveEffectiveConsent(storage, policyVersion, requestedCategories, mode, hasGlobalPrivacyControlSignal)→resolveEffectiveConsent(options); newResolveDefaultConsentOptions/ResolveEffectiveConsentOptionstypes.readStoredDecision(storage, policyVersion)keeps its positional args. - GA4 Measurement Protocol debug flag:
Ga4MeasurementProtocolDestinationOptions.debug→debugMode(matchesdebugModeon the gtag options). - Server
groupsignature:ServerTracker.group(groupId, traits, context)→group(groupId, context, traits?)sotraitsis truly optional instead of a forcedundefined. - Remove
assertNeverfrom the public exports (generic, non-tracking helper; internal-only now). readCookieValueis now also exported from the root entry (previously only on./core).
- Envelope field:
#611
a5527c3Thanks @thevuong! - Extends the ad-destination frame with two more reference vendors (spec-destinations §5), consuming the same{ ads, analytics }decision:createMicrosoftUetDestination/toMicrosoftUetConsentmapadsto UET's only enforced signal,ad_storage(UET has noanalytics_storage);createTiktokDestination/toTiktokConsentmapadsto TikTok's singlelimited_data_useboolean (not Meta'sdataProcessingOptionsstructure). Both take an injectedtransport(no pixel id, tag id, or network client baked in) and implementonErasureas cookie-clear + stop-send, since neither exposes a per-visitor deletion API.consentRequirementstays"required".#563
bad015cThanks @thevuong! - Discriminate event envelopes bytypeand add a consent-exempt destination lane.TrackedEventis now a union discriminated ontype: "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 exhaustiveswitch(identifycarriestraits,groupcarriesgroupId/traits,aliascarriespreviousId), and the app-chosennameonly exists ontrack/page. This changes the wire format seen by HTTP destinations and the queue storage; stale queue records without atypeare dropped silently.Destinationgainsconsent?: "exempt" | "required"— while the tracker's consent gate is closed, exempt immediate destinations keep receivingtrack/pageevents stripped ofanonymousId/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
1e80096Thanks @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
bad015cThanks @thevuong! - Fix bugs found in review of the consent-gated tracking pipeline, and dedupe the GA4/Vercel destinations.createClientTracker'sidentify()no longer commitsuserIdto the tracker's closure while consent is denied — a deniedidentifycould otherwise leak itsuserIdonto a later, allowedtrack/page/groupcall.createServerTracker's per-requesteventIdderivation now factors inuserId, so twoalias()calls with the samepreviousIdbut different merge targets in one request no longer collide oneventId.createLocalStorageQueueStorage.load()now drops pre-migration/malformed queue records via a newisTrackedEventguard (exported from@codefast/tracking/core), instead of relying on each destination'sswitchto silently no-op on an unrecognized shape.Also: the GA4 (
gtag/Measurement Protocol) and Vercel Analytics destinations now share one prop-flattening helper and onegroup→join_groupmapping (@codefast/tracking/destinations's internalshared.ts) instead of three near-duplicate implementations;buildGtagConsentBootstrapScript's pre-hydration Consent Mode signal mapping is generated from the same tabletoGoogleConsentParamsuses instead of a hand-duplicated literal; and the package'sensureGtaggtag.js stub helper, plus a newloadGtagScript(options)(loads gtag.js on demand, idempotent), are now exported so apps don't have to reimplement on-demand script loading themselves.#565
1e80096Thanks @thevuong! - GuardcreateLocalStorageConsentStoragewithisConsentRecordso malformed localStorage JSON cannot be treated as a valid consent record.46c32d6Thanks @thevuong! - Defaultengagement_time_msecon 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
1e80096Thanks @thevuong! - SkipclearOnServerwhen 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
cb46bddThanks @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 —analyticsdrives whether events transmit,adsdrives Limited Data Use — so per-vendor mappings cannot drift. Ships a reference Meta destination (createMetaDestination,toMetaDataProcessingOptions) that maps each event and the liveadsdecision to Meta'sdataProcessingOptions(geolocated LDU whenadsis denied) and hands it to an injectedtransport. Consent-restriction mapping only — the Pixel/CAPI transport and credentials are the integrator's to supply; an ad sink is neverexempt.#612
1337fc3Thanks @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 tightensads), covering conformance vectors V1–V6.hasTcfApi/hasGppApidetect the__tcfapi/__gppread 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 theCmpConsentSignalit passes in.#615
2bcd31fThanks @thevuong! - Letcodefast mirrorgeneratepackage.json#exportsfromdist/, 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/trackingis 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./destinationsgroup 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_SUBPATHSnow deniesserver/**andadapters/**.
- The root
#617
b979371Thanks @thevuong! - Harden the package from a full audit — correctness, coverage, and a leaner public surface.isConsentReceiptInputnow validatesmethodandsubjectIdTypeagainst their enums, not justtypeof === "string"— the untrusted-body guard no longer narrows a bogus value to a closed union member.CookieAnonymousIdgainscurrent()— a non-minting read of the existing id (undefinedwhen none) so a consent receipt stamps the id the visitor already carries instead of a throwaway that never correlates for erasure.coarsenIprejects out-of-range IPv4 octets ("999.…") rather than storing a malformed coarse value.- Microsoft UET consent routes through the shared
toAdConsentStatead lever, so itsad_storagemapping can't drift from Meta/TikTok. - Dropped unused foreign type re-exports so each type has one home:
InitialConsentno longer re-exported fromadapters/tanstack-startorserver/initial-consent(import it fromcore/consent), and theAnonymousIdResponseCookieOptionsalias is gone —setAnonymousIdResponseCookietakesAnonymousIdCookieOptionsfromserver/anonymous-id-cookiedirectly. - Collapsed the
TrackedEventenvelope to a single interface — the unusedTrackedEventBaseandTrackEventnames are gone (TrackedEventkeeps thetype: "track"discriminant for a future additive union).
Also adds test coverage for the previously-untested
recordConsentReceiptFromRequestadapter path (no-store header, body-IP rejection, coarsened IP, PII-free ack).#617
b979371Thanks @thevuong! - Remove two leftover indirection layers in the server lane that no call site used.Breaking:
- The
@codefast/tracking/adapters/request-contextsubpath is gone. ItsRequestContextseam (agetHeader/setHeaderinterface) plus the parallel*FromContext/*OnContexthelpers existed only to back a hypothetical future./next/./remixadapter, but there was exactly one adapter and it duplicated every signature and doc comment.@codefast/tracking/adapters/tanstack-startnow callsgetRequestHeader/setResponseHeaderdirectly; 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. UseresolveRegionFromCountryCode(what the production path already uses viaresolveInitialConsent), orresolveInitialConsentFromRequestfor the full per-request resolution.
- The
#606
5a4ff42Thanks @thevuong! - AddscreateDurableReceiptStore({ backend })— a durableReceiptStoreover an injectedReceiptStoreBackend(a minimal id-keyedget/putprimitive). 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.putMUST be idempotent-by-id so the append-only guarantee holds atomically under retries/concurrency (e.g. KV set-if-absent, PostgresINSERT … ON CONFLICT DO NOTHING) — the frame delegates rather than doing a racy get-then-put. Pair it with a real backend in production, wherecreateInMemoryReceiptStoreis not a lawful store on its own.#604
7bb4be7Thanks @thevuong! -createClientTrackernow accepts an optionalisExemptionAllowedgate, consulted before anexemptdestination 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 — returningfalsewithholds 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
6cdd930Thanks @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 andsubmitGa4UserDeletion({ …, accessToken, transport? })POSTs it — targeting the current Analytics Admin APIproperties.submitUserDeletion(the legacy v3userDeletionRequests:upsertwas sunset with Universal Analytics), keyed by a flatclientId. Authorization is the caller's: pass a bearer token for theanalytics.editscope; no OAuth or HTTP client is baked in. Server-only (@codefast/tracking/server).#617
b979371Thanks @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 thansetResponseHeader("set-cookie", …). The raw header call replaces any existingSet-Cookieon the response — it would clobber a session or framework cookie set on the same response — whereassetCookieappends. No behavior change to the emitted cookie (stillPath=/; Max-Age=1y; SameSite=Lax; Secure, notHttpOnly). - The connection IP for consent receipts is now read with
getRequestIP({ xForwardedFor: true })— the maintained, platform-aware path — instead of hand-parsingx-forwarded-for/x-real-ip.
Breaking (
@codefast/tracking/server/anonymous-id-cookie): the string buildersbuildAnonymousIdSetCookie/buildClearAnonymousIdSetCookieare replaced byresolveAnonymousIdCookie/resolveClearAnonymousIdCookie, which return the validated name/value plus cookie attributes for a frameworksetCookie/deleteCookiecall.isValidAnonymousIdis unchanged; the cookie-name guard is now the exportedassertValidAnonymousIdCookieName.- The anonymous-id cookie is now written with
#607
749dd16Thanks @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):sendMeasurementProtocolEventsPOSTs{ client_id, events, consent? }to the credentialed/mp/collectendpoint through an injectedtransport(defaultfetch), so no HTTP client or credentials are baked in;extractGaClientIdderives the GA4client_idfrom a_gacookie;toMeasurementProtocolConsentmaps the packageConsentDecisionto the MPconsentsignals. Server-only (@codefast/tracking/server). The caller owns the credentials and the consent gate.#602
5ca04e2Thanks @thevuong! -createClientTrackernow accepts an optionalonDeliveryErrorhook, 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 theDeliveryErrorContexttype from@codefast/tracking/client.#610
fdb8d7cThanks @thevuong! - Adds the per-destination erasure capability for DSR withdrawal (spec-data-subject-rights §3, DSR-V2/V4):Destinationgains an optionalonErasure(id)hook, andcreateClientTrackerreturns anerase(id)method that invokes each destination'sonErasureonce on withdrawal, swallowing failures so a destination can never break the flow. The referencecreateMetaDestinationimplementsonErasureas cookie-clear (via an injectedclearCookiesseam) 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
a5527c3Thanks @thevuong! - Extends the ad-destination frame with two more reference vendors (spec-destinations §5), consuming the same{ ads, analytics }decision:createMicrosoftUetDestination/toMicrosoftUetConsentmapadsto UET's only enforced signal,ad_storage(UET has noanalytics_storage);createTiktokDestination/toTiktokConsentmapadsto TikTok's singlelimited_data_useboolean (not Meta'sdataProcessingOptionsstructure). Both take an injectedtransport(no pixel id, tag id, or network client baked in) and implementonErasureas cookie-clear + stop-send, since neither exposes a per-visitor deletion API.consentRequirementstays"required".
1.0.0-canary.6
Major Changes
#565
1e80096Thanks @thevuong! - RemovedefaultConsentExpressionfrom gtag/GTM consent bootstraps. Pass a literaldefaultConsent(strictest bake on shared HTML) and upgrade after hydration via the server-fn lane +updateGoogleConsent.#567
74c52acThanks @thevuong! - Collapse the consent "must match" contracts into oneConsentConfig, and addcreateConsentRuntime.Previously
storageKey,policyVersion, andrequestedCategorieshad to be hand-threaded — matching exactly — throughuseConsent,createIsAnalyticsAllowed, and the gtag consent bootstrap; one drifted string was a silent consent bug. Now:ConsentConfig+defineConsentConfig(root/core) — the one bag forstorageKey,policyVersion, andrequestedCategories. Isomorphic plain data: the same object is imported on both sides.createConsentRuntime(client) — derives the live client instances from the config: the sharedConsentStorage, the initial-consent store over your server lane,ensureInitialConsentResolved, and theisAnalyticsAllowedtracker 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 forrequestedCategoriesis gone — the config always states the requested purposes explicitly.createIsAnalyticsAllowed({ policyVersion, requestedCategories, ... })→createIsAnalyticsAllowed({ config, ... }).GtagConsentBootstrapOptions(and the<GtagConsentBootstrap />props):consentStorageKey+policyVersion→config.
#567
74c52acThanks @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/pageon the tracker and the envelope union,EventDefinition.owner+EventsOf(with no server side there is nothing to split),attachRouterPageTracking— page views belong to gtagconfig+ Enhanced Measurement and Vercel's native<Analytics />. - GTM: destination, bootstrap, loader.
- Unused gtag helpers:
setGoogleConsentDefault,setGoogleAdsDataRedaction,setGoogleUrlPassthrough,extractGa4ClientId/extractGa4SessionId.
Follow-on API changes:
ClientTrackeris nowtrack()only (clear()had nothing left to clear, soConsentWithdrawalHandlerOptions.clearTrackeris gone too); catalogs drop theownertag ({ schema }only);TrackedEventis thetrackenvelope alone, still discriminated ontypeso a future kind is additive.- Server-side tracking:
#567
74c52acThanks @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/coreand@codefast/tracking/core/*→@codefast/tracking(the root has always re-exported the whole isomorphic core surface).@codefast/tracking/client/*→@codefast/tracking/client; same pattern forserverandreact.- Google helpers →
@codefast/tracking/destinations.
One destination keeps a dedicated subpath on purpose:
@codefast/tracking/destinations/vercel-analytics— its top-level@vercel/analyticsimport 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
1e80096Thanks @thevuong! - Remove the deprecated edge-middleware cookie bootstrap path:- Drop
buildInitialConsentBootstrapScriptand the@codefast/tracking/destinations/initial-consent-bootstrapsubpath. - Resolve region consent via a server function (
resolveInitialConsent) plus a client snapshot instead — seeapps/uivisitor-consent.ts/resolve-visitor-consent.ts.
- Drop
#565
1e80096Thanks @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.props→properties(and thetrack/pagemethod parameters). Segment-style name;Propsis reserved for React components. - Analytics gate:
isTrackingAllowed→isAnalyticsAllowedonClientTrackerOptionsandUseConsentResult(the gate reads theanalyticscategory only).createIsTrackingAllowed→createIsAnalyticsAllowed; subpath./client/is-tracking-allowed→./client/is-analytics-allowed. - Options naming (drop
Create*/Build*filler):CreateIsAnalyticsAllowedOptions→IsAnalyticsAllowedOptions;CreateConsentWithdrawalHandlerOptions→ConsentWithdrawalHandlerOptions;BuildInitialConsentOptions→InitialConsentOptions;BuildAnonymousIdSetCookieOptions→AnonymousIdSetCookieOptions.ClientLifecycleOptionsstays (product-named, no verb prefix). - Prompt scope option:
categories→requestedCategoriesonUseConsentOptions,IsAnalyticsAllowedOptions, andInitialConsentOptions. Destination.consent/VercelAnalyticsDestinationOptions.consent→consentRequirement("exempt" | "required").googleConsentBootstrapPreamble→buildGoogleConsentBootstrapPreamble;dataLayerOf→ensureDataLayer.UseConsentResult.save→saveDecision.- Demote package-private deep exports: remove
./client/queue,./destinations/shared, and./destinations/google-consentfrompackage.json#exports.EventQueue/EventQueueOptionsleave the./clientbarrel;EventQueueStoragestays (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)andresolveEffectiveConsent(storage, policyVersion, requestedCategories, mode, hasGlobalPrivacyControlSignal)→resolveEffectiveConsent(options); newResolveDefaultConsentOptions/ResolveEffectiveConsentOptionstypes.readStoredDecision(storage, policyVersion)keeps its positional args. - GA4 Measurement Protocol debug flag:
Ga4MeasurementProtocolDestinationOptions.debug→debugMode(matchesdebugModeon the gtag options). - Server
groupsignature:ServerTracker.group(groupId, traits, context)→group(groupId, context, traits?)sotraitsis truly optional instead of a forcedundefined. - Remove
assertNeverfrom the public exports (generic, non-tracking helper; internal-only now). readCookieValueis now also exported from the root entry (previously only on./core).
- Envelope field:
Minor Changes
#563
bad015cThanks @thevuong! - Switch gtag/GTM bootstraps to Google Consent Mode advanced:buildGtagConsentBootstrapScript/buildGtmConsentBootstrapScriptalways set Consent Mode v2default(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/loadGtmScriptremain 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
bad015cThanks @thevuong! - Tighten the package's API contracts and framework independence, found in an architecture audit.Breaking:
ClientTrackerOptions.anonymousIdis now() => stringonly — the plain-stringform 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.sendnow always returnsPromise<void>— the previousPromise<void> | voidlet sync and async destinations disagree on contract. Mark a synchronoussendasyncso a thrown error rejects the returned promise instead of throwing synchronously.
Also:
createVercelAnalyticsDestinationnow importstrackfrom the framework-agnostic@vercel/analyticsinstead 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 thedefaultcase of everyswitch (event.type)across the GA4/Vercel destinations — extendingTrackedEventwith 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 ofexport *, matching theclient/server/destinations/reactsubpaths, which were already explicit. useConsent's returned object and itssave/denyAll/grantAllcallbacks 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
bad015cThanks @thevuong! - Gate the client tracker on consent and keep destinations from leaking pre-consent or duplicate data.createClientTrackergainsisTrackingAllowed?: () => boolean, consulted per event — while it returnsfalsenothing is sent or queued, so a mid-session consent change applies immediately.anonymousIdalso accepts a() => stringresolver, invoked only when an event is actually allowed to send, so apps can defer minting an identifier cookie until consent exists.storageis now optional; without it the queue lives in memory only instead of persisting tolocalStorage. The Vercel destination takes an options object ({ name?, trackPageViews? }replaces the positional name), drops$page_viewedunlesstrackPageViewsis 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 globalgtagtype gains theconfigandjscommand signatures so apps can queue them directly, e.g. when loading gtag.js on demand for basic Consent Mode.#563
bad015cThanks @thevuong! - Consent internals cleanup. The gtag and GTM bootstraps now share one preamble builder (googleConsentBootstrapPreamble— generated output unchanged),toGoogleConsentParamsderives from the signal map instead of hand-writing it, the runtime consent setters (updateGoogleConsent,setGoogleConsentDefault,setGoogleAdsDataRedaction,setGoogleUrlPassthrough) accept adataLayerName,VercelAnalyticsDestinationOptionsis exported from the destinations barrel, and the cookie-string parser is shared asreadCookieValue(@codefast/tracking/core/cookie). Removed never-consumed exports:GOOGLE_CONSENT_SIGNAL_CATEGORIES,GoogleConsentSignal,isGa4EventName,consentDecisionShapeCheckExpression,consentSignalAssignmentsExpression.#565
1e80096Thanks @thevuong! -resolveInitialConsent(néebuildInitialConsent) 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 passedcountryCode: undefinedand relied on the opt-out fallback; callers that guarded the missing case themselves can drop the guard.#563
bad015cThanks @thevuong! - Improve gtag/GTM loader DX without changing consent-first loading:ensureGtag/loadGtagScript/buildGtagConsentBootstrapScriptaccept optionaldataLayerName,nonce(CSP), anddebugMode.- Add
createGoogleTagManagerDestination,buildGtmConsentBootstrapScript, andloadGtmScriptfor consent-gated GTM. - Add
<GtagConsentBootstrap />— a framework-agnostic inline script wrapper for the pre-hydration bootstrap.
#563
bad015cThanks @thevuong! - Rename thehttp-destinationmodule tohttp, matching thecreate<X>Destinationfile-naming convention used by every other destination. Breaking for deep imports only:@codefast/tracking/destinations/http-destinationis now@codefast/tracking/destinations/http; imports from the@codefast/tracking/destinationsbarrel are unaffected.#563
bad015cThanks @thevuong! - Add three helpers that pull common consent/tracking wiring out of consumer apps and into the package:resolveEffectiveConsent(storage, policyVersion, categories, mode, hasGpc)andreadStoredDecision(storage, policyVersion)(@codefast/tracking/core) — the same "stored decision, else region default" ruleuseConsentapplies internally, now exposed so a non-React gate (e.g. a tracker'sisTrackingAllowedoption) 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-indocument.cookie-backed anonymous idgetOrCreate/clearpair for apps that don't need a custom identity strategy.
None of these change existing exports' behavior;
useConsentis refactored internally to usereadStoredDecisionbut its output is unchanged.#563
bad015cThanks @thevuong! - RenameUseConsentResult.needsPrompttoisPromptNeeded— a boolean should read as an assertion, matchingisTrackingAllowedon the same result (Swift API Design Guidelines pass).Breaking: consumers of
useConsent/ConsentBannerreadingneedsPromptmust switch toisPromptNeeded.#563
bad015cThanks @thevuong! - AddderiveEventId(requestId, discriminant)(@codefast/tracking/core) and wire it intocreateServerTracker: passrequestIdonServerTrackerContextto make a server-owned event'seventIddeterministic instead of random. Retrying the same request with the sametrack/group/aliascall now reproduces the sameeventId, 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. OmittingrequestIdkeeps the existing random behavior, so this is additive and non-breaking.#566
ffd777cThanks @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) —EventDefinitionis typed onStandardSchemaV1, validation runs through the newassertValidEventProperties, andzodis no longer a dependency (@standard-schema/specis the only one). buildInitialConsent→resolveInitialConsent;ServerTrackContext→ServerTrackerContext.attachClientLifecycledropsflushIntervalMs— 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 keepalivefetchfallback) and flush-on-reconnect.- The
./destinationsbarrel is browser-lane only: importcreateVercelAnalyticsDestinationfrom./destinations/vercel-analytics(its top-level@vercel/analyticsimport made the optional peer mandatory for barrel consumers) andcreateGa4MeasurementProtocolDestinationfrom its own subpath. ./server,./server/*,./tanstack-start, and./destinations/ga4-measurement-protocolare server-only by contract: on TanStack Start, deny them in the client environment viaimportProtection.client.specifiers(README shows the config) so a leak fails the build with a traced violation instead of silently shipping server code or the GA4apiSecret.
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 newisInitialConsentguard, fail-closed-but-retryable errors, retry on tab-visible.createServerTracker:waitUntilhands delivery (and its retry ladder) to the platform's post-response scheduler;withContextbinds per-request identity once.- Beacon receive half:
relayTrackedEvents+createTrackedEventIngestHandler(Request → Response) validate client envelopes, re-stamp server-read identity, and keep clienteventIds 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,keepalivethreading for unload flushes,Destination.sendBatch(implemented bycreateHttpDestination— one POST per queue flush). CookieAnonymousId.refresh()prolongs an existing id without ever minting;STRICTEST_INITIAL_CONSENT,CONSENT_REGIONS,isConsentRegionare exported from core; the localStorage consent storage caches parsed records behind a raw-string compare; client-only react modules ship"use client".
- Event catalogs now accept any Standard Schema library (zod,
#563
bad015cThanks @thevuong! - Add server-persisted anonymous id ("client mints, server persists").createServerPersistedAnonymousId(client) keeps the lazy, post-consent minting ofcreateCookieAnonymousIdand 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: alwaysSecure; 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
bad015cThanks @thevuong! - Add a TanStack Start wiring kit so consumer apps no longer hand-roll consent/bootstrap glue:resolveInitialConsent(néebuildInitialConsent) + exportedEU_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 Modeupdate+ optional gtag load, including cross-tab / privacy-page decisions.
InitialConsentis exported from@codefast/tracking/@codefast/tracking/core. None of these change existing export behavior.#563
bad015cThanks @thevuong! - Discriminate event envelopes bytypeand add a consent-exempt destination lane.TrackedEventis now a union discriminated ontype: "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 exhaustiveswitch(identifycarriestraits,groupcarriesgroupId/traits,aliascarriespreviousId), and the app-chosennameonly exists ontrack/page. This changes the wire format seen by HTTP destinations and the queue storage; stale queue records without atypeare dropped silently.Destinationgainsconsent?: "exempt" | "required"— while the tracker's consent gate is closed, exempt immediate destinations keep receivingtrack/pageevents stripped ofanonymousId/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
1e80096Thanks @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
bad015cThanks @thevuong! - Fix bugs found in review of the consent-gated tracking pipeline, and dedupe the GA4/Vercel destinations.createClientTracker'sidentify()no longer commitsuserIdto the tracker's closure while consent is denied — a deniedidentifycould otherwise leak itsuserIdonto a later, allowedtrack/page/groupcall.createServerTracker's per-requesteventIdderivation now factors inuserId, so twoalias()calls with the samepreviousIdbut different merge targets in one request no longer collide oneventId.createLocalStorageQueueStorage.load()now drops pre-migration/malformed queue records via a newisTrackedEventguard (exported from@codefast/tracking/core), instead of relying on each destination'sswitchto silently no-op on an unrecognized shape.Also: the GA4 (
gtag/Measurement Protocol) and Vercel Analytics destinations now share one prop-flattening helper and onegroup→join_groupmapping (@codefast/tracking/destinations's internalshared.ts) instead of three near-duplicate implementations;buildGtagConsentBootstrapScript's pre-hydration Consent Mode signal mapping is generated from the same tabletoGoogleConsentParamsuses instead of a hand-duplicated literal; and the package'sensureGtaggtag.js stub helper, plus a newloadGtagScript(options)(loads gtag.js on demand, idempotent), are now exported so apps don't have to reimplement on-demand script loading themselves.#565
1e80096Thanks @thevuong! - GuardcreateLocalStorageConsentStoragewithisConsentRecordso malformed localStorage JSON cannot be treated as a valid consent record.#565
1e80096Thanks @thevuong! - SkipclearOnServerwhen 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
08f10fbThanks @thevuong! - RebuildConsentBanneras composable compound parts (ConsentBannerTitle/Description/Actions/Accept/Reject/Customize/Preferences/Category/Save) — the root owns visibility (needsPrompt, overridable viaopenfor a "Cookie settings" reopen) and the preferences-layer state, action parts wire their own clicks and compose the consumer'sonClick, so any markup including a design system's button styles slots in viaclassName. The monolithicmessage/acceptLabel/categoriesprops are gone. An optional plain-CSS default theme ships at@codefast/tracking/css/consent.css— data-slot selectors,--consent-*custom properties withlight-dark()fallbacks, zero Tailwind dependency.
0.5.0-canary.4
Minor Changes
079b8dfThanks @thevuong! - Rebuild the consent layer onuseSyncExternalStoreand exposedata-slotstyling hooks on the consent UI.useConsenttreats the storedConsentRecordas 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 olderpolicyVersionis ignored so bumping the version re-prompts as documented.- Breaking:
ConsentStoragegains a requiredsubscribe(listener)method — custom implementations must notify on changes.createLocalStorageConsentStorageimplements it (same-tab saves plus the cross-tabstorageevent) and now degrades a blockedlocalStorage(private mode/quota) to a session-scoped in-memory record instead of re-prompting in a loop. - Breaking:
ConsentBannerrenders 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'sComponentPropsand exposedata-slotattributes (consent-message,consent-actions,consent-action,consent-toggle) for Tailwind**:data-[slot=...]styling.
41951dfThanks @thevuong! - Expose the stored decision fromuseConsentand ignore tampered consent records.UseConsentResultgainsdecision— the stored decision under the current policy version,undefineduntil 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 booleanisTrackingAllowedcannot distinguish.useConsentnow counts only a well-formeddecision("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
createLocalStorageConsentStoragepersists the record as plainJSON.stringify(ConsentRecord)— a stable contract, so inline scripts can read the decision synchronously before any tag fires.
079b8dfThanks @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:$identify→gtag('set', { user_id }),$group→ the recommendedjoin_groupevent (group_idparam), and other invalid names are warned about and dropped instead of being sent to nowhere. $page_viewedis 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 withtrackPageViews: trueafter disabling both.setGoogleConsentDefault/updateGoogleConsentnow grantanalytics_storageonly; thead_*Consent Mode v2 categories stay denied unless the newincludeAdsoption 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.
- GA4 rejects
079b8dfThanks @thevuong! - Correlate GA4 Measurement Protocol hits with gtag.js's own identifiers.GA4 joins hits on gtag's client ID (the
_gacookie), 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. NewextractGa4ClientId/extractGa4SessionIdhelpers read gtag's_ga/_ga_<stream>request cookies (both GS1 and GS2 formats) so the destination can echo them via the newclientId/sessionIdoptions. Events now also carryengagement_time_msec,session_id, andtimestamp_micros— without them GA4 accepts the hit but leaves it out of realtime and session-scoped reports, and retried events drift to receipt time.$groupmaps tojoin_group;$aliasis dropped (GA4 merges identities viauser_id).079b8dfThanks @thevuong! - Deliver events to SDK-backed destinations at track time instead of through the batching queue.Destinationgains an optionaldelivery: "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 theflushWithBeaconpath unchanged.2ebb0c0Thanks @thevuong! - Make consent per-category, mirroring Google Consent Mode v2.ConsentDecisionis now{ ads: boolean, analytics: boolean }instead of a single"granted" | "denied"flag,useConsenttakes thecategoriesthe app's prompt asks about (grantAll/denyAll/savereplacegrant/deny), andConsentBannergains a per-category preferences layer plus aReactNodemessage for the privacy-policy link. The GA4 helpers map the decision onto the v2 signals (adsdrivesad_storage/ad_user_data/ad_personalization), takewait_for_update/region, and gainsetGoogleAdsDataRedaction/setGoogleUrlPassthrough; the destination-sideincludeAdsoverride is gone — the visitor's decision carries ads consent.resolveDefaultConsentreplacesshouldTrackByDefaultand honors GPC as an ads-only opt-out. Previously stored string decisions fail shape validation and re-prompt, no policy-version bump needed.