,

Entwickeln einer Edge-Anwendung mit Cloudflare

Jens Schlegel

Einleitung

Englisch spielt eine große Rolle in meinem Beruf und Alltag, doch immer noch passieren mir Grammatikfehler. Um meine Englischkenntnisse zu verbessern, habe ich eine kleine Webseite entwickelt, auf der das Schreiben von englischen Sätzen geübt werden kann. Dem Nutzer wird ein Satz präsentiert, der dann in die festgelegte Sprache übersetzt werden muss. Satzteile, die fehlerhaft übersetzt wurden, werden rot makiert. Alle absolvierten Aufgaben werden auf einer Übersichtsseite gespeichert. Aufgaben können zudem favorisiert werden, was einen besseren Überblick über eigene Fehler ermöglicht.

Ziel des Projektes

Ein zentrales Ziel dieses Projekts bestand darin, den Nutzern eine hohe Performance zu bieten. Zudem war es mir persönlich wichtig, mein Wissen über neue Technologien zu erweitern. Deshalb lag das Hauptziel des Projekts auf das Verwenden einer Edge-Umgebung. Ich wollte lernen, was es bedeutet eine App auf einer Edge Umgebung zu deployen und welche Vor- und Nachteile dies mit sich bringt.

Die Anwendung

Die Webseite kann unter dem folgendem Link gefunden werden: https://language-trainer.pages.dev

Als erstes muss ein Account erstellt werden, um die Anwendung benutzen zu können.

Im nächsten Schritt muss dann die E-Mail-Adresse bestätigt werden.

Dann ist man angemeldet und kann beginnen, mein Englisch zu üben. In der Mitte der Webseite wird der zu übersetzende Satz angezeigt. Direkt unter diesem Satz befindet sich das Antwortfeld, in das die Übersetzung eingegeben wird. Anschließend muss nur noch die Eingabetaste gedrückt oder auf den “Submit”-Knopf geklickt werden, um die Aufgabe einzureichen.

Wenn die Aufgabe eingereicht wurde, erscheint nach kurzer Zeit die Lösung oberhalb der Aufgabenbox. So kann sofort gesehen werden, ob die Übersetzung korrekt war. Dazu können Lösungen durch das Klicken auf den Stern favorisiert werden.

Auf der rechten Seite kann die Sprache eingestellt werden. Dabei kann durch einen Klick auf den mittleren Knopf, die Frage mit der Eingabe Sprache gewechselt werden.

Auf der linken Seite befindet sich das Navigationsmenü. Von dort aus kann zu den Seiten “History” und “Favorites” navigiert werden.

Ganz unten auf der linken Seite wird die E-Mail-Adresse des angemeldeten Accounts angezeigt. Durch einen Klick öffnet sich ein Dropdown-Menü, über das sich der Benutzer ausloggen kann.

Verwendete Technologien

Die Anwendung setzt sich aus einem Next.js-Frontend, einem Hono.js-Backend und einer SQLite-Datenbank zusammen.

Als Basis für das Frontend dient Next.js, das aufgrund seiner Fähigkeit, die Seiten im Voraus zu rendern, hervorragend geeignet ist. Dies ermöglicht die statische Generierung aller Seiten während des Build-Schritts, die bei jedem Seitenaufruf wiederverwendet werden. Dadurch eliminiert es die Generierung auf der Client-Seite, was zu wesentlich kürzeren Ladezeiten führt.

Das Backend nutzt Hono.js, da es sich um ein schnelles, kleines Backend-Framework handelt, welches für die Nutzung in Edge-Umgebungen entwickelt wurde. Ein weiterer Vorteil von Hono.js ist die Verfügbarkeit zahlreicher zusätzlicher Middlewares für eine Vielzahl von Funktionen. So habe ich die CORS-Middleware implementiert, um sicherzustellen, dass ausschließlich mein Frontend Zugriff auf das Backend hat.

Um Fehler zu vermeiden, wurde auf eine vollständige typsichere Entwicklung geachtet. Hierzu wurde TypeScript im Frontend sowie im Backend eingesetzt. Für die Datenbank wurde das Drizzle ORM verwendet.
Es ist type safe und generiert Types für die Datenbanktabellen. Sollten die Daten nicht dem erwarteten Schema entsprechen, wird die Abfrage rot unterstrichen und es wird angezeigt, was korrigiert werden muss. DrizzleORM ist zudem ein leichtes und schnelles ORM, das problemlos in der EDGE-Umgebung ausgeführt werden kann.

Die Kommunikation zwischen Frontend und Backend wird über tRPC realisiert, um eine vollständig typsichere API-Kommunikation zu ermöglichen. Das bedeutet, dass im Frontend ersichtlich ist, welche Daten das Backend benötigt und welche es bereitstellt. Um die Eingabedaten zu validieren, nutze ich die Bibliothek Zod.
Dazu habe ich die TanStack Query Bibliothek auf dem Client verwendet, um Daten abzurufen und zu senden. Sie bietet zahlreiche hilfreiche Hooks, die den Abrufprozess erleichtert. Beispielsweise werden Daten automatisch erneut abgerufen, wenn sie veraltet sind. Des Weiteren sind Caching und State Management integriert. Ein zusätzlicher Vorteil ist der “useInfiniteQuery” Hook, der die Implementierung von unendlichem Scrollen in der Historie und bei den Favoriten erheblich erleichterte.

Da Frontend und Backend als separate Projekte sind, habe ich ein Monorepo mit Turborepo erstellt. Turborepo ist ein Build-System, das für die Verwaltung von Monorepos konzipiert wurde. Es bestimmt, welche Elemente bereits gebaut wurden und welche Änderungen erfordern, um nur die benötigten Aspekte zu erstellen. Ich nutze Turborepo hauptsächlich zur Einrichtung eines Monorepos, sodass ich sowohl Backend als auch Frontend gleichzeitig starten kann. Hierfür richtete ich die Packages “Apps” und “Packages” ein. In “Apps” befindet sich die Nextjs-Anwendung und unter “Packages” das API-Backend mit dem Hono.js-Backend. Turborepo erkennt diese Projekte anhand der in den Verzeichnissen enthaltenen package.json-Dateien. Wird das folgende Package.json-Script im Hauptprojekt ausgeführt, starten Frontend und Backend gleichzeitig.

"dev": "turbo dev --parallel --filter={next-app,api}"

Hierbei stehen “next-app” und “api” für die Namen in der package.json-Datei. Turborepo führt dann jeweils das Dev-Skript für “next-app” und “api” aus. Somit laufen dann Backend und Frontend in einem Terminal, was einen übersichtlichen Einblick über die Logs bietet.

Ein weiterer Vorteil von Turborepo ist seine Effizienz bei der Installation: Es erstellt nur ein node_modules-Verzeichnis im Hauptordner, das alle Pakete aller Anwendungen enthält. Dies ist besonders praktisch, wenn Front- und Backend dieselben Pakete verwenden. In diesem Fall wird jedes Paket nur einmal installiert.

Infrastruktur

Mit den verwendeten Technologien musste ich dann das Hono.js-Backend, Next.js-Frontend und eine SQLite Datenbank deployen.

Zuerst habe ich darüber Gadanken gemacht, wie ich das Backend deployen sollte. Die Wahl fiel dabei auf Cloudflare Workers, da diese recht bekannt für ihre Serverless Edge-Umgebung sind.

Serverless bedeutet, dass die Anwendung automatisch skaliert und der Entwickler sich keine Sorgen darüber machen muss. Ein typisches Problem von Serverless-Architekturen besteht in sogenannte “Cold Starts”, bei denen die erste Anfrage einige Sekunden benötigen kann, bevor der Server antwortet. Das liegt daran, dass die Serverinstanz erst gestartet werden muss, was die Benutzererfahrung beeinträchtigen kann. Bei Cloudflare Workers gibt es allerdings keine solchen “Cold Starts”. Edge bedeutet, dass die Anwendung so nahe wie möglich beim Nutzer ausgeführt wird. Je nach Standort des Nutzers erfolgt der Zugriff daher immer auf den nächstgelegenen Server, was zu einer niedrigen Latenz und kurze Ladezeiten führt.

Beim Next.js-Frontend ist Vercel, der Ersteller des Frameworks, eine beliebte Wahl für das Deployment der App. Ich hatte damit aber bereits schon Erfahrung und da bereits Cloudflare für das Backend verwendet hatte, beschloss ich, auch für das Frontend auf Cloudflare zu setzen. Hier kam Cloudflare Pages ins Spiel, das viele Frameworks unterstützt, darunter auch Next.js und ebenfalls in einer Edge-Umgebung läuft.

Für die Datenbank erwägte ich zunächst Neon oder Planetscale als Deployment-Optionen, da beide einen Serverless-Treiber anbieten, der in einer Edge-Umgebung ausgeführt werden kann. Bei Neon wäre es eine PostgreSQL-Datenbank gewesen und bei Planetscale eine MySQL-Datenbank. Obwohl ich normalerweise PostgreSQL bevorzuge, wurde ich auf die Datenbankoption Cloudflare D1 aufmerksam.

Ein Vorteil von Cloudflare D1 besteht darin, dass die Datenbank einfach an den Worker gebunden werden kann. Zudem ist Cloudflare D1 im Vergleich zu anderen Anbietern PostgreSQL relativ schnell.

https://blog.cloudflare.com/d1-turning-it-up-to-11/

In Zukunft soll es noch möglich sein, schreibgeschützte Replikate über das globale Cloudflare-Netzwerk zu verteilen. Daraus würde resultieren, dass die Daten so nah wie möglich beim Nutzer gespeichert werden, was die Datenladegeschwindigkeit noch weiter verbessern könnte. Aktuell befindet sich Cloudflare D1 noch in der Alpha-Version, was bedeutet, dass die Speicherkapazität der Datenbank auf 500 MB begrenzt ist.

Da bei der Durchführung von Übersetzungsaufgaben die Lösungen zum Nutzer gespeichert werden, musste zusätzlich eine Authentifizierung implementiert werden.

Hierbei entschied ich mich für Supabase Auth, da es durch den Auth-Client einfach einzurichten ist und ich mich nicht um die Speicherung kritischer Authentifizierungsdaten kümmern muss.

Deployement

Frontend

Das Next.js-Frontend wurde mittels einer Github Action auf Cloudflare Pages deployt. Damit die Github Action die Next.js Anwendung auf Cloudflare Pages deployen kann, müssen die Variablen “CF_API_TOKEN”, “CF_ACCOUNT_ID” und “CF_PROJECT_NAME” als Secrets auf Github hinterlegt sein.

Die Github Action besitzt die folgenden Stages:

Checkout code: Um das Github-Repository auszuchecken.
Setup Node.js environment: Um die Node-Version 18 festzulegen.
Monorepo install: Hier habe ich diese Composite github action verwendet. Diese bringt den Vorteil mit, dass der Cache erhalten bleibt. Dadurch kann das Projekt schneller installiert werden.
Build: Zunächst wird das Arbeitsverzeichnis auf Next.js geändert. Anschließend werden die Umgebungsvariablen exportiert. Abschließend wird ein Cloudflare-CLI-Tool für Next.js ausgeführt, um die Anwendung zu bauen.
Publish to Cloudflare Pages: Im letzen Stage wird das zuvor erstellte Build auf Cloudflare deployt mit den Umgebungsvariablen die auf Github abgespeichert wurden.

Somit wird sichergestellt, dass das Next.js-Frontend erfolgreich auf Cloudflare Pages deployt wird.

Backend

Das Backend habe ich über Cloudflare Wrangler CLI deployt. Dies hat sich als leichter herausgestellt als ich es mir vorgestellt habe.

Als erstes musste ich das Deployment vorbereiten. Dazu musste ich als erstes die benötigten Umgebungsvariablen in das Projekt auf Cloudflare abspeichern. Mit dem folgendem CLI-Befehl konnte ich die Umgebungsvariablen zu dem Projekt hochladen. Sofern noch kein Cloudflare-Worker mit dem “PROJECT_NAME” existiert, wird ein neues Projekt erstellt.

echo <VALUE> | wrangler secret put <NAME> -- name <PROJECT_NAME>

Da das Backend auch eine Datenbank benötigt, habe ich als nächsten Schritt eine Datenbank erstellt, indem ich den folgenden Befehl verwendet habe.

wrangler d1 create <DB_NAME>

Das Ergebnis sieht dann wie folgt aus.

✅ Successfully created DB 'example' in region WEUR
Created your database using D1's new storage backend. The new storage backend is
not yet recommended for production workloads, but backs up your data via
point-in-time restore.

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "example"
database_id = "8a6819bc-51a0-4fc2-9ae6-9014ac78f2a7"

Das Ende des Terminal-Outputs ist wichtig, da diese Information benötigt wird, um den Worker mit der Datenbank zu verknüpfen.

Im nächsten Schritt muss dann eine wrangler.toml-Datei erstellt werden. Dies ist die Konfigurationsdatei, die für das Deployment benötigt wird.

In dieser Datei muss unter “name” den Projektnamen definiert werden, sowie den “compatibility_date”, die “account_id” und die Bindings für die Datenbank eingeben werden. Es ist wichtig, dass der gleiche Projektname wie bei der Speicherung Umgebungsvariablen verwendet wird. Das Ganze sollte dann in etwa so aussehen:

name = "language-trainer"
compatibility_date = "2023-01-01"
send_metrics = false
account_id = "..."

[[d1_databases]]
binding = "DB"
database_name = "example"
database_id = "8a6819bc-51a0-4fc2-9ae6-9014ac78f2a7"
migrations_dir = "migrations"

Jetzt muss das Backend noch deployt werden. Dazu muss der folgende Befehl eingegeben werden.

wrangler deploy <Main Entry File>

Zum Abschluss muss noch eine Migration auf die Datenbank angewendet werden. Sollte noch keine Migration vorhanden sein, kann mit dem folgenden Befehl eine Migration erstellt werden.

pnpm generate

Die erstellte Migration muss dann noch mit folgendem Befehl angewendet werden.

wrangler d1 migrations apply <DB_NAME>

Probleme und Lösungen

Das Frontend über die Cloudflare-Webseite deployen

Als ich versuchte, das Next.js-Frontend über die Cloudflare-Webseite zu deployen, erhielt ich ständig Build-Fehler. Interessanterweise traten diese Fehler nicht auf, wenn ich das Cloudflare CLI für Next.js lokal ausgeführt habe.

Das machte es ziemlich schwierig, den Fehler zu finden. Der erste Fehler weist darauf hin, dass tRPC den Typ “favorites” nicht besitzt.

| 21:43:48.962 | ▲  Type error: Property 'favorites' does not exist on type '"The property 'useContext' in your router collides with a built-in method, rename this router or procedure on your backend." | "The property 'withTRPC' in your router collides with a built-in method, rename this router or procedure on your backend." | "The property 'useQueries' in your router collides with a built-i...'. |
| 21:43:48.962 | ▲  Property 'favorites' does not exist on type '"The property 'useContext' in your router collides with a built-in method, rename this router or procedure on your backend."'. |
| 21:43:48.962 | ▲   |
| 21:43:48.963 | ▲     8 |  |
| 21:43:48.963 | ▲     9 |   const { data, isSuccess, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, refetch } = |
| 21:43:48.963 | ▲  **> 10 |     trpc.favorites.getFavorites.useInfiniteQuery(** |
| 21:43:48.963 | ▲       |          **^** |
| 21:43:48.963 | ▲    11 |       { |
| 21:43:48.963 | ▲    12 |         limit: LIMIT, |
| 21:43:48.964 | ▲    13 |       }, |
| 21:43:49.041 | ▲  ELIFECYCLE  Command failed with exit code 1. |

Bei der Erstellung des tRPC-Clients gebe ich den importierten “AppRouter”-Typ aus dem Backend an.

import type { AppRouter } from 'api/src/router';

export const trpc = createTRPCNext<AppRouter>({
  ...
})

Somit sollte das Frontend eigentlich wissen, dass der App-Router den “favorites”-Router enthält.

import { favoritesRouter } from './routes/favorites';

export const appRouter = router({
  favorites: favoritesRouter,
});

export type AppRouter = typeof appRouter;

Bei der Erstellung des tRPC-Clients importiere ich den “AppRouter”-Typ vom Backend. Doch ich hatte den Pfad des Next.js-Projekts als Root-Ordner angegeben, was möglicherweise das Problem verursachte, da der Typ vom Backend dann möglicherweise nicht importiert werden konnte.
Deshalb habe ich nach alternativen Wegen gesucht, das Frontend zu deployen und habe letztendlich Github Actions dafür eingesetzt. Damit funktionierte es dann. Im Nachhinein stellte ich fest, dass es über die Cloudflare Webseite funktioniert hätte, wenn ich in der Next.js-Konfiguration die TypeScript-Build-Fehler ignoriert hätte.

typescript: {
      ignoreBuildErrors: true,
    },

Das wäre sicherlich nicht eine optimale Lösung gewesen, gegangen wäre es.

Port-Konflikt

Bei der Bearbeitung einer Mutation im Backend war ich mir unsicher, ob diese so funktioniert wie gedacht. Ich konnte zwar durch die Browser-Console sehen, dass ein Request und eine Response stattfanden, doch die Daten in der Response waren “null”. Um zu überprüfen, ob alles richtig funktionierte, fügte ich einige console.logs im Backend hinzu. Allerdings erhielt ich keine Ausgaben.

Nach einem Blick in die Dokumentation des Backend-Frameworks stellte ich fest, dass es eine Logging-Middleware gibt. Diese hatte ich dann implementiert, aber wieder wurden keine Ausgaben im Terminal angezeigt. Nach einigen Neustarts bemerkte ich, dass das Backend jedes Mal auf einem anderen Port startete anstatt den vorgegebenen Port zu verwenden.

Nachdem ich die laufenden Ports überprüft hatte, fiel mir auf, dass es zwei offene Ports mit dem Namen “Workerd” gab, was für den Cloudflare-Worker steht. Nachdem ich beide Ports geschlossen und das Projekt neu gestartet hatte, funktionierte der Code wie erwartet und die Logs wurden ausgegeben.

Es stellte sich also heraus, dass mein Frontend das Backend von einem älteren Code-Stand anfragte und nicht den aktuellen Code, an dem ich gerade arbeitete.

Gitignore

Während der Entwicklung erstellte ich einige Types und speicherte diese im “types”-Ordner ab. Als ich jedoch versuchte, das Projekt zu deployen, trat plötzlich ein Fehler beim Import der Types auf: Angeblich konnte der Pfad nicht gefunden werden. Ich überprüfte als Erstes den Pfad und war über den Fehler verwundert, da alles korrekt aussah. Dann bemerkte ich jedoch, dass der “types”-Ordner im GitHub-Repository fehlte. Somit war dann klar, dass dieser Ordner aufgrund der Gitignore-Datei nicht committet wurde.

CORS Error

Als das Backend und das Frontend deployt waren, konnte das Frontend aufgrund eines CORS-Fehlers nicht auf das Backend zugreifen.

Der Fehler entstand dadurch, dass ich die URL des Backends aus der Browser-Suchleiste kopierte und dabei ein Slash am Ende der URL hinzufügt war.

Learnings

  • Workers: Arbeiten mit Workers war eine angenehme Erfahrung. Nicht nur wegen ihrer beeindruckenden Edge Performance und keinen Cold Starts, sondern auch wegen dem einfachen deployen über die CLI. Dazu kann mit “Miniflare” ein Cloudflare Worker lokal simuliert werden, was den Worker auch einfach zum entwickeln in einer lokalen Umgebung macht. Auch das verknüpfen mit der Datenbank war einfach.
  • Serverless: Dadurch das nur Serverless-Anbieter eingesetzt wurden, musste ich mir keine Gedanken um Skalierung und Wartung von Servern machen, was viel Zeit gespart hat.
  • Kosten: Mit dem kostenlosen Plan stehen jeden Tag 100.000 kostenlose Anfragen zur Verfügung. Der Bezahltarif beginnt bei fünf Euro. Damit erhält man jeden Monat 10 Millionen Requests und jede zusätzliche Millionen Requests kostet $0,50. Dieser geringe Preis war überraschend.
  • SQLite: Es war das erste mal das ich in einem Projekt SQLite verwendet habe. Hier habe ich gelernt dass es bei SQLite keine Boolean Werte gibt und noch zusätzliche Befehle hinter einem query hinzugefügt werden müssen, um die queries auszuführen. So muss um einen Eintrag von der Datenbank zu bekommen, hinter dem query noch ein get und bei einem insert noch ein run hinzugefügt werden
  • Github Actions: Durch die Einrichtung von Github Actions wird bei jedem Commit automatisch die Next.js-App deployt. Das beschleunigt und vereinfacht die Entwicklung und darüber hinaus kann die CI/CD nach Belieben angepasst werden.
  • Turborepo: Turborepo erweist sich als nützlich, wenn die Anwendung aus mehreren Apps besteht. Mit einem Skriptbefehl im Root des Projekts können beide Apps gleichzeitig ausgeführt werden. Hinzu kommt der Vorteil, dass bei der Installation nur ein einziges node_modules-Verzeichnis für beide Apps erstellt wird.

Fazit

Trotz einiger kleiner Herausforderungen hat sich das Deployment der Anwendung als relativ unkompliziert herausgestellt. Cloudflare bietet zudem eine hohe Performance ohne den Aufwand von Skalierung und Wartung und das zu geringen Kosten. Der größte Nachteil dürfte sicherlich sein, dass nicht alle Anwendungen in einer Edge-Umgebung ausgeführt werden können. Viele ORMs und Frameworks sind dafür zu groß. Dank neuer, schlanker Frameworks wie Hono.js, die vergleichbare Features wie populäre Frameworks wie Express bieten, wird die Entwicklung in einer Edge-Umgebung jedoch zunehmend einfacher und angenehmer.

Das Repository kann unter dem folgendem Link gefunden werden: https://github.com/j3sch/language-trainer



Posted

in

,

by

Jens Schlegel

Comments

Leave a Reply