Le tunnel WebSocket, et qui regarde quoi
Le tunnel est le moyen pour le serveur de joindre un navigateur sans y être invité : un bloc rechargé, une feuille de style rafraîchie, « cet enregistrement vient de changer », « quelqu'un d'autre est sur cette page ». Tout ce qui suit part du serveur ; le chemin du POST est décrit dans Les événements et le POST.
Qui reçoit un tunnel
BSPK_WS_CONFIG en décide page par page et écrit le résultat sous forme d'attributs sur <body> (data-ws, data-ws-indicator), là où bspk.js le lit.
Deux réglages, deux portées, volontairement :
BSPK_WEB_DOMAIN.useWebSocket— ce site a-t-il besoin du temps réel ? Par domaine et non par page : un socket ouvert puis fermé à chaque navigation n'aurait aucun sens.BSPK_WEB_DOMAIN_MENU.showWebSocketState— cette page affiche-t-elle l'état de la liaison ? C'est un choix d'affichage, donc page par page. Sans tunnel, pas d'indicateur.
Quelques conséquences faciles à mal comprendre :
- L'éditeur a toujours son tunnel, quoi que dise le domaine : c'est l'outil propre du dev-panel — rechargement du CSS, état de l'annuler/rétablir, et « quelqu'un d'autre modifie cette page ».
- Mais il n'hérite pas de la présence sur les enregistrements. Le tunnel est un moyen (domaine ou droit d'édition) ; la présence sur les enregistrements est une fonctionnalité du site (domaine et page, sans exception).
- Le tunnel n'est pas réservé aux utilisateurs connectés. Un rendu poussé par le serveur a autant de sens pour un visiteur anonyme ; l'ancienne restriction était un accident côté client (
bspk.jsne se connectait que si le cookieregisterClientexistait).
BSPK_WS_DOMAIN_OFF est la condition qui grise les deux réglages temps réel sur /bweb/domain-menu. C'est une méthode plutôt qu'une formule en ligne parce que 4D n'a pas d'évaluation paresseuse : Entity#Null | Not(Bool(Entity.useWebSocket)) évalue quand même le second terme. Le garde doit être une vraie instruction.
Le protocole de service
WSClientHandler.onMessage accepte quatre formes :
| Message | Sens | Réponse |
|---|---|---|
{vt_Action: "hello", vt_ClientId, ...} | L'onglet se présente (anciennement init, toujours accepté) | setClientWsName |
{vt_Action: "ping"} | Battement de cœur applicatif | {vt_Action: "pong"} |
{vt_Action: "who"} | « Qui d'autre regarde ce que je regarde ? » | un message presence |
{vt_Url: ...} | Une requête HTTP simulée à travers le socket | la réponse rendue |
Le dernier cas mérite d'être connu : un message WS qui porte vt_Url part directement dans BSPK_WEB_ON_CONNECTION. Le socket mène au même routeur que les requêtes HTTP.
Le numéro de connexion n'identifie rien
$ws.id change à chaque reconnexion. Tout ce qui est adressé par ce numéro devient muet dès que la liaison revient : le tuyau est là, mais personne ne sait comment l'adresser, et rien ne signale l'échec.
L'onglet se nomme donc lui-même, une fois, et le registre traduit cette identité stable vers la connexion du moment (BSPKWebSocketServer.sendTo). L'identité vit dans sessionStorage (bspkWsClientId) : deux onglets sur le même site sont deux clients avec deux sockets. En navigation privée stricte, l'accès peut lever une erreur ; on retombe alors sur un identifiant en mémoire, perdu au rechargement — c'est l'ancien comportement, pas une régression.
sendTo accepte toujours un numéro de connexion brut pour les appelants existants, et "broadcast" pour tout le monde.
Le battement de cœur, et pourquoi il existe
L'API WebSocket du navigateur n'expose pas les trames ping du protocole. Une liaison coupée proprement lève close ; une liaison figée (proxy, tunnel, mise en veille) ne lève rien du tout — l'onglet reste en readyState OPEN sur un tuyau mort. Seul un aller-retour applicatif le révèle.
| Constante | Valeur | Pourquoi |
|---|---|---|
PING_EVERY | 25 s | sous le délai d'inactivité habituel de 60 s des proxys |
PONG_TIMEOUT | 10 s | |
MAX_MISSED_PONG | 2 | deux battements sans réponse et le client ferme lui-même le socket — c'est la fermeture qui déclenche la reconnexion |
BACKOFF | 1, 2, 4, 8, 15 s | plafonné à 15 et non à 30 : un redémarrage de 4D prend environ 30 s, et un palier de 30 s pourrait ajouter encore 30 s de liaison morte après le retour du serveur (environ 50 s mesurées auparavant) |
Le délai comporte une part aléatoire (0.7 + random * 0.6) : sans elle, tous les onglets rouverts après un redémarrage de 4D frappent à la même milliseconde.
hello est envoyé sur l'événement open, jamais sur une minuterie — un setTimeout(1000) partait parfois sur un socket pas encore ouvert.
Le registre
Storage.vo_SharedStorage.vc_RegisterClient, une entrée par connexion vivante.
Une entrée doit rester plate. Pas de collection imbriquée : OB Copy(entry; ck shared) sur un objet qui contient une collection constitue déjà son groupe partagé, et l'affecter ensuite dans vo_SharedStorage lève « already belongs to another shared group ». C'est pourquoi scopePage, scopeRecord et scopeTables sont des chaînes et non des collections. Le registre entier est réécrit à partir d'une copie non partagée (_writeRegistry) plutôt que modifié en place — le même idiome que WebFormController.redo.
Seul onTerminate retire une entrée. onError ne fait volontairement rien : une erreur ne signifie pas que la connexion est fermée (4D appelle onTerminate pour cela), et retirer l'entrée à ce moment rendrait un client vivant injoignable. Sans cette discipline, le registre devient un journal des arrivées qui n'oublie jamais personne.
Une entrée porte trois notions distinctes :
| Champ | Se lit |
|---|---|
scopePage | page:<key> — où je suis |
scopeRecord | rec:<table>:<pk> — quel enregistrement j'ai ouvert |
scopeTables | les tables affichées par les listbox de cette page, lues dans le DOM (data-listboxtable) — « mon écran peut devenir périmé si T change » |
Les portées sont calculées au rendu (BSPK_PRESENCE_SCOPE) et renvoyées telles quelles par le navigateur : le client ne voit jamais qu'une adresse, et deux adresses peuvent désigner le même enregistrement. Protégez chaque conversion par un test #Null d'abord — String(Null) vaut le texte "null", et un client sans portée atterrirait dans une portée littéralement nommée null (voir Le langage 4D).
canEdit et wantsPresence sont eux aussi calculés par la page servie et envoyés par le client : le process WebSocket n'a sous la main ni la page ni la session.
Les listbox arrivent après le socket. Mesuré : au moment du
hello, aucune n'est encore dans le DOM ; l'onglet s'annonçait donc sans table et ne recevait jamais rien. Le client surveille désormais le DOM et se réannonce quand la signature des tables affichées change — ce qui couvre aussi les listbox chargées à la demande, les blocs remplacés par un rechargement et les tables ajoutées par le dev-panel.
Ce que le serveur pousse
BSPKWebSocketServer offre broadCast (tout le WebFormController sérialisé, vers chaque connexion), sendTo (une identité) et sendToScope (chaque connexion d'une portée, sauf une identité — typiquement l'auteur du changement).
C'est GG_DATACLASS qui déclenche les messages de données, depuis les événements ORDA : rowChanged / rowDropped (une ligne de listbox), recordChanged (l'enregistrement derrière une portée), structureChanged. Voir Écrire des fonctions 4D.
refreshCss recharge output.css dans le Storage et prévient chaque client. Il comporte un anti-rebond de 500 ms et un drapeau vb_disableRefreshCss pour les imports en masse — sans eux, une opération de masse inonde chaque onglet ouvert.
Côté client, le routeur de bspk.js intercepte pong, recordChanged, rowChanged / rowDropped, structureChanged et presence par comparaison de chaîne avant le parsing, puis passe la main au répartiteur d'actions générique. Un pong n'atteint jamais le répartiteur : il concerne le tunnel, pas la page.
Pièges
Les erreurs du process WS sont invisibles. Ce n'est pas une requête web : pas de page, pas de session, et un échec ne laisse aucun toast. _traceError écrit la dernière dans Storage.vo_SharedStorage.vt_WsLastError — lisez-la là, c'est souvent la seule trace.
Un redémarrage de 4D fait perdre son socket à chaque onglet. La reconnexion est automatique (voir le palier ci-dessus), mais tout ce que le serveur tenait pour ce numéro de connexion est perdu. C'est la raison d'être de l'identité stable.
broadCast sérialise tout le WebFormController vers chaque client, y compris les onglets ouverts sur d'autres pages. Préférez sendToScope.
Voir aussi
- Les événements et le POST — le chemin du POST, l'autre moitié du dialogue
- Écrire des fonctions 4D — là où
recordChangedet consorts sont déclenchés - Le dev-panel — le panneau est le premier consommateur du tunnel

