Dónde vive una integración con Xero en una app Next.js
La vuelta del OAuth va en un Route Handler. Casi nada más. Un mapa de qué partes de una integración contable sobreviven a un entorno sin servidor y cuáles no, en silencio.
Una integración contable construida dentro de una aplicación Next.js suele funcionar a la primera en desarrollo y después se comporta de forma extraña en producción, de maneras que no tienen nada que ver con el sistema contable. Las llamadas son correctas. Las credenciales son correctas. Lo que cambia es cuántas copias de la aplicación existen y cuánto tiempo se le permite vivir a cada una.
Conviene dibujar el mapa antes de escribir nada, porque las piezas que pertenecen a una aplicación Next.js y las que no son fáciles de distinguir una vez sabe qué mirar, y muy difíciles de separar después.
La parte que sí pertenece aquí
La vuelta desde la pantalla de consentimiento es un Route Handler, y es la única pieza de la integración para la que Next.js es el sitio natural.
// 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')
}Es corto porque debe serlo. El valor state sale en una cookie httpOnly cuando
manda al usuario a la pantalla de consentimiento y se compara a la vuelta: sin
esa comparación el endpoint aceptará un código de cualquiera que consiga que un
navegador lo visite. El código se canjea en el servidor, los tokens nunca entran
en una carga que un componente de cliente pueda leer, y el manejador redirige en
lugar de renderizar nada.
La URL de autorización se construye en una Server Action o en un componente de servidor, por la misma razón: el identificador de cliente no es secreto, pero el flujo es más fácil de razonar cuando solo un lado de la aplicación lo compone.
Ese es el límite. Todo lo que hay por debajo de esta línea es donde Next.js deja de ayudar.
El ámbito del módulo no se comparte, y los tokens no son fijos
La optimización obvia es guardar el token de acceso en una variable a nivel de módulo para no leer la base de datos en cada render.
let cachedToken: string | null = null // mejor noEn desarrollo eso es correcto y rápido. Desplegado, cada instancia de función tiene su propio ámbito de módulo, así que esa variable no es una caché: son tantas cachés como instancias tenga calientes la plataforma en ese momento. Es el mismo hecho estructural que hace que el pool de conexiones se comporte de forma inesperada
- memoria separada por instancia -, pero aquí la consecuencia es peor que un pool agotado.
El token de refresco de Xero es de un solo uso. Renovarlo le entrega uno nuevo y retira el que envió. Así que si dos instancias notan por separado que el token ha caducado y cada una renueva, no están duplicando una petición inocua. Están compitiendo por una credencial que solo una puede acabar teniendo, y la escritura perdedora puede dejar un token muerto en su base de datos sin que aparezca ningún error.
El arreglo no es una caché mejor. Es que la renovación ocurra exactamente en un sitio, detrás de un cerrojo que viva fuera del proceso: un bloqueo de fila en su base de datos, o una clave en Redis con caducidad corta. Cada instancia lee el token del almacenamiento, y si está caducado espera a quien tenga el cerrojo en vez de renovar por su cuenta.
Lo que plantea dónde debería ejecutarse ese código, y la respuesta es que no debería ser un Route Handler en absoluto. Una petición web es el sitio equivocado para sostener un cerrojo.
El endpoint de webhook y el arranque en frío
Un Route Handler es la forma correcta de recibir un webhook, y lo que tiene que hacer es responder de inmediato.
Xero firma sus envíos con un HMAC sobre el cuerpo en bruto, así que lea el cuerpo como texto y calcule el hash de ese texto: cualquier cosa que lo interprete y lo vuelva a serializar antes produce una cadena distinta y un fallo de firma idéntico al de una clave equivocada. La suscripción además se activa con una llamada de validación que hay que responder correctamente antes de que llegue ningún evento real.
El envío no espera mucho. La plataforma tampoco, y un arranque en frío se consume antes de que su código llegue a ejecutarse. Así que el manejador verifica, registra la notificación en algún sitio duradero y devuelve:
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) // duradero, fuera de este proceso
return new Response(null, { status: 200 })
}after resulta tentador aquí y no es la herramienta adecuada. Ejecuta la
función una vez enviada la respuesta, pero sigue siendo dentro de la misma
invocación y dentro de la misma duración máxima de la ruta, lo que significa que
no puede reintentarse, no puede sobrevivir a la petición y no está garantizado
que haya terminado si la instancia desaparece. Para registrar está bien. Para la
única copia de un evento que le van a enviar, no.
Lo que enqueue señala está de verdad fuera de la aplicación: una cola, un
flujo duradero, una fila en una tabla de trabajos que un worker consulta. La
carga del webhook lleva un identificador y no la factura, así que algo tiene que
ir a buscar el registro después, y esa lectura está sujeta a un límite de
peticiones, con lo que necesita poder esperar y reintentar más tarde. Ninguna de
esas palabras describe un Route Handler.
Qué deberían leer las páginas
Una vez tiene los datos, la tentación es llamar a Xero desde un componente de servidor para que la página muestre cifras en vivo. Resístase, y la razón no es la estrategia de caché.
Cada render de esa página se convierte en una llamada contra un límite por inquilino que comparte con su proceso de sincronización. Cada render hereda la latencia del otro sistema y sus ventanas de mantenimiento. Y un 429 durante un render es una página que falla, no una cifra brevemente desactualizada.
Así que los componentes de servidor leen su propia base de datos, que es rápida, siempre está disponible y es suya para indexar. Cómo la cachea pasa entonces a ser una decisión corriente sobre sus propios datos en lugar de una negociación con la API de otro.
La única pregunta de diseño que queda es etiquetar con honestidad. Las cifras en pantalla son una copia, y la copia tiene una edad. Guarde la marca de tiempo de la última sincronización correcta junto a los registros y muéstrela: "a las 14:20" cuesta una línea y evita la clase de incidencia en que alguien mira un número que cree en vivo. Cuando la sincronización lleve fallando, dígalo en la página y no solo en un canal de alertas.
La forma que funciona
Ponga la vuelta del consentimiento, el receptor de webhooks y el camino de lectura en la aplicación Next.js. Ponga la renovación de tokens, la consulta programada, la lógica de reintento y la conciliación en algo que corra con su propio horario y sobreviva a un despliegue. Esa segunda cosa es un servicio pequeño, un worker de cola, o un framework de backend que ya lo trae todo.
Esto no es una limitación que haya que sortear, y una aplicación Next.js que habla así con un sistema externo habla igual con el quinto. Es la misma conclusión que la de cuándo no recurrir a Next.js: cuando la dificultad está en los trabajos y no en el camino de render, el frontend es la segunda decisión y no la primera.
Si el sistema contable es Sage, el mapa cambia en un aspecto importante, porque para varios productos de Sage no hay nada en internet público a lo que llamar y toda la integración se traslada detrás de una frontera que su aplicación no puede ver.
