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):bootstrap()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,language-changed). I call site non emettono mai direttamente — aggiornano stato, questo modulo osserva e ri-emette, così lo schema pubblico non può divergere.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).
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.
Archivio consensi (Consent Solution)
helpers/consent-archive.ts (notifyConsentArchive) — implementazione di config.consentSolutionUrl (vedi Reference — config). Vive in cmp-web, non nel core: packages/core è condiviso con cmp-mobile, dove fetch/keepalive/le API di storage del browser non sono quelle giuste — la chiave di config sta nel core (è un concetto di prodotto), l'implementazione qui.
L'optin è dichiarato al call site, 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. Il parametro è obbligatorio proprio per questo — un punto di chiamata nuovo che se lo dimentica non compila.
Id di correlazione separato dal consenso stesso, non un campo dentro avacy_consent: l'uuid arriva nella risposta al POST, cioè DOPO che il consenso è già stato scritto, e infilarcelo dentro vorrebbe dire riscrivere il consenso una seconda volta — lo store del core non ha scrittura parziale. È il difetto vivo del legacy (che riscriveva il cookie da una fotografia presa prima dell'invio). Chiave separata ma non slegata: ${store.consentKey}_id — chi rinomina avacy_consent si ritrova comunque una chiave coerente, senza una seconda config da tenere allineata a mano. Storage per tecnologia (cookie/local-storage, undefined per storage non raggiungibili da qui come la shell nativa RAI — degrada a una copia in memoria).
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.
Sospesa in anteprima: isPreviewBuild() blocca la notifica — l'editor SaaS gira sulla config vera del cliente, un click esplorativo non deve entrare nel suo archivio reale (stesso meccanismo di Ambiente di esecuzione).
Il payload — cosa manda cmp-web, cosa aggiunge il backend
Quello che cmp-web 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 (cmp-web) | 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, funzione sincrona che ritorna void (non una Promise — nessun chiamante può mettere un await nel percorso di salvataggio del consenso per errore). 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.
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).
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/avacy-tab-item) — il click sui tab è pienamente funzionante; la navigazione da frecce (pattern WCAG tab-panel standard) non risulta implementata/verificata a oggi.
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.isPreviewBuild()— non è la stessa cosa del punto sopra, e non condivide il meccanismo di proposito. È una costante iniettata a build time da esbuild (__AVACY_IS_PREVIEW__, dead-code-eliminata in ogni bundle che non siacmp-saas-preview— non esiste proprio come identificatore altrove), NON un flagwindow. Serve a spegnere gli effetti collaterali verso l'esterno: senza questo gate, un click su "Accetta" nell'editor SaaS per guardare un'anteprima scriverebbe un consenso finto nell'archivio consensi reale di quel cliente. Il motivo per cui non è un flagwindowcome il precedente: se lo fosse, uno script terzo/XSS potrebbe disattivare l'archiviazione in silenzio, senza errori, col banner che continua a funzionare normalmente — si perderebbe solo la prova del consenso, che è l'intera ragione della feature. Per il layout il rischio è cosmetico e un flag debole va bene; qui no.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).