1L'idea in trenta secondi
Il server non parla mai direttamente col telefono. Non potrebbe.
Un telefono non ha un indirizzo stabile a cui bussare. Il Web Push risolve così: è il browser a chiedere a Google o ad Apple una casella postale per quel dispositivo, e a passartene l'indirizzo. Tu lo conservi e, quando hai qualcosa da dire, lo consegni a Google o ad Apple — che ci pensano loro a svegliare il telefono.
I passi 1–3 avvengono una volta, quando l'utente attiva le notifiche. I passi 4–5 ogni volta che c'è da dire qualcosa. Il server non parla mai direttamente col telefono: passa sempre dal servizio push.
La «casella» è un oggetto JSON — la subscription: un URL lungo più le due chiavi con cui il contenuto viene cifrato. La conservi tu.
Il contenuto viaggia cifrato con quelle chiavi: Google e Apple
non possono leggerlo. Non devi fare nulla, ci pensa web-push.
Non c'è un canale aperto. Il telefono non è connesso al tuo server. Dorme, e il sistema operativo lo sveglia quando arriva qualcosa.
2Le quattro rotte
Tutto il protocollo sta qui dentro.
| Rotta | Chi la chiama | Cosa passa |
|---|---|---|
GET /api/chiave-pubblica |
la pagina, all'avvio | ← { "chiave": "BAwo…" }la chiave pubblica VAPID, serve a subscribe() |
POST /api/iscrivi |
la pagina, dopo subscribe() |
→ { "iscrizione": {…} }la casella postale del dispositivo |
POST /api/disiscrivi |
la pagina, quando l'utente spegne | → { "endpoint": "https://…" } |
POST /api/invia |
te, o il tuo codice | → { "titolo": "…", "testo": "…" }richiede un token: fa squillare tutti |
/api/invia fa suonare ogni telefono iscritto. Se la
lasci aperta, chiunque conosca l'indirizzo può farlo. Nasce chiusa:
se il token non è configurato, risponde chiusa — non aperta. Le altre tre non
hanno bisogno di segreti, perché non fanno nulla di dannoso.
3Passo 0 — le chiavi
Si fa una volta sola, e poi non si tocca più.
npm install express web-push
node -e "const w=require('web-push');const k=w.generateVAPIDKeys();
console.log('VAPID_PUBLIC_KEY='+k.publicKey);
console.log('VAPID_PRIVATE_KEY='+k.privateKey)" > .env
Aggiungi a mano nel .env:
VAPID_SUBJECT=mailto:tua@email.it
TOKEN_INVIO=una-frase-lunga-a-caso
PORT=3020
Firma le notifiche a nome del tuo sito: chi ce l'ha può spedire a tutti i tuoi
iscritti. chmod 600, mai nei commit, mai nei log.
Rigenerare la coppia invalida tutte le iscrizioni esistenti, perché
i dispositivi sono legati alla vecchia chiave pubblica: ogni invio fallirebbe
con 403. Se la rigeneri, svuota anche l'archivio.
4Il server, per intero
Questo file funziona. Copialo, cambia il nome, sei a posto.
server.js
const fs = require('fs');
const path = require('path');
const express = require('express');
const webpush = require('web-push');
// Un .env letto a mano: aggiungere "dotenv" per venti righe di parsing non
// vale una dipendenza in piu' in un progetto che ne ha due.
for (const riga of fs.readFileSync('.env', 'utf8').split('\n')) {
const m = riga.match(/^\s*([A-Z_]+)\s*=\s*(.*)\s*$/);
if (m) process.env[m[1]] = m[2];
}
webpush.setVapidDetails(
process.env.VAPID_SUBJECT, // mailto: o https: VERI
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// ─── L'archivio: un file JSON ───────────────────────────────────────────
// Le iscrizioni di un'app piccola sono decine, non milioni. Un database qui
// aggiungerebbe un servizio da tenere vivo senza risolvere nulla.
const ARCHIVIO = './dati/iscrizioni.json';
function leggi() {
try { return JSON.parse(fs.readFileSync(ARCHIVIO, 'utf8')); }
catch (e) { return []; } // file assente al primo avvio: e' la normalita'
}
function scrivi(elenco) {
fs.mkdirSync(path.dirname(ARCHIVIO), { recursive: true });
// Scrittura atomica: un'interruzione a meta' lascerebbe un JSON troncato,
// e al riavvio si perderebbero TUTTE le iscrizioni invece di una.
fs.writeFileSync(ARCHIVIO + '.tmp', JSON.stringify(elenco, null, 2));
fs.renameSync(ARCHIVIO + '.tmp', ARCHIVIO);
}
// ─── L'applicazione ─────────────────────────────────────────────────────
const app = express();
app.use(express.json({ limit: '32kb' })); // i payload sono minuscoli
app.use(express.static('public'));
// Il service worker DEVE arrivare dalla radice: il suo raggio d'azione non
// puo' risalire sopra la cartella da cui e' stato scaricato.
app.get('/sw.js', (req, res) => {
res.set({
'Content-Type': 'application/javascript',
// Senza questo il browser serve per ore un worker vecchio, e una
// correzione sembra non avere alcun effetto.
'Cache-Control': 'no-cache',
'Service-Worker-Allowed': '/',
});
res.sendFile(path.resolve('sw.js'));
});
// ① La chiave PUBBLICA: e' fatta per essere distribuita. Nessun segreto.
app.get('/api/chiave-pubblica', (req, res) => {
res.json({ chiave: process.env.VAPID_PUBLIC_KEY });
});
// ② Iscrizione. L'endpoint e' la chiave: lo stesso telefono ripresenta lo
// stesso endpoint, quindi si AGGIORNA, non si duplica.
app.post('/api/iscrivi', (req, res) => {
const s = req.body?.iscrizione;
if (!s?.endpoint?.startsWith('https://') || !s.keys?.p256dh || !s.keys?.auth) {
return res.status(400).json({ errore: 'iscrizione incompleta' });
}
const elenco = leggi().filter((x) => x.endpoint !== s.endpoint);
elenco.push({ endpoint: s.endpoint, keys: s.keys, quando: new Date().toISOString() });
scrivi(elenco);
res.json({ ok: true, totale: elenco.length });
});
// ③ Disiscrizione.
app.post('/api/disiscrivi', (req, res) => {
scrivi(leggi().filter((x) => x.endpoint !== req.body?.endpoint));
res.json({ ok: true });
});
// ④ Invio a tutti. Fa squillare OGNI telefono iscritto: nasce chiusa.
app.post('/api/invia', async (req, res) => {
if (!process.env.TOKEN_INVIO ||
req.get('x-token-invio') !== process.env.TOKEN_INVIO) {
return res.status(401).json({ errore: 'token non valido' });
}
const carico = JSON.stringify({
titolo: String(req.body?.titolo || 'Notifica').slice(0, 100),
testo: String(req.body?.testo || '').slice(0, 300),
url: '/',
});
const elenco = leggi();
const vivi = [];
let ok = 0;
for (const v of elenco) {
try {
// TTL 24h: una notifica piu' vecchia di un giorno non interessa
// piu' a nessuno, e il servizio push puo' smettere di riprovare.
await webpush.sendNotification(v, carico, { TTL: 86400 });
vivi.push(v); ok++;
} catch (e) {
// 404/410 = app disinstallata o permesso revocato. Va CANCELLATA:
// e' l'unico modo che il protocollo offre per sapere che un
// dispositivo se n'e' andato. Chi non lo fa accumula invii falliti
// per sempre.
if (e.statusCode !== 404 && e.statusCode !== 410) vivi.push(v);
console.error('invio fallito', e.statusCode, e.body);
}
}
if (vivi.length !== elenco.length) scrivi(vivi);
res.json({ inviate: ok, totale: elenco.length });
});
// Dietro un reverse proxy si ascolta SOLO sul loopback: da fuori si arriva
// unicamente passando dal proxy, che e' quello che fa HTTPS.
app.listen(process.env.PORT || 3020, '127.0.0.1', () =>
console.log('in ascolto'));
5Il client, per intero
Sessanta righe. Le tre insidie sono segnate nei commenti.
public/app.js
// La chiave VAPID arriva in base64url (- e _ al posto di + e /, senza =) ma
// subscribe() vuole un Uint8Array. Senza questa conversione fallisce senza un
// messaggio utile: e' l'errore piu' comune in assoluto.
function toUint8(base64Url) {
const pad = '='.repeat((4 - (base64Url.length % 4)) % 4);
const b64 = (base64Url + pad).replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
let registrazione = null;
let iscrizione = null;
async function avvia() {
if (!('serviceWorker' in navigator)) return;
await navigator.serviceWorker.register('/sw.js', { scope: '/' });
// .ready attende un worker ATTIVO. register() da solo puo' restituirne uno
// ancora in installazione, e subscribe() su quello fallisce senza dire perche'.
registrazione = await navigator.serviceWorker.ready;
// Su iPhone in Safari con l'app NON installata, pushManager non esiste.
// Non e' un guasto: e' il modello di Apple, ed e' il caso piu' frequente.
if (!registrazione.pushManager) return;
iscrizione = await registrazione.pushManager.getSubscription();
aggiornaPulsante();
}
// DA UN TOCCO. Nessun await prima di requestPermission(): un'attesa rompe il
// legame col gesto, e su iOS la richiesta viene ignorata in silenzio.
document.getElementById('attiva').addEventListener('click', async () => {
const permesso = await Notification.requestPermission();
if (permesso !== 'granted') return; // 'denied' e' DEFINITIVO
const { chiave } = await (await fetch('/api/chiave-pubblica')).json();
iscrizione = await registrazione.pushManager.subscribe({
userVisibleOnly: true, // obbligatorio su Chrome
applicationServerKey: toUint8(chiave),
});
await fetch('/api/iscrivi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// .toJSON() serve: la subscription non e' un oggetto semplice e
// JSON.stringify() diretto restituirebbe {} su alcuni browser.
body: JSON.stringify({ iscrizione: iscrizione.toJSON() }),
});
aggiornaPulsante();
});
document.getElementById('spegni').addEventListener('click', async () => {
if (!iscrizione) return;
const endpoint = iscrizione.endpoint;
await iscrizione.unsubscribe();
await fetch('/api/disiscrivi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
});
iscrizione = null;
aggiornaPulsante();
});
function aggiornaPulsante() {
// ATTENZIONE: permission === 'granted' NON implica un'iscrizione attiva.
// Sono due cose distinte, ed e' li' che si perde mezza giornata.
document.getElementById('attiva').hidden = !!iscrizione;
document.getElementById('spegni').hidden = !iscrizione;
}
avvia();
1. La chiave va convertita in Uint8Array.
2. Serve .ready, non solo .register().
3. requestPermission() deve partire dal tocco, senza
await prima.
6Il service worker
Venti righe, e una regola che se violata ti costa l'iscrizione.
sw.js — nella radice del progetto, non in public/
self.addEventListener('push', (event) => {
let d = {};
try { d = event.data ? event.data.json() : {}; } catch (e) { d = {}; }
// waitUntil e' OBBLIGATORIO: senza, il browser puo' terminare il worker
// prima che showNotification() abbia finito, e la notifica sparisce.
event.waitUntil(
self.registration.showNotification(d.titolo || 'Notifica', {
body: d.testo || 'Hai un aggiornamento.',
icon: '/icone/192.png',
badge: '/icone/192.png',
data: { url: d.url || '/' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((finestre) => {
// Se l'app e' gia' aperta si riporta in primo piano quella: aprirne
// una seconda copia confonde, e su iOS non e' nemmeno possibile.
const f = finestre.find((x) => x.url.includes(self.location.origin));
return f ? f.focus() : self.clients.openWindow(url);
})
);
});
Ogni evento push deve finire in una notifica visibile.
Se il gestore non chiama showNotification(), Safari
revoca l'iscrizione e Chrome mostra al posto tuo «questo sito è
stato aggiornato in background».
Guarda il catch qui sopra: non esce, imposta
d = {} e tira dritto. Un push senza corpo è legittimo e capita. Se
metti un return lì dentro, fra qualche giorno le notifiche
smetteranno di arrivare e non capirai perché.
7Provarlo
In locale si arriva fin dove è possibile. Poi serve il telefono.
node server.js
# apri http://127.0.0.1:3020 — localhost e' considerato contesto sicuro,
# quindi il service worker si registra e il permesso si puo' chiedere.
# poi, da un altro terminale:
curl -X POST http://127.0.0.1:3020/api/invia \
-H 'X-Token-Invio: una-frase-lunga-a-caso' \
-H 'Content-Type: application/json' \
-d '{"titolo":"Funziona","testo":"Prima notifica."}'
Un certificato autofirmato non basta: su iOS impedisce anche l'installazione. Servono un dominio e un certificato valido — con Caddy sono tre righe e il certificato lo ottiene da solo:
/etc/caddy/Caddyfile
tuo.dominio.it {
encode gzip
reverse_proxy 127.0.0.1:3020
}
1. L'app va aggiunta alla schermata Home — in Safari, oppure (da iOS 16.4) da Chrome, Edge, Firefox o DuckDuckGo, sempre dal menu Condividi. 2. Va aperta dall'icona. In Safari, il pulsante non farà mai nulla.
Se non arriva
| Cosa vedi | Cosa significa |
|---|---|
subscribe() fallisce, messaggio inutile |
Chiave non convertita in Uint8Array. |
403 a ogni invio |
Le iscrizioni sono di un'altra coppia di chiavi. Vanno rifatte dal dispositivo. |
410 |
App disinstallata o permesso revocato. Cancella quell'iscrizione. |
Il server dice inviate: 1 ma niente arriva |
Consegnata a Google/Apple, non mostrata dal telefono: offline, o notifiche silenziate per quell'app nelle impostazioni di sistema. |
| Su iPhone il permesso non viene mai chiesto | L'app è stata aperta dal browser e non dall'icona. |
Le correzioni a sw.js non hanno effetto |
È servito dalla cache: manca Cache-Control: no-cache. |
La riga inviate: 1 dice solo che Google o Apple hanno
accettato. Non dice che la notifica sia comparsa su uno schermo. Se hai
dieci minuti, falli spendere al service worker per riferirtelo: una
fetch('/api/annota') dentro il .then() di
showNotification(), e il tuo registro sa una cosa che altrimenti
nessuno saprebbe mai. Ma dopo la notifica, e con un
.catch() che assorba gli errori: lo strumento di misura non deve
mai poter guastare ciò che misura.
8Un messaggio per ogni utente
Sì, si fa — ed è il caso normale. Ma ci sono due trappole di sicurezza che fanno arrivare i messaggi alla persona sbagliata.
L'iscrizione appartiene al dispositivo, non alla persona. Il browser chiede una casella postale e non sa nulla dei tuoi utenti. Il collegamento fra la casella e l'utente lo fai tu, al momento dell'iscrizione, e da quel momento in poi è responsabilità tua tenerlo giusto.
Da qui discende tutto: un utente può avere più dispositivi (più righe), e un dispositivo può cambiare utente — ed è lì che si fa il danno.
L'utente si legge dalla sessione, mai dal corpo della richiesta.
Un dispositivo usato da un altro utente cambia proprietario: è la stessa riga,
con un nuovo utenteId.
L'archivio: una riga per dispositivo
[
{ "utenteId": 1, "endpoint": "https://web.push.apple.com/QF3…", "keys": {…} },
{ "utenteId": 1, "endpoint": "https://fcm.googleapis.com/…", "keys": {…} },
{ "utenteId": 2, "endpoint": "https://web.push.apple.com/8kM…", "keys": {…} }
]
L'utente 1 ha due dispositivi — iPhone e portatile — e riceverà su entrambi.
L'endpoint resta la chiave primaria: è unico per
dispositivo + browser + sito.
Iscrizione: l'utente si legge dalla sessione, mai dal corpo
Se il server si fida di un utenteId mandato dalla pagina, chiunque
può iscriversi come utente 5 e ricevere le notifiche di utente 5.
Basta cambiare una riga nella console del browser. L'utente va letto
dalla sessione, esattamente come faresti per qualunque altra
rotta riservata.
server.js — la rotta di iscrizione, autenticata
app.post('/api/iscrivi', (req, res) => {
// L'utente viene da QUI: sessione, cookie firmato, JWT verificato —
// quello che gia' usi nel resto dell'applicazione.
// MAI da req.body.utenteId: quello lo scrive il browser.
const utenteId = req.session?.utenteId;
// Se non e' loggato non si iscrive nessuno. Non e' una restrizione:
// un'iscrizione senza proprietario e' una casella che non sapresti a chi
// attribuire, e prima o poi ci manderesti dentro la cosa sbagliata.
if (!utenteId) return res.status(401).json({ errore: 'non autenticato' });
const s = req.body?.iscrizione;
if (!s?.endpoint?.startsWith('https://') || !s.keys?.p256dh || !s.keys?.auth) {
return res.status(400).json({ errore: 'iscrizione incompleta' });
}
// L'endpoint e' la chiave: se quel dispositivo era di un altro utente, la
// riga viene SOSTITUITA. E' il caso del telefono prestato, o del computer
// in ufficio: chi si iscrive adesso e' il nuovo proprietario.
const elenco = leggi().filter((x) => x.endpoint !== s.endpoint);
elenco.push({
utenteId,
endpoint: s.endpoint,
keys: s.keys,
quando: new Date().toISOString(),
});
scrivi(elenco);
res.json({ ok: true });
});
Invio mirato
server.js — una funzione, non una rotta pubblica
// Questa la chiama il TUO codice quando succede qualcosa che riguarda
// quell'utente: un ordine spedito, un messaggio ricevuto, una scadenza.
async function notifica(utenteId, titolo, testo, url = '/') {
const carico = JSON.stringify({ titolo, testo, url });
const elenco = leggi();
const vivi = [];
for (const v of elenco) {
// Tutti i dispositivi di quell'utente, e solo quelli.
if (v.utenteId !== utenteId) { vivi.push(v); continue; }
try {
await webpush.sendNotification(v, carico, { TTL: 86400 });
vivi.push(v);
} catch (e) {
if (e.statusCode !== 404 && e.statusCode !== 410) vivi.push(v);
}
}
if (vivi.length !== elenco.length) scrivi(vivi);
}
// Uso, da qualunque punto dell'applicazione:
await notifica(1, 'Ordine spedito', 'Il tuo ordine #4821 è partito.', '/ordini/4821');
await notifica(2, 'Nuovo messaggio', 'Hai ricevuto un messaggio.', '/messaggi');
Il logout deve disiscrivere
Se al logout non cancelli l'iscrizione, quel dispositivo continua a ricevere le notifiche dell'utente che se n'è andato. Il telefono è ancora iscritto, la riga nell'archivio punta ancora a lui, e il sistema operativo mostrerà «Hai un nuovo messaggio da…» sullo schermo bloccato di chiunque abbia quel telefono in mano.
Sul computer di un ufficio o su un telefono prestato è una fuga di dati vera, e non se ne accorge nessuno finché non è successo.
public/app.js — da chiamare PRIMA di chiudere la sessione
async function esci() {
const reg = await navigator.serviceWorker?.ready;
const sub = await reg?.pushManager?.getSubscription();
if (sub) {
// Prima si dice al server di dimenticare questo dispositivo — mentre la
// sessione e' ancora valida, o la rotta risponderebbe 401 — poi si
// disdice la casella lato browser.
await fetch('/api/disiscrivi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
}
await fetch('/api/logout', { method: 'POST' });
location.href = '/';
}
E come rete di sicurezza, perché un logout può anche non avvenire mai — schede chiuse, sessioni scadute, telefoni persi:
server.js — al logout lato server, e alla scadenza sessione
// Se l'utente esce senza che la pagina abbia fatto in tempo a disiscriversi,
// il server toglie comunque le sue caselle. Meglio una notifica non
// recapitata che una recapitata alla persona sbagliata.
app.post('/api/logout', (req, res) => {
const utenteId = req.session?.utenteId;
if (utenteId) scrivi(leggi().filter((x) => x.utenteId !== utenteId));
req.session?.destroy?.();
res.json({ ok: true });
});
Il codice qui sopra al logout toglie tutte le caselle di
quell'utente, cioè lo disiscrive anche dagli altri suoi dispositivi.
È la scelta prudente, non l'unica: se vuoi che uscendo dal portatile il
telefono continui a ricevere, filtra anche per endpoint — ma
allora il logout lato client diventa l'unico che pulisce davvero quel
dispositivo, e va fatto funzionare bene.
Non loggato: nessun messaggio
Tre righe di difesa, e servono tutte e tre:
- Il pulsante non c'è. Si mostra «attiva le notifiche» solo a sessione aperta. Chi non è loggato non vede nemmeno la proposta.
- La rotta rifiuta.
/api/iscrivirisponde401senza sessione — perché il pulsante nascosto ferma l'utente distratto, non chi apre la console. - L'invio filtra per
utenteId. Una riga senza proprietario non corrisponde a nessun invio mirato, quindi non riceve nulla per costruzione, non per controllo.
Il payload viaggia cifrato con le chiavi del dispositivo: né Google né Apple possono leggerlo. Ma la notifica poi compare sullo schermo bloccato, dove la legge chiunque guardi il telefono.
Quindi: «Hai un nuovo messaggio» va bene, «Il dott. Rossi conferma la visita di
giovedì» no. Se il contenuto è delicato, manda l'avviso e fai aprire l'app —
notificationclick porta l'utente dove serve, e lì c'è la sessione
a proteggere ciò che mostri.
Riepilogo dei casi
| Situazione | Cosa succede |
|---|---|
| Utente 1 con iPhone e portatile | Due righe, stesso utenteId. Riceve su entrambi. |
| Utente 2 si iscrive sul telefono dell'utente 1 | Stesso endpoint: la riga viene sostituita. Il nuovo proprietario
è l'utente 2. |
| Utente 1 fa logout | Le sue caselle vengono cancellate: quel telefono smette di ricevere. |
| Visitatore non loggato | Nessun pulsante, 401 se ci prova, e nessuna riga nell'archivio. |
| Utente disinstalla l'app | Il primo invio risponde 410 e la riga si cancella da sé. |
| Utente cancella l'account | Cancella anche le sue righe: sono dati personali quanto l'indirizzo email. |
9Quando cresce
Cosa cambiare, e solo quando serve davvero.
| Quando | Cosa cambiare |
|---|---|
| Oltre qualche migliaio di iscritti | Il file JSON si rilegge tutto a ogni scrittura. SQLite basta e avanza, e resta un file solo. |
| Devi mandare a una persona | È la sezione 8: un utenteId sulla riga
d'archivio, letto dalla sessione e mai dal client. |
| Invii lenti o numerosi | Metti gli invii in coda invece di farli dentro la richiesta HTTP:
con mille dispositivi il for qui sopra tiene aperta la
connessione per minuti. |
| Un utente su più dispositivi | Funziona già: ogni dispositivo è una riga a sé. Ma il logout deve disiscrivere, o il telefono continua a ricevere i messaggi di chi se n'è andato — sezione 8. |
| Vuoi sapere chi apre le notifiche | notificationclick nel service worker: una
fetch verso il tuo server prima del focus(). |
Firebase, OneSignal, un SDK, un account. Tutto quello che c'è qui sopra è lo standard W3C: gira sul tuo server, i dati restano tuoi, e funziona su Android, iPhone e desktop con lo stesso codice.