Salta al contenuto

Istruzioni · pwa.giuseppebosi.com

App ↔ server:
il sistema di comunicazione

Il minimo che serve perché una PWA e il suo server si parlino per le notifiche push. Quattro rotte, quattro pezzi di codice, in ordine. Nessun framework, nessun servizio terzo, due dipendenze — express e web-push. Tutto quello che c'è qui è in produzione e ha fatto squillare un iPhone.

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.

Telefono pagina + service worker Servizio push FCM (Google) · APNs (Apple) Il tuo server Node + web-push 1 · subscribe() — «dammi una casella» 2 · la casella: indirizzo + chiavi 3 · POST /api/iscrivi — la consegna al server … minuti, ore o giorni dopo, quando c'è qualcosa da dire … 4 · sendNotification() — firmata e cifrata 5 · sveglia il telefono → notifica

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.

Le tre cose che contano

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.

RottaChi la chiamaCosa 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
L'unica decisione di sicurezza che conta

/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
La chiave privata

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();
Tre insidie, in una riga ciascuna

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);
      })
  );
});
La regola che non si viola

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."}'
Sul telefono serve HTTPS vero

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
}
Su iPhone: due cose, in quest'ordine

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 vediCosa 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.
Il consiglio che vale più di tutti

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.

Il concetto che spiega tutto il resto

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.

UTENTI (dalla sessione) ARCHIVIO ISCRIZIONI — una riga per dispositivo INVIO utente 1 due dispositivi utente 2 un dispositivo utenteId 1 · iPhone · endpoint …QF3a utenteId 1 · portatile · endpoint …8kMz utenteId 2 · Android · endpoint …P1xd notifica(1, …) filtra per utenteId l'utente 2 non riceve nulla Logout dell'utente 1 → il server cancella le sue due righe: quei dispositivi smettono subito di ricevere

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

Trappola 1 — il campo utenteId che arriva dal client

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

Trappola 2 — la più insidiosa delle due

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 });
});
Una scelta da fare, consapevolmente

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:

  1. Il pulsante non c'è. Si mostra «attiva le notifiche» solo a sessione aperta. Chi non è loggato non vede nemmeno la proposta.
  2. La rotta rifiuta. /api/iscrivi risponde 401 senza sessione — perché il pulsante nascosto ferma l'utente distratto, non chi apre la console.
  3. L'invio filtra per utenteId. Una riga senza proprietario non corrisponde a nessun invio mirato, quindi non riceve nulla per costruzione, non per controllo.
Sul contenuto: la cifratura non è privacy sullo schermo

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

SituazioneCosa 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.

QuandoCosa 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().
Quello che NON serve

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.