Who picks the tool
-->
Direkt zum Hauptbereich
Fiatbitcoin ist eine Phoenix-LiveView-App. Seit Kurzem kann man sie auf dem Homescreen installieren wie eine native App, und sie meldet sich von selbst, sobald eine der eigenen Bitcoin-Positionen steuerfrei wird. Beides zusammen macht aus der Web-App eine Progressive Web App.
Das Interessante daran ist nicht die Checkliste aus Manifest und Service Worker, die kennt man. Interessant ist, dass LiveView die übliche PWA-Erzählung an einer Stelle bricht. Eine LiveView lebt von einer dauerhaften WebSocket-Verbindung, und genau die stellt die Frage, was „offline” hier überhaupt heißen soll. Dieser Artikel geht den Weg in drei Schritten durch: installierbar machen, Web Push anbinden, und das Ganze an die fachliche Steuerlogik hängen.
Das klassische PWA-Versprechen ist Offline-Fähigkeit. Ein Service Worker fängt die Requests ab, liefert aus dem Cache, und die App funktioniert auch ohne Netz. Für eine LiveView trägt dieser Plan nicht weit.
Die eigentlichen Inhalte einer LiveView werden serverseitig gerendert und über den WebSocket geschoben. Der Portfolio-Wert, der Steuerstatus, jede Aktualisierung kommt vom Server. Fällt das Netz weg, fällt die Verbindung, und es gibt nichts mehr zu rendern. Ein Service Worker kann das statische Gerüst cachen, also CSS, JavaScript, Icons, aber nicht den dynamischen Zustand. Wer hier auf Offline-Betrieb hinarbeitet, müsste die Daten clientseitig vorhalten und eine eigene Sync-Schicht bauen. Das ist ein anderes Projekt.
Bleiben zwei Dinge, die auch auf LiveView sauber funktionieren und echten Nutzen bringen: die App installierbar machen und Benachrichtigungen senden. Auf die habe ich mich konzentriert.
Für die Installierbarkeit braucht der Browser ein Web-App-Manifest
mit ein paar Pflichtfeldern und einen registrierten Service Worker. Das
Manifest ist eine statische Datei, ausgeliefert über
Plug.Static. Der einzige Phoenix-Stolperstein dabei: Der
Service Worker muss von der Wurzel kommen, nicht aus
/assets/, sonst darf er nur einen Teil der App
kontrollieren. Also wandern sw.js und
manifest.webmanifest in die Liste der erlaubten statischen
Pfade.
Der Service Worker selbst ist bewusst zurückhaltend. Er cacht
ausschließlich statische Assets und lässt alles andere unangetastet
durch ins Netz. Das ist kein Detail, sondern der Kern: Würde er
Navigationen oder LiveView-Antworten cachen, bekäme der Nutzer veraltete
Portfoliodaten zu sehen. Die Regel steht direkt im
fetch-Handler.
const CACHEABLE_PREFIXES = ["/assets/", "/icons/", "/images/", "/fonts/"];
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
const cacheable = CACHEABLE_PREFIXES.some((p) => url.pathname.startsWith(p));
if (!cacheable) return; // Navigationen, /live, /api gehen direkt ins Netz
// Stale-while-revalidate: sofort aus dem Cache, im Hintergrund auffrischen
event.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(request);
const network = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
});
return cached || network;
})
);
});
Alles, was nicht unter einem der Asset-Präfixe liegt, fällt durch das
frühe return und wird ganz normal vom Netz geholt. Die
LiveView-Verbindung und die API sieht der Cache nie. Was bleibt, ist
genau das gewünschte Verhalten: schneller Start aus dem Cache, frische
Daten vom Server.
Der zweite Schritt ist die eigentliche Benachrichtigung. Web Push
läuft nicht direkt von meinem Server zum Gerät, sondern über den
Push-Dienst des jeweiligen Browsers, also Googles FCM, Mozilla oder
Apple. Damit der Dienst die Nachricht annimmt, muss sie mit einem
VAPID-Schlüsselpaar signiert und ihr Inhalt verschlüsselt sein. Diese
Verschlüsselung ist ECDH plus AES-GCM, und das baut man nicht selbst.
Dafür gibt es web_push_encryption, das die Signierung und
die Verschlüsselung übernimmt.
Jedes Abo, das der Browser ausstellt, gehört zu einem Gerät und zu
einem Nutzer. Es besteht aus einem Endpoint und zwei Schlüsseln.
Gespeichert wird es in einer eigenen Tabelle, gekoppelt an die
user_id, damit später jeder nur seine eigenen Hinweise
bekommt.
Das Abonnieren passiert komplett im Browser und meldet sich danach
bei der LiveView, ohne Umweg über einen Controller. Ein LiveView-Hook
fragt die Erlaubnis ab, abonniert über den Service Worker und schickt
das Ergebnis per pushEvent nach oben.
const PushSubscribe = {
mounted() {
this.el.addEventListener("click", async () => {
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(this.el.dataset.vapidPublicKey),
});
this.pushEvent("subscribed", sub.toJSON());
});
},
};
Auf der Serverseite landet das Abo im
handle_event("subscribed", ...) und wird mit der
user_id des angemeldeten Nutzers gespeichert. Diesen Weg
über den Hook finde ich angenehm: Kein zusätzlicher JSON-Endpoint, kein
CSRF-Token von Hand, kein Controller. Die LiveView, die ohnehin schon
eine offene Verbindung hat, nimmt das Abo einfach entgegen.
Senden ist dann eine Schleife über die Geräte eines Nutzers. Ein Push-Dienst antwortet mit 404 oder 410, wenn ein Abo nicht mehr existiert, etwa weil der Nutzer die Erlaubnis entzogen hat. Solche toten Einträge räume ich gleich beim Senden weg.
def send_to_user(user_id, payload) when is_map(payload) do
message = Jason.encode!(payload)
{sent, pruned} =
from(s in PushSubscription, where: s.user_id == ^user_id)
|> Repo.all()
|> Enum.reduce({0, 0}, fn sub, {sent, pruned} ->
case deliver(message, sub) do
:ok -> {sent + 1, pruned}
:gone -> Repo.delete(sub); {sent, pruned + 1}
:error -> {sent, pruned}
end
end)
{:ok, %{sent: sent, pruned: pruned}}
end
Das deliver darunter ordnet die HTTP-Antwort des
Push-Dienstes einem der drei Ausgänge zu. 200er heißt zugestellt, 404
und 410 heißt weg, alles andere wird geloggt und gilt als Fehler.
Eine Benachrichtigung ist nur so gut wie ihr Anlass. Der Anlass kommt hier aus dem Steuermodul. In Deutschland ist ein verkaufter Bitcoin nach einem Jahr Haltedauer steuerfrei, und die App kann ausrechnen, wann die nächste Position diese Grenze erreicht. Genau dieser Termin ist die Nachricht: „Deine nächste Position wird in vierzehn Tagen steuerfrei.”
Der Auslöser hat zwei Gesichter. Auf der Einstellungsseite gibt es einen Knopf, der den aktuellen Steuerstatus sofort an die eigenen Geräte schickt, gut zum Ausprobieren. Den produktiven Teil übernimmt ein täglicher Oban-Job. Er geht durch alle Nutzer, die überhaupt ein Gerät registriert haben, prüft, ob deren nächster steuerfreier Termin in den nächsten zwei Wochen liegt, und schickt dann die Nachricht.
defp maybe_remind(user_id) do
with {:ok, %DateTime{} = date} <- Tax.next_tax_free_date(user_id),
days when days >= 0 and days <= @threshold_days <-
DateTime.diff(date, DateTime.utc_now(), :day),
false <- already_reminded?(user_id, date) do
TaxFreePush.notify(user_id)
log_reminder(user_id, date)
else
_ -> :ok
end
end
Der with-Block liest sich wie die Bedingung selbst: Es
gibt einen Termin, er liegt im Fenster, und es wurde noch nicht
erinnert. Fällt eine der drei Bedingungen, passiert nichts. Die letzte
ist die wichtigste, denn ein täglicher Job würde sonst jeden Morgen
dieselbe Erinnerung schicken. Deshalb hält ein Log fest, für welchen
Nutzer und welchen Termin schon einmal eine Nachricht rausging, und
already_reminded? schlägt beim zweiten Mal an.
Drei Dinge, die sich bewährt haben, und eines, bei dem ich die Erwartung früh geradegerückt habe.
1. Auf LiveView ist Offline die falsche Schlacht.
Die ersten Gehversuche mit PWA enden oft beim Versuch, die App
offline lauffähig zu machen. Auf einer LiveView-App führt dieser Weg
gegen die Architektur, weil der Zustand am WebSocket hängt. Die
lohnenden Ziele sind Installierbarkeit und Push. Der Service Worker darf
dabei alles cachen außer dem, was vom Server kommt. Diese eine Regel im
fetch-Handler verhindert die ganze Klasse von Fehlern, bei
denen jemand veraltete Daten sieht.
2. Verschlüsselung gehört nicht selbst gebaut.
Web Push verlangt eine korrekte ECDH- und AES-GCM-Verschlüsselung des Payloads. Das ist die Art Krypto, bei der ein kleiner Fehler still bleibt, bis nichts mehr ankommt. Eine geprüfte Bibliothek war hier die einzig vernünftige Wahl. Sie bringt allerdings als transitive Abhängigkeit einen alten HTTP-Client mit, was bei einem Projekt, das ansonsten konsequent auf einen modernen Client setzt, ein kleiner Wermutstropfen ist.
3. Der Hook spart den ganzen Controller.
Das Abo über einen LiveView-Hook entgegenzunehmen statt über einen
eigenen JSON-Endpoint hat den Weg deutlich verkürzt. Keine zusätzliche
Route, kein CSRF-Handling, kein Controller. Die LiveView hat die
Verbindung schon, also nimmt sie das Abo per pushEvent
direkt entgegen und speichert es mit der user_id aus der
Session.
4. Was ich vorher klargestellt habe: Web Push ist kein Pushover.
Es lag nahe, mit dem neuen Kanal gleich den bestehenden Pushover-Dienst abzulösen, der die betrieblichen Alarme verschickt. Das wäre ein Rückschritt gewesen. Pushover stellt zuverlässig an die Geräte des Betreibers zu, mit Priorität, Ton und Wiederholung. Web Push hängt daran, dass gerade ein gültiges Abo existiert, und auf iOS funktioniert es nur, wenn die App vorher installiert wurde. Für serverseitige Alarme, die garantiert ankommen müssen, ist das die schwächere Zusage. Also bleibt Pushover für den Betreiber, und Web Push ist der ergänzende Kanal für den Endnutzer, der seine eigenen Hinweise auf seinem eigenen Gerät bekommt. Zwei Kanäle für zwei Zielgruppen, kein Ersatz.
Wenn euch eine Stelle genauer interessiert, der Cache-Trick im Service Worker, der Hook-Weg ohne Controller oder die Dedup-Logik im täglichen Job, schreibt es in die Kommentare. Ich gehe gerne tiefer.
Kommentare