Saltar al contenido

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.

8 min de lectura

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 no

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

Preguntas relacionadas

¿Puede ser toda la integración Route Handlers?
Puede escribirla así y pasará la revisión. Lo que no hará es sobrevivir a una caída del otro lado, porque nada en ese modelo reintenta. Un Route Handler se ejecuta cuando llega una petición y termina cuando sale la respuesta, así que cualquier trabajo que deba ocurrir más tarde no tiene dónde ejecutarse.
¿after() nos da un trabajo en segundo plano?
Le da trabajo que corre después de enviar la respuesta, que no es lo mismo. La función sigue ejecutándose dentro de la misma invocación y dentro de la misma duración máxima de la ruta, así que no puede sobrevivirla ni reintentarse por separado. Es la herramienta correcta para registrar, y la equivocada para cualquier cosa cuya pérdida le disgustaría.
¿Dónde deberían guardarse los tokens?
En su propia base de datos, cifrados, con el inquilino al que pertenecen como clave. No en el ámbito del módulo, porque cada instancia de función tiene una copia distinta. No en una cookie, porque un proceso en segundo plano no tiene ninguna petición de la que leerla. No en una variable de entorno, porque cambia cada media hora.
¿Alojarlo uno mismo evita todo esto?
Elimina el problema del número de instancias, porque un proceso Node de larga vida tiene un ámbito de módulo y una memoria. No elimina la necesidad de reintentos duraderos ni de trabajo programado, y construir eso dentro del proceso web es la forma en que un despliegue se convierte en una sincronización perdida. El modelo de alojamiento cambia qué problemas tiene, no cuántos.

Volver a todos los artículos