Aller au contenu

Pousser du temps réel

Envoyer des événements au navigateur depuis une route /za/api en Server-Sent Events, alimentée par le bus temps réel du core.

Une page qui doit refléter un changement dès qu'il arrive, comme le statut d'une commande ou un compteur, interroge sinon le serveur à intervalle fixe. Ce guide montre comment la route pousse l'événement elle-même, sans quitter /za/api ni sa chaîne de sécurité.

Trois pièces travaillent ensemble :

PièceRôle
publish()Signale un changement, depuis n'importe quel processus.
Une route eventStream qui renvoie apiEventStream()Écoute ces signaux et envoie l'état au navigateur.
openEventStream()Lit le flux dans la page et le rouvre après une coupure.

Prérequis

  • Un hébergement où le processus Node dure : Coolify, Docker ou un serveur Node. Une plateforme serverless coupe la fonction avant la fin du flux.
  • FRAZA_DATABASE_URL vise PostgreSQL directement, ou un pooler en mode session. Un pooler en mode transaction ne transmet pas LISTEN : la route répond alors 503 et la page doit savoir s'en passer.

1. Signaler le changement

À l'endroit où l'état change, publier un signal court sur un canal propre à l'objet. Dans une transaction, passer son client : l'événement part au commit, jamais sur une annulation.

import { transaction } from '@fraza/core/database';
import { publish } from '@fraza/core/realtime';

export async function shipOrder(id) {
  await transaction(async (client) => {
    await client.query('UPDATE shop_orders SET status = $1 WHERE id = $2', ['shipped', id]);
    await publish(`shop.orders:${id}`, { changed: 'status' }, { client });
  });
}

L'événement ne porte pas la commande : il dit qu'elle a changé. La route relit la base, ce qui garde les droits d'accès et la forme des données au même endroit.

2. Déclarer la route du flux

import { defineApiRoutes, apiEventStream, notFound } from '@fraza/core/api';
import { ensureRealtime, subscribe } from '@fraza/core/realtime';
import { findOrder, readOrderState } from './orders.server.js';

export const routes = defineApiRoutes([
  {
    path: '/shop/orders/:id/events',
    method: 'GET',
    auth: 'user',
    permission: 'shop.orders.view',
    eventStream: { maxDurationMs: 5 * 60_000, maxPerIdentity: 3 },
    handler: streamOrder,
  },
]);

async function streamOrder(request, { id }) {
  // Les refus restent des réponses JSON : ils passent avant le flux.
  if (!(await findOrder(id))) throw notFound('Commande introuvable.');
  await ensureRealtime();

  return apiEventStream(async ({ send, signal }) => {
    // S'abonner d'abord, puis envoyer l'état : rien n'est perdu entre les deux.
    const events = await subscribe(`shop.orders:${id}`, { signal });
    send('state', await readOrderState(id));
    for await (const _ of events) {
      send('state', await readOrderState(id));
    }
  });
}

eventStream n'accepte que GET. La route compte ses connexions sur le préréglage stream, pas sur le budget api : les reconnexions automatiques du navigateur ne vident pas celui du reste de l'API.

ensureRealtime() est attendue avant apiEventStream(). Si l'écoute de la base est impossible, la route répond un vrai 503, de code realtime_unavailable, que la page sait lire. Une fois le flux commencé, une erreur ne peut plus changer le statut HTTP.

Une permission déclarée dans le permissions.js du projet convient aussi bien qu'une permission de module.

3. Lire le flux dans la page

'use client';

import { useEffect, useState } from 'react';
import { apiFetch, openEventStream } from '@fraza/core/shared/http';

export function OrderStatus({ id }) {
  const [state, setState] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    let poll = null;

    openEventStream(`/za/api/shop/orders/${id}/events`, {
      signal: controller.signal,
      onEvent: (event, data) => { if (event === 'state') setState(data); },
      onError: (error, { fatal }) => {
        // Sans temps réel, une relecture toutes les 30 secondes.
        if (fatal && error.code === 'realtime_unavailable') {
          const read = () => apiFetch(`/za/api/shop/orders/${id}`, { signal: controller.signal }).then(setState, () => {});
          read();
          poll = setInterval(read, 30_000);
        }
      },
    });

    return () => {
      controller.abort();
      clearInterval(poll);
    };
  }, [id]);

  return state ? <p>{state.label}</p> : null;
}

openEventStream() se reconnecte tout seul après une coupure, après un 429 ou à la fin de maxDurationMs. Il s'arrête pour de bon sur un 401, un 403, un 404 ou un 503 realtime_unavailable, et le signale avec fatal: true.

Fermer le flux quand la page n'en a plus besoin : un onglet caché peut le fermer sur visibilitychange et le rouvrir au retour. Le plafond maxPerIdentity ne compte alors que les onglets visibles.

Ce que le core garantit

  • La même chaîne qu'une autre route avant le premier octet : rate limit, session, CSRF, auth, permission.
  • Un accès retiré cesse au plus tard après maxDurationMs. À cette échéance, le core ferme le flux, le navigateur se reconnecte, et le routeur relit la session et les permissions. Une route sensible réduit cette durée.
  • Un plafond de flux ouverts par compte, ou par adresse pour un appelant anonyme, au-delà duquel la route répond 429. Il est compté dans chaque processus.
  • Aucun message interne au navigateur. Une erreur du producteur devient un event: error avec le message du catalogue, et l'erreur reste dans le journal.

Notes

Un canal par objet, préfixé par le module. shop.orders:42 plutôt que orders : un abonné ne reçoit que ce qui le concerne, et deux modules ne se croisent pas.

Ne pas envoyer de données dans l'événement publié. Il passe par NOTIFY, plafonné à 7 900 octets, et il est perdu pendant une coupure. Un abonné qui relit l'état n'a besoin que du signal.

Sur cette page