Zum Inhalt springen

Wo eine Xero-Integration in einer Next.js-App lebt

Der OAuth-Rückkanal gehört in einen Route Handler. Fast nichts sonst. Eine Karte, welche Teile einer Buchhaltungsanbindung eine serverlose Laufzeit überstehen und welche still nicht.

7 Min. Lesezeit

Eine Buchhaltungsanbindung, die in eine Next.js-Anwendung eingebaut wird, funktioniert meist beim ersten Versuch in der Entwicklung und verhält sich dann in Produktion seltsam, auf eine Weise, die mit dem Buchhaltungssystem nichts zu tun hat. Die Aufrufe stimmen. Die Zugangsdaten stimmen. Anders ist, wie viele Kopien der Anwendung existieren und wie lange jede davon leben darf.

Es lohnt sich, die Karte zu zeichnen, bevor irgendetwas davon geschrieben wird, denn die Teile, die in eine Next.js-App gehören, und die, die es nicht tun, sind leicht zu unterscheiden, wenn man weiß, worauf man achtet - und sehr schwer hinterher zu trennen.

Der Teil, der wirklich hierher gehört

Die Rückleitung vom Zustimmungsbildschirm ist ein Route Handler, und das ist das eine Stück der Integration, für das Next.js der natürliche Ort ist.

// app/api/xero/callback/route.ts
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
 
export async function GET(request: Request) {
  const params = new URL(request.url).searchParams
  const jar = await cookies()
  const expected = jar.get('xero_state')?.value
 
  if (!expected || params.get('state') !== expected) {
    redirect('/settings/accounting?error=state')
  }
 
  jar.delete('xero_state')
  await exchangeCodeForTokens(params.get('code')!)
  redirect('/settings/accounting?connected=1')
}

Er ist kurz, weil er es sein soll. Der state-Wert geht in einem httpOnly-Cookie mit, wenn Sie den Benutzer zum Zustimmungsbildschirm schicken, und wird auf dem Rückweg verglichen - ohne diesen Vergleich akzeptiert der Endpunkt einen Code von jedem, der einen Browser dazu bringt, ihn aufzurufen. Der Code wird auf dem Server getauscht, die Token landen nie in einer Payload, die eine Client-Komponente lesen könnte, und der Handler leitet weiter, statt etwas zu rendern.

Die Autorisierungs-URL wird in einer Server Action oder einer Server-Komponente gebaut, aus demselben Grund: Die Client-ID ist kein Geheimnis, aber der Ablauf ist leichter zu durchdenken, wenn immer nur eine Seite der Anwendung sie zusammensetzt.

Das ist die Grenze. Alles unterhalb dieser Linie ist der Bereich, in dem Next.js aufhört zu helfen.

Modul-Scope wird nicht geteilt, und Token sind nicht statisch

Die naheliegende Optimierung ist, das Access-Token in einer Variablen auf Modulebene zu halten, damit nicht bei jedem Render die Datenbank gelesen wird.

let cachedToken: string | null = null   // besser nicht

In der Entwicklung ist das korrekt und schnell. Deployed hat jede Funktionsinstanz einen eigenen Modul-Scope, diese Variable ist also nicht ein Cache, sondern so viele Caches, wie die Plattform gerade warm hält. Es ist derselbe strukturelle Umstand, der Connection Pooling unerwartet verhalten lässt

  • getrennter Speicher pro Instanz -, aber die Folge ist hier schlimmer als ein erschöpfter Pool.

Xeros Refresh-Token ist einmal verwendbar. Eine Erneuerung gibt Ihnen ein neues und zieht das eingesendete ein. Wenn also zwei Instanzen unabhängig ein abgelaufenes Token bemerken und jede erneuert, verdoppeln sie nicht eine harmlose Anfrage. Sie streiten um eine Berechtigung, die am Ende nur eine von ihnen halten kann, und der unterlegene Schreibvorgang kann ein totes Token in Ihrer Datenbank hinterlassen, ohne dass irgendwo ein Fehler auftaucht.

Die Lösung ist kein besserer Cache. Sie ist, dass die Erneuerung an genau einer Stelle passiert, hinter einer Sperre außerhalb des Prozesses - ein Zeilenlock in Ihrer Datenbank oder ein Schlüssel in Redis mit kurzer Lebensdauer. Jede Instanz liest das Token aus dem Speicher, und wenn es abgelaufen ist, wartet sie auf den, der die Sperre hält, statt selbst zu erneuern.

Womit sich die Frage stellt, wo dieser Code laufen sollte, und die Antwort ist: gar nicht in einem Route Handler. Ein Web-Request ist der falsche Ort, um eine Sperre zu halten.

Der Webhook-Endpunkt und der Kaltstart

Ein Route Handler ist die richtige Form, um einen Webhook entgegenzunehmen, und was er tun muss, ist sofort antworten.

Xero signiert seine Zustellungen mit einem HMAC über den Rohtext des Bodys, lesen Sie den Body also als Text und hashen Sie diesen Text - alles, was vorher parst und neu serialisiert, erzeugt eine andere Zeichenkette und einen Signaturfehler, der genau wie ein falscher Schlüssel aussieht. Das Abonnement wird außerdem durch einen Validierungsaufruf aktiviert, der korrekt beantwortet sein muss, bevor ein echtes Ereignis eintrifft.

Die Zustellung wartet nicht lange. Die Plattform auch nicht, und ein Kaltstart ist verbraucht, bevor Ihr Code überhaupt läuft. Der Handler prüft also, legt die Benachrichtigung dauerhaft ab und antwortet:

export async function POST(request: Request) {
  const raw = await request.text()
  if (!verify(raw, request.headers.get('x-xero-signature'))) {
    return new Response(null, { status: 401 })
  }
 
  await enqueue(JSON.parse(raw).events)   // dauerhaft, außerhalb dieses Prozesses
  return new Response(null, { status: 200 })
}

after ist hier verlockend und nicht das richtige Werkzeug. Es führt den Callback aus, nachdem die Antwort gesendet wurde, aber weiterhin im selben Aufruf und innerhalb derselben maximalen Laufzeit wie die Route - er kann also nicht wiederholt werden, die Anfrage nicht überleben und ist nicht garantiert fertig, wenn die Instanz verschwindet. Für Logging ist das in Ordnung. Für die einzige Kopie eines Ereignisses, das Ihnen je geschickt wird, nicht.

Worauf enqueue zeigt, liegt wirklich außerhalb der Anwendung: eine Queue, ein dauerhafter Workflow, eine Zeile in einer Jobtabelle, die ein Worker abholt. Die Webhook-Payload trägt eine ID statt der Rechnung, irgendetwas muss den Datensatz also anschließend holen - und dieses Holen unterliegt einem Limit, muss also zurückstecken und es später erneut versuchen können. Keines dieser Wörter beschreibt einen Route Handler.

Was die Seiten lesen sollten

Hat man die Daten, ist die Versuchung groß, Xero aus einer Server-Komponente heraus aufzurufen, damit die Seite Live-Zahlen zeigt. Widerstehen Sie, und der Grund ist keine Caching-Strategie.

Jedes Rendern dieser Seite wird zu einem Aufruf gegen ein Mandantenlimit, das Sie sich mit Ihrem Synchronisationsprozess teilen. Jedes Rendern erbt die Latenz des anderen Systems und dessen Wartungsfenster. Und eine 429 während eines Renders ist eine Seite, die fehlschlägt, keine Zahl, die kurz veraltet ist.

Server-Komponenten lesen also Ihre eigene Datenbank, die schnell ist, immer verfügbar und von Ihnen indizierbar. Wie Sie das cachen, ist dann eine gewöhnliche Entscheidung über Ihre eigenen Daten statt eine Verhandlung mit der API eines anderen.

Die eine Entwurfsfrage, die bleibt, ist ehrliche Beschriftung. Die Zahlen auf dem Bildschirm sind eine Kopie, und die Kopie hat ein Alter. Speichern Sie den Zeitstempel der letzten erfolgreichen Synchronisation neben den Datensätzen und rendern Sie ihn - "Stand 14:20" kostet eine Zeile und verhindert die Sorte Support-Ticket, bei der jemand auf eine Zahl schaut, die er für live hält. Wenn die Synchronisation scheitert, sagen Sie das auf der Seite und nicht nur in einem Alarmkanal.

Die Form, die funktioniert

Legen Sie den Rückkanal, den Webhook-Empfänger und den Lesepfad in die Next.js-Anwendung. Legen Sie die Token-Erneuerung, den geplanten Abruf, die Wiederholungslogik und die Abstimmung in etwas, das nach eigenem Zeitplan läuft und ein Deploy überlebt. Dieses Zweite ist ein kleiner Dienst, ein Queue-Worker oder ein Backend-Framework, das all das bereits mitbringt.

Das ist keine Einschränkung, die man umgeht, und eine Next.js-Anwendung, die so mit einem externen System spricht, spricht mit dem fünften genauso. Es ist derselbe Schluss wie bei der Frage, wann man nicht zu Next.js greift: Liegt die Schwierigkeit in den Jobs statt im Renderpfad, ist das Frontend die zweite Entscheidung und nicht die erste.

Ist das Buchhaltungssystem stattdessen Sage, ändert sich die Karte in einem wichtigen Punkt, denn für mehrere Sage-Produkte gibt es nichts im öffentlichen Internet zum Aufrufen, und die ganze Integration wandert hinter eine Grenze, hinter die Ihre Anwendung nicht sehen kann.

Verwandte Fragen

Kann die ganze Integration aus Route Handlern bestehen?
Sie können es so schreiben und es kommt durch das Review. Was es nicht tut, ist eine Störung auf der Gegenseite überstehen, denn nichts in diesem Modell wiederholt etwas. Ein Route Handler läuft, wenn eine Anfrage eintrifft, und endet, wenn die Antwort raus ist - jede Arbeit, die später stattfinden muss, hat keinen Ort zum Laufen.
Gibt uns after() einen Hintergrundjob?
Es gibt Ihnen Arbeit nach dem Senden der Antwort, und das ist nicht dasselbe. Der Callback läuft weiterhin im selben Aufruf und innerhalb derselben maximalen Laufzeit wie die Route, kann diese Route also nicht überleben und nicht unabhängig wiederholt werden. Für Logging ist es richtig, für alles, dessen Verlust Sie ärgern würde, nicht.
Wo sollen die Token gespeichert werden?
In Ihrer eigenen Datenbank, verschlüsselt, mit dem Mandanten als Schlüssel. Nicht im Modul-Scope, weil jede Funktionsinstanz eine eigene Kopie davon hat. Nicht in einem Cookie, weil ein Hintergrundprozess keine Anfrage hat, aus der er eines lesen könnte. Nicht in einer Umgebungsvariable, weil es sich alle halbe Stunde ändert.
Umgeht Self-Hosting all das?
Es beseitigt das Problem der Instanzanzahl, denn ein dauerhaft laufender Node-Prozess hat einen Modul-Scope und einen Speicher. Es beseitigt nicht die Notwendigkeit dauerhafter Wiederholungen und geplanter Arbeit, und beides in den Webprozess zu bauen ist der Weg, auf dem ein Deploy zu einer verlorenen Synchronisation wird. Das Betriebsmodell ändert, welche Probleme Sie haben, nicht wie viele.

Zurück zu allen Artikeln