Il contesto

Il cliente aveva già installato sul proprio sito uno script di live-chat di terze parti, e da lì era nata l'idea: "possiamo vedere chi è sul sito e in che pagina, ma senza usare quel servizio?". Due vincoli rendevano la richiesta meno banale del solito: il sito non era ancora migrato sullo stack che uso di solito — girava ancora su WordPress, su un hosting diverso — e non c'era nessuna intenzione di introdurre Firebase lato WordPress.

Passo 1 — capire cosa fa davvero un widget di live-chat

Prima di costruire qualcosa, valeva la pena guardare cosa faceva esattamente lo script già installato. Il tag incollato nel sito era solo un piccolo loader: crea un iframe nascosto, carica al suo interno il vero "cervello" del widget ospitato sui server del provider, che poi si connette via websocket al loro backend per trasmettere presenza, pagina visitata ed eventi in tempo reale.

La parte interessante — "vedere chi è sul sito e in che pagina" — non è nello script pubblico: è tutta lato server del provider, dietro autenticazione, dashboard proprietaria e abbonamento. Non c'era niente da "staccare" da quel servizio: è client legato al backend altrui by design. Ma il pattern stesso — un tag script che si aggancia a un backend esterno — è esattamente ciò che serve per costruire la stessa funzionalità in autonomia, e si sposa bene con Firebase, che ha un meccanismo di presenza nativo pensato apposta per questo.

Passo 2 — presenza live con Firebase Realtime Database

Firebase Realtime Database ha un pattern nativo per la presenza, basato su onDisconnect(): si registra lato server un'operazione di scrittura o cancellazione da eseguire automaticamente quando la connessione del client cade, in qualunque modo — tab chiuso, rete persa, crash del browser — senza che il client debba fare nulla in quel momento.

pattern di presenza, sessione anonima
// Ogni visitatore ottiene un id di sessione anonimo (localStorage)
// e scrive sotto sitePresence/{sessionId}
const ref = db.ref(`sitePresence/${sessionId}`);

// Cosa scrivere quando la connessione cade, registrato SUBITO
ref.onDisconnect().remove();

// Solo dopo, il dato "sono online" con pagina corrente
ref.set({
  page: location.pathname,
  referrer: document.referrer,
  userAgent: navigator.userAgent,
  lastSeen: firebase.database.ServerValue.TIMESTAMP
});

Un dettaglio che vale la pena notare: onDisconnect() va registrato prima di scrivere il dato "online", non dopo — altrimenti c'è una finestra in cui il client risulta online sul database ma il server non ha ancora ricevuto istruzioni su cosa fare in caso di disconnessione improvvisa in quella stessa finestra.

Passo 3 — un tag su un sito che non è il tuo

Il sito restava su WordPress, su un hosting diverso, senza toccare PHP o database del CMS. Esattamente come il widget di live-chat originale, un solo tag script incollato nell'header o nel footer del tema (o via plugin tipo "Insert Headers and Footers") carica un file JavaScript ospitato altrove, che si connette a un progetto Firebase completamente esterno al sito WordPress:

tag sul sito WordPress
<script src="https://[pannello-cliente].it/site-tracker.js" async></script>

Il tracker genera un id di sessione anonimo (localStorage), fa login anonimo su Firebase Authentication, e scrive presenza, pagina corrente, referrer/UTM e user agent — con onDisconnect() per la pulizia automatica. Il dominio del sito WordPress non deve essere aggiunto da nessuna parte lato Firebase: la lista "Authorized domains" di Firebase Authentication riguarda solo i flussi OAuth con redirect o popup, mentre signInAnonymously() funziona da qualunque origine.

Passo 4 — le regole: chi legge, chi scrive

Con l'autenticazione anonima già in uso nel progetto Firebase per altre funzionalità, restava da isolare correttamente il nuovo nodo sitePresence: i visitatori anonimi devono poter scrivere solo il proprio nodo di sessione, mai leggere quelli degli altri; solo l'account amministrativo deve poter leggere l'intera lista.

Realtime Database rules — nodo sitePresence
{
  "rules": {
    "sitePresence": {
      ".read": "auth != null && auth.uid === '<ADMIN_UID>'",
      "$sid": { ".write": "auth != null" }
    }
  }
}
💡

Nota sulla granularità: con questa regola una sessione anonima può in teoria sovrascrivere il nodo di un'altra sessione anonima, perché l'id è generato lato client e non verificato contro auth.uid. Per dati di sola presenza/analytics — nessun dato sensibile in gioco — è un compromesso accettabile; per blindarlo del tutto basta salvare l'auth.uid nel record e verificarlo nella regola di scrittura.

Passo 5 — storico persistente e geolocalizzazione

La presenza live risponde a "chi c'è ora", ma non basta per fare statistiche: i dati sotto sitePresence spariscono alla disconnessione, by design. Per uno storico che non si perde serve un posto diverso — Firestore, con una collection dedicata scritta da una Netlify Function invece che dal client, così da poter anche geolocalizzare l'IP lato server senza esporre nessuna chiave nel browser.

Un dettaglio onesto sulla geolocalizzazione: nessun servizio gratuito di IP-geolocation restituisce la provincia italiana esatta — è un dato troppo fine per una tecnica precisa a livello di città/regione nella maggior parte dei casi, a volte solo di ISP. Città e regione sono il meglio disponibile senza un servizio dedicato a pagamento; per le sigle provincia delle città principali servirebbe una tabella di mappatura aggiuntiva.

Firestore security rules — collection siteVisits
// Scritta SOLO dalla Netlify Function tramite service account,
// che bypassa comunque queste regole: write:false è difesa in profondità.
match /siteVisits/{doc} {
  allow read: if request.auth != null;
  allow write: if false;
}

Ostacolo #1 — la cache immutabile che nasconde ogni aggiornamento

Dopo il primo deploy, la presenza live funzionava — il cliente si vedeva navigare in tempo reale — ma lo storico su Firestore restava a zero sessioni. Il sospetto principale: i file .js serviti da Netlify avevano Cache-Control: public, max-age=31536000, immutable per convenzione del progetto, pensata per gli asset interni versionati con ?v=. Il tag sul sito WordPress puntava però a un URL senza versione — quindi, ad ogni aggiornamento del tracker, i visitatori continuavano a scaricare la versione vecchia per un anno intero, senza nessun modo pratico di forzare l'aggiornamento se non chiedere al cliente di modificare a mano il tag ogni volta.

La soluzione non era bumpare manualmente un ?v= a ogni deploy — sarebbe stato un intervento su WordPress per ogni singola modifica — ma dare a quel file specifico una politica di cache molto più corta, così che si aggiorni da solo:

netlify.toml — eccezione per site-tracker.js
[[headers]]
  for = "/site-tracker.js"
  [headers.values]
    Cache-Control = "public, max-age=300, must-revalidate"

La regola generale /*.js (un anno, immutable) resta intatta per tutti gli asset interni versionati; solo il tracker esposto verso l'esterno ha una cache di cinque minuti. Da quel momento, ogni aggiornamento del file arriva ai visitatori entro breve, senza mai più dover toccare il tag su WordPress.

Ostacolo #2 — l'errore CORS che in realtà era un crash sulla risposta 204

Risolta la cache, la presenza live continuava a funzionare ma lo storico restava vuoto. Il browser mostrava un errore inequivocabile:

console del browser
Access to fetch at '.../site-visit-log' from origin '...' has been blocked
by CORS policy: Response to preflight request doesn't pass access control
check: No 'Access-Control-Allow-Origin' header is present on the requested
resource.

Un test diretto della function via curl con una richiesta POST, bypassando il browser, rispondeva regolarmente 200 OK — il che sembrava confermare un problema di whitelist dei domini nel CORS. Ma quel test non replicava davvero cosa fa un browser: prima di una POST cross-origin con Content-Type: application/json, il browser esegue sempre una richiesta preflight OPTIONS, che curl -X POST salta del tutto. Il test "positivo" non stava testando il percorso che si rompeva davvero.

Ripetendo il test con un vero preflight OPTIONS, e controllando i log delle invocazioni della function su Netlify, è emersa la causa reale — un'eccezione lanciata prima ancora che una risposta potesse essere costruita:

log Netlify Functions
TypeError: Response constructor: Invalid response status code 204
    at initializeResponse (node:internal/deps/undici/undici)
    at new Response (node:internal/deps/undici/undici)
    at Object.handler (site-visit-log.mjs:88)

La causa: la risposta all'OPTIONS veniva costruita con status 204 e un body impostato a stringa vuota ('') invece di null. Lo standard Fetch considera gli status "senza contenuto" — 204, 205, 304 — incompatibili con qualunque body, anche vuoto; Node.js, tramite la libreria undici che implementa Response, applica questa regola in modo rigoroso e lancia un'eccezione già nel costruttore, prima ancora che la risposta venga inviata.

Da qui la falsa pista: la function crashava internamente su ogni richiesta OPTIONS, quindi Netlify restituiva un errore generico senza header. Il browser, non trovando Access-Control-Allow-Origin nella risposta, riportava — correttamente dal suo punto di vista, ma fuorviante sulla causa — un blocco CORS. Un crash del server travestito da problema di configurazione dominio.

prima / dopo
// Prima — crasha: body vuoto non ammesso su status 204
return new Response('', { status: 204, headers: corsHeaders });

// Dopo — body esplicitamente null
return new Response(null, { status: 204, headers: corsHeaders });
⚠️

Lezione operativa: un test con curl -X POST non prova che una chiamata cross-origin dal browser funzioni. Per verificare davvero un endpoint pensato per il browser, il preflight va replicato esplicitamente: curl -X OPTIONS ... -H "Origin: ..." -H "Access-Control-Request-Method: POST".

Ostacolo #3 — dati mancanti perché scritti solo sul primo evento

Con lo storico finalmente popolato, sono comparsi altri due sintomi dello stesso bug di fondo, in momenti diversi: i filtri "Oggi / 7 giorni / 30 giorni" nel pannello di amministrazione restituivano zero risultati anche con sessioni reali già registrate, e alcune sessioni mostravano città/regione "Sconosciuta" nonostante la geolocalizzazione funzionasse per altre.

In entrambi i casi la causa era identica: un campo — rispettivamente il timestamp di prima visita e i dati di geolocalizzazione — veniva calcolato e scritto solo sull'evento start (il primo caricamento pagina di una sessione), mai sugli eventi update successivi. Le sessioni il cui primo evento intercettato dal server era già un update — tipico di chi aveva una sessione ancora valida in localStorage da prima di un deploy — restavano permanentemente senza quel campo: i filtri per data lo usano per includere/escludere le sessioni, quindi le escludevano tutte, mentre la geolocalizzazione restava vuota per sempre.

La correzione è stata la stessa in entrambi i casi: ricalcolare e riscrivere il campo su ogni evento, non solo su start. Operazione idempotente — stesso identico valore per tutta la durata della sessione — quindi senza rischio di sovrascrivere un dato corretto, e con il vantaggio che le sessioni già scritte in modo incompleto si autocorreggono da sole al prossimo evento, senza bisogno di nessun intervento manuale sui dati vecchi.

Sintomo Causa
Storico a zero sessioni Cache immutabile 1 anno sul tracker: i visitatori scaricavano ancora la versione vecchia dello script
"No Access-Control-Allow-Origin" nel browser Crash sul costruttore Response: body stringa vuota su status 204 invece di null
Filtri Oggi/7gg/30gg vuoti, geo "Sconosciuta" Campi calcolati solo sull'evento start, mai su update

Cosa mi porto a casa

  1. Analizzare un widget di terze parti prima di sostituirlo fa risparmiare tempo. Capire che il tag pubblico è solo un loader collegato a un backend proprietario ha chiarito subito che l'obiettivo era ricostruire un backend equivalente, non "staccare" funzionalità da uno script che non le contiene.
  2. Un errore CORS nel browser non implica sempre un problema di whitelist dei domini. Se il server crasha prima di costruire una risposta, il browser non vede nessun header CORS e riporta un blocco — indistinguibile, dal lato client, da una configurazione errata. I log delle invocazioni lato server sono la fonte di verità, non la console del browser.
  3. Un test con curl -X POST non verifica un endpoint pensato per il browser. Le richieste cross-origin con JSON passano prima da un preflight OPTIONS che un semplice POST via curl salta del tutto; un test "verde" può nascondere il vero punto di rottura.
  4. Gli status HTTP "senza contenuto" richiedono un body esplicitamente null. Una stringa vuota non è equivalente a nessun body per 204/205/304: Node.js/undici lo valida in modo rigoroso e lancia un'eccezione nel costruttore stesso della risposta.
  5. Calcolare un dato solo sul "primo" evento presume che il server veda sempre quel primo evento. Sessioni già esistenti su un client possono presentarsi al server con un evento intermedio come primo contatto reale; ricalcolare i campi derivati su ogni evento, in modo idempotente, evita buchi di dati permanenti.

Domande frequenti

Come si può vedere chi è online su un sito senza usare un widget di terze parti come Tidio?

Uno script leggero che scrive periodicamente su un database realtime la pagina corrente e un id di sessione anonimo, più un meccanismo che rimuova il dato alla disconnessione. Firebase Realtime Database lo offre nativamente con onDisconnect(). Un pannello admin con un listener realtime mostra la lista aggiornata, senza servizi di terze parti.

È possibile usare Firebase su un sito che non è costruito con Firebase, tipo WordPress?

Sì, è lo stesso pattern dei widget di live-chat: un tag script nell'header o footer del sito che si aggancia a un progetto Firebase ospitato altrove. Il sito originale non viene toccato in nessuna sua parte — la logica vive tutta nel file JS esterno e nel backend Firebase collegato.

Perché un errore CORS nel browser può in realtà nascondere un crash del server?

Il browser esegue prima un preflight OPTIONS. Se il codice che lo gestisce va in errore prima di rispondere, il server restituisce un errore generico senza header — e il browser, non vedendo Access-Control-Allow-Origin, riporta un blocco CORS. La vera causa va cercata nei log delle invocazioni, non nella configurazione CORS.

Perché il body di una risposta HTTP 204 deve essere null e non una stringa vuota?

Lo standard Fetch considera gli status "senza contenuto" — 204, 205, 304 — incompatibili con qualunque body, anche vuoto. Node.js, tramite undici, applica questa regola in modo rigoroso e lancia un'eccezione nel costruttore della risposta se si passa '' invece di null.

Perché salvare un dato solo sul primo evento di una sessione può causare valori mancanti?

Se il client aveva già una sessione salvata localmente prima di una modifica, il primo evento che il server riceve può essere un aggiornamento intermedio invece del vero evento di apertura. Se un campo viene scritto solo su quell'evento di apertura, quelle sessioni restano senza quel valore per sempre. Ricalcolarlo su ogni evento, in modo idempotente, evita il problema.