Serveur temps réel Python (Socket.IO)
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.
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ôle | Valeur par défaut |
|---|---|---|
socketio_port | Port d'écoute (toutes les interfaces) | 9000 |
socketio_uds | Chemin d'un socket Unix à utiliser à la place du port | aucune |
redis_queue | URL du Redis de la file d'attente, où le serveur écoute les messages publiés | redis://127.0.0.1:11311 si la clé est absente |
default_site | Site utilisé quand la connexion vient de localhost ou 127.0.0.1 | aucune |
webserver_host | Hôte du serveur web que le serveur temps réel appelle pour authentifier et vérifier les droits | 127.0.0.1 |
webserver_port | Port de ce serveur web | 8000 |
socketio_worker_threads | Nombre de threads pour le travail bloquant (authentification, gestionnaires synchrones) | 4 |
developer_mode | Active le relais « ouvrir dans l'éditeur » du mode développeur | dé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éfautFalse) etallow_guest(défautFalse). Avecallow_guest=False, l'événement est ignoré sisocket.uservautGuest. - 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
Noneest 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
- Appel HTTP (recommandé).
await socket.has_permission(doctype, name=None)interroge le serveur web et renvoieTrueouFalse. Aucune connexion à la base de données n'est ouverte dans le processus temps réel. En cas d'erreur, la méthode renvoieFalse. - 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 appelerfrappe.has_permissiondirectement. 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 unasync defavecfrappe_context=Truelève une erreur à l'import.
L'objet Socket
| Membre | Rôle |
|---|---|
socket.site, socket.user, socket.user_type, socket.installed_apps | Identité du socket, en lecture seule (user vaut Guest pour un anonyme) |
socket.sid, socket.namespace | Identifiant 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 client | Effet |
|---|---|
ping | Répond par pong |
doctype_subscribe / doctype_unsubscribe | Rejoint ou quitte la salle doctype:{doctype} (droit de lecture vérifié à l'abonnement) |
doc_subscribe / doc_unsubscribe | Rejoint ou quitte la salle doc:{doctype}/{nom} (droit de lecture vérifié) |
task_subscribe / progress_subscribe | Rejoint la salle task_progress:{task_id} |
task_unsubscribe | Quitte la salle task_progress:{task_id} |
doc_open / doc_close | Suit les utilisateurs qui ouvrent un document et émet doc_viewers avec la liste des utilisateurs |
open_in_editor | Ré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)
| Fonction | Salle | Destinataires |
|---|---|---|
publish_to_user | user:{user} | Tous les sockets de cet utilisateur sur ce site |
publish_to_doc | doc:{doctype}/{docname} | Sockets abonnés à ce document |
publish_to_doctype | doctype:{doctype} | Sockets abonnés à ce type de document |
publish_task_progress | task_progress:{task_id} | Sockets qui suivent cette tâche (pas d'argument event) |
publish_to_website | website | Tous les sockets de ce site |
publish_to_all | all | Utilisateurs système (System User) de ce site, pas les invités |
publish_to_room | la salle passée en argument | Les 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
- Architecture technique : les composants du bench et le rôle de
redis_queue. - Commandes Bench