Pinned Post

Who picks the tool

Bild
The previous post ended with an agent that writes a missing tool into a running node. Generate, sanitize, persist, compile, load, execute, about 25 milliseconds after the model answers. I was happy with that for roughly a week. Then I looked at the demo page again and counted the text fields. Tool name. What it should do. Parameters as JSON. I had filled in all three. The gap-finding was real. The deciding was mine. MetaPlannerAgent.run("Publish the article", [ %{tool: "classic_plan", params: %{goal: "Write draft, review, publish"}}, %{tool: "slugify_text", description: "Turns a title into a URL slug", params: %{text: "Hello World"}} ]) Everything interesting in that call sits in the second argument, and I typed it. The agent resolved slugify_text , noticed it wasn't in the catalog and had it written. It never asked whether the task needed a slug at all. So this post is about the agent in front of that, the on...

Vom Browser-Tab zur installierten App: PWA und Web Push auf Phoenix LiveView

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.

Warum offline nicht das Ziel ist

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.

Installierbar: ein Manifest und ein disziplinierter Service Worker

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.

Web Push: VAPID, Verschlüsselung und ein Abo pro Gerät

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.

Die Brücke zur Fachlichkeit: steuerfrei in N Tagen

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.

Lessons Learned

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

Beliebte Posts aus diesem Blog

Splitting an ML model and a web app across two BEAM nodes — the technical blueprint

Jido in Practice: Agents in Elixir as Composable Actions

A Foundation Model in the BEAM: On-Chain Anomalies with Google TimesFM in Elixir