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