Développeurs

Serveur temps réel Python (Socket.IO)

Lancer le serveur temps réel Python optionnel de Dodock, écrire des gestionnaires d'événements Socket.IO dans une application et publier des événements vers les navigateurs.

Serveur temps réel Python (Socket.IO)

Dodock fournit un serveur temps réel Socket.IO écrit en Python (asyncio et uvicorn). Il est optionnel : le serveur Node.js (socketio.js) reste le serveur par défaut et rien ne change sur un bench tant que vous ne choisissez pas le serveur Python.

Cette page s'adresse aux développeurs et aux administrateurs de bench. Elle explique comment lancer le serveur, comment une application y enregistre ses propres événements, et comment le code serveur publie des messages vers les navigateurs connectés.

Sur Dodock v5, aucun processus du bench ne démarre ce serveur automatiquement : vous le lancez vous-même (voir ci-dessous). Les dépendances python-socketio, python-engineio, uvicorn et httpx sont installées avec Dodock, mais seul le serveur Python les importe. import frappe et le processus web ne les chargent pas.

Lancer le serveur Python

Le serveur est un processus séparé du serveur web (Gunicorn). Pour l'utiliser à la place du serveur Node.js, remplacez la ligne socketio: du Procfile du bench par :

socketio: ./env/bin/python -m frappe.realtime.server

En production (Supervisor et Nginx)

L'outil bench ne connaît pas le serveur Python : bench setup supervisor génère toujours le programme <bench>-node-socketio avec le serveur Node.js. Dans le fichier config/supervisor.conf du bench, remplacez la commande de ce programme :

[program:mon-bench-node-socketio]
command=/home/dokos/mon-bench/env/bin/python -m frappe.realtime.server
directory=/home/dokos/mon-bench

Rechargez ensuite Supervisor (sudo supervisorctl reread puis sudo supervisorctl update). bench setup supervisor et bench setup production réécrivent ce fichier : refaites la modification après chaque exécution.

La configuration Nginx ne change pas : le bloc location /socket.io envoie déjà les connexions vers 127.0.0.1:<socketio_port>, le port sur lequel le serveur Python écoute. Si vous utilisez socketio_uds, modifiez vous-même l'upstream <bench>-socketio-server du fichier Nginx pour pointer vers unix:<chemin du socket>.

Configuration

Le serveur lit sa configuration dans sites/common_site_config.json :

CléRôleValeur par défaut
socketio_portPort d'écoute (toutes les interfaces)9000
socketio_udsChemin d'un socket Unix à utiliser à la place du portaucune
redis_queueURL du Redis de la file d'attente, où le serveur écoute les messages publiésredis://127.0.0.1:11311 si la clé est absente
default_siteSite utilisé quand la connexion vient de localhost ou 127.0.0.1aucune
webserver_hostHôte du serveur web que le serveur temps réel appelle pour authentifier et vérifier les droits127.0.0.1
webserver_portPort de ce serveur web8000
socketio_worker_threadsNombre de threads pour le travail bloquant (authentification, gestionnaires synchrones)4
developer_modeActive le relais « ouvrir dans l'éditeur » du mode développeurdésactivé

Exemple fictif de common_site_config.json pour un bench dont le serveur web écoute sur le port 8001 :

{
  "socketio_port": 9000,
  "webserver_port": 8001,
  "socketio_worker_threads": 8
}

Les clients se connectent sur le namespace /{site} et doivent présenter soit le cookie sid, soit un en-tête Authorization. Le serveur délègue l'authentification au serveur web : il appelle la méthode frappe.realtime.get_user_info avec un secret partagé, stocké dans Redis (clé socketio_auth_secret) et comparé en temps constant. La connexion est refusée si l'origine ne correspond pas à l'hôte, si le namespace n'est pas le site, ou si l'utilisateur n'est pas reconnu.

Écrire des gestionnaires dans une application

Créez le module realtime/handlers.py dans le paquet Python de votre application (mon_app.realtime.handlers). Au démarrage, le serveur importe ce module pour chaque application installée du bench :

  • si le module n'existe pas, l'application n'a simplement aucun gestionnaire ;
  • si le module existe mais lève une erreur à l'import, le serveur échoue au démarrage : l'erreur n'est pas masquée.

Exemple

Exemple fictif : un utilisateur s'abonne aux mises à jour d'un projet s'il a le droit de le lire.

from frappe.realtime import Socket, realtime


@realtime.on("project_subscribe")
async def project_subscribe(socket: Socket, project: str) -> dict:
    allowed = await socket.has_permission("Project", project)
    if allowed:
        await socket.join(f"project:{project}")
    return {"subscribed": allowed}

Côté serveur, le code qui modifie le projet publie ensuite vers la même salle :

from frappe.realtime import publish_to_room

publish_to_room("project:PROJ-0001", "project_updated", {"status": "Open"})

Le nom de salle publié doit être identique à celui rejoint par le gestionnaire.

Côté navigateur, émettez l'événement avec frappe.realtime.emit, et écoutez les messages publiés avec frappe.realtime.on. Les arguments qui suivent le nom de l'événement sont transmis au gestionnaire. Une fonction placée en dernier argument reçoit l'accusé de réception (la valeur retournée par le gestionnaire) :

frappe.realtime.emit("project_subscribe", "PROJ-0001", (result) => {
  console.log(result.subscribed);
});
frappe.realtime.on("project_updated", (data) => {
  console.log(data.status);
});

Règles du décorateur realtime.on

  • Le premier argument du gestionnaire est toujours le Socket. Les arguments suivants sont ceux envoyés par le client, dans l'ordre.
  • Le décorateur retourne la fonction inchangée : vous pouvez l'appeler directement dans un test.
  • Options : frappe_context (défaut False) et allow_guest (défaut False). Avec allow_guest=False, l'événement est ignoré si socket.user vaut Guest.
  • Une application qui n'est pas installée sur le site de la connexion ne voit pas ses gestionnaires exécutés. L'application propriétaire est détectée automatiquement à l'import.
  • Une exception levée par un gestionnaire est journalisée ; les autres gestionnaires du même événement s'exécutent quand même.
  • Si plusieurs gestionnaires répondent au même événement, le dernier résultat différent de None est renvoyé comme accusé de réception au client qui a émis avec une fonction de rappel.

Gestionnaire async ou synchrone

Écrivez de préférence des gestionnaires async def : ils s'exécutent sur la boucle d'événements et utilisent await pour les appels au Socket. Ne bloquez jamais la boucle depuis un gestionnaire async : une entrée/sortie bloquante ou un calcul long figerait tous les autres sockets.

Un gestionnaire def ordinaire est accepté. Il s'exécute dans un thread de travail et reçoit un SyncSocket, dont l'API est identique mais sans await. Utilisez-le quand le corps doit bloquer (bibliothèque sans client asynchrone, contexte Frappe).

Vérifier les droits : deux méthodes

  1. Appel HTTP (recommandé). await socket.has_permission(doctype, name=None) interroge le serveur web et renvoie True ou False. Aucune connexion à la base de données n'est ouverte dans le processus temps réel. En cas d'erreur, la méthode renvoie False.
  2. Contexte Frappe. Avec @realtime.on("project_subscribe", frappe_context=True), le corps du gestionnaire s'exécute dans un contexte Frappe complet (base de données et session de l'utilisateur) : vous pouvez appeler frappe.has_permission directement. Chaque événement paie alors l'initialisation du contexte et ouvre une connexion à la base : réservez cette option aux cas qui en ont besoin. Le gestionnaire doit être une fonction ordinaire ; marquer un async def avec frappe_context=True lève une erreur à l'import.

L'objet Socket

MembreRôle
socket.site, socket.user, socket.user_type, socket.installed_appsIdentité du socket, en lecture seule (user vaut Guest pour un anonyme)
socket.sid, socket.namespaceIdentifiant du socket et namespace (/{site})
await socket.join(room) / await socket.leave(room)Ajouter ou retirer le socket d'une salle
await socket.emit(event, data=None, room=None)Émettre vers une salle, ou vers ce client si room est omis
await socket.set(key, value) / socket.get(key, default=None)État transitoire du socket, effacé à la déconnexion
socket.participants(room)Identifiants des sockets présents dans une salle
await socket.has_permission(doctype, name=None)Vérification des droits via le serveur web

Événements fournis par Dodock

Le module frappe.realtime.handlers enregistre les événements du cœur. À la connexion, le socket rejoint les salles user:{utilisateur} et website, ainsi que all si l'utilisateur est de type System User.

Événement clientEffet
pingRépond par pong
doctype_subscribe / doctype_unsubscribeRejoint ou quitte la salle doctype:{doctype} (droit de lecture vérifié à l'abonnement)
doc_subscribe / doc_unsubscribeRejoint ou quitte la salle doc:{doctype}/{nom} (droit de lecture vérifié)
task_subscribe / progress_subscribeRejoint la salle task_progress:{task_id}
task_unsubscribeQuitte la salle task_progress:{task_id}
doc_open / doc_closeSuit les utilisateurs qui ouvrent un document et émet doc_viewers avec la liste des utilisateurs
open_in_editorRéservé au mode développeur : relaie la demande vers le canal Redis open_in_editor

Publier des événements depuis le code serveur

Le processus web ne parle pas directement aux sockets. Il publie dans Redis (publish_realtime, inchangé) et le serveur temps réel relaie chaque message du canal events vers les clients connectés :

  • avec une salle : le message est émis dans cette salle, sur le namespace du site ;
  • sans salle : le message est diffusé à tous les sites connectés (événements de build).

Le serveur se reconnecte seul à Redis en cas de coupure et ignore les messages mal formés.

Le module frappe.realtime fournit des fonctions nommées, qui s'appuient sur publish_realtime. Toutes acceptent l'option after_commit=False (mot-clé uniquement) : avec after_commit=True, la publication attend la validation de la transaction en cours.

publish_to_user(user, event, message=None, *, after_commit=False)
publish_to_doc(doctype, docname, event, message=None, *, after_commit=False)
publish_to_doctype(doctype, event, message=None, *, after_commit=False)
publish_task_progress(task_id, message=None, *, after_commit=False)
publish_to_website(event, message=None, *, after_commit=False)
publish_to_all(event, message=None, *, after_commit=False)
publish_to_room(room, event, message=None, *, after_commit=False)
FonctionSalleDestinataires
publish_to_useruser:{user}Tous les sockets de cet utilisateur sur ce site
publish_to_docdoc:{doctype}/{docname}Sockets abonnés à ce document
publish_to_doctypedoctype:{doctype}Sockets abonnés à ce type de document
publish_task_progresstask_progress:{task_id}Sockets qui suivent cette tâche (pas d'argument event)
publish_to_websitewebsiteTous les sockets de ce site
publish_to_allallUtilisateurs système (System User) de ce site, pas les invités
publish_to_roomla salle passée en argumentLes sockets de cette salle

publish_to_all est limité au site courant ; ce n'est pas la diffusion sans salle utilisée pour les événements de build.

Voir aussi