Internals — @avacy/cmp-web
Non è API pubblica. Questa pagina documenta componenti e meccanismi interni di
@avacy/cmp-web— non è la superficie che un integratore chiama, è come funziona sotto Tier 1/Tier 2. Materiale per il team, punto di partenza per decidere insieme al PO cosa (se qualcosa) promuovere a pubblico.
Bridge interno: core ⇄ store Stencil ⇄ eventi pubblici
@avacy/core non sa nulla di Stencil. Tre moduli fanno da ponte:
store-sync.ts(initStoreSync) — un listener wildcard suCoreEventscopia ogni signal del core nello store reattivo Stencil (state). È idempotente per costruzione (installedflag):init()rigira su ognireload(), ma il listener è agganciato a un emitter singleton — ri-agganciarlo per ogni reload perderebbe un handler per volta, causando scritture doppie sullo store a lungo andare (non visibile con un solo reload).public-events.ts(setupPublicEventBridges) — osservaCoreEvents+ transizioni dello store e ri-emette comeCustomEventpubblici sudocument, prefissatiavacy:(ready,banner-shown,banner-hidden,cpc-opened,cpc-closed,consent-saved,consent-loaded,consent-updated,consent-changed,language-changed). I call site non emettono mai direttamente — aggiornano stato, questo modulo osserva e ri-emette, così lo schema pubblico non può divergere. Gli eventi del consenso prendono il nome dal motivo della scrittura (meta.cause), da una sola tabella (CONSENT_EVENT_BY_CAUSE). Il ponte del consenso si aggancia prima del load (setupConsentEventBridge), cosìconsent-loadedarriva anche al primo boot.store-listeners.ts(initStoreListeners) — la direzione opposta: un solo listener su 3 chiavi dello store Stencil pilota side-effect UI.config→ ri-applica il theming (applyTheme(), altrimenti una custom property della config precedente resterebbe appiccicata dopo unreload());consent(quando diventa dato) → smonta il banner, emetteavacy:banner-hidden { reason: 'consent' }da QUI (non dapublic-events.ts— è l'unico posto che conosce ilreasongiusto) e monta lo shield (a meno dishowShield: false);cpcOpen→ mount lazy diavacy-cpcal primotrue(gli eventiavacy:cpc-opened/cpc-closedrestano invece apublic-events.ts, che osserva la stessa chiave per un motivo diverso).
init e reload: sorgenti di config
init(override?, options?) (init.ts) è l'orchestratore del boot; window.Avacy.init è la stessa funzione.
- Installa
window.Avacyse manca (ensureAvacyApiInstalled), poi esce subito seavacy-banneroavacy-shieldsono già nel DOM: il DOM è la fonte di verità dell'idempotenza. optionsèInitOptions=CoreInitOptions+remote.remoteè ilbanner.jsonche il loader v3 ha scaricato;addons({ expected, failed }) passa intatto al core per il gate degli add-on. Il secondo parametro non è API pubblica: lo usa il loader.- Fonde le sorgenti con
mergeConfigLayers, dalla più debole:remote,#avacy-config(readInlineConfig),window.avacyFilters(come{ filters }), il flag di debug (getDebugOverride, come{ loggers }),override. Poi imponecmpVersion(CMP_VERSION) ecore.autoLoad: false. - Ponte del consenso (
setupConsentEventBridge),initCore(merged, coreOptions), ambiente e tema,triggerLoad(), lingua da<html lang>seautoLanguage, mount di banner o shield, gli altri ponti,avacy:ready.
remote viene salvato in lastRemote a ogni init(). reloadOrchestrator (cioè window.Avacy.reload()) emette banner-hidden { reason: 'destroy' }, ferma l'osservatore di <html lang>, smonta banner/shield/CPC e richiama init(undefined, { remote: lastRemote }): rilegge le sorgenti locali, riusa il banner.json del primo boot senza riscaricarlo, perde override e non ripassa addons (al reload il core non aspetta nessun add-on: sono già registrati).
L'auto-init gating su core.autoInit (shouldAutoInit + appInit) è il globalScript di Stencil: vale solo per l'output dist (playground, bundler). Le build in builds/ chiamano init() da sé.
Componenti — avacy-banner, avacy-cpc, avacy-shield
avacy-banner — focus trap
Il banner è un dialog di consenso bloccante: handleKeyDown intrappola Tab/Shift+Tab dentro il set di elementi focusabili del proprio shadow root (calcolato da getFocusableElements(), filtrando elementi con getClientRects().length === 0). Su Tab dall'ultimo elemento torna al primo; su Shift+Tab dal primo va all'ultimo. Escape non è collegato di proposito: chiudere il gate non deve implicare una decisione di (non-)consenso.
avacy-banner — guardia contro il doppio consenso
consentInFlight è una guardia per componente, non per singolo handler: dal primo click a quando il consenso è scritto e archiviato passa una finestra non istantanea (il core può dover scaricare la GVL prima di costruire la TC String). Un secondo click in quella finestra — sia sullo stesso bottone, sia su un bottone diverso (Accetta poi Rifiuta in rapida successione) — viene ignorato, non solo il duplicato sullo stesso handler. Nell'archivio consensi il doppione non è innocuo: entrambe le richieste partono senza uuid (nessuna delle due ha ancora la risposta dell'altra), la SaaS ne conia due, la regola primo-vince ne scarta una e resta una riga orfana. Si è scelto di ignorare il click extra invece di disabilitare il bottone (finestra di decimi di secondo, un bottone che sfarfalla è peggio del nulla) — nessun @State, quindi nessun re-render per questo.
avacy-cpc — virtualizzazione della lista fornitori
Il componente più grande del pacchetto (>1700 righe). Per vendor-list realistiche (centinaia di fornitori, es. RAI) monta per intero solo un budget iniziale di pannelli — calcolato da computeVirtualInitialCount() (funzione pura, avacy-cpc.virtualization.ts, deliberatamente estratta dal componente Stencil per restare testabile senza runtime Stencil) come ceil(viewportHeight / placeholderHeight) + buffer, con fallback statici se viewport o altezza placeholder non sono ancora misurabili. Il resto della lista resta come placeholder finché non entra nel viewport (via IntersectionObserver), poi si converte in contenuto reale. Uno scroll-spy separato (altro IntersectionObserver) sincronizza il tab attivo nella sidebar con la sezione effettivamente visibile.
avacy-shield — icona ricolorabile e tooltip
L'icona custom (ui.branding.shieldIconUrl) segue due strade secondo l'estensione: un URL .svg viene scaricato e il markup iniettato inline, così diventa ricolorabile dal tema (se usa var(--avacy-shield-primary-bg, …)) senza bisogno di un nuovo fetch a ogni cambio tema; qualunque altra estensione resta un <img>, non ricolorabile. Il tooltip (quando showShieldTooltip) si chiude con Escape, che riporta anche il focus sul bottone icona (handleWrapKeyDown).
Add-on consent-archive — archivio consensi
Non vive più in cmp-web: è l'add-on post packages/addons/consent-archive (AVA-221), acceso da una voce di addons[] (vedi Add-on › consent-archive). Lo carica solo il loader v3, quindi non c'è nei bundle che non ci passano (rai, cmp-saas-preview). Legge tutto da host: l'intento dal payload di onConsent, le decisioni prese prima del suo arrivo da host.history (con l'ora del click), l'id da host.consent.
L'optin è l'intent del payload, dichiarato al call site e non derivato dallo stato. public-events.ts osserva lo stato e ri-emette; qui succede il contrario di proposito: optin è quale bottone l'utente ha visto e premuto, un'informazione che lo stato del consenso da solo non porta. Tre bottoni distinti del first layer producono lo stesso RejectAll (Rifiuta tutto, la X, Chiudi senza scegliere) — a valle sono indistinguibili; il coalescing di on-consent.ts riclassifica un "Salva" con tutti i toggle accesi come AcceptAll, che manderebbe consent-all dove la mappatura di prodotto dice save-preferences. I componenti lo passano a triggerConsent come intent; l'add-on lo legge da lì.
Id di correlazione dentro il consenso (decisione del 12/10/2026, supera la chiave separata del PBI 30): avacy_consent.meta.consentId, un solo dato da raccontare nelle policy. L'uuid arriva nella risposta al POST, cioè DOPO che il consenso è già stato scritto: la seconda scrittura la fa il core, non l'add-on. host.consent.update({ consentId }, { revision }) passa dal servizio ConsentRecord del core: una coda in memoria per scheda, rilettura del record dallo store (mai una fotografia, il difetto del legacy), controllo di meta.revision, data copiato come lo restituisce lo store. Se il record è cambiato la patch salta (stale) e l'add-on rilegge una volta; primo-vince. La patch non è una scelta: niente onConsent, parte avacy:consent-updated. Una scelta nuova riporta l'id. Se lo store non tiene il record l'id resta in memoria per la pagina. La chiave ${store.consentKey}_id delle release precedenti viene migrata nel record al boot e poi cancellata.
Guardia anti-race (idEpoch): un contatore catturato quando la richiesta parte e ricontrollato quando la risposta arriva. Se nel frattempo l'id è stato azzerato (un reload() con config che invalida il consenso, o un bump di policyVersion), la risposta appartiene a un'epoca finita e viene scartata — altrimenti una risposta lenta potrebbe resuscitare un id già morto, e per la regola primo-vince l'id del consenso nuovo verrebbe scartato al posto suo: tutte le decisioni successive resterebbero correlate al consenso sbagliato.
Assente in anteprima: cmp-saas-preview non passa dal loader, quindi l'add-on non c'è e un click esplorativo nell'editor non entra nell'archivio del cliente.
Il payload — cosa manda l'add-on, cosa aggiunge il backend
Quello che l'add-on invia è deliberatamente minimo (vedi sopra: consent_type, optin, uuid?). Il record finale che costituisce la prova legale del consenso — persistito lato SaaS (SolutionConsentController::store, tabella consents) — è più ricco perché il backend compila lui stesso i campi che il client non può/deve fornire:
| Campo | Fonte | Note |
|---|---|---|
consent_type, optin, uuid | client (add-on) | uuid lato server: se il client non lo manda (prima chiamata), il backend ne genera uno nuovo — non resta mai vuoto nel record finale |
webspace_id | URL della request | scoping multi-tenant, non un campo del body |
ip_address | $request->ip() lato server | mai inviato dal client — dedotto dalla connessione HTTP stessa, non falsificabile lato browser |
domain | header Origin, fallback Referer | stesso motivo: dedotto server-side, non un campo che cmp-web scrive |
versions | WebSpace->privacyPolicy / cookiePolicy al momento della request | non un numero di versione — un array [{slug: 'privacy_policy'|'cookie_policy', link: <URL CDN alla esatta versione HTML della policy>, date}]. È il pezzo che rende il record una prova legale vera: non "l'utente ha accettato la policy versione 3", ma un link diretto al testo esatto che era pubblicato in quel momento. |
consent_features, consent_data, identifier, source, html_form | — | mai popolati per consent_type: 'banner' — sono campi condivisi con un flusso diverso (consent_type: 'form', form di raccolta consenso su pagina, non il banner), obbligatori solo lì (required_if:consent_type,form in StoreConsentRequest). Un record da banner li ha sempre vuoti. |
Lo stesso endpoint (StoreConsentRequest) serve entrambi i consent_type — banner (cmp-web) e form — con regole di validazione condizionali diverse per ciascuno. L'optin grezzo del banner (consent-all/reject-all/save-preferences/close-banner) viene salvato così com'è, non rimappato: il controller ha una tabella di mapping verso un enum più corto (accepted/rejected/partially_accepted, condiviso col flusso form) dichiarata nel codice ma mai usata — verificato, zero riferimenti fuori dalla propria dichiarazione in tutto services/api. Codice vestigiale, non un comportamento attivo.
Resilienza: fire-and-forget dichiarato. Il core non aspetta i listener degli add-on e ne logga gli errori, quindi l'archivio non può rallentare né rompere il salvataggio del consenso. Timeout 15s ampio di proposito (non è protezione UX, è solo il guinzaglio della richiesta — abortire presto farebbe perdere l'uuid nella risposta). keepalive: true invece di navigator.sendBeacon: serve la risposta per leggere l'uuid, sendBeacon non la dà. Nessun retry, come il legacy — la consegna è best-effort dichiarato, mai un blocco per il salvataggio del consenso vero e proprio.
Accessibilità — meccanismi non esposti via config
Oltre al focus trap del banner:
announce()(helpers/a11y.ts) — una singola live region condivisa (role="status",aria-live="polite",aria-atomic="true"), appesa adocument.bodyfuori da qualunque shadow root apposta perché sopravviva allo smontaggio del componente che l'ha invocata (es. CPC che si chiude dopo un bulk select conclosePreferencesOnBulkSelect: true). Il testo si azzera e si riscrive al frame successivo, così messaggi identici consecutivi vengono ri-annunciati invece di essere ignorati come "già uguale".- Navigazione da tastiera nella tab-bar della CPC (
avacy-tabs.tsx,handleKeyDown) — frecce destra/giù vanno al tab successivo, sinistra/su al precedente, con wrap ai capi; il tab raggiunto prende il focus e viene attivato (click()). Coperta dal test E2Etests/e2e/cpc-sidebar-tabs-keyboard.spec.ts(77997a8b3). Home/End non sono gestiti.
Ambiente di esecuzione
helpers/environment.ts espone 4 segnali, non risolti insieme da un solo punto: applyCmpEnvironment() (chiamata da init.ts a boot) ne usa solo uno — imposta/rimuove data-cmp-env="iframe" su <html> in base a isCmpEmbeddedInIframe(), che pilota il layout compatto del banner. Gli altri tre sono funzioni indipendenti, lette dove serve, non da questo attributo:
isInIframe()— rilevazione grezza (window.self !== window.top,truese l'accesso awindow.topsolleva per un parent cross-origin — fallback conservativo: se non riesco a verificare, assumo di essere embedded).isCmpEmbeddedInIframe()— come sopra, con UN override:window.__AVACY_FORCE_STANDARD_LAYOUT__. Deliberatamente un flag suwindow(scrivibile da qualunque script della pagina) perché la posta in gioco è solo estetica — usato dalla buildcmp-saas-previewper mostrare il layout standard anche dentro il proprio iframe di anteprima dell'editor SaaS.isNativeShell()— non build-time, non window-based: leggestate.config.store.type === 'rai-native-webview'.truequando il CMP gira dentro la WebView nativa RAI (lettura/scrittura del consenso passa dal bridge nativo, non da cookie/localStorage) — pilota superfici che devono restituire il controllo all'app host invece di navigare nella pagina (es. il controllo che dismissa la WebView quando la preference center si riapre dopo un consenso già dato).