Blog

Websockets en Next.js: tiempo real en hosting serverless

· Andrij Tkachenko · 6 min de lectura

Tres funciones de servidor de vida corta, cada una con un cronómetro, unidas a una nube que mantiene conexiones en directo abiertas con cuatro ventanas de navegador

Añadir actualizaciones en directo a una app de Next.js suele empezar bien. Abres un websocket desde un client component, lo apuntas a un pequeño servidor y con next dev todo funciona: el pedido pasa a enviado, aparece el mensaje nuevo, se mueve el contador de quién está en línea. Luego despliegas, y la conexión se cae, se reconecta, vuelve a caerse o ni siquiera llega a abrirse.

El problema rara vez es el código. La mayoría de las apps de Next.js se ejecutan en hosting serverless, y una función serverless no puede mantener una conexión abierta. Este artículo explica por qué, qué cuestan las soluciones habituales y cómo añadir tiempo real a una app de Next.js sin mantener un servidor websocket.

Por qué una función serverless no puede mantener un websocket

Un route handler, una server action y un server component funcionan igual en un hosting serverless. Llega una petición, una función arranca o se despierta, responde y poco después se congela o se detiene. La siguiente petición puede caer en otra instancia completamente distinta.

Un websocket necesita justo lo contrario: un proceso que mantenga una conexión abierta con cada navegador mientras la página siga abierta, a menudo durante horas, y que pueda enviarle datos en cualquier momento. Una función serverless no vive tanto, no tiene forma de llegar a una conexión que abrió otra instancia y se factura por cada segundo que sigue en marcha. Por eso tantos primeros intentos funcionan en desarrollo, donde un único proceso de larga duración lo sirve todo, y fallan en producción.

Las soluciones habituales

Polling. La página pregunta a un route handler cada pocos segundos si ha cambiado algo. Funciona en cualquier sitio, pero cada pregunta es una llamada a una función, la mayoría sin nada nuevo que contar, y la página siempre va unos segundos por detrás. Con 1.000 personas en una página que hace polling cada 5 segundos, son 200 llamadas a funciones por segundo, a todas horas.

Streaming desde un route handler. Un route handler puede mantener una respuesta abierta y enviar eventos a la página en streaming. Se acerca más al tiempo real, pero cada stream abierto es una función que sigue en marcha, todas las plataformas cortan las funciones pasado un tiempo máximo y el stream vive en una sola instancia, así que un evento enviado desde otra nunca le llega.

Un custom server. Next.js puede ejecutarse detrás de tu propio servidor Node.js, con una librería de websockets al lado. Eso saca la app del hosting serverless: tienes que ejecutar un servidor de larga duración, mantenerlo en pie durante los deploys y, en cuanto tienes más de uno, compartir cada evento entre ellos, normalmente a través de Redis, y dejar cada navegador en un servidor que lo conozca.

Un servidor websocket propio junto a la app. La app sigue siendo serverless y un servidor aparte mantiene las conexiones. Es la más limpia de las cuatro, y aun así es un segundo sistema que mantener: su certificado, su escalado, su monitorización y una forma de saber quién es el usuario, ya que no puede leer la sesión de tu app.

O deja que una nube en tiempo real mantenga las conexiones

Un servicio de tiempo real alojado se encarga por completo de la parte de larga duración. Los navegadores mantienen sus conexiones abiertas con el servicio. Tu código de Next.js, en una server action o un route handler, envía cada evento en una petición HTTP corta, que es justo lo que una función serverless hace bien. El servicio lo entrega a cada página que deba recibirlo, sea cual sea la instancia que lo envió.

Lo importante es que encaja con la forma en que Next.js ya funciona: tu servidor decide quién puede unirse a qué, tus páginas se suscriben desde client components y tu hosting no cambia en nada.

Next.js en WordPlus Cloud

Con WordPlus Cloud, tu servidor declara sus salas una sola vez, con una regla para quién puede unirse a cada una:

lib/realtime.ts
import { createRealtime } from '@wordplus/realtime/server';
import { currentUserId, profileOf } from '@/lib/auth';

export const realtime = createRealtime( {
    site: process.env.WORDPLUS_SITE!,
    secret: process.env.WORDPLUS_SECRET!,
    localKey: process.env.WORDPLUS_LOCAL_KEY!,
    user: () => currentUserId(),
    profile: ( user ) => profileOf( user ),
} );

// The room of an order, order:1042: its customer may join, and the last 20 updates are kept.
realtime.room( 'order:{id}', { history: 20 }, async ( user, { id } ) => user !== null && ( await ownerOf( id ) ) === user );

Un route handler catch-all responde a las páginas cuando piden unirse y sirve los archivos del SDK para el navegador:

app/api/realtime/[...path]/route.ts
import { realtime } from '@/lib/realtime';

export const GET = realtime.route;
export const POST = realtime.route;

El layout de las páginas en las que el usuario ha iniciado sesión renderiza RealtimeProvider con realtime.config() para ese usuario, así que todas las páginas que cuelgan de él comparten una conexión, que se conserva entre navegaciones del lado del cliente. Después, un client component se une a una sala con un hook:

app/(app)/orders/[id]/status.tsx
'use client';

import { useState } from 'react';
import { useRoom } from '@wordplus/realtime/react';

export function Status( { id, initial }: { id: string; initial: string } ) {
    const [ status, setStatus ] = useState( initial );
    useRoom( `order:${ id }`, { updated: ( data: { status: string } ) => setStatus( data.status ) } );
    return <p>{ status }</p>;
}

Y una server action envía el evento, después de guardar el cambio:

app/(app)/orders/[id]/actions.ts
'use server';

import { realtime } from '@/lib/realtime';

export async function markShipped( id: string ) {
    await saveStatus( id, 'shipped' );
    await realtime.to( `order:${ id }` ).emit( 'updated', { status: 'shipped' } );
}

Ese es el ciclo completo, y funciona tal cual en hosting serverless. Más allá de eso:

  • Tu inicio de sesión sigue siendo tuyo. El usuario es el id con el que tu app lo conoce, leído dentro del route handler, así que tu sesión y tus cookies funcionan ahí.
  • Nada se pierde al reconectar. Una sala guarda sus últimos eventos, así que una página que vuelve recibe lo que se perdió, en orden.
  • Una conexión para todas las pestañas. Las pestañas de un navegador comparten una conexión, así que una persona con tres pestañas abiertas cuenta una sola vez.
  • Sellado en tránsito. Lo que envía tu app viaja cifrado con claves que solo tienen tu servidor y tus usuarios.
  • Más que eventos. Quién está en línea, llamadas que suenan en todas las pestañas, llamadas en grupo y transmisiones en directo llegan por la misma conexión, con una pantalla de llamada, un chat y un reproductor listos para usar de Better Messages, o tus propios componentes sobre la misma API.

La guía de Next.js tiene toda la configuración, y la demo en vivo es una pequeña tienda construida sobre ella, en la que un pedido pasa a enviado en otra ventana en cuanto haces clic.

Videollamadas en una app de Next.js, paso a paso: Cómo implementar videollamadas en Next.js.

WordPlus Cloud for Apps tiene un precio según las conexiones simultáneas, a partir de 1.000, con facturación mensual o anual. Descubre lo que hace para apps, o consulta los precios para apps. ¿Preguntas sobre tu integración? Escríbenos.