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.
The context
The client already had a third-party live-chat script installed on their site, and that's where the idea came from: "can we see who's on the site and on which page, without using that service?" Two constraints made the request less trivial than usual: the site hadn't been migrated to my usual stack yet — it was still running on WordPress, on different hosting — and there was no intention of introducing Firebase on the WordPress side.
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.
Step 1 — understanding what a live-chat widget actually does
Before building anything, it was worth looking at what the already-installed script actually did. The tag pasted into the site was just a small loader: it creates a hidden iframe, loads the widget's real "brain" inside it from the provider's servers, which then connects via websocket to their backend to stream presence, current page and events in real time.
The interesting part — "see who's on the site and on which page" — isn't in the public script: it all lives server-side at the provider, behind authentication, a proprietary dashboard and a subscription. There was nothing to "detach" from that service: it's client code tied to someone else's backend by design. But the pattern itself — a script tag hooking into an external backend — is exactly what's needed to build the same feature independently, and it fits well with Firebase, which has a native presence mechanism built for precisely this.
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.
Step 2 — live presence with Firebase Realtime Database
Firebase Realtime Database has a native presence pattern based on onDisconnect(): it registers a write or delete operation server-side to run automatically whenever the client's connection drops, no matter how — tab closed, network lost, browser crash — without the client having to do anything at that moment.
// 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.
One detail worth noting: onDisconnect() must be registered before writing the "online" data, not after — otherwise there's a window where the client shows as online in the database but the server hasn't yet received instructions on what to do if it disconnects abruptly during that same window.
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:
Step 3 — a tag on a site that isn't yours
The site stayed on WordPress, on different hosting, without touching the CMS's PHP or database. Just like the original live-chat widget, a single script tag pasted into the theme's header or footer (or via a plugin like "Insert Headers and Footers") loads a JavaScript file hosted elsewhere, which connects to a Firebase project completely external to the WordPress site:
<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.
The tracker generates an anonymous session id (localStorage), signs in anonymously via Firebase Authentication, and writes presence, current page, referrer/UTM and user agent — with onDisconnect() for automatic cleanup. The WordPress site's domain doesn't need to be added anywhere on the Firebase side: Firebase Authentication's "Authorized domains" list only affects OAuth redirect/popup flows, while signInAnonymously() works from any origin.
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.
Step 4 — the rules: who reads, who writes
With anonymous authentication already in use elsewhere in the Firebase project, what remained was correctly isolating the new sitePresence node: anonymous visitors must be able to write only their own session node, never read anyone else's; only the admin account should be able to read the full list.
{
"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.
Step 5 — persistent history and geolocation
Live presence answers "who's here now", but it isn't enough for statistics: data under sitePresence disappears on disconnect, by design. A durable history needs a different place — Firestore, with a dedicated collection written by a Netlify Function instead of the client, so the IP can also be geolocated server-side without exposing any key in the browser.
An honest note on geolocation: no free IP-geolocation service returns the exact Italian province — that's too fine-grained a detail for a technique that's mostly accurate at city/region level, sometimes only at ISP level. City and region are the best available without a dedicated paid service; province-level accuracy for major cities would need an extra lookup table.
// 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:
Obstacle #1 — the immutable cache hiding every update
After the first deploy, live presence worked — the client could see themselves navigating in real time — but Firestore history stayed at zero sessions. The main suspect: .js files served by Netlify carried Cache-Control: public, max-age=31536000, immutable by project convention, meant for internal assets versioned with ?v=. But the tag on the WordPress site pointed to a version-less URL — so on every tracker update, visitors kept downloading the old version for a full year, with no practical way to force a refresh short of asking the client to manually edit the tag every time.
The fix wasn't manually bumping a ?v= on every deploy — that would have meant a WordPress edit for every single change — but giving that specific file a much shorter cache policy, so it updates itself:
[[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.
The general /*.js rule (one year, immutable) stays intact for all internally-versioned assets; only the externally-exposed tracker gets a five-minute cache. From that point on, every file update reaches visitors within minutes, with no need to ever touch the WordPress tag again.
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:
Obstacle #2 — the CORS error that was actually a crash on the 204 response
With caching fixed, live presence kept working but history stayed empty. The browser showed an unambiguous error:
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:
A direct test of the function via curl with a POST request, bypassing the browser, consistently returned 200 OK — which seemed to confirm a CORS domain-whitelist issue. But that test wasn't actually replicating what a browser does: before a cross-origin POST with Content-Type: application/json, the browser always issues a preflight OPTIONS request, which curl -X POST skips entirely. The "passing" test wasn't exercising the path that was actually broken.
Repeating the test with a real OPTIONS preflight, and checking the function's invocation logs on Netlify, revealed the real cause — an exception thrown before a response could even be built:
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.
The cause: the OPTIONS response was built with status 204 and a body set to an empty string ('') instead of null. The Fetch standard treats "no content" statuses — 204, 205, 304 — as incompatible with any body, even an empty one; Node.js, via the undici library that implements Response, enforces this strictly and throws in the constructor itself, before the response is even sent.
Hence the false lead: the function was crashing internally on every OPTIONS request, so Netlify returned a generic error with no headers. The browser, finding no Access-Control-Allow-Origin in the response, reported — correctly from its own point of view, but misleadingly about the cause — a CORS block. A server crash disguised as a domain-configuration problem.
// 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.
Obstacle #3 — missing data because it was only written on the first event
With history finally populated, two more symptoms of the same underlying bug showed up at different times: the "Today / 7 days / 30 days" filters in the admin panel returned zero results even with real sessions already logged, and some sessions showed "Unknown" city/region despite geolocation working for others.
In both cases the cause was identical: a field — the first-seen timestamp and the geolocation data, respectively — was computed and written only on the start event (a session's first page load), never on subsequent update events. Sessions whose first event reaching the server was already an update — typical of visitors with a still-valid session in localStorage from before a deploy — stayed permanently without that field: date filters use it to include or exclude sessions, so they excluded all of them, while geolocation stayed empty forever.
The fix was the same in both cases: recompute and rewrite the field on every event, not just on start. An idempotent operation — the exact same value for the whole session lifetime — so there's no risk of overwriting a correct value, with the added benefit that already-incomplete sessions self-correct on their next event, with no manual intervention needed on old data.
| 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
- 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.
- 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.
-
Un test con
curl -X POSTnon 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. -
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. - 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.
What I'm taking away
- Analyzing a third-party widget before replacing it saves time. Realizing the public tag is just a loader tied to a proprietary backend immediately clarified that the goal was to rebuild an equivalent backend, not "detach" functionality from a script that doesn't contain any.
- A browser CORS error doesn't always mean a domain-whitelist problem. If the server crashes before building a response, the browser sees no CORS headers and reports a block — indistinguishable, client-side, from a misconfiguration. Server-side invocation logs are the source of truth, not the browser console.
-
A
curl -X POSTtest doesn't verify a browser-facing endpoint. Cross-origin requests with JSON go through an OPTIONS preflight first, which a plain curl POST skips entirely; a "passing" test can hide the real breaking point. -
"No content" HTTP statuses require an explicitly
nullbody. An empty string isn't equivalent to no body for 204/205/304: Node.js/undici enforces this strictly and throws in the response constructor itself. - Computing a value only on the "first" event assumes the server always sees that first event. Sessions already existing on a client can reach the server with an intermediate event as their actual first contact; recomputing derived fields on every event, idempotently, avoids permanent data gaps.
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.
Frequently asked questions
How can you see who's online on a site without a third-party widget like Tidio?
A lightweight script that periodically writes the current page and an anonymous session id to a realtime database, plus a mechanism that removes the data on disconnect. Firebase Realtime Database offers this natively with onDisconnect(). An admin panel with a realtime listener shows the updated list, no third-party service needed.
Can you use Firebase on a site that isn't built with Firebase, like WordPress?
Yes, it's the same pattern as live-chat widgets: a script tag in the site's header or footer hooking into a Firebase project hosted elsewhere. The original site isn't touched at all — the logic lives entirely in the external JS file and the connected Firebase backend.
Why can a browser CORS error actually be hiding a server crash?
The browser first runs an OPTIONS preflight. If the code handling it errors out before responding, the server returns a generic error with no headers — and the browser, seeing no Access-Control-Allow-Origin, reports a CORS block. The real cause is in the invocation logs, not the CORS config.
Why must a 204 HTTP response body be null instead of an empty string?
The Fetch standard treats "no content" statuses — 204, 205, 304 — as incompatible with any body, even empty. Node.js, via undici, enforces this strictly and throws in the response constructor if you pass '' instead of null.
Why can saving a value only on a session's first event cause missing data?
If the client already had a locally saved session before a change shipped, the first event the server receives may be an intermediate update rather than the real opening event. If a field is written only on that opening event, those sessions stay without it forever. Recomputing it on every event, idempotently, avoids the problem.