Skip to main content

Internals — @avacy/core

Non è API pubblica. Questa pagina documenta come funziona @avacy/core sotto la superficie di Tier 1/Tier 2 — bootstrap, storage, framework di consenso. È materiale per il team, punto di partenza per decidere insieme al PO cosa (se qualcosa) promuovere a pubblico.

Bootstrap e ciclo di vita interno​

Core.init(config, options) crea un CoreContext nuovo (anche a ogni reload(): BaseProvider.clearInstances() svuota prima la cache dei singleton) e inizializza i provider in un ordine fisso: config → logger → [gate degli add-on] → store → language → framework. Ogni provider è un singleton risolto via initProvider(name), che chiama uno dei cinque metodi privati (initConfig, initLoggers, initStore, initLanguage, initFrameworks). L'ordine non è arbitrario: il framework provider ha bisogno della config già caricata e dello store già pronto per decodificare un consenso persistito, e il gate degli add-on sta prima dello store perché un add-on che porta un proprio store provider deve poterlo registrare prima che initStore legga store.type (vedi Sistema add-on).

La config passa per ConfigProvider.loadConfig() subito dopo initProvider('config'): un errore della config remota (remoteConfigUrl/remoteConfigName) viene registrato lì e loggato solo dopo, quando il logger esiste.

Quattro registri, tutti a livello di modulo:

  • LOGGER_PROVIDER_REGISTRY (core-context.ts) — console e event, chiusi: si scelgono con loggers, non si estendono.
  • STORE_PROVIDER_REGISTRY (core-context.ts) — cookie/local-storage, estendibile via registerStoreProvider(type, ProviderClass) (Tier 2). Un tipo non registrato fa lanciare initStore.
  • FRAMEWORK_PROVIDER_REGISTRY (core-context.ts) — i 4 framework built-in (tcf/gcm/custom/acm), chiuso.
  • ADDON_REGISTRY (addons.ts) — le definizioni degli add-on per nome, riempito da registerAddon. Sopravvive a reload() (è fuori dal CoreContext).

Merge della config​

Due livelli distinti:

  • mergeConfigLayers(...layers) (merge-config.ts) — usato da cmp-web per fondere le sorgenti in pagina prima di chiamare initCore. Merge profondo sui plain object, qualunque altro valore (array compresi) sostituisce; layer null/undefined saltati, input mai mutati, chiavi __proto__/constructor/prototype ignorate.
  • ConfigProvider.loadFullConfig() (providers/config.ts) — DEFAULT_CONFIG, poi l'eventuale config remota scaricata dal core, poi la config ricevuta da init: spread piatto di primo livello, tranne core (chiave per chiave) e ui (mergeUi, un namespace alla volta). Poi normalizza:
    • normalizeFrameworks — 'tcf' e { tcf: {…} } diventano oggetti con i default di DEFAULT_FRAMEWORK_CONFIG sotto; voci dello stesso framework si fondono; voci malformate scartate con un warning (mai un throw: il JSON remoto non ha schema).
    • normalizeAddons — stessa forma, senza default (ogni add-on fonde i suoi in setup), deduplica per nome mantenendo la prima posizione, scarta voci non stringa/non oggetto a una chiave.
    • assetPath completato con l'origine se relativo; consentExpiryDays e policyVersion forzati a interi positivi (fallback al default); core.addonsTimeoutMs ≥ 0; il vincolo showRejectAllButton/closeWithoutConsents (normalizeUiBehavior).

Lettura sincrona dello stato: getContextData()​

Core.getContextData() (esposto anche come funzione standalone in index.ts) ritorna lo snapshot corrente del contesto — core, config, storage, language, consent, customization, glossary, stacks, standardTexts, più decodedConsent/acmAddtlConsent opzionali (ContextData, context.types.ts) — in modo sincrono, oppure null se il boot non è ancora completo. Serve a sincronizzare una UI che monta dopo che gli eventi sono già stati emessi — tipicamente il banner al primo load, quando lo store non ha ancora un consenso e il flusso normale a eventi non basta perché non c'è nessun listener ancora agganciato quando l'evento parte.

triggerChangeLanguage non forza il download dei framework differiti​

Un cambio lingua rietichetta solo le slice di customization già costruite (Object.keys(privacySettings)), mai tutti i framework configurati. Il motivo: un cambio lingua non deve mai far scattare il fetch di un framework differito (GVL TCF, lista ACM) prima che una superficie lo richieda davvero — altrimenti cambiare lingua alla prima visita, prima di aver mai aperto il second layer, scaricherebbe dati che l'utente potrebbe non vedere mai. Il chiamante può forzare un set esplicito di framework via options.frameworks (es. ['custom'] al boot, quando ancora nulla è costruito).

Listener del ciclo di vita del consenso​

Quattro listener, uno per evento (onLoad al boot, onConsent a ogni scrittura, onCustomization quando si costruisce/ricostruisce la vista second-layer, onChangeLanguage a un cambio lingua). Orchestrati da Core, mai chiamati direttamente dall'esterno. Dopo la catena del core, ogni evento finisce nella storia degli add-on e viene passato ai loro listener (emitToAddons): fuori dall'await del core, così un add-on che lancia non interrompe la scrittura del consenso.

onLoad — bootstrap del consenso​

Al boot: carica la lingua, poi legge il consenso grezzo dallo store. Ogni caso "nessun consenso usabile" passa da un solo punto (noUsableConsent): i framework vengono comunque notificati con un decoded consent vuoto (notifyConsentLoaded) e lo stato diventa { ready: true, valid: false }. La notifica serve perché TCF deve transitare cmpStatus da loading a loaded e rispondere subito a __tcfapi('getTCData', …) con eventStatus: 'cmpuishown' — senza, un partner che fa gate del proprio bootstrap su quell'evento (l'esempio storico: slot pubblicitari smartclip) vede il CMP "in caricamento" per sempre.

I motivi di rifiuto (StoredConsentRejection, consent.utils.ts):

MotivoQuandoWarning nei log
(nessun valore)readConsent() → nullno
unreadablemeta assente o JSON malformato (parseRawConsent torna metadata: null)sì
no-expirymeta.expiry non è un numero finito (anche NaN)sì
expiredmeta.expiry nel passatono
policy-version-supersededconfig.policyVersion > meta.policyVersion, oppure meta.policyVersion non finitono
foreign-cmpla TC String salvata ha un cmpId diverso da tcf.cmpId della configsì
not-service-specificla TC String salvata ha isServiceSpecific: falsesì

I primi cinque si decidono sui metadata (storedConsentRejection), gli ultimi due dopo il decode, solo se TCF è configurato (foreignConsentRejection): una TC String decodificabile non è per forza nostra. Il warning va al logger, non alla console: i log sono opt-in. expired e policy-version-superseded sono il sistema che funziona, gli altri indicano che qualcos'altro scrive sulla chiave.

Altrimenti: decodifica per framework (decodeAllConsent), notifica i framework, estrae le choices; consent.type viene da metadata.consentType (fallback 'Personalized' per consensi salvati senza type). Il timestamp dello stato è l'ora del caricamento (Date.now()), non quella in cui il consenso fu dato.

onConsent — scrittura del consenso​

  • CloseBanner esce subito: nessuna scrittura, nessun cambio di stato. Il payload arriva comunque agli add-on (emitToAddons sta fuori dal listener).
  • Il payload (OnConsentPayload) porta type, screen ('first'/'second'/'api', finisce in decoded.screen e da lì nel consentScreen della TC String) e un intent opzionale (quale bottone è stato premuto: consent-all, reject-all, save-preferences, close-banner). intent non entra nel consenso salvato: passa nel ChangeMeta e nella storia degli add-on, dove lo legge consent-archive.
  • Coalescenza Personalized → AcceptAll/RejectAll: solo i payload Personalized vengono ricontrollati contro un riferimento canonico (frameworkProvider.getConsent({type: 'AcceptAll'|'RejectAll', ...})) — se le choices finali combaciano esattamente, il type salvato viene promosso al canonico. Nessuna scorciatoia strutturale su AcceptAll ("tutti i flag true"): un AcceptAll legittimo produce comunque hasLegitimateInterest: false per purpose/vendor senza LI interattivo, quindi il confronto è sempre contro il riferimento vero, mai un check sui flag.
  • consentFilter (hook di config, adattamento a livello di build) gira PRIMA di encode/scrittura — riceve anche currentChoices (lo stato appena superato), così un filtro può preservare/derivare dal consenso che sta per essere sostituito.
  • La scadenza (decoded.expiry) si calcola da config.consentExpiryDays al momento della scrittura, non è fissa.
  • Scrive tramite store.writeConsent(serializeRawConsent(metadata, encodedMap)) — vedi formato sotto — e solo dopo pubblica consent nel contesto con ChangeMeta { type, intent, screen, source: 'listener' }; infine notifyConsentChanged (TCF aggiorna __tcfapi, GCM spinge gtag('consent', 'update')).

onCustomization — costruzione della vista second-layer​

Quattro slice si costruiscono in parallelo (Promise.all), indipendenti fra loro: choices (privacySettings), glossario, stack e testi standard (standardTexts).

  • Un rebuild scoped (payload.frameworks esplicito, es. ['custom'] al boot) fa merge sopra le slice già costruite — gli altri framework mantengono il proprio valore precedente. Un rebuild non-scoped sostituisce tutto.
  • Stack: null da getAllStacks significa "nessun framework in scope ha il concetto di stack" → la slice esistente resta intatta (un rebuild scoped su custom non deve azzerare gli stack che TCF aveva già risolto). Un array vuoto [] è invece una risposta vera ("nessuna whitelist configurata") e sovrascrive.
  • Testi standard: stesso contratto null vs [] degli stack.
  • pruneUnpresentableStacks gira prima che il renderer calcoli i covered-ids — l'ordine conta: uno stack si presenta solo se TUTTI i suoi membri sono sopravvissuti al filtro vendor, e la potatura deve avvenire prima o i membri di uno stack scartato spariscono invece di tornare voci individuali.

onChangeLanguage​

Il più semplice: languageProvider.setLanguage(lang) → mainFramework.onLanguageChange(lang) (se implementato) → aggiorna language.activeLanguage/words nel context. Nessuna logica di coalescenza o guardia — l'orchestrazione più delicata (non forzare il fetch di framework differiti) vive un livello sopra, in Core.triggerChangeLanguage (vedi sezione Bootstrap).

consent.utils.ts — formato di RawConsent​

RawConsent è documentato come "stringa opaca" per chi implementa IStoreProvider (Tier 2) — nella pratica è JSON, JSON.stringify({ meta: RawConsentMetadata, data: FrameworkRawConsentMap }):

{
"meta": { "expiry": 1234567890000, "policyVersion": 2, "cmpVersion": "3.0.0", "consentType": "AcceptAll" },
"data": { "tcf": "CPz...", "gcm": "...", "acm": "2~1.2.3~dv." }
}

parseRawConsent/serializeRawConsent fanno il round-trip; un JSON malformato in lettura ritorna { metadata: null, data: {} } invece di lanciare — un consenso corrotto nello storage si comporta come "nessun consenso", non come un errore di boot.

preferences-order.ts — ordine di default della nav​

DEFAULT_PREFERENCES_ORDER (usato quando ui.behavior.preferencesOrder è omesso): gcm::purpose, tcf::purpose, tcf::specialPurpose, tcf::feature, tcf::specialFeature, tcf::vendor, custom::vendor, acm::vendor, custom::customPurpose, glossary, retention.

applyPreferencesOrder è una funzione pura: gli id presenti in order vengono portati in testa (ordinati secondo la loro posizione nell'array), il resto resta nell'ordine relativo originale in coda. Id in order senza una voce nav a runtime sono ignorati silenziosamente — la visibilità resta guidata dai dati, non dalla config dell'ordine.

Il tipo di storage è una scelta statica, decisa una volta al boot da config.store.type ('cookie' | 'local-storage', estendibile con un tipo custom via registerStoreProvider). initStore() istanzia una singola classe e non la cambia mai più durante la sessione — non esiste un meccanismo che passi da uno storage all'altro a runtime.

⚠️ Nessun fallback automatico cookie → local-storage. CookieStoreProvider.writeConsent() scrive il cookie senza mai controllare la dimensione del valore, senza try/catch attorno alla scrittura. Se il consenso codificato (es. TCF + ACM combinati, con molte finalità/fornitori) supera il limite pratico di un cookie (circa 4KB, limite del browser, non di Avacy), il browser tronca o rifiuta silenziosamente la scrittura — nessun errore, nessun avviso, il consenso va perso senza segnalazione a nessun livello. Verificato anche nel legacy avacy_banner: lo stesso comportamento, nessun controllo di dimensione da nessuna parte, in nessuna delle due generazioni del CMP. Se questo è un requisito reale (segnalato da Fabio, 2026-09-03) è una feature da costruire da zero, non una configurazione da attivare.

LocalStorageStoreProvider, dal lato opposto, propaga un QuotaExceededError del browser come eccezione (non lo assorbe, non prova un fallback) — coerente col fatto che oggi lo storage è una scelta singola e non switchabile.

Framework di consenso — meccaniche interne​

TCF​

Stack IAB (tcf.stacks) — resolveStacks() applica una whitelist disgiunta, non una lista di preferenze: se due stack whitelistati condividono una finalità o una special feature già "reclamata" da uno stack precedente, il secondo viene scartato per intero (non tagliato alle sole finalità libere) e un warning riporta l'elenco delle finalità/feature contese. Le finalità dello stack scartato tornano a comparire come voci individuali. collectCoveredIds() calcola l'unione delle finalità/feature coperte dagli stack risolti, usata a valle per escluderle dalle liste individuali.

Allineamento lingua della GVL — getGvl({ alignLanguage: true }) mantiene una singola promise condivisa (gvlAlign) per allineare la libreria @iabtechlabtcf/core alla lingua attiva. Un codice ISO malformato (es. "cz" invece di "cs") fa fallire l'allineamento senza far rigettare la promise condivisa: tutti i chiamanti in attesa la vedono comunque risolversi, con un warning loggato e la GVL che resta nella sua ultima lingua valida (di default EN) invece di un banner bloccato. Un allineamento fallito non resta memoizzato — la chiamata successiva ritenta.

⚠️ Trappola della libreria sottostante (non un bug Avacy, ma da conoscere): GVL.LANGUAGE_CACHE di @iabtechlabtcf/core è una cache statica per-processo, chiave sulla sola lingua — due istanze GVL costruite da fixture/vendor-list diverse che si allineano alla stessa lingua, nello stesso processo, si contaminano a vicenda. GVL.emptyCache() (il pattern oggi in uso) pulisce solo il fetch-cache per versione, non questa cache linguistica — serve GVL.emptyLanguageCache(). Rilevante soprattutto per chi scrive test contro una GVL reale, non per la libreria in produzione (un solo processo browser, una sola vendor-list attiva).

Vendors ristretti (tcf.vendors) e rivendicazioni incrociate — ogni voce TCF (purpose, special purpose, feature, special feature) porta un count di fornitori che la dichiarano. Il conteggio lo costruisce il TCF stesso, in resolveEffectiveBases, sommando ai vendor GVL superstiti i fornitori custom che rivendicano la voce. Il collegamento fra framework è opzionale per costruzione:

  • getClaimedIds(framework) — lato di chi rivendica (oggi solo custom): quanti fornitori rivendicano ciascun purpose del framework chiesto e, solo per tcf, ciascuna special feature (nel file dei fornitori custom le special feature sono id IAB nudi).
  • useClaimedIds(resolve) — lato di chi è rivendicato (oggi solo tcf): riceve una funzione, risolta tardi perché la lista custom può arrivare dalla rete.
  • MainFrameworkProvider.linkClaimedIds() — l'unico punto che vede tutti i framework: collega ogni useClaimedIds ai getClaimedIds degli altri, sommando i conteggi. Gira una volta, al primo onInit/getConsent/getAllPrivacyChoices.
  • MainFrameworkProvider.dropUndeclared() — dopo la costruzione, toglie dalle choices TCF le voci rimaste a count 0.

Siccome la somma vive dentro il TCF, la leggono allo stesso modo l'interattività del toggle, "accetta tutto" e la codifica della TC String (fix 095df5282; prima la somma stava a valle, in applyCustomVendorPurposeOverride, oggi rimosso, e una voce rivendicata solo da un custom era visibile ma inerte).

Due modalità, decise da gcm.mapToPurpose (isMappingMode(): array non vuoto):

  • Standalone (default) — 7 purpose GCM propri (id 1–7 → ad_storage, ad_user_data, ad_personalization, analytics_storage, functionality_storage, personalization_storage, security_storage), mostrati nel CPC e codificati nella propria slice ("1:t,2:f,…", u = non espresso).
  • Mapping — il consenso GCM si deriva dalle scelte di altri framework ({ fw, purpose, gcmPurpose }, AND su array). Nessuna voce nel CPC (getPrivacyChoices → []), nessuna persistenza (encode → '', decode → null): si ricalcola sempre dagli altri framework.

In entrambe le modalità getConsent produce comunque le 7 choices GCM. Il provider spinge nel dataLayer (creando dataLayer e uno shim gtag se mancano): solo gtag('consent', 'update', …), in onConsentLoaded (se c'è qualcosa) e onConsentChanged. Il default lo manda il loader, variante gcm (#628): deve arrivare prima dei tag Google, e il core parte troppo tardi. Non ha storage né UI propri oltre a questo.

getConsentMap(choices) costruisce lo stesso oggetto a 7 chiavi dalle choices passate, undefined se GCM non è configurato. Lo espone getGcmConsentMap(choices) (index.ts → Core → MainFrameworkProvider), usato dal ponte degli eventi di cmp-web per avacy:consent-saved → detail.gcm. Le choices si passano invece di leggerle dallo stato perché il ponte scatta quando consent è già scritto ma decodedConsent non ancora.

remoteGVLName (sia in GcmFrameworkConfig sia a livello radice) è nel tipo ma nessuno lo legge.

ensureConfig() risolve la lista fornitori in ordine di priorità: config inline (acmProvidersConfig) → URL esplicito (acmProvidersUrl) → nome file + assetPath (acmProvidersConfigName, pattern legacy). Il fetch è deferred: non parte al boot, solo alla prima richiesta reale (apertura CPC, o una mutazione di consenso che deve codificare l'AC string). fetchConfig() normalizza il formato risposta, che può arrivare come array (mock interni) o come oggetto keyed-by-id (formato reale Google ATP/RAI, es. {"39": {id: "39", ...}}) — un id assente sia dal payload sia dalla chiave viene scartato per evitare un fornitore fantasma con id 0.

getVendorsCount() ha 3 fonti in ordine di autorevolezza: lista già caricata (se presente, autoritativa — è già stata intersecata con vendors configurato) → array vendors esplicito (locale, nessun fetch) → fetch della lista (solo quando vendors: 'all', l'unico caso in cui il conteggio esatto richiede la lista remota).

Custom (vendor list non-IAB)​

Stessa forma di ensureConfig/fetchConfig di ACM (config inline / URL / config-name+assetPath), applicata a una vendor list completamente custom (non TCF, non Google). Include un glossario opzionale (getGlossaryEntries) con titolo/descrizione localizzati per lingua, fallback a cascata: lingua richiesta → lingua di default → primo valore disponibile nell'oggetto localizzato.

Nota di prodotto: il core non fornisce contenuto di default per un glossario non-TCF — è tutto a carico della config del cliente. Come popolarlo in fase di wizard/migrazione resta una decisione di prodotto aperta.

Sistema add-on​

Il core sa avviare pezzi di codice che non stanno nel bundle. Non sa quali esistono: lo dice addons[] della config (schema aperto) e chi li registra.

Definizione (AddonDefinition<S>, addons.types.ts): { name, defaults?, setup(host) }. registerAddon(definition) (index.ts → Core.registerAddon) la mette in ADDON_REGISTRY (una registrazione malformata è scartata con un errore nei log, stesso nome = last-write-wins). Si può chiamare prima o dopo init: se il core sta già girando e l'add-on è in addons[], parte subito.

host (AddonHost<S>), costruito per ogni add-on a ogni init() (createAddonHost):

CampoCosa dà
settingsdefaults dell'add-on sotto la sua voce in addons[], merge a un livello
loggerIl logger del core con topic addons:<nome>
registerListener(event, fn)Iscrizione a onLoad / onConsent / onCustomization / onChangeLanguage. Rifiutata (con warning) se arriva da un init() superato o da un add-on fallito
storageCassetto chiave-valore get/set/remove con expiresAt opzionale (AddonStorage)
historyevents() (gli eventi già emessi, con at) e changes(key?) (i cambi di stato del contesto): un add-on arrivato tardi ricostruisce cosa si è perso. Vive nel CoreContext, quindi si azzera a ogni reload()

Gate e timeout. Core.init(config, options) riceve da chi lo chiama CoreInitOptions.addons = { expected, failed }: i nomi che il loader sta scaricando e un Set (vivo) di quelli falliti. Dopo config e logger, waitForExpectedAddons aspetta che ogni expected attivo in addons[] sia registrato o in failed, fino a config.core.addonsTimeoutMs (default DEFAULT_ADDONS_TIMEOUT_MS = 1500, in tutto), ricontrollando failed ogni 25 ms. Un expected non presente in addons[] non si aspetta (warning). Scaduto il tempo, logga chi manca e prosegue: chi arriva dopo parte quando si registra. Poi startAddons() avvia ogni add-on registrato e attivo; quelli non ancora registrati (tipicamente i post) partono al registerAddon.

Fallimenti. setup è chiamato fuori dall'await del core, con doppia guardia (throw sincrono e promise rigettata): un add-on che fallisce viene marcato failed, le sue iscrizioni rimosse, e il boot va avanti. Un secondo registerAddon con un nome già avviato vale dal prossimo init().

Storage degli add-on (addon-storage.ts). createAddonStorage aspetta che lo store del core esista (setup gira prima di initStore), poi delega a IStoreProvider.getAddonStorage() se il provider lo implementa (cookie: un cookie per chiave, path=/; local-storage: il valore alla chiave, la scadenza in una mappa a parte). Tiene sempre una copia in memoria, che è anche il fallback per un provider senza getAddonStorage o uno storage che rifiuta; nessun errore esce verso l'add-on.

Disclosure (divulgazione storage fornitori)​

fetchDisclosures(url) scarica e normalizza le disclosure di storage per fornitore (identificatore, tipo, durata massima, domini, finalità) — usato dal pannello disclosure nella CPC. Normalizza domain (singolare, legacy) o domains (plurale), ciascuno stringa singola o array, a un array uniforme. Degrada in modo pulito su qualunque fallimento (rete giù, risposta non-ok, JSON malformato, disclosures assente) a { ok: false, error: <chiave i18n> } — mai un'eccezione che risale al chiamante.

Logger​

Due implementazioni selezionabili via loggers: LoggerType[] (LoggerType = 'console' | 'event'): 'console' (passthrough diretto a console.info/warn/error), 'event' (DOMEventLoggerProvider — dispatcha un CustomEvent('avacy:log', { detail: { level, message, topic }, bubbles: true }) su document, un contratto pubblico de facto per chi integra e vuole intercettare i log senza aprire la console). Sopra queste, MainLoggerProvider è sempre istanziato (non selezionabile) — orchestra e smista ai subloggers attivi in loggers.